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:
@@ -25,6 +25,7 @@ return [
|
|||||||
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
|
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
|
||||||
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline', 'module' => 'project-management'],
|
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline', 'module' => 'project-management'],
|
||||||
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline', 'module' => 'project-management'],
|
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline', 'module' => 'project-management'],
|
||||||
|
['label' => 'sidebar.general.portal', 'to' => '/portal', 'icon' => 'mdi:account-box-outline', 'module' => 'client-portal'],
|
||||||
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking'],
|
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking'],
|
||||||
// Gating module uniquement (cf. en-tête) : rendu visuel + badge gérés côté layout.
|
// Gating module uniquement (cf. en-tête) : rendu visuel + badge gérés côté layout.
|
||||||
['label' => 'sidebar.general.mail', 'to' => '/mail', 'icon' => 'mdi:email-outline', 'module' => 'mail'],
|
['label' => 'sidebar.general.mail', 'to' => '/mail', 'icon' => 'mdi:email-outline', 'module' => 'mail'],
|
||||||
|
|||||||
@@ -14,6 +14,22 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||||||
return navigateTo('/')
|
return navigateTo('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cloisonnement portail client : un utilisateur ROLE_CLIENT "pur" (a ROLE_CLIENT
|
||||||
|
// mais PAS ROLE_USER) n'a accès qu'aux pages /portal. Toute autre route interne
|
||||||
|
// est redirigée vers /portal. Les ROLE_ADMIN / ROLE_USER ne sont pas concernés
|
||||||
|
// (ils peuvent aussi visiter /portal pour prévisualiser).
|
||||||
|
if (auth.isAuthenticated && !isLogin) {
|
||||||
|
const roles = auth.user?.roles ?? []
|
||||||
|
const isPureClient = roles.includes('ROLE_CLIENT') && !roles.includes('ROLE_USER')
|
||||||
|
|
||||||
|
if (isPureClient) {
|
||||||
|
const isPortalRoute = to.path === '/portal' || to.path.startsWith('/portal/')
|
||||||
|
if (!isPortalRoute) {
|
||||||
|
return navigateTo('/portal')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { loaded: sidebarLoaded, loadSidebar, resetSidebar } = useSidebar()
|
const { loaded: sidebarLoaded, loadSidebar, resetSidebar } = useSidebar()
|
||||||
const { loaded: modulesLoaded, loadModules, resetModules } = useModules()
|
const { loaded: modulesLoaded, loadModules, resetModules } = useModules()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Named middleware for portal pages (`/portal/**`).
|
||||||
|
* Ensures the user is authenticated. Access is open to every authenticated user
|
||||||
|
* (ROLE_CLIENT see their portal, ROLE_ADMIN/ROLE_USER may preview it).
|
||||||
|
*/
|
||||||
|
export default defineNuxtRouteMiddleware(() => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
if (!auth.isAuthenticated) {
|
||||||
|
return navigateTo('/login')
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h2 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.adminTitle') }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtres -->
|
||||||
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
|
<div class="[&>div]:!mt-0">
|
||||||
|
<MalioSelect
|
||||||
|
v-model="filters.project"
|
||||||
|
:options="projectOptions"
|
||||||
|
:label="$t('clientTicket.filterProject')"
|
||||||
|
group-class="!w-48"
|
||||||
|
empty-option-label="—"
|
||||||
|
@update:model-value="load"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="[&>div]:!mt-0">
|
||||||
|
<MalioSelect
|
||||||
|
v-model="filters.status"
|
||||||
|
:options="statusOptions"
|
||||||
|
:label="$t('clientTicket.filterStatus')"
|
||||||
|
group-class="!w-48"
|
||||||
|
empty-option-label="—"
|
||||||
|
@update:model-value="load"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
class="mt-4"
|
||||||
|
:columns="columns"
|
||||||
|
:items="tickets"
|
||||||
|
:loading="isLoading"
|
||||||
|
:empty-message="$t('portal.noTickets')"
|
||||||
|
@row-click="openDetail"
|
||||||
|
>
|
||||||
|
<template #cell-number="{ item }">
|
||||||
|
<span class="font-mono font-semibold text-primary-500">
|
||||||
|
{{ formatTicketNumber((item as ClientTicket).number) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-type="{ item }">
|
||||||
|
<ClientTicketTypeBadge :type="(item as ClientTicket).type" />
|
||||||
|
</template>
|
||||||
|
<template #cell-status="{ item }">
|
||||||
|
<ClientTicketStatusBadge :status="(item as ClientTicket).status" />
|
||||||
|
</template>
|
||||||
|
<template #cell-project="{ item }">
|
||||||
|
{{ projectName((item as ClientTicket).project) }}
|
||||||
|
</template>
|
||||||
|
<template #cell-submittedBy="{ item }">
|
||||||
|
{{ submitterName((item as ClientTicket).submittedBy) }}
|
||||||
|
</template>
|
||||||
|
<template #cell-createdAt="{ item }">
|
||||||
|
{{ formatDate((item as ClientTicket).createdAt) }}
|
||||||
|
</template>
|
||||||
|
<template #cell-actions="{ item }">
|
||||||
|
<MalioButton
|
||||||
|
:label="$t('clientTicket.changeStatus')"
|
||||||
|
button-class="w-auto px-3 py-1 text-xs"
|
||||||
|
@click.stop="openStatus(item as ClientTicket)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<!-- Detail modal (read-only + documents) -->
|
||||||
|
<ClientTicketDetailModal
|
||||||
|
v-model="detailOpen"
|
||||||
|
:ticket="selectedTicket"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Status change modal -->
|
||||||
|
<AppModal v-model="statusOpen" :title="$t('clientTicket.changeStatus')" width="md">
|
||||||
|
<div v-if="statusTicket" class="flex flex-col gap-4">
|
||||||
|
<p class="text-sm text-neutral-500">
|
||||||
|
{{ formatTicketNumber(statusTicket.number) }} — {{ statusTicket.title }}
|
||||||
|
</p>
|
||||||
|
<MalioSelect
|
||||||
|
v-model="statusForm.status"
|
||||||
|
:options="statusEditOptions"
|
||||||
|
:label="$t('clientTicket.status.label')"
|
||||||
|
group-class="w-full"
|
||||||
|
/>
|
||||||
|
<MalioInputTextArea
|
||||||
|
v-model="statusForm.statusComment"
|
||||||
|
:label="$t('clientTicket.statusComment')"
|
||||||
|
:rows="3"
|
||||||
|
input-class="w-full"
|
||||||
|
:error="statusError"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
:label="$t('common.cancel')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
variant="secondary"
|
||||||
|
@click="statusOpen = false"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
:label="$t('common.save')"
|
||||||
|
button-class="w-auto px-6"
|
||||||
|
:disabled="isSavingStatus"
|
||||||
|
@click="saveStatus"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</AppModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
ClientTicket,
|
||||||
|
ClientTicketStatus,
|
||||||
|
} from '~/modules/client-portal/services/dto/client-ticket'
|
||||||
|
import type { Project } from '~/modules/project-management/services/dto/project'
|
||||||
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
|
import { useClientTicketService } from '~/modules/client-portal/services/client-tickets'
|
||||||
|
import { useProjectService } from '~/modules/project-management/services/projects'
|
||||||
|
import { useUserService } from '~/services/users'
|
||||||
|
import { formatTicketNumber, iriToId } from '~/modules/client-portal/utils/ticket'
|
||||||
|
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { getAll, getById, updateStatus } = useClientTicketService()
|
||||||
|
const { getAll: getProjects } = useProjectService()
|
||||||
|
const { getAll: getUsers } = useUserService()
|
||||||
|
|
||||||
|
const columns: DataTableColumn[] = [
|
||||||
|
{ key: 'number', label: 'N°', primary: true },
|
||||||
|
{ key: 'type', label: t('clientTicket.typeLabel') },
|
||||||
|
{ key: 'title', label: t('clientTicket.title') },
|
||||||
|
{ key: 'status', label: t('clientTicket.status.label') },
|
||||||
|
{ key: 'project', label: t('clientTicket.project') },
|
||||||
|
{ key: 'submittedBy', label: t('clientTicket.submittedBy') },
|
||||||
|
{ key: 'createdAt', label: t('clientTicket.date') },
|
||||||
|
{ key: 'actions', label: '' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const STATUSES: ClientTicketStatus[] = ['new', 'in_progress', 'done', 'rejected']
|
||||||
|
|
||||||
|
const statusOptions = computed(() =>
|
||||||
|
STATUSES.map((s) => ({ label: t(`clientTicket.status.${s}`), value: s })),
|
||||||
|
)
|
||||||
|
const statusEditOptions = statusOptions
|
||||||
|
|
||||||
|
const tickets = ref<ClientTicket[]>([])
|
||||||
|
const projects = ref<Project[]>([])
|
||||||
|
const users = ref<UserData[]>([])
|
||||||
|
const isLoading = ref(true)
|
||||||
|
|
||||||
|
const filters = reactive({
|
||||||
|
project: null as string | null,
|
||||||
|
status: null as string | null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const projectOptions = computed(() =>
|
||||||
|
projects.value.map((p) => ({
|
||||||
|
label: p.name,
|
||||||
|
value: p['@id'] ?? `/api/projects/${p.id}`,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
function projectName(iri: string): string {
|
||||||
|
const id = iriToId(iri)
|
||||||
|
return projects.value.find((p) => p.id === id)?.name ?? '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitterName(iri: string | null): string {
|
||||||
|
if (!iri) return '—'
|
||||||
|
const id = iriToId(iri)
|
||||||
|
return users.value.find((u) => u.id === id)?.username ?? '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string): string {
|
||||||
|
return new Date(value).toLocaleDateString('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detail modal
|
||||||
|
const detailOpen = ref(false)
|
||||||
|
const selectedTicket = ref<ClientTicket | null>(null)
|
||||||
|
|
||||||
|
async function openDetail(ticket: ClientTicket) {
|
||||||
|
try {
|
||||||
|
selectedTicket.value = await getById(ticket.id)
|
||||||
|
} catch {
|
||||||
|
selectedTicket.value = ticket
|
||||||
|
}
|
||||||
|
detailOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status modal
|
||||||
|
const statusOpen = ref(false)
|
||||||
|
const statusTicket = ref<ClientTicket | null>(null)
|
||||||
|
const isSavingStatus = ref(false)
|
||||||
|
const statusForm = reactive({
|
||||||
|
status: 'new' as ClientTicketStatus,
|
||||||
|
statusComment: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusError = computed(() => {
|
||||||
|
if (statusForm.status === 'rejected' && !statusForm.statusComment.trim()) {
|
||||||
|
return t('clientTicket.rejectionRequired')
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function openStatus(ticket: ClientTicket) {
|
||||||
|
statusTicket.value = ticket
|
||||||
|
statusForm.status = ticket.status
|
||||||
|
statusForm.statusComment = ticket.statusComment ?? ''
|
||||||
|
statusOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveStatus() {
|
||||||
|
if (!statusTicket.value) return
|
||||||
|
if (statusForm.status === 'rejected' && !statusForm.statusComment.trim()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isSavingStatus.value = true
|
||||||
|
try {
|
||||||
|
await updateStatus(statusTicket.value.id, {
|
||||||
|
status: statusForm.status,
|
||||||
|
statusComment: statusForm.statusComment.trim() || null,
|
||||||
|
})
|
||||||
|
statusOpen.value = false
|
||||||
|
await load()
|
||||||
|
} finally {
|
||||||
|
isSavingStatus.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
tickets.value = await getAll({
|
||||||
|
project: filters.project ?? undefined,
|
||||||
|
status: filters.status ?? undefined,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const [proj, usr] = await Promise.all([getProjects(), getUsers()])
|
||||||
|
projects.value = proj
|
||||||
|
users.value = usr
|
||||||
|
await load()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -48,6 +48,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Compte client (portail) -->
|
||||||
|
<div v-if="isClient" class="mt-6 border-t border-neutral-200 pt-4">
|
||||||
|
<p class="mb-3 text-sm font-semibold text-neutral-700">{{ $t('users.clientAccount') }}</p>
|
||||||
|
<MalioSelect
|
||||||
|
v-model="form.client"
|
||||||
|
:options="clientOptions"
|
||||||
|
:label="$t('users.client')"
|
||||||
|
group-class="w-full"
|
||||||
|
empty-option-label="—"
|
||||||
|
/>
|
||||||
|
<div class="mt-3">
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-model="form.allowedProjects"
|
||||||
|
:options="projectOptions"
|
||||||
|
:label="$t('users.allowedProjects')"
|
||||||
|
group-class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- RH / Absences -->
|
<!-- RH / Absences -->
|
||||||
<div class="mt-6 border-t border-neutral-200 pt-4">
|
<div class="mt-6 border-t border-neutral-200 pt-4">
|
||||||
<MalioCheckbox v-model="form.isEmployee" label="Employé (soumis à la gestion des absences)" />
|
<MalioCheckbox v-model="form.isEmployee" label="Employé (soumis à la gestion des absences)" />
|
||||||
@@ -71,6 +91,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { UserData, UserWrite } from '~/services/dto/user-data'
|
import type { UserData, UserWrite } from '~/services/dto/user-data'
|
||||||
import { useUserService } from '~/services/users'
|
import { useUserService } from '~/services/users'
|
||||||
|
import { useClientService } from '~/modules/directory/services/clients'
|
||||||
|
import { useProjectService } from '~/modules/project-management/services/projects'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
@@ -87,7 +109,7 @@ const isOpen = computed({
|
|||||||
set: (v) => emit('update:modelValue', v),
|
set: (v) => emit('update:modelValue', v),
|
||||||
})
|
})
|
||||||
|
|
||||||
const availableRoles = ['ROLE_ADMIN', 'ROLE_USER']
|
const availableRoles = ['ROLE_ADMIN', 'ROLE_USER', 'ROLE_CLIENT']
|
||||||
|
|
||||||
const isEditing = computed(() => !!props.item)
|
const isEditing = computed(() => !!props.item)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
@@ -99,37 +121,93 @@ const form = reactive({
|
|||||||
password: '',
|
password: '',
|
||||||
roles: [] as string[],
|
roles: [] as string[],
|
||||||
isEmployee: false,
|
isEmployee: false,
|
||||||
|
client: null as string | null,
|
||||||
|
allowedProjects: [] as string[],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isClient = computed(() => form.roles.includes('ROLE_CLIENT'))
|
||||||
|
|
||||||
const touched = reactive({
|
const touched = reactive({
|
||||||
username: false,
|
username: false,
|
||||||
password: false,
|
password: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => props.modelValue, (open) => {
|
const clientOptions = ref<{ label: string, value: string | null }[]>([])
|
||||||
if (open) {
|
const projectOptions = ref<{ label: string, value: string }[]>([])
|
||||||
if (props.item) {
|
|
||||||
form.username = props.item.username ?? ''
|
const { getAll: getClients } = useClientService()
|
||||||
form.firstName = props.item.firstName ?? ''
|
const { getAll: getProjects } = useProjectService()
|
||||||
form.lastName = props.item.lastName ?? ''
|
const { create, update, getById } = useUserService()
|
||||||
form.password = ''
|
|
||||||
form.roles = [...props.item.roles]
|
async function loadOptions() {
|
||||||
form.isEmployee = props.item.isEmployee ?? false
|
if (clientOptions.value.length && projectOptions.value.length) {
|
||||||
} else {
|
return
|
||||||
form.username = ''
|
}
|
||||||
form.firstName = ''
|
const [clients, projects] = await Promise.all([getClients(), getProjects()])
|
||||||
form.lastName = ''
|
clientOptions.value = clients.map((c) => ({
|
||||||
form.password = ''
|
label: c.name,
|
||||||
form.roles = ['ROLE_USER']
|
value: c['@id'] ?? `/api/clients/${c.id}`,
|
||||||
form.isEmployee = false
|
}))
|
||||||
|
projectOptions.value = projects.map((p) => ({
|
||||||
|
label: p.name,
|
||||||
|
value: p['@id'] ?? `/api/projects/${p.id}`,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize allowedProjects (embedded objects or bare IRIs) into IRI strings. */
|
||||||
|
function toProjectIris(allowed: UserData['allowedProjects'] | undefined): string[] {
|
||||||
|
if (!allowed) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return allowed.map((p) => {
|
||||||
|
if (typeof p === 'string') {
|
||||||
|
return p
|
||||||
}
|
}
|
||||||
touched.username = false
|
return p['@id'] ?? `/api/projects/${p.id}`
|
||||||
touched.password = false
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyUser(user: UserData) {
|
||||||
|
form.username = user.username ?? ''
|
||||||
|
form.firstName = user.firstName ?? ''
|
||||||
|
form.lastName = user.lastName ?? ''
|
||||||
|
form.password = ''
|
||||||
|
form.roles = [...user.roles]
|
||||||
|
form.isEmployee = user.isEmployee ?? false
|
||||||
|
form.client = user.client ?? null
|
||||||
|
form.allowedProjects = toProjectIris(user.allowedProjects)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.modelValue, async (open) => {
|
||||||
|
if (!open) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
touched.username = false
|
||||||
|
touched.password = false
|
||||||
|
await loadOptions()
|
||||||
|
|
||||||
|
if (props.item) {
|
||||||
|
// The list payload (user:list) omits client / allowedProjects → fetch the full item.
|
||||||
|
applyUser(props.item)
|
||||||
|
try {
|
||||||
|
const full = await getById(props.item.id)
|
||||||
|
applyUser(full)
|
||||||
|
} catch {
|
||||||
|
// Keep the list data if the detailed fetch fails.
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
form.username = ''
|
||||||
|
form.firstName = ''
|
||||||
|
form.lastName = ''
|
||||||
|
form.password = ''
|
||||||
|
form.roles = ['ROLE_USER']
|
||||||
|
form.isEmployee = false
|
||||||
|
form.client = null
|
||||||
|
form.allowedProjects = []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const { create, update } = useUserService()
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
touched.username = true
|
touched.username = true
|
||||||
touched.password = true
|
touched.password = true
|
||||||
@@ -149,6 +227,15 @@ async function handleSubmit() {
|
|||||||
payload.plainPassword = form.password
|
payload.plainPassword = form.password
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Client portal fields: only relevant when the user is a client.
|
||||||
|
if (isClient.value) {
|
||||||
|
payload.client = form.client
|
||||||
|
payload.allowedProjects = form.allowedProjects
|
||||||
|
} else {
|
||||||
|
payload.client = null
|
||||||
|
payload.allowedProjects = []
|
||||||
|
}
|
||||||
|
|
||||||
if (isEditing.value && props.item) {
|
if (isEditing.value && props.item) {
|
||||||
await update(props.item.id, payload)
|
await update(props.item.id, payload)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -193,7 +193,10 @@
|
|||||||
"updated": "Utilisateur mis à jour avec succès.",
|
"updated": "Utilisateur mis à jour avec succès.",
|
||||||
"deleted": "Utilisateur supprimé avec succès.",
|
"deleted": "Utilisateur supprimé avec succès.",
|
||||||
"addUser": "Ajouter un utilisateur",
|
"addUser": "Ajouter un utilisateur",
|
||||||
"editUser": "Modifier un utilisateur"
|
"editUser": "Modifier un utilisateur",
|
||||||
|
"clientAccount": "Compte client (portail)",
|
||||||
|
"client": "Client",
|
||||||
|
"allowedProjects": "Projets autorisés"
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"roles": {
|
"roles": {
|
||||||
@@ -354,7 +357,8 @@
|
|||||||
"myTasks": "Mes tâches",
|
"myTasks": "Mes tâches",
|
||||||
"projects": "Projets",
|
"projects": "Projets",
|
||||||
"timeTracking": "Suivi de temps",
|
"timeTracking": "Suivi de temps",
|
||||||
"mail": "Messagerie"
|
"mail": "Messagerie",
|
||||||
|
"portal": "Portail client"
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"section": "Administration",
|
"section": "Administration",
|
||||||
@@ -423,7 +427,9 @@
|
|||||||
"thisWeek": "Cette semaine",
|
"thisWeek": "Cette semaine",
|
||||||
"clear": "Effacer",
|
"clear": "Effacer",
|
||||||
"day": "Jour",
|
"day": "Jour",
|
||||||
"weekShort": "Sem."
|
"weekShort": "Sem.",
|
||||||
|
"submit": "Soumettre",
|
||||||
|
"close": "Fermer"
|
||||||
},
|
},
|
||||||
"gitea": {
|
"gitea": {
|
||||||
"settings": {
|
"settings": {
|
||||||
@@ -943,5 +949,49 @@
|
|||||||
"empty": "Aucun prospect trouvé.",
|
"empty": "Aucun prospect trouvé.",
|
||||||
"allStatuses": "Tous les statuts"
|
"allStatuses": "Tous les statuts"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"portal": {
|
||||||
|
"title": "Portail client",
|
||||||
|
"projects": "Mes projets",
|
||||||
|
"openTickets": "tickets ouverts",
|
||||||
|
"newTicket": "Nouveau ticket",
|
||||||
|
"ticketDetail": "Détail du ticket",
|
||||||
|
"noProjects": "Aucun projet accessible.",
|
||||||
|
"noTickets": "Aucun ticket pour le moment."
|
||||||
|
},
|
||||||
|
"clientTicket": {
|
||||||
|
"typeLabel": "Type",
|
||||||
|
"type": {
|
||||||
|
"bug": "Bug",
|
||||||
|
"improvement": "Amélioration",
|
||||||
|
"other": "Autre"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"label": "Statut",
|
||||||
|
"new": "Nouveau",
|
||||||
|
"in_progress": "En cours",
|
||||||
|
"done": "Terminé",
|
||||||
|
"rejected": "Rejeté"
|
||||||
|
},
|
||||||
|
"title": "Titre",
|
||||||
|
"titleRequired": "Le titre est requis",
|
||||||
|
"description": "Description",
|
||||||
|
"descriptionRequired": "La description est requise",
|
||||||
|
"url": "URL (page concernée)",
|
||||||
|
"statusComment": "Commentaire de statut",
|
||||||
|
"project": "Projet",
|
||||||
|
"submittedBy": "Soumis par",
|
||||||
|
"date": "Date",
|
||||||
|
"created": "Ticket créé",
|
||||||
|
"statusChanged": "Statut mis à jour",
|
||||||
|
"confirmDelete": "Supprimer ce ticket ?",
|
||||||
|
"deleted": "Ticket supprimé",
|
||||||
|
"linkedTooltip": "Lié au ticket client {number}",
|
||||||
|
"rejectionRequired": "Un commentaire est requis pour rejeter un ticket",
|
||||||
|
"changeStatus": "Changer le statut",
|
||||||
|
"adminTab": "Tickets client",
|
||||||
|
"adminTitle": "Tickets client",
|
||||||
|
"filterProject": "Projet",
|
||||||
|
"filterStatus": "Statut"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -20,6 +20,12 @@
|
|||||||
name="mdi:flag-variant"
|
name="mdi:flag-variant"
|
||||||
class="h-3.5 w-3.5 text-red-600"
|
class="h-3.5 w-3.5 text-red-600"
|
||||||
/>
|
/>
|
||||||
|
<Icon
|
||||||
|
v-if="task.clientTicket"
|
||||||
|
name="heroicons:user-circle"
|
||||||
|
class="h-3.5 w-3.5 text-primary-500"
|
||||||
|
:title="$t('clientTicket.linkedTooltip', { number: formatTicketNumber(task.clientTicket.number) })"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h4 class="line-clamp-2 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
<h4 class="line-clamp-2 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,6 +109,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||||
|
import { formatTicketNumber } from '~/modules/client-portal/utils/ticket'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
task: Task
|
task: Task
|
||||||
|
|||||||
@@ -50,13 +50,14 @@ import { useTaskDocumentService } from '~/modules/project-management/services/ta
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
taskId?: number
|
taskId?: number
|
||||||
|
clientTicketId?: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
uploaded: []
|
uploaded: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { upload: uploadFile } = useTaskDocumentService()
|
const { upload: uploadFile, uploadForClientTicket } = useTaskDocumentService()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -111,6 +112,8 @@ async function processFiles(files: File[]) {
|
|||||||
try {
|
try {
|
||||||
if (props.taskId) {
|
if (props.taskId) {
|
||||||
await uploadFile(props.taskId, file)
|
await uploadFile(props.taskId, file)
|
||||||
|
} else if (props.clientTicketId) {
|
||||||
|
await uploadForClientTicket(props.clientTicketId, file)
|
||||||
}
|
}
|
||||||
state.uploading = false
|
state.uploading = false
|
||||||
state.progress = 100
|
state.progress = 100
|
||||||
|
|||||||
@@ -28,6 +28,12 @@
|
|||||||
name="mdi:flag-variant"
|
name="mdi:flag-variant"
|
||||||
class="h-3.5 w-3.5 text-red-600"
|
class="h-3.5 w-3.5 text-red-600"
|
||||||
/>
|
/>
|
||||||
|
<Icon
|
||||||
|
v-if="task.clientTicket"
|
||||||
|
name="heroicons:user-circle"
|
||||||
|
class="h-3.5 w-3.5 text-primary-500"
|
||||||
|
:title="$t('clientTicket.linkedTooltip', { number: formatTicketNumber(task.clientTicket.number) })"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- Row 2: title -->
|
<!-- Row 2: title -->
|
||||||
<h4 class="mt-1 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
<h4 class="mt-1 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||||
@@ -111,6 +117,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||||
|
import { formatTicketNumber } from '~/modules/client-portal/utils/ticket'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
task: Task
|
task: Task
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ export type Task = {
|
|||||||
deadline: string | null
|
deadline: string | null
|
||||||
syncToCalendar: boolean
|
syncToCalendar: boolean
|
||||||
calendarSyncError: string | null
|
calendarSyncError: string | null
|
||||||
|
clientTicket: {
|
||||||
|
id: number
|
||||||
|
'@id'?: string
|
||||||
|
number: number
|
||||||
|
type: 'bug' | 'improvement' | 'other'
|
||||||
|
status: 'new' | 'in_progress' | 'done' | 'rejected'
|
||||||
|
title: string
|
||||||
|
} | null
|
||||||
recurrence: {
|
recurrence: {
|
||||||
id: number
|
id: number
|
||||||
'@id'?: string
|
'@id'?: string
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ export function useTaskDocumentService() {
|
|||||||
return extractHydraMembers(data)
|
return extractHydraMembers(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getByClientTicket(clientTicketId: number): Promise<TaskDocument[]> {
|
||||||
|
const data = await api.get<HydraCollection<TaskDocument>>('/task_documents', {
|
||||||
|
clientTicket: `/api/client_tickets/${clientTicketId}`,
|
||||||
|
})
|
||||||
|
return extractHydraMembers(data)
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadWithRelation(relationField: string, relationIri: string, file: File): Promise<TaskDocument> {
|
async function uploadWithRelation(relationField: string, relationIri: string, file: File): Promise<TaskDocument> {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
@@ -31,6 +38,10 @@ export function useTaskDocumentService() {
|
|||||||
return uploadWithRelation('task', `/api/tasks/${taskId}`, file)
|
return uploadWithRelation('task', `/api/tasks/${taskId}`, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadForClientTicket(clientTicketId: number, file: File): Promise<TaskDocument> {
|
||||||
|
return uploadWithRelation('clientTicket', `/api/client_tickets/${clientTicketId}`, file)
|
||||||
|
}
|
||||||
|
|
||||||
async function linkShare(taskId: number, sharePath: string): Promise<TaskDocument> {
|
async function linkShare(taskId: number, sharePath: string): Promise<TaskDocument> {
|
||||||
return $fetch<TaskDocument>(`${baseURL}/task_documents`, {
|
return $fetch<TaskDocument>(`${baseURL}/task_documents`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -51,11 +62,12 @@ export function useTaskDocumentService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getContent(id: number): Promise<string> {
|
async function getContent(id: number): Promise<string> {
|
||||||
return $fetch<string>(`${baseURL}/task_documents/${id}/download`, {
|
const response = await fetch(`${baseURL}/task_documents/${id}/download`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
responseType: 'text',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return response.text()
|
||||||
}
|
}
|
||||||
|
|
||||||
return { getByTask, upload, linkShare, remove, getDownloadUrl, getContent }
|
return { getByTask, getByClientTicket, upload, uploadForClientTicket, linkShare, remove, getDownloadUrl, getContent }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
<AdminPriorityTab v-if="activeTab === 'priorities'" />
|
<AdminPriorityTab v-if="activeTab === 'priorities'" />
|
||||||
<AdminTagTab v-if="activeTab === 'tags'" />
|
<AdminTagTab v-if="activeTab === 'tags'" />
|
||||||
<AdminUserTab v-if="activeTab === 'users'" />
|
<AdminUserTab v-if="activeTab === 'users'" />
|
||||||
|
<AdminClientTicketTab v-if="activeTab === 'clientTickets'" />
|
||||||
<AdminRoleTab v-if="activeTab === 'roles' && canViewRoles" />
|
<AdminRoleTab v-if="activeTab === 'roles' && canViewRoles" />
|
||||||
<AdminAuditTab v-if="activeTab === 'audit' && canViewAudit" />
|
<AdminAuditTab v-if="activeTab === 'audit' && canViewAudit" />
|
||||||
<AdminGiteaTab v-if="activeTab === 'gitea'" />
|
<AdminGiteaTab v-if="activeTab === 'gitea'" />
|
||||||
@@ -56,6 +57,7 @@ const tabs = [
|
|||||||
{ key: 'priorities', label: 'Priorités' },
|
{ key: 'priorities', label: 'Priorités' },
|
||||||
{ key: 'tags', label: 'Tags' },
|
{ key: 'tags', label: 'Tags' },
|
||||||
{ key: 'users', label: 'Utilisateurs' },
|
{ key: 'users', label: 'Utilisateurs' },
|
||||||
|
{ key: 'clientTickets', label: t('clientTicket.adminTab') },
|
||||||
{ key: 'roles', label: t('admin.roles.title'), permission: 'core.roles.view' },
|
{ key: 'roles', label: t('admin.roles.title'), permission: 'core.roles.view' },
|
||||||
{ key: 'audit', label: t('admin.audit.title'), permission: 'core.audit_log.view' },
|
{ key: 'audit', label: t('admin.audit.title'), permission: 'core.audit_log.view' },
|
||||||
{ key: 'gitea', label: 'Gitea' },
|
{ key: 'gitea', label: 'Gitea' },
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
export type ContractType = 'CDI' | 'CDD' | 'STAGE' | 'ALTERNANCE' | 'AUTRE'
|
export type ContractType = 'CDI' | 'CDD' | 'STAGE' | 'ALTERNANCE' | 'AUTRE'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project as embedded in the `me:read` serialization group (id + name).
|
||||||
|
* On `/me`, `allowedProjects` is returned as embedded objects, not bare IRIs.
|
||||||
|
*/
|
||||||
|
export type AllowedProject = {
|
||||||
|
'@id'?: string
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
export type UserData = {
|
export type UserData = {
|
||||||
id: number
|
id: number
|
||||||
'@id'?: string
|
'@id'?: string
|
||||||
@@ -10,6 +20,9 @@ export type UserData = {
|
|||||||
effectivePermissions?: string[]
|
effectivePermissions?: string[]
|
||||||
avatarUrl?: string | null
|
avatarUrl?: string | null
|
||||||
apiToken?: string | null
|
apiToken?: string | null
|
||||||
|
// Client portal
|
||||||
|
client?: string | null // IRI of the linked Client (null = internal user)
|
||||||
|
allowedProjects?: AllowedProject[] // Projects a client user can access (embedded id + name)
|
||||||
// HR / absence management
|
// HR / absence management
|
||||||
isEmployee?: boolean
|
isEmployee?: boolean
|
||||||
hireDate?: string | null
|
hireDate?: string | null
|
||||||
@@ -27,6 +40,9 @@ export type UserWrite = {
|
|||||||
lastName?: string | null
|
lastName?: string | null
|
||||||
plainPassword?: string
|
plainPassword?: string
|
||||||
roles: string[]
|
roles: string[]
|
||||||
|
// Client portal
|
||||||
|
client?: string | null
|
||||||
|
allowedProjects?: string[]
|
||||||
// HR / absence management
|
// HR / absence management
|
||||||
isEmployee?: boolean
|
isEmployee?: boolean
|
||||||
hireDate?: string | null
|
hireDate?: string | null
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export function useUserService() {
|
|||||||
return extractHydraMembers(data)
|
return extractHydraMembers(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getById(id: number): Promise<UserData> {
|
||||||
|
return api.get<UserData>(`/users/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
async function create(payload: UserWrite): Promise<UserData> {
|
async function create(payload: UserWrite): Promise<UserData> {
|
||||||
return api.post<UserData>('/users', payload as Record<string, unknown>, {
|
return api.post<UserData>('/users', payload as Record<string, unknown>, {
|
||||||
toastSuccessKey: 'users.created',
|
toastSuccessKey: 'users.created',
|
||||||
@@ -28,5 +32,5 @@ export function useUserService() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return { getAll, create, update, remove }
|
return { getAll, getById, create, update, remove }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user