1961 lines
69 KiB
Markdown
1961 lines
69 KiB
Markdown
# Client Portal Phase 2 — Portal & UI
|
|
|
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Build the client-facing portal pages (project list, ticket list, ticket creation with document upload), add client ticket indicators on internal kanban/my-tasks views, and create the admin "Tickets client" tab for managing all tickets.
|
|
|
|
**Architecture:** Portal pages live under `/portal/` and use the existing default layout with a simplified sidebar for ROLE_CLIENT users. Auth middleware is extended to redirect ROLE_CLIENT to `/portal` and block internal pages. Client ticket data on internal task views flows through the `task:read` serialization group (no extra API call). Admin tab follows the existing tab pattern in `admin.vue`.
|
|
|
|
**Tech Stack:** PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16, Nuxt 4, Vue 3, TypeScript, Tailwind CSS
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-03-15-client-portal-design.md`
|
|
|
|
**Depends on:** Phase 1 (`docs/superpowers/plans/2026-03-15-client-portal-phase1.md`)
|
|
|
|
---
|
|
|
|
## Chunk 1: Auth Middleware & Portal Layout
|
|
|
|
### Task 1: Update auth middleware for ROLE_CLIENT routing
|
|
|
|
- [ ] **Modify `frontend/middleware/auth.global.ts`** — Add ROLE_CLIENT redirect logic. After the existing login redirect (line 14), add portal routing. Replace the full file with:
|
|
|
|
```typescript
|
|
export default defineNuxtRouteMiddleware(async (to) => {
|
|
const auth = useAuthStore()
|
|
const isLogin = to.path === '/login'
|
|
|
|
if (!auth.checked) {
|
|
await auth.ensureSession()
|
|
}
|
|
|
|
if (!isLogin && !auth.isAuthenticated) {
|
|
return navigateTo('/login')
|
|
}
|
|
|
|
if (isLogin && auth.isAuthenticated) {
|
|
const isClient = auth.user?.roles?.includes('ROLE_CLIENT') ?? false
|
|
return navigateTo(isClient ? '/portal' : '/')
|
|
}
|
|
|
|
// ROLE_CLIENT: redirect to /portal, block internal pages
|
|
if (auth.isAuthenticated && auth.user?.roles?.includes('ROLE_CLIENT')) {
|
|
const isPortalRoute = to.path.startsWith('/portal')
|
|
const isLoginRoute = to.path === '/login'
|
|
if (!isPortalRoute && !isLoginRoute) {
|
|
return navigateTo('/portal')
|
|
}
|
|
}
|
|
})
|
|
```
|
|
|
|
- [ ] **Commit:**
|
|
```bash
|
|
git add frontend/middleware/auth.global.ts
|
|
git commit -m "feat(auth) : redirect ROLE_CLIENT to /portal and block internal pages"
|
|
```
|
|
|
|
### Task 2: Create portal layout
|
|
|
|
- [ ] **Create `frontend/layouts/portal.vue`** — Simplified layout for client users with minimal sidebar (logo, portal link, logout):
|
|
|
|
```vue
|
|
<template>
|
|
<div class="h-screen overflow-hidden">
|
|
<div class="flex h-full">
|
|
<!-- Mobile sidebar overlay -->
|
|
<Transition name="sidebar-overlay">
|
|
<div
|
|
v-if="ui.sidebarOpen"
|
|
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
|
@click="ui.closeMobileSidebar()"
|
|
/>
|
|
</Transition>
|
|
|
|
<aside
|
|
class="fixed inset-y-0 left-0 z-50 flex h-full w-64 flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500 transition-transform duration-300 lg:static lg:z-auto lg:translate-x-0"
|
|
:class="ui.sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<img src="/malio.png" alt="Logo" class="w-auto" />
|
|
<button
|
|
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
|
|
@click="ui.closeMobileSidebar()"
|
|
>
|
|
<Icon name="mdi:close" size="20" />
|
|
</button>
|
|
</div>
|
|
<nav class="flex-1 px-4 pb-6">
|
|
<SidebarLink
|
|
to="/portal"
|
|
icon="mdi:folder-outline"
|
|
label="Mes projets"
|
|
:collapsed="false"
|
|
class="border-t border-secondary-500 pt-6"
|
|
@click="ui.closeMobileSidebar()"
|
|
/>
|
|
</nav>
|
|
|
|
<div class="flex flex-col gap-2 items-center p-4">
|
|
<p class="font-bold">v {{ version }}</p>
|
|
</div>
|
|
</aside>
|
|
|
|
<div class="h-full flex-1 flex flex-col min-h-0">
|
|
<AppTopNav :user="auth.user" />
|
|
<main class="flex flex-1 flex-col overflow-y-auto bg-white px-4 pb-24 sm:px-8 lg:px-16">
|
|
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
|
|
<slot />
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { useAppVersion } from '~/composables/useAppVersion'
|
|
|
|
const auth = useAuthStore()
|
|
const ui = useUiStore()
|
|
const route = useRoute()
|
|
const { version } = useAppVersion()
|
|
|
|
// Close mobile sidebar on route change
|
|
watch(() => route.path, () => {
|
|
ui.closeMobileSidebar()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.sidebar-overlay-enter-active,
|
|
.sidebar-overlay-leave-active {
|
|
transition: opacity 0.3s ease;
|
|
}
|
|
.sidebar-overlay-enter-from,
|
|
.sidebar-overlay-leave-to {
|
|
opacity: 0;
|
|
}
|
|
</style>
|
|
```
|
|
|
|
- [ ] **Commit:**
|
|
```bash
|
|
git add frontend/layouts/portal.vue
|
|
git commit -m "feat(portal) : add portal layout with simplified sidebar for client users"
|
|
```
|
|
|
|
### Task 3: Add i18n keys for portal and client tickets
|
|
|
|
- [ ] **Modify `frontend/i18n/locales/fr.json`** — Add portal and clientTicket sections. After the `"bookstack"` block (before the closing `}`), add:
|
|
|
|
```json
|
|
"portal": {
|
|
"title": "Portail client",
|
|
"projects": "Mes projets",
|
|
"openTickets": "tickets ouverts",
|
|
"noProjects": "Aucun projet disponible.",
|
|
"newTicket": "Nouveau ticket",
|
|
"ticketDetail": "Détail du ticket",
|
|
"backToProject": "Retour au projet",
|
|
"submitTicket": "Soumettre le ticket",
|
|
"ticketCreated": "Ticket soumis avec succès."
|
|
},
|
|
"clientTicket": {
|
|
"type": {
|
|
"bug": "Bug",
|
|
"improvement": "Amélioration",
|
|
"other": "Autre"
|
|
},
|
|
"status": {
|
|
"new": "Nouveau",
|
|
"in_progress": "En cours",
|
|
"done": "Terminé",
|
|
"rejected": "Rejeté"
|
|
},
|
|
"title": "Titre",
|
|
"description": "Description",
|
|
"url": "URL (page concernée)",
|
|
"statusComment": "Commentaire de statut",
|
|
"created": "Ticket créé",
|
|
"statusChanged": "Statut mis à jour",
|
|
"confirmDelete": "Supprimer ce ticket ?",
|
|
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.",
|
|
"linkedTooltip": "Lié au ticket client {number}",
|
|
"rejectionRequired": "Un commentaire est requis pour rejeter un ticket",
|
|
"noTickets": "Aucun ticket.",
|
|
"allStatuses": "Tous les statuts",
|
|
"allProjects": "Tous les projets",
|
|
"submittedBy": "Soumis par",
|
|
"createdAt": "Créé le",
|
|
"deleted": "Ticket supprimé avec succès.",
|
|
"statusUpdated": "Statut mis à jour avec succès.",
|
|
"adminTab": "Tickets client",
|
|
"selectType": "Type de ticket",
|
|
"changeStatus": "Changer le statut"
|
|
}
|
|
```
|
|
|
|
- [ ] **Commit:**
|
|
```bash
|
|
git add frontend/i18n/locales/fr.json
|
|
git commit -m "feat(i18n) : add portal and client ticket translation keys"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 2: DTOs & Services
|
|
|
|
### Task 4: Create ClientTicket DTO
|
|
|
|
- [ ] **Create `frontend/services/dto/client-ticket.ts`** — TypeScript types for client tickets:
|
|
|
|
```typescript
|
|
import type { TaskDocument } from './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
|
|
submittedBy: string | null
|
|
createdAt: string
|
|
updatedAt: string
|
|
documents?: TaskDocument[]
|
|
}
|
|
|
|
export type ClientTicketWrite = {
|
|
type: ClientTicketType
|
|
title: string
|
|
description: string
|
|
url?: string | null
|
|
project: string
|
|
}
|
|
|
|
export type ClientTicketStatusUpdate = {
|
|
status: ClientTicketStatus
|
|
statusComment?: string | null
|
|
}
|
|
```
|
|
|
|
- [ ] **Commit:**
|
|
```bash
|
|
git add frontend/services/dto/client-ticket.ts
|
|
git commit -m "feat(dto) : add ClientTicket TypeScript types"
|
|
```
|
|
|
|
### Task 5: Create client-tickets service
|
|
|
|
- [ ] **Create `frontend/services/client-tickets.ts`** — API service for client tickets following the existing service pattern (`useTaskService`):
|
|
|
|
```typescript
|
|
import type { ClientTicket, ClientTicketWrite, ClientTicketStatusUpdate } from './dto/client-ticket'
|
|
import type { HydraCollection } from '~/utils/api'
|
|
import { extractHydraMembers } from '~/utils/api'
|
|
|
|
export function useClientTicketService() {
|
|
const api = useApi()
|
|
|
|
async function getAll(params?: { project?: number; status?: string; submittedBy?: number }): Promise<ClientTicket[]> {
|
|
const query: Record<string, unknown> = {}
|
|
if (params?.project) query.project = `/api/projects/${params.project}`
|
|
if (params?.status) query.status = params.status
|
|
if (params?.submittedBy) query.submittedBy = `/api/users/${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: ClientTicketWrite): Promise<ClientTicket> {
|
|
return api.post<ClientTicket>('/client_tickets', payload as Record<string, unknown>, {
|
|
toastSuccessKey: 'portal.ticketCreated',
|
|
})
|
|
}
|
|
|
|
async function updateStatus(id: number, payload: ClientTicketStatusUpdate): Promise<ClientTicket> {
|
|
return api.patch<ClientTicket>(`/client_tickets/${id}`, payload as Record<string, unknown>, {
|
|
toastSuccessKey: 'clientTicket.statusUpdated',
|
|
})
|
|
}
|
|
|
|
async function remove(id: number): Promise<void> {
|
|
await api.delete(`/client_tickets/${id}`, {}, {
|
|
toastSuccessKey: 'clientTicket.deleted',
|
|
})
|
|
}
|
|
|
|
return { getAll, getById, create, updateStatus, remove }
|
|
}
|
|
```
|
|
|
|
- [ ] **Commit:**
|
|
```bash
|
|
git add frontend/services/client-tickets.ts
|
|
git commit -m "feat(service) : add client-tickets API service"
|
|
```
|
|
|
|
### Task 6: Extend Task DTO with clientTicket field
|
|
|
|
- [ ] **Modify `frontend/services/dto/task.ts`** — Add `clientTicket` field to the `Task` type. After the `documents: TaskDocument[]` line (line 23), add:
|
|
|
|
```typescript
|
|
clientTicket: {
|
|
id: number
|
|
number: number
|
|
type: string
|
|
status: string
|
|
title: string
|
|
} | null
|
|
```
|
|
|
|
The full `Task` type should now include `clientTicket` after `documents`:
|
|
|
|
```typescript
|
|
import type { TaskStatus } from './task-status'
|
|
import type { TaskEffort } from './task-effort'
|
|
import type { TaskPriority } from './task-priority'
|
|
import type { TaskTag } from './task-tag'
|
|
import type { TaskGroup } from './task-group'
|
|
import type { UserData } from './user-data'
|
|
import type { Project } from './project'
|
|
import type { TaskDocument } from './task-document'
|
|
|
|
export type Task = {
|
|
id: number
|
|
'@id'?: string
|
|
number: number
|
|
title: string
|
|
description: string | null
|
|
status: TaskStatus | null
|
|
effort: TaskEffort | null
|
|
priority: TaskPriority | null
|
|
assignee: UserData | null
|
|
group: TaskGroup | null
|
|
project: Project | null
|
|
tags: TaskTag[]
|
|
documents: TaskDocument[]
|
|
archived: boolean
|
|
clientTicket: {
|
|
id: number
|
|
number: number
|
|
type: string
|
|
status: string
|
|
title: string
|
|
} | null
|
|
}
|
|
|
|
export type TaskWrite = {
|
|
title: string
|
|
description: string | null
|
|
status: string | null
|
|
effort: string | null
|
|
priority: string | null
|
|
assignee: string | null
|
|
group: string | null
|
|
project: string
|
|
tags: string[]
|
|
archived?: boolean
|
|
}
|
|
```
|
|
|
|
- [ ] **Commit:**
|
|
```bash
|
|
git add frontend/services/dto/task.ts
|
|
git commit -m "feat(dto) : add clientTicket field to Task type"
|
|
```
|
|
|
|
### Task 7: Update UserData DTO for allowedProjects
|
|
|
|
- [ ] **Modify `frontend/services/dto/user-data.ts`** — Add `client` and `allowedProjects` fields for client users. This must happen before portal pages are built because `auth.user.allowedProjects` needs proper typing. Replace the full file with:
|
|
|
|
```typescript
|
|
import type { Project } from './project'
|
|
|
|
export type UserData = {
|
|
id: number
|
|
'@id'?: string
|
|
username: string
|
|
roles: string[]
|
|
client?: { id: number; name: string } | null
|
|
allowedProjects?: Project[]
|
|
}
|
|
|
|
export type UserWrite = {
|
|
username: string
|
|
password?: string
|
|
roles: string[]
|
|
client?: string | null
|
|
allowedProjects?: string[]
|
|
}
|
|
```
|
|
|
|
- [ ] **Commit:**
|
|
```bash
|
|
git add frontend/services/dto/user-data.ts
|
|
git commit -m "feat(dto) : add client and allowedProjects fields to UserData type"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 3: Portal Pages
|
|
|
|
### Task 8: Create portal project list page
|
|
|
|
- [ ] **Create `frontend/pages/portal/index.vue`** — List of client's allowed projects with open ticket count. Uses the `portal` layout. **Note:** For admin users (ROLE_ADMIN), the page loads all projects via the projects service as a fallback, since admins have no `allowedProjects`:
|
|
|
|
```vue
|
|
<template>
|
|
<div>
|
|
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
|
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('portal.projects') }}</h1>
|
|
</div>
|
|
|
|
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
|
|
{{ $t('common.loading') }}
|
|
</div>
|
|
|
|
<div v-else-if="projects.length === 0" class="py-8 text-center text-sm text-neutral-400">
|
|
{{ $t('portal.noProjects') }}
|
|
</div>
|
|
|
|
<div v-else class="mt-4 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="rounded-lg border border-neutral-200 bg-white p-5 shadow-sm transition hover:shadow-md"
|
|
>
|
|
<h3 class="text-lg font-bold text-neutral-900">{{ project.name }}</h3>
|
|
<p class="mt-2 text-sm text-neutral-500">
|
|
{{ ticketCountByProject[project.id] ?? 0 }} {{ $t('portal.openTickets') }}
|
|
</p>
|
|
</NuxtLink>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { Project } from '~/services/dto/project'
|
|
import type { ClientTicket } from '~/services/dto/client-ticket'
|
|
import { useClientTicketService } from '~/services/client-tickets'
|
|
import { useProjectService } from '~/services/projects'
|
|
|
|
definePageMeta({
|
|
layout: 'portal',
|
|
})
|
|
|
|
const { t } = useI18n()
|
|
useHead({ title: t('portal.title') })
|
|
|
|
const auth = useAuthStore()
|
|
const clientTicketService = useClientTicketService()
|
|
const projectService = useProjectService()
|
|
|
|
const projects = ref<Project[]>([])
|
|
const tickets = ref<ClientTicket[]>([])
|
|
const isLoading = ref(true)
|
|
|
|
const ticketCountByProject = computed(() => {
|
|
const counts: Record<number, number> = {}
|
|
for (const ticket of tickets.value) {
|
|
if (ticket.status === 'new' || ticket.status === 'in_progress') {
|
|
// Extract project ID from IRI
|
|
const match = ticket.project.match(/\/api\/projects\/(\d+)/)
|
|
if (match) {
|
|
const projectId = Number(match[1])
|
|
counts[projectId] = (counts[projectId] ?? 0) + 1
|
|
}
|
|
}
|
|
}
|
|
return counts
|
|
})
|
|
|
|
async function loadData() {
|
|
isLoading.value = true
|
|
try {
|
|
if (auth.user?.roles?.includes('ROLE_ADMIN')) {
|
|
// Admin sees all projects
|
|
const allProjects = await projectService.getAll({ archived: false })
|
|
projects.value = allProjects
|
|
} else {
|
|
// Client sees allowed projects
|
|
projects.value = auth.user?.allowedProjects ?? []
|
|
}
|
|
tickets.value = await clientTicketService.getAll()
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadData()
|
|
})
|
|
</script>
|
|
```
|
|
|
|
- [ ] **Commit:**
|
|
```bash
|
|
git add frontend/pages/portal/index.vue
|
|
git commit -m "feat(portal) : add portal project list page"
|
|
```
|
|
|
|
### Task 9: Create portal ticket list page
|
|
|
|
- [ ] **Create `frontend/pages/portal/projects/[id]/index.vue`** — List of tickets for a project with status badges and ticket detail modal:
|
|
|
|
```vue
|
|
<template>
|
|
<div>
|
|
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div class="min-w-0">
|
|
<NuxtLink
|
|
to="/portal"
|
|
class="text-sm text-neutral-400 hover:text-primary-500"
|
|
>
|
|
{{ $t('portal.backToProject') }}
|
|
</NuxtLink>
|
|
<h1 class="mt-1 text-xl font-bold text-primary-500 sm:text-2xl">{{ projectName }}</h1>
|
|
</div>
|
|
<NuxtLink
|
|
:to="`/portal/projects/${projectId}/new-ticket`"
|
|
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm"
|
|
>
|
|
<span class="hidden sm:inline">+ {{ $t('portal.newTicket') }}</span>
|
|
<span class="sm:hidden">+ Ticket</span>
|
|
</NuxtLink>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
|
|
{{ $t('common.loading') }}
|
|
</div>
|
|
|
|
<div v-else-if="tickets.length === 0" class="py-8 text-center text-sm text-neutral-400">
|
|
{{ $t('clientTicket.noTickets') }}
|
|
</div>
|
|
|
|
<div v-else class="mt-4 space-y-3">
|
|
<div
|
|
v-for="ticket in tickets"
|
|
:key="ticket.id"
|
|
class="flex cursor-pointer items-center justify-between gap-3 rounded-lg border border-neutral-200 bg-white p-4 shadow-sm transition hover:shadow-md"
|
|
@click="openDetail(ticket)"
|
|
>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-sm font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</span>
|
|
<span
|
|
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
|
:class="typeBadgeClass(ticket.type)"
|
|
>
|
|
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
|
</span>
|
|
</div>
|
|
<h4 class="mt-1 text-sm font-semibold text-neutral-900">{{ ticket.title }}</h4>
|
|
<p class="mt-1 text-xs text-neutral-400">
|
|
{{ formatDate(ticket.createdAt) }}
|
|
</p>
|
|
</div>
|
|
<span
|
|
class="shrink-0 rounded-full px-3 py-1 text-xs font-semibold"
|
|
:class="statusBadgeClass(ticket.status)"
|
|
>
|
|
{{ $t(`clientTicket.status.${ticket.status}`) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Ticket detail modal -->
|
|
<ClientTicketDetailModal
|
|
v-model="detailOpen"
|
|
:ticket="selectedTicket"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { ClientTicket } from '~/services/dto/client-ticket'
|
|
import { useClientTicketService } from '~/services/client-tickets'
|
|
|
|
definePageMeta({
|
|
layout: 'portal',
|
|
})
|
|
|
|
const route = useRoute()
|
|
const { t } = useI18n()
|
|
const projectId = computed(() => Number(route.params.id))
|
|
|
|
useHead({ title: t('portal.title') })
|
|
|
|
const clientTicketService = useClientTicketService()
|
|
const auth = useAuthStore()
|
|
|
|
const tickets = ref<ClientTicket[]>([])
|
|
const isLoading = ref(true)
|
|
const detailOpen = ref(false)
|
|
const selectedTicket = ref<ClientTicket | null>(null)
|
|
|
|
const projectName = computed(() => {
|
|
const me = auth.user as any
|
|
if (me?.allowedProjects) {
|
|
const project = me.allowedProjects.find((p: any) => p.id === projectId.value)
|
|
return project?.name ?? ''
|
|
}
|
|
return ''
|
|
})
|
|
|
|
function typeBadgeClass(type: string): string {
|
|
switch (type) {
|
|
case 'bug': return 'bg-red-500'
|
|
case 'improvement': return 'bg-blue-500'
|
|
default: return 'bg-neutral-500'
|
|
}
|
|
}
|
|
|
|
function statusBadgeClass(status: string): string {
|
|
switch (status) {
|
|
case 'new': return 'bg-blue-100 text-blue-700'
|
|
case 'in_progress': return 'bg-yellow-100 text-yellow-700'
|
|
case 'done': return 'bg-green-100 text-green-700'
|
|
case 'rejected': return 'bg-red-100 text-red-700'
|
|
default: return 'bg-neutral-100 text-neutral-700'
|
|
}
|
|
}
|
|
|
|
function formatDate(iso: string): string {
|
|
return new Date(iso).toLocaleDateString('fr-FR', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
})
|
|
}
|
|
|
|
function openDetail(ticket: ClientTicket) {
|
|
selectedTicket.value = ticket
|
|
detailOpen.value = true
|
|
}
|
|
|
|
async function loadTickets() {
|
|
isLoading.value = true
|
|
try {
|
|
tickets.value = await clientTicketService.getAll({ project: projectId.value })
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadTickets()
|
|
})
|
|
</script>
|
|
```
|
|
|
|
- [ ] **Commit:**
|
|
```bash
|
|
git add frontend/pages/portal/projects/[id]/index.vue
|
|
git commit -m "feat(portal) : add ticket list page for a project"
|
|
```
|
|
|
|
### Task 10: Create ClientTicketDetailModal component
|
|
|
|
- [ ] **Create `frontend/components/client-ticket/ClientTicketDetailModal.vue`** — Read-only modal showing ticket details (title, description, url, status, statusComment, documents). Follows the `TaskModal` pattern for styling:
|
|
|
|
```vue
|
|
<template>
|
|
<Teleport v-if="isOpen" to="body">
|
|
<Transition name="ticket-modal" appear>
|
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<!-- Backdrop -->
|
|
<div
|
|
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
|
@click="close"
|
|
/>
|
|
|
|
<!-- Modal -->
|
|
<div
|
|
class="relative z-10 flex w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
|
|
style="max-height: min(90vh, 900px)"
|
|
>
|
|
<!-- Header -->
|
|
<div class="border-b border-neutral-100 bg-neutral-50/80 px-4 py-4 sm:px-8 sm:py-5">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-3">
|
|
<span
|
|
v-if="ticket"
|
|
class="rounded-md bg-primary-500 px-2.5 py-1 text-xs font-bold tracking-wide text-white"
|
|
>
|
|
CT-{{ String(ticket.number).padStart(3, '0') }}
|
|
</span>
|
|
<h2 class="text-lg font-bold tracking-tight text-neutral-900">
|
|
{{ $t('portal.ticketDetail') }}
|
|
</h2>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-200/60 hover:text-neutral-600"
|
|
@click="close"
|
|
>
|
|
<Icon name="mdi:close" size="20" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Body -->
|
|
<div v-if="ticket" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
|
|
<!-- Title -->
|
|
<h3 class="text-base font-bold text-neutral-900">{{ ticket.title }}</h3>
|
|
|
|
<!-- Badges -->
|
|
<div class="mt-3 flex items-center gap-2">
|
|
<span
|
|
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
|
:class="typeBadgeClass(ticket.type)"
|
|
>
|
|
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
|
</span>
|
|
<span
|
|
class="rounded-full px-3 py-1 text-xs font-semibold"
|
|
:class="statusBadgeClass(ticket.status)"
|
|
>
|
|
{{ $t(`clientTicket.status.${ticket.status}`) }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Description -->
|
|
<div class="mt-4">
|
|
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p>
|
|
<p class="mt-1 whitespace-pre-wrap text-sm text-neutral-600">{{ ticket.description }}</p>
|
|
</div>
|
|
|
|
<!-- URL (if bug) -->
|
|
<div v-if="ticket.url" class="mt-4">
|
|
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.url') }}</p>
|
|
<a
|
|
:href="ticket.url"
|
|
target="_blank"
|
|
class="mt-1 text-sm text-primary-500 underline hover:text-primary-600"
|
|
>
|
|
{{ ticket.url }}
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Status comment -->
|
|
<div v-if="ticket.statusComment" class="mt-4">
|
|
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.statusComment') }}</p>
|
|
<p class="mt-1 whitespace-pre-wrap rounded-lg bg-neutral-50 p-3 text-sm text-neutral-600">{{ ticket.statusComment }}</p>
|
|
</div>
|
|
|
|
<!-- Documents -->
|
|
<TaskDocumentList
|
|
v-if="ticket.documents && ticket.documents.length"
|
|
:documents="ticket.documents"
|
|
:is-admin="false"
|
|
@preview="openPreview"
|
|
/>
|
|
|
|
<!-- Document preview -->
|
|
<TaskDocumentPreview
|
|
:document="previewDoc"
|
|
:has-prev="previewIndex > 0"
|
|
:has-next="previewIndex < (ticket.documents?.length ?? 0) - 1"
|
|
@close="previewDoc = null"
|
|
@prev="prevPreview"
|
|
@next="nextPreview"
|
|
/>
|
|
|
|
<!-- Date -->
|
|
<p class="mt-6 text-xs text-neutral-400">
|
|
{{ $t('clientTicket.createdAt') }} : {{ formatDate(ticket.createdAt) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { ClientTicket } from '~/services/dto/client-ticket'
|
|
import type { TaskDocument } from '~/services/dto/task-document'
|
|
|
|
const props = defineProps<{
|
|
modelValue: boolean
|
|
ticket: ClientTicket | null
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'update:modelValue', value: boolean): void
|
|
}>()
|
|
|
|
const isOpen = computed({
|
|
get: () => props.modelValue,
|
|
set: (v) => emit('update:modelValue', v),
|
|
})
|
|
|
|
function close() {
|
|
isOpen.value = false
|
|
}
|
|
|
|
function typeBadgeClass(type: string): string {
|
|
switch (type) {
|
|
case 'bug': return 'bg-red-500'
|
|
case 'improvement': return 'bg-blue-500'
|
|
default: return 'bg-neutral-500'
|
|
}
|
|
}
|
|
|
|
function statusBadgeClass(status: string): string {
|
|
switch (status) {
|
|
case 'new': return 'bg-blue-100 text-blue-700'
|
|
case 'in_progress': return 'bg-yellow-100 text-yellow-700'
|
|
case 'done': return 'bg-green-100 text-green-700'
|
|
case 'rejected': return 'bg-red-100 text-red-700'
|
|
default: return 'bg-neutral-100 text-neutral-700'
|
|
}
|
|
}
|
|
|
|
function formatDate(iso: string): string {
|
|
return new Date(iso).toLocaleDateString('fr-FR', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
})
|
|
}
|
|
|
|
// Document preview
|
|
const previewDoc = ref<TaskDocument | null>(null)
|
|
|
|
const previewIndex = computed(() => {
|
|
if (!previewDoc.value || !props.ticket?.documents) return -1
|
|
return props.ticket.documents.findIndex(d => d.id === previewDoc.value!.id)
|
|
})
|
|
|
|
function openPreview(doc: TaskDocument) {
|
|
previewDoc.value = doc
|
|
}
|
|
|
|
function prevPreview() {
|
|
if (previewIndex.value > 0 && props.ticket?.documents) {
|
|
previewDoc.value = props.ticket.documents[previewIndex.value - 1]
|
|
}
|
|
}
|
|
|
|
function nextPreview() {
|
|
if (props.ticket?.documents && previewIndex.value < props.ticket.documents.length - 1) {
|
|
previewDoc.value = props.ticket.documents[previewIndex.value + 1]
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.ticket-modal-enter-active,
|
|
.ticket-modal-leave-active {
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
|
|
.ticket-modal-enter-active > div:last-child,
|
|
.ticket-modal-leave-active > div:last-child {
|
|
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
|
}
|
|
|
|
.ticket-modal-enter-from,
|
|
.ticket-modal-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
.ticket-modal-enter-from > div:last-child {
|
|
transform: scale(0.95) translateY(8px);
|
|
opacity: 0;
|
|
}
|
|
|
|
.ticket-modal-leave-to > div:last-child {
|
|
transform: scale(0.97);
|
|
opacity: 0;
|
|
}
|
|
</style>
|
|
```
|
|
|
|
- [ ] **Commit:**
|
|
```bash
|
|
git add frontend/components/client-ticket/ClientTicketDetailModal.vue
|
|
git commit -m "feat(portal) : add client ticket detail modal component"
|
|
```
|
|
|
|
### Task 11: Create new ticket form page
|
|
|
|
- [ ] **Create `frontend/pages/portal/projects/[id]/new-ticket.vue`** — Ticket creation form with type select, title, description, url (if bug), and document upload:
|
|
|
|
```vue
|
|
<template>
|
|
<div>
|
|
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
|
<NuxtLink
|
|
:to="`/portal/projects/${projectId}`"
|
|
class="text-sm text-neutral-400 hover:text-primary-500"
|
|
>
|
|
{{ $t('portal.backToProject') }}
|
|
</NuxtLink>
|
|
<h1 class="mt-1 text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('portal.newTicket') }}</h1>
|
|
</div>
|
|
|
|
<form class="mt-4 max-w-2xl" @submit.prevent="handleSubmit">
|
|
<!-- Type -->
|
|
<div>
|
|
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('clientTicket.selectType') }}</label>
|
|
<select
|
|
v-model="form.type"
|
|
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
>
|
|
<option value="bug">{{ $t('clientTicket.type.bug') }}</option>
|
|
<option value="improvement">{{ $t('clientTicket.type.improvement') }}</option>
|
|
<option value="other">{{ $t('clientTicket.type.other') }}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Title -->
|
|
<div class="mt-4">
|
|
<MalioInputText
|
|
v-model="form.title"
|
|
:label="$t('clientTicket.title')"
|
|
input-class="w-full"
|
|
:error="touched.title && !form.title.trim() ? $t('clientTicket.title') + ' requis' : ''"
|
|
@blur="touched.title = true"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Description -->
|
|
<div class="mt-4">
|
|
<MalioInputTextArea
|
|
v-model="form.description"
|
|
:label="$t('clientTicket.description')"
|
|
:size="5"
|
|
/>
|
|
</div>
|
|
|
|
<!-- URL (only for bug type) -->
|
|
<div v-if="form.type === 'bug'" class="mt-4">
|
|
<MalioInputText
|
|
v-model="form.url"
|
|
:label="$t('clientTicket.url')"
|
|
input-class="w-full"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Document upload (only after ticket is created) -->
|
|
<div class="mt-4 rounded-lg border border-dashed border-neutral-300 p-4">
|
|
<p class="text-sm text-neutral-500">
|
|
<Icon name="heroicons:information-circle" class="mr-1 inline h-4 w-4" />
|
|
Les documents pourront être ajoutés après la soumission du ticket.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Submit -->
|
|
<div class="mt-6 flex items-center gap-3">
|
|
<NuxtLink
|
|
:to="`/portal/projects/${projectId}`"
|
|
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
|
>
|
|
{{ $t('common.cancel') }}
|
|
</NuxtLink>
|
|
<button
|
|
type="submit"
|
|
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
|
:disabled="isSubmitting"
|
|
>
|
|
{{ $t('portal.submitTicket') }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { ClientTicketType } from '~/services/dto/client-ticket'
|
|
import { useClientTicketService } from '~/services/client-tickets'
|
|
|
|
definePageMeta({
|
|
layout: 'portal',
|
|
})
|
|
|
|
const route = useRoute()
|
|
const { t } = useI18n()
|
|
const projectId = computed(() => Number(route.params.id))
|
|
|
|
useHead({ title: t('portal.newTicket') })
|
|
|
|
const clientTicketService = useClientTicketService()
|
|
|
|
const form = reactive({
|
|
type: 'bug' as ClientTicketType | string,
|
|
title: '',
|
|
description: '',
|
|
url: '',
|
|
})
|
|
|
|
const touched = reactive({
|
|
title: false,
|
|
})
|
|
|
|
const isSubmitting = ref(false)
|
|
|
|
async function handleSubmit() {
|
|
touched.title = true
|
|
if (!form.title.trim()) return
|
|
if (!form.description.trim()) return
|
|
|
|
isSubmitting.value = true
|
|
try {
|
|
await clientTicketService.create({
|
|
type: form.type as ClientTicketType,
|
|
title: form.title.trim(),
|
|
description: form.description.trim(),
|
|
url: form.type === 'bug' && form.url.trim() ? form.url.trim() : null,
|
|
project: `/api/projects/${projectId.value}`,
|
|
})
|
|
await navigateTo(`/portal/projects/${projectId.value}`)
|
|
} finally {
|
|
isSubmitting.value = false
|
|
}
|
|
}
|
|
</script>
|
|
```
|
|
|
|
- [ ] **Commit:**
|
|
```bash
|
|
git add frontend/pages/portal/projects/[id]/new-ticket.vue
|
|
git commit -m "feat(portal) : add new ticket creation form page"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 4: Document Upload on Tickets
|
|
|
|
### Task 12: Generalize TaskDocumentUpload with optional clientTicketId prop
|
|
|
|
- [ ] **Modify `frontend/components/task/TaskDocumentUpload.vue`** — Add an optional `clientTicketId` prop as an alternative to `taskId`. Replace the `<script setup>` section (lines 48-132) with:
|
|
|
|
Replace the props definition (lines 51-53):
|
|
```typescript
|
|
const props = defineProps<{
|
|
taskId: number
|
|
}>()
|
|
```
|
|
|
|
With:
|
|
```typescript
|
|
const props = defineProps<{
|
|
taskId?: number
|
|
clientTicketId?: number
|
|
}>()
|
|
```
|
|
|
|
Replace the `uploadFile` call in `processFiles` (line 112):
|
|
```typescript
|
|
await uploadFile(props.taskId, file)
|
|
```
|
|
|
|
With:
|
|
```typescript
|
|
if (props.clientTicketId) {
|
|
await uploadForTicket(props.clientTicketId, file)
|
|
} else if (props.taskId) {
|
|
await uploadFile(props.taskId, file)
|
|
}
|
|
```
|
|
|
|
Replace the service destructuring (line 59):
|
|
```typescript
|
|
const { upload: uploadFile } = useTaskDocumentService()
|
|
```
|
|
|
|
With:
|
|
```typescript
|
|
const { upload: uploadFile, uploadForTicket } = useTaskDocumentService()
|
|
```
|
|
|
|
- [ ] **Commit:**
|
|
```bash
|
|
git add frontend/components/task/TaskDocumentUpload.vue
|
|
git commit -m "feat(documents) : generalize TaskDocumentUpload to support client ticket uploads"
|
|
```
|
|
|
|
### Task 13: Add uploadForTicket and getByTicket methods to task-documents service
|
|
|
|
- [ ] **Modify `frontend/services/task-documents.ts`** — Add `uploadForTicket` and `getByTicket` methods. After the existing `upload` function (after line 28), add:
|
|
|
|
```typescript
|
|
async function uploadForTicket(clientTicketId: number, file: File): Promise<TaskDocument> {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
formData.append('clientTicket', `/api/client_tickets/${clientTicketId}`)
|
|
|
|
return await $fetch<TaskDocument>(`${baseURL}/task_documents`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
credentials: 'include',
|
|
})
|
|
}
|
|
|
|
async function getByTicket(clientTicketId: number): Promise<TaskDocument[]> {
|
|
const data = await api.get<HydraCollection<TaskDocument>>('/task_documents', {
|
|
clientTicket: `/api/client_tickets/${clientTicketId}`,
|
|
})
|
|
return extractHydraMembers(data)
|
|
}
|
|
```
|
|
|
|
Update the return statement (line 41) to include the new methods:
|
|
```typescript
|
|
return { getByTask, getByTicket, upload, uploadForTicket, remove, getDownloadUrl }
|
|
```
|
|
|
|
- [ ] **Commit:**
|
|
```bash
|
|
git add frontend/services/task-documents.ts
|
|
git commit -m "feat(documents) : add uploadForTicket and getByTicket methods to service"
|
|
```
|
|
|
|
### Task 14: Add document upload to portal ticket detail modal
|
|
|
|
- [ ] **Modify `frontend/components/client-ticket/ClientTicketDetailModal.vue`** — Add document upload zone and refresh capability inside the modal. After the `TaskDocumentList` block (before the date paragraph), add the upload zone:
|
|
|
|
After the existing `TaskDocumentList` block, add:
|
|
```vue
|
|
<!-- Upload zone -->
|
|
<TaskDocumentUpload
|
|
v-if="ticket"
|
|
:client-ticket-id="ticket.id"
|
|
@uploaded="refreshDocuments"
|
|
/>
|
|
```
|
|
|
|
Add document refresh logic and update template references. The full updated `<script setup>` block should be replaced with:
|
|
|
|
```typescript
|
|
<script setup lang="ts">
|
|
import type { ClientTicket } from '~/services/dto/client-ticket'
|
|
import type { TaskDocument } from '~/services/dto/task-document'
|
|
import { useTaskDocumentService } from '~/services/task-documents'
|
|
|
|
const props = defineProps<{
|
|
modelValue: boolean
|
|
ticket: ClientTicket | null
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'update:modelValue', value: boolean): void
|
|
}>()
|
|
|
|
const isOpen = computed({
|
|
get: () => props.modelValue,
|
|
set: (v) => emit('update:modelValue', v),
|
|
})
|
|
|
|
function close() {
|
|
isOpen.value = false
|
|
}
|
|
|
|
const { getByTicket } = useTaskDocumentService()
|
|
|
|
const localDocuments = ref<TaskDocument[]>([])
|
|
|
|
watch(() => props.ticket?.documents, (docs) => {
|
|
localDocuments.value = docs ? [...docs] : []
|
|
}, { immediate: true })
|
|
|
|
async function refreshDocuments() {
|
|
if (!props.ticket) return
|
|
localDocuments.value = await getByTicket(props.ticket.id)
|
|
}
|
|
|
|
function typeBadgeClass(type: string): string {
|
|
switch (type) {
|
|
case 'bug': return 'bg-red-500'
|
|
case 'improvement': return 'bg-blue-500'
|
|
default: return 'bg-neutral-500'
|
|
}
|
|
}
|
|
|
|
function statusBadgeClass(status: string): string {
|
|
switch (status) {
|
|
case 'new': return 'bg-blue-100 text-blue-700'
|
|
case 'in_progress': return 'bg-yellow-100 text-yellow-700'
|
|
case 'done': return 'bg-green-100 text-green-700'
|
|
case 'rejected': return 'bg-red-100 text-red-700'
|
|
default: return 'bg-neutral-100 text-neutral-700'
|
|
}
|
|
}
|
|
|
|
function formatDate(iso: string): string {
|
|
return new Date(iso).toLocaleDateString('fr-FR', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
})
|
|
}
|
|
|
|
// Document preview
|
|
const previewDoc = ref<TaskDocument | null>(null)
|
|
|
|
const previewIndex = computed(() => {
|
|
if (!previewDoc.value) return -1
|
|
return localDocuments.value.findIndex(d => d.id === previewDoc.value!.id)
|
|
})
|
|
|
|
function openPreview(doc: TaskDocument) {
|
|
previewDoc.value = doc
|
|
}
|
|
|
|
function prevPreview() {
|
|
if (previewIndex.value > 0) {
|
|
previewDoc.value = localDocuments.value[previewIndex.value - 1]
|
|
}
|
|
}
|
|
|
|
function nextPreview() {
|
|
if (previewIndex.value < localDocuments.value.length - 1) {
|
|
previewDoc.value = localDocuments.value[previewIndex.value + 1]
|
|
}
|
|
}
|
|
</script>
|
|
```
|
|
|
|
Also update the template references. Replace:
|
|
```vue
|
|
<TaskDocumentList
|
|
v-if="ticket.documents && ticket.documents.length"
|
|
:documents="ticket.documents"
|
|
:is-admin="false"
|
|
@preview="openPreview"
|
|
/>
|
|
```
|
|
|
|
With:
|
|
```vue
|
|
<TaskDocumentList
|
|
v-if="localDocuments.length"
|
|
:documents="localDocuments"
|
|
:is-admin="false"
|
|
@preview="openPreview"
|
|
/>
|
|
```
|
|
|
|
And replace:
|
|
```vue
|
|
<TaskDocumentPreview
|
|
:document="previewDoc"
|
|
:has-prev="previewIndex > 0"
|
|
:has-next="previewIndex < (ticket.documents?.length ?? 0) - 1"
|
|
@close="previewDoc = null"
|
|
@prev="prevPreview"
|
|
@next="nextPreview"
|
|
/>
|
|
```
|
|
|
|
With:
|
|
```vue
|
|
<TaskDocumentPreview
|
|
:document="previewDoc"
|
|
:has-prev="previewIndex > 0"
|
|
:has-next="previewIndex < localDocuments.length - 1"
|
|
@close="previewDoc = null"
|
|
@prev="prevPreview"
|
|
@next="nextPreview"
|
|
/>
|
|
```
|
|
|
|
- [ ] **Commit:**
|
|
```bash
|
|
git add frontend/components/client-ticket/ClientTicketDetailModal.vue
|
|
git commit -m "feat(portal) : add document upload to ticket detail modal"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 5: Client Ticket Icon on Internal Views
|
|
|
|
### Task 15: Add client ticket icon to TaskCard
|
|
|
|
- [ ] **Modify `frontend/components/task/TaskCard.vue`** — Add a small `heroicons:user-circle` icon next to the task code if `task.clientTicket` is set. In the template, after the `<span>` showing `task.project.code` (line 11), add the icon. Replace:
|
|
|
|
```vue
|
|
<span v-if="task.project && task.number" class="text-xs font-medium text-neutral-400">{{ task.project.code }}{{ task.number }}</span>
|
|
```
|
|
|
|
With:
|
|
```vue
|
|
<div class="flex items-center gap-1">
|
|
<span v-if="task.project && task.number" class="text-xs font-medium text-neutral-400">{{ task.project.code }}{{ task.number }}</span>
|
|
<Icon
|
|
v-if="task.clientTicket"
|
|
name="heroicons:user-circle"
|
|
class="h-4 w-4 text-blue-400"
|
|
:title="$t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') })"
|
|
/>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Commit:**
|
|
```bash
|
|
git add frontend/components/task/TaskCard.vue
|
|
git commit -m "feat(kanban) : show client ticket icon on task cards linked to a ticket"
|
|
```
|
|
|
|
### Task 16: Add client ticket icon to my-tasks list view
|
|
|
|
- [ ] **Modify `frontend/pages/my-tasks.vue`** — Add the same `heroicons:user-circle` icon in the list view. In the list view task row, after the task code span (around line 418), add the icon. Replace:
|
|
|
|
```vue
|
|
<span
|
|
v-if="task.project && task.number"
|
|
class="text-sm font-medium text-primary-500"
|
|
>
|
|
{{ task.project.code }}-{{ task.number }}
|
|
</span>
|
|
```
|
|
|
|
With:
|
|
```vue
|
|
<div class="flex items-center gap-1.5">
|
|
<Icon
|
|
v-if="task.clientTicket"
|
|
name="heroicons:user-circle"
|
|
class="h-4 w-4 text-blue-400"
|
|
:title="$t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') })"
|
|
/>
|
|
<span
|
|
v-if="task.project && task.number"
|
|
class="text-sm font-medium text-primary-500"
|
|
>
|
|
{{ task.project.code }}-{{ task.number }}
|
|
</span>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Commit:**
|
|
```bash
|
|
git add frontend/pages/my-tasks.vue
|
|
git commit -m "feat(my-tasks) : show client ticket icon on tasks linked to a ticket"
|
|
```
|
|
|
|
### Task 17: Show client ticket info in TaskModal
|
|
|
|
- [ ] **Modify `frontend/components/task/TaskModal.vue`** — Show client ticket link info when editing a task that has `clientTicket` set. In the template, after the header `<h2>` tag (line 27), add a client ticket badge. After the closing `</div>` of the header flex container (line 29), add:
|
|
|
|
```vue
|
|
<!-- Client ticket link -->
|
|
<div
|
|
v-if="isEditing && task?.clientTicket"
|
|
class="mt-2 flex items-center gap-2 rounded-lg bg-blue-50 px-3 py-2"
|
|
>
|
|
<Icon name="heroicons:user-circle" class="h-5 w-5 text-blue-500" />
|
|
<span class="text-sm font-medium text-blue-700">
|
|
{{ $t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') }) }}
|
|
</span>
|
|
<span
|
|
class="ml-auto rounded-full px-2 py-0.5 text-xs font-semibold"
|
|
:class="ticketStatusClass(task.clientTicket.status)"
|
|
>
|
|
{{ $t(`clientTicket.status.${task.clientTicket.status}`) }}
|
|
</span>
|
|
</div>
|
|
```
|
|
|
|
In the `<script setup>`, add the helper function after `isAdmin`:
|
|
|
|
```typescript
|
|
function ticketStatusClass(status: string): string {
|
|
switch (status) {
|
|
case 'new': return 'bg-blue-100 text-blue-700'
|
|
case 'in_progress': return 'bg-yellow-100 text-yellow-700'
|
|
case 'done': return 'bg-green-100 text-green-700'
|
|
case 'rejected': return 'bg-red-100 text-red-700'
|
|
default: return 'bg-neutral-100 text-neutral-700'
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Commit:**
|
|
```bash
|
|
git add frontend/components/task/TaskModal.vue
|
|
git commit -m "feat(task-modal) : show linked client ticket info in task modal header"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 6: Admin Client Tickets Tab
|
|
|
|
### Task 18: Create AdminClientTicketTab component
|
|
|
|
- [ ] **Create `frontend/components/admin/AdminClientTicketTab.vue`** — Admin tab with ticket list, filters (project, status), status change modal, and ticket detail view:
|
|
|
|
```vue
|
|
<template>
|
|
<div>
|
|
<div class="flex items-center justify-between">
|
|
<h2 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.adminTab') }}</h2>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="mt-4 flex flex-wrap gap-3">
|
|
<MalioSelect
|
|
v-model="filterProjectId"
|
|
:options="projectOptions"
|
|
label="Projet"
|
|
:empty-option-label="$t('clientTicket.allProjects')"
|
|
min-width="!w-40"
|
|
text-field="text-sm"
|
|
text-value="text-sm"
|
|
/>
|
|
<div>
|
|
<label class="mb-1 block text-sm font-medium text-neutral-700">Statut</label>
|
|
<select
|
|
v-model="filterStatus"
|
|
class="rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
>
|
|
<option :value="null">{{ $t('clientTicket.allStatuses') }}</option>
|
|
<option value="new">{{ $t('clientTicket.status.new') }}</option>
|
|
<option value="in_progress">{{ $t('clientTicket.status.in_progress') }}</option>
|
|
<option value="done">{{ $t('clientTicket.status.done') }}</option>
|
|
<option value="rejected">{{ $t('clientTicket.status.rejected') }}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Ticket list -->
|
|
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
|
|
{{ $t('common.loading') }}
|
|
</div>
|
|
|
|
<div v-else-if="filteredTickets.length === 0" class="py-8 text-center text-sm text-neutral-400">
|
|
{{ $t('clientTicket.noTickets') }}
|
|
</div>
|
|
|
|
<div v-else class="mt-4 overflow-x-auto">
|
|
<table class="w-full text-left text-sm">
|
|
<thead>
|
|
<tr class="border-b border-neutral-200 text-xs font-semibold uppercase text-neutral-500">
|
|
<th class="px-3 py-3">#</th>
|
|
<th class="px-3 py-3">Type</th>
|
|
<th class="px-3 py-3">{{ $t('clientTicket.title') }}</th>
|
|
<th class="px-3 py-3">Statut</th>
|
|
<th class="px-3 py-3">Projet</th>
|
|
<th class="px-3 py-3">{{ $t('clientTicket.submittedBy') }}</th>
|
|
<th class="px-3 py-3">{{ $t('clientTicket.createdAt') }}</th>
|
|
<th class="px-3 py-3">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
v-for="ticket in filteredTickets"
|
|
:key="ticket.id"
|
|
class="cursor-pointer border-b border-neutral-100 transition-colors hover:bg-neutral-50"
|
|
@click="openDetail(ticket)"
|
|
>
|
|
<td class="px-3 py-3 font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</td>
|
|
<td class="px-3 py-3">
|
|
<span
|
|
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
|
:class="typeBadgeClass(ticket.type)"
|
|
>
|
|
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
|
</span>
|
|
</td>
|
|
<td class="px-3 py-3 font-medium text-neutral-900">{{ ticket.title }}</td>
|
|
<td class="px-3 py-3">
|
|
<span
|
|
class="rounded-full px-2 py-0.5 text-xs font-semibold"
|
|
:class="statusBadgeClass(ticket.status)"
|
|
>
|
|
{{ $t(`clientTicket.status.${ticket.status}`) }}
|
|
</span>
|
|
</td>
|
|
<td class="px-3 py-3 text-neutral-600">{{ getProjectName(ticket.project) }}</td>
|
|
<td class="px-3 py-3 text-neutral-600">{{ getSubmitterName(ticket.submittedBy) }}</td>
|
|
<td class="px-3 py-3 text-neutral-400">{{ formatDate(ticket.createdAt) }}</td>
|
|
<td class="px-3 py-3">
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
class="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-primary-500"
|
|
:title="$t('clientTicket.changeStatus')"
|
|
@click.stop="openStatusChange(ticket)"
|
|
>
|
|
<Icon name="mdi:swap-horizontal" size="18" />
|
|
</button>
|
|
<button
|
|
class="rounded p-1 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500"
|
|
@click.stop="openDeleteConfirm(ticket)"
|
|
>
|
|
<Icon name="mdi:delete-outline" size="18" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Status change modal -->
|
|
<Teleport v-if="statusModalOpen" to="body">
|
|
<Transition name="status-modal" appear>
|
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<div
|
|
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
|
@click="statusModalOpen = false"
|
|
/>
|
|
<div class="relative z-10 w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
|
|
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.changeStatus') }}</h3>
|
|
<p v-if="statusTarget" class="mt-1 text-sm text-neutral-500">
|
|
CT-{{ String(statusTarget.number).padStart(3, '0') }} — {{ statusTarget.title }}
|
|
</p>
|
|
|
|
<div class="mt-4">
|
|
<label class="mb-1 block text-sm font-medium text-neutral-700">Nouveau statut</label>
|
|
<select
|
|
v-model="newStatus"
|
|
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
>
|
|
<option :value="null" disabled>—</option>
|
|
<option
|
|
v-for="s in availableStatusTransitions"
|
|
:key="s.value"
|
|
:value="s.value"
|
|
>
|
|
{{ s.label }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div v-if="newStatus === 'rejected'" class="mt-4">
|
|
<MalioInputTextArea
|
|
v-model="statusComment"
|
|
:label="$t('clientTicket.statusComment')"
|
|
:size="3"
|
|
/>
|
|
<p v-if="rejectionError" class="mt-1 text-xs text-red-500">
|
|
{{ $t('clientTicket.rejectionRequired') }}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="mt-6 flex justify-end gap-3">
|
|
<button
|
|
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
|
@click="statusModalOpen = false"
|
|
>
|
|
{{ $t('common.cancel') }}
|
|
</button>
|
|
<button
|
|
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
|
:disabled="isUpdatingStatus"
|
|
@click="confirmStatusChange"
|
|
>
|
|
Confirmer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
|
|
<!-- Delete confirm modal -->
|
|
<Teleport v-if="deleteModalOpen" to="body">
|
|
<Transition name="status-modal" appear>
|
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<div
|
|
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
|
@click="deleteModalOpen = false"
|
|
/>
|
|
<div class="relative z-10 w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl">
|
|
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.confirmDelete') }}</h3>
|
|
<p class="mt-2 text-sm text-neutral-600">{{ $t('clientTicket.confirmDeleteMessage') }}</p>
|
|
<div class="mt-6 flex justify-end gap-3">
|
|
<button
|
|
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
|
@click="deleteModalOpen = false"
|
|
>
|
|
{{ $t('common.cancel') }}
|
|
</button>
|
|
<button
|
|
class="rounded-lg bg-red-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50"
|
|
:disabled="isDeleting"
|
|
@click="confirmDelete"
|
|
>
|
|
Supprimer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
|
|
<!-- Ticket detail modal (read-only) -->
|
|
<ClientTicketDetailModal
|
|
v-model="detailOpen"
|
|
:ticket="detailTicket"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
|
|
import type { Project } from '~/services/dto/project'
|
|
import type { UserData } from '~/services/dto/user-data'
|
|
import { useClientTicketService } from '~/services/client-tickets'
|
|
import { useProjectService } from '~/services/projects'
|
|
import { useUserService } from '~/services/users'
|
|
|
|
const { t } = useI18n()
|
|
const clientTicketService = useClientTicketService()
|
|
const projectService = useProjectService()
|
|
const userService = useUserService()
|
|
|
|
const tickets = ref<ClientTicket[]>([])
|
|
const projects = ref<Project[]>([])
|
|
const users = ref<UserData[]>([])
|
|
const isLoading = ref(true)
|
|
|
|
// Filters
|
|
const filterProjectId = ref<number | null>(null)
|
|
const filterStatus = ref<string | null>(null)
|
|
|
|
const projectOptions = computed(() =>
|
|
projects.value.map(p => ({ label: p.name, value: p.id }))
|
|
)
|
|
|
|
const filteredTickets = computed(() => {
|
|
let result = tickets.value
|
|
if (filterProjectId.value) {
|
|
result = result.filter(t => t.project === `/api/projects/${filterProjectId.value}`)
|
|
}
|
|
if (filterStatus.value) {
|
|
result = result.filter(t => t.status === filterStatus.value)
|
|
}
|
|
return result
|
|
})
|
|
|
|
// Status change modal
|
|
const statusModalOpen = ref(false)
|
|
const statusTarget = ref<ClientTicket | null>(null)
|
|
const newStatus = ref<string | null>(null)
|
|
const statusComment = ref('')
|
|
const rejectionError = ref(false)
|
|
const isUpdatingStatus = ref(false)
|
|
|
|
// Delete modal
|
|
const deleteModalOpen = ref(false)
|
|
const deleteTarget = ref<ClientTicket | null>(null)
|
|
const isDeleting = ref(false)
|
|
|
|
// Detail modal
|
|
const detailOpen = ref(false)
|
|
const detailTicket = ref<ClientTicket | null>(null)
|
|
|
|
const availableStatusTransitions = computed(() => {
|
|
if (!statusTarget.value) return []
|
|
const current = statusTarget.value.status
|
|
const allStatuses: { label: string; value: ClientTicketStatus }[] = [
|
|
{ label: t('clientTicket.status.new'), value: 'new' },
|
|
{ label: t('clientTicket.status.in_progress'), value: 'in_progress' },
|
|
{ label: t('clientTicket.status.done'), value: 'done' },
|
|
{ label: t('clientTicket.status.rejected'), value: 'rejected' },
|
|
]
|
|
// Filter out forbidden transitions
|
|
return allStatuses.filter(s => {
|
|
if (s.value === current) return false
|
|
if ((current === 'done' || current === 'rejected') && s.value === 'new') return false
|
|
return true
|
|
})
|
|
})
|
|
|
|
function typeBadgeClass(type: string): string {
|
|
switch (type) {
|
|
case 'bug': return 'bg-red-500'
|
|
case 'improvement': return 'bg-blue-500'
|
|
default: return 'bg-neutral-500'
|
|
}
|
|
}
|
|
|
|
function statusBadgeClass(status: string): string {
|
|
switch (status) {
|
|
case 'new': return 'bg-blue-100 text-blue-700'
|
|
case 'in_progress': return 'bg-yellow-100 text-yellow-700'
|
|
case 'done': return 'bg-green-100 text-green-700'
|
|
case 'rejected': return 'bg-red-100 text-red-700'
|
|
default: return 'bg-neutral-100 text-neutral-700'
|
|
}
|
|
}
|
|
|
|
function getProjectName(iri: string): string {
|
|
const match = iri.match(/\/api\/projects\/(\d+)/)
|
|
if (!match) return ''
|
|
const id = Number(match[1])
|
|
return projects.value.find(p => p.id === id)?.name ?? ''
|
|
}
|
|
|
|
function getSubmitterName(iri: string | null): string {
|
|
if (!iri) return '-'
|
|
const match = iri.match(/\/api\/users\/(\d+)/)
|
|
if (!match) return ''
|
|
const id = Number(match[1])
|
|
return users.value.find(u => u.id === id)?.username ?? ''
|
|
}
|
|
|
|
function formatDate(iso: string): string {
|
|
return new Date(iso).toLocaleDateString('fr-FR', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
})
|
|
}
|
|
|
|
function openDetail(ticket: ClientTicket) {
|
|
detailTicket.value = ticket
|
|
detailOpen.value = true
|
|
}
|
|
|
|
function openStatusChange(ticket: ClientTicket) {
|
|
statusTarget.value = ticket
|
|
newStatus.value = null
|
|
statusComment.value = ''
|
|
rejectionError.value = false
|
|
statusModalOpen.value = true
|
|
}
|
|
|
|
function openDeleteConfirm(ticket: ClientTicket) {
|
|
deleteTarget.value = ticket
|
|
deleteModalOpen.value = true
|
|
}
|
|
|
|
async function confirmStatusChange() {
|
|
if (!statusTarget.value || !newStatus.value) return
|
|
|
|
if (newStatus.value === 'rejected' && !statusComment.value.trim()) {
|
|
rejectionError.value = true
|
|
return
|
|
}
|
|
|
|
isUpdatingStatus.value = true
|
|
try {
|
|
await clientTicketService.updateStatus(statusTarget.value.id, {
|
|
status: newStatus.value as ClientTicketStatus,
|
|
statusComment: newStatus.value === 'rejected' ? statusComment.value.trim() : null,
|
|
})
|
|
statusModalOpen.value = false
|
|
await loadTickets()
|
|
} finally {
|
|
isUpdatingStatus.value = false
|
|
}
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
if (!deleteTarget.value) return
|
|
isDeleting.value = true
|
|
try {
|
|
await clientTicketService.remove(deleteTarget.value.id)
|
|
deleteModalOpen.value = false
|
|
await loadTickets()
|
|
} finally {
|
|
isDeleting.value = false
|
|
}
|
|
}
|
|
|
|
async function loadTickets() {
|
|
tickets.value = await clientTicketService.getAll()
|
|
}
|
|
|
|
async function loadData() {
|
|
isLoading.value = true
|
|
try {
|
|
const [t, p, u] = await Promise.all([
|
|
clientTicketService.getAll(),
|
|
projectService.getAll(),
|
|
userService.getAll(),
|
|
])
|
|
tickets.value = t
|
|
projects.value = p
|
|
users.value = u
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadData()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.status-modal-enter-active,
|
|
.status-modal-leave-active {
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
.status-modal-enter-from,
|
|
.status-modal-leave-to {
|
|
opacity: 0;
|
|
}
|
|
</style>
|
|
```
|
|
|
|
- [ ] **Commit:**
|
|
```bash
|
|
git add frontend/components/admin/AdminClientTicketTab.vue
|
|
git commit -m "feat(admin) : add client tickets tab with list, filters, status change, and delete"
|
|
```
|
|
|
|
### Task 19: Register the new tab in admin.vue
|
|
|
|
- [ ] **Modify `frontend/pages/admin.vue`** — Add the "Tickets client" tab. In the `tabs` array (line 39), add a new entry after the `bookstack` tab:
|
|
|
|
Replace:
|
|
```typescript
|
|
const tabs = [
|
|
{ key: 'clients', label: 'Clients' },
|
|
{ key: 'statuses', label: 'Statuts' },
|
|
{ key: 'efforts', label: 'Efforts' },
|
|
{ key: 'priorities', label: 'Priorités' },
|
|
{ key: 'tags', label: 'Tags' },
|
|
{ key: 'users', label: 'Utilisateurs' },
|
|
{ key: 'gitea', label: 'Gitea' },
|
|
{ key: 'bookstack', label: 'BookStack' },
|
|
] as const
|
|
```
|
|
|
|
With:
|
|
```typescript
|
|
const tabs = [
|
|
{ key: 'clients', label: 'Clients' },
|
|
{ key: 'statuses', label: 'Statuts' },
|
|
{ key: 'efforts', label: 'Efforts' },
|
|
{ key: 'priorities', label: 'Priorités' },
|
|
{ key: 'tags', label: 'Tags' },
|
|
{ key: 'users', label: 'Utilisateurs' },
|
|
{ key: 'client-tickets', label: 'Tickets client' },
|
|
{ key: 'gitea', label: 'Gitea' },
|
|
{ key: 'bookstack', label: 'BookStack' },
|
|
] as const
|
|
```
|
|
|
|
In the template, after the `AdminBookStackTab` (line 31), add:
|
|
```vue
|
|
<AdminClientTicketTab v-if="activeTab === 'client-tickets'" />
|
|
```
|
|
|
|
- [ ] **Commit:**
|
|
```bash
|
|
git add frontend/pages/admin.vue
|
|
git commit -m "feat(admin) : register client tickets tab in admin page"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 7: Final Touches
|
|
|
|
### Task 20: Update login redirect to portal for client users
|
|
|
|
- [ ] **Modify `frontend/pages/login.vue`** — After successful login, redirect ROLE_CLIENT users to `/portal` instead of `/`. The actual login page uses `router.push`, not `navigateTo`.
|
|
|
|
Find this line (around line 66):
|
|
```typescript
|
|
await router.push('/')
|
|
```
|
|
|
|
Replace with:
|
|
```typescript
|
|
const isClient = auth.user?.roles?.includes('ROLE_CLIENT') ?? false
|
|
await router.push(isClient ? '/portal' : '/')
|
|
```
|
|
|
|
- [ ] **Commit:**
|
|
```bash
|
|
git add frontend/pages/login.vue
|
|
git commit -m "feat(auth) : redirect client users to /portal after login"
|
|
```
|
|
|
|
### Task 21: Extract duplicated helpers to composable
|
|
|
|
- [ ] **Create `frontend/composables/useClientTicketHelpers.ts`** — Extract the `typeBadgeClass`, `statusBadgeClass`, and `formatDate` functions that are duplicated in `ClientTicketDetailModal.vue`, `portal/projects/[id]/index.vue`, and `AdminClientTicketTab.vue`:
|
|
|
|
```typescript
|
|
export function useClientTicketHelpers() {
|
|
function typeBadgeClass(type: string): string {
|
|
switch (type) {
|
|
case 'bug': return 'bg-red-500'
|
|
case 'improvement': return 'bg-blue-500'
|
|
default: return 'bg-neutral-500'
|
|
}
|
|
}
|
|
|
|
function statusBadgeClass(status: string): string {
|
|
switch (status) {
|
|
case 'new': return 'bg-blue-100 text-blue-700'
|
|
case 'in_progress': return 'bg-yellow-100 text-yellow-700'
|
|
case 'done': return 'bg-green-100 text-green-700'
|
|
case 'rejected': return 'bg-red-100 text-red-700'
|
|
default: return 'bg-neutral-100 text-neutral-700'
|
|
}
|
|
}
|
|
|
|
function formatDate(iso: string): string {
|
|
return new Date(iso).toLocaleDateString('fr-FR', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
})
|
|
}
|
|
|
|
return { typeBadgeClass, statusBadgeClass, formatDate }
|
|
}
|
|
```
|
|
|
|
- [ ] **Update the 3 components** to import and use the composable instead of local functions:
|
|
- `frontend/components/client-ticket/ClientTicketDetailModal.vue` — Remove local `typeBadgeClass`, `statusBadgeClass`, `formatDate` functions and add `const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()`
|
|
- `frontend/pages/portal/projects/[id]/index.vue` — Same replacement
|
|
- `frontend/components/admin/AdminClientTicketTab.vue` — Remove local `typeBadgeClass`, `statusBadgeClass`, `formatDate` functions and add `const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()`
|
|
|
|
- [ ] **Commit:**
|
|
```bash
|
|
git add frontend/composables/useClientTicketHelpers.ts frontend/components/client-ticket/ClientTicketDetailModal.vue frontend/pages/portal/projects/\[id\]/index.vue frontend/components/admin/AdminClientTicketTab.vue
|
|
git commit -m "refactor(portal) : extract duplicated ticket helpers to useClientTicketHelpers composable"
|
|
```
|
|
|
|
### Task 22: Final commit — verify all files
|
|
|
|
- [ ] **Run a final check** — Verify all new files are properly created and existing files are updated:
|
|
```bash
|
|
git status
|
|
```
|
|
|
|
Verify the following files exist:
|
|
- `frontend/middleware/auth.global.ts` (modified)
|
|
- `frontend/layouts/portal.vue` (new)
|
|
- `frontend/i18n/locales/fr.json` (modified)
|
|
- `frontend/services/dto/client-ticket.ts` (new)
|
|
- `frontend/services/client-tickets.ts` (new)
|
|
- `frontend/services/dto/task.ts` (modified)
|
|
- `frontend/services/dto/user-data.ts` (modified)
|
|
- `frontend/services/task-documents.ts` (modified)
|
|
- `frontend/pages/portal/index.vue` (new)
|
|
- `frontend/pages/portal/projects/[id]/index.vue` (new)
|
|
- `frontend/pages/portal/projects/[id]/new-ticket.vue` (new)
|
|
- `frontend/components/client-ticket/ClientTicketDetailModal.vue` (new)
|
|
- `frontend/components/task/TaskDocumentUpload.vue` (modified)
|
|
- `frontend/components/task/TaskCard.vue` (modified)
|
|
- `frontend/components/task/TaskModal.vue` (modified)
|
|
- `frontend/pages/my-tasks.vue` (modified)
|
|
- `frontend/pages/admin.vue` (modified)
|
|
- `frontend/components/admin/AdminClientTicketTab.vue` (new)
|
|
- `frontend/pages/login.vue` (modified)
|
|
- `frontend/composables/useClientTicketHelpers.ts` (new)
|