Compare commits

...

10 Commits

Author SHA1 Message Date
318b6198da feat(portal) : add drag & drop status change on client ticket kanban
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:16:22 +01:00
4e3e854aa2 fix(portal) : allow admin to edit tickets and enable document deletion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:12:55 +01:00
49cd971e3e feat(project) : add client tickets panel to project page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 20:41:28 +01:00
ffe4a0117c feat(portal) : allow client to edit own tickets
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 20:41:25 +01:00
d2f6d84d03 feat(portal) : replace ticket list with kanban board
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 20:41:23 +01:00
2a874046d3 feat : allow client to edit own tickets and protect status fields
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:35:11 +01:00
f09ef67117 feat : date filter, project drawer, and misc frontend improvements
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:25:26 +01:00
046ee396d3 feat(fixtures) : add users alice/bob/charlie and distribute task assignees
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:25:14 +01:00
0ba487cfa9 feat(fixtures) : add client users, client tickets, and ticket-task link
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:20:27 +01:00
a2fc8e6e52 feat(task) : add client ticket selector in TaskModal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:14:56 +01:00
19 changed files with 1762 additions and 129 deletions

View File

@@ -0,0 +1,10 @@
services:
Psr\Http\Message\RequestFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\ResponseFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\ServerRequestFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\StreamFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\UploadedFileFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\UriFactoryInterface: '@http_discovery.psr17_factory'
http_discovery.psr17_factory:
class: Http\Discovery\Psr17Factory

View File

@@ -0,0 +1,385 @@
# Date Filter Component Implementation Plan
> **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:** Add a reusable date filter component to the time-tracking page using `@vuepic/vue-datepicker`, enabling filtering by single day or date range.
**Architecture:** A wrapper component `DateFilter.vue` encapsulates `VueDatePicker` with project-consistent styling. It integrates into the existing filter bar on the time-tracking page. Filtering is client-side, matching the existing project/tag filter pattern.
**Tech Stack:** Vue 3, @vuepic/vue-datepicker, Tailwind CSS, @nuxtjs/i18n
---
## Chunk 1: Setup and Component
### Task 1: Install @vuepic/vue-datepicker and configure Nuxt
**Files:**
- Modify: `frontend/package.json`
- Modify: `frontend/nuxt.config.ts:1-66`
- [ ] **Step 1: Install the package**
Run inside the PHP container (where Node is available):
```bash
cd /home/r-dev/Lesstime/frontend && npm install @vuepic/vue-datepicker
```
- [ ] **Step 2: Add transpile config to nuxt.config.ts**
In `frontend/nuxt.config.ts`, add `build.transpile` after the `typescript` block:
```typescript
export default defineNuxtConfig({
// ... existing config ...
typescript: {
strict: true
},
build: {
transpile: ['@vuepic/vue-datepicker']
}
})
```
- [ ] **Step 3: Commit**
```bash
git add frontend/package.json frontend/package-lock.json frontend/nuxt.config.ts
git commit -m "feat(frontend) : add @vuepic/vue-datepicker dependency"
```
---
### Task 2: Add i18n translations
**Files:**
- Modify: `frontend/i18n/locales/fr.json:167-170`
- [ ] **Step 1: Add date filter translations to fr.json**
In `frontend/i18n/locales/fr.json`, add keys inside the existing `"common"` block:
```json
"common": {
"cancel": "Annuler",
"loading": "Chargement...",
"dateFilter": "Date",
"today": "Aujourd'hui",
"thisWeek": "Cette semaine",
"clear": "Effacer"
}
```
- [ ] **Step 2: Commit**
```bash
git add frontend/i18n/locales/fr.json
git commit -m "feat(frontend) : add date filter i18n translations"
```
---
### Task 3: Create DateFilter.vue component
**Files:**
- Create: `frontend/components/ui/DateFilter.vue`
- [ ] **Step 1: Create the component**
Create `frontend/components/ui/DateFilter.vue`:
```vue
<template>
<div class="date-filter">
<VueDatePicker
v-model="internalValue"
:range="isRange"
:enable-time-picker="false"
:text-input="textInputConfig"
:locale="'fr'"
:format="formatDate"
:preview-format="formatDate"
auto-apply
:multi-calendars="false"
position="left"
@update:model-value="onUpdate"
@cleared="onClear"
>
<template #dp-input="{ value, onInput, onEnter, onTab, onClear, openMenu }">
<div class="relative">
<input
:value="value"
class="w-full cursor-pointer rounded-md border border-neutral-300 bg-white px-3 py-[7px] text-sm text-neutral-700 outline-none transition placeholder:text-neutral-400 focus:border-primary-500"
:placeholder="placeholder || t('common.dateFilter')"
readonly
@click="openMenu"
@input="onInput"
@keydown.enter="onEnter"
@keydown.tab="onTab"
/>
<button
v-if="value"
class="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600"
@click.stop="onClear"
>
<Icon name="mdi:close-circle" size="16" />
</button>
<Icon
v-else
name="mdi:calendar"
size="16"
class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400"
/>
</div>
</template>
<template #action-buttons>
<div class="flex gap-2 px-3 pb-2">
<button
class="rounded px-2 py-1 text-xs font-medium text-primary-500 hover:bg-primary-500/10 transition"
@click="selectToday"
>
{{ t('common.today') }}
</button>
<button
class="rounded px-2 py-1 text-xs font-medium text-primary-500 hover:bg-primary-500/10 transition"
@click="selectThisWeek"
>
{{ t('common.thisWeek') }}
</button>
</div>
</template>
</VueDatePicker>
</div>
</template>
<script setup lang="ts">
import VueDatePicker from '@vuepic/vue-datepicker'
import '@vuepic/vue-datepicker/dist/main.css'
const { t } = useI18n()
const props = defineProps<{
modelValue?: Date | [Date, Date] | null
placeholder?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: Date | [Date, Date] | null]
}>()
const isRange = ref(false)
const internalValue = ref<Date | Date[] | null>(null)
const firstClick = ref<Date | null>(null)
const textInputConfig = {
enterSubmit: true,
tabSubmit: true,
format: 'dd/MM/yyyy',
rangeSeparator: ' - ',
}
function formatDate(date: Date | Date[]): string {
if (Array.isArray(date)) {
return date.map(d => formatSingleDate(d)).join(' - ')
}
return formatSingleDate(date)
}
function formatSingleDate(d: Date): string {
const day = String(d.getDate()).padStart(2, '0')
const month = String(d.getMonth() + 1).padStart(2, '0')
const year = d.getFullYear()
return `${day}/${month}/${year}`
}
function onUpdate(value: Date | Date[] | null) {
if (value === null) {
firstClick.value = null
isRange.value = false
emit('update:modelValue', null)
return
}
if (Array.isArray(value) && value.length === 2) {
emit('update:modelValue', [value[0], value[1]])
return
}
if (value instanceof Date) {
if (firstClick.value === null) {
// First click — select single day, store for potential range
firstClick.value = value
emit('update:modelValue', value)
// Enable range mode for next click
nextTick(() => {
isRange.value = true
})
}
}
}
function onClear() {
internalValue.value = null
firstClick.value = null
isRange.value = false
emit('update:modelValue', null)
}
function selectToday() {
const today = new Date()
today.setHours(0, 0, 0, 0)
isRange.value = false
firstClick.value = null
internalValue.value = today
emit('update:modelValue', today)
}
function selectThisWeek() {
const now = new Date()
const day = now.getDay()
const monday = new Date(now)
monday.setDate(now.getDate() - day + (day === 0 ? -6 : 1))
monday.setHours(0, 0, 0, 0)
const sunday = new Date(monday)
sunday.setDate(monday.getDate() + 6)
sunday.setHours(23, 59, 59, 999)
isRange.value = true
firstClick.value = null
internalValue.value = [monday, sunday]
emit('update:modelValue', [monday, sunday])
}
// Sync external modelValue to internal state
watch(() => props.modelValue, (val) => {
if (val === null || val === undefined) {
internalValue.value = null
firstClick.value = null
isRange.value = false
} else if (Array.isArray(val)) {
isRange.value = true
internalValue.value = [...val]
} else {
isRange.value = false
internalValue.value = val
}
}, { immediate: true })
</script>
<style>
.date-filter .dp__theme_light {
--dp-primary-color: #222783;
--dp-primary-text-color: #fff;
--dp-border-color: #d4d4d8;
--dp-menu-border-color: #d4d4d8;
--dp-border-color-hover: #222783;
--dp-hover-color: #f3f4f8;
--dp-font-size: 0.875rem;
}
.date-filter .dp__input_wrap {
width: auto;
}
.date-filter .dp__main {
font-family: inherit;
}
</style>
```
- [ ] **Step 2: Verify the component renders**
Run `make dev-nuxt` and navigate to the time-tracking page (integration comes in Task 4). Check that no build errors occur.
- [ ] **Step 3: Commit**
```bash
git add frontend/components/ui/DateFilter.vue
git commit -m "feat(frontend) : create DateFilter reusable component"
```
---
## Chunk 2: Integration
### Task 4: Integrate DateFilter into time-tracking page
**Files:**
- Modify: `frontend/pages/time-tracking.vue:15-73` (template filter bar)
- Modify: `frontend/pages/time-tracking.vue:138` (add ref)
- Modify: `frontend/pages/time-tracking.vue:184-193` (filteredEntries computed)
- [ ] **Step 1: Add the date filter ref**
In `frontend/pages/time-tracking.vue`, after line 138 (`selectedProjectId`), add:
```typescript
const selectedDateFilter = ref<Date | [Date, Date] | null>(null)
```
- [ ] **Step 2: Add DateFilter to the template filter bar**
In the filter bar `<div>` (line 15), after the tag MalioSelect block (after line 72), add:
```vue
<DateFilter
v-model="selectedDateFilter"
/>
```
- [ ] **Step 3: Add date filtering to filteredEntries computed**
In `frontend/pages/time-tracking.vue`, update the `filteredEntries` computed (around line 184) to include date filtering:
```typescript
const filteredEntries = computed(() => {
let result = entries.value
if (selectedProjectId.value) {
result = result.filter((e) => e.project?.id === selectedProjectId.value)
}
if (selectedTagId.value) {
result = result.filter((e) => e.tags.some((t) => t.id === selectedTagId.value))
}
if (selectedDateFilter.value) {
if (Array.isArray(selectedDateFilter.value)) {
const [start, end] = selectedDateFilter.value
const startDay = new Date(start)
startDay.setHours(0, 0, 0, 0)
const endDay = new Date(end)
endDay.setHours(23, 59, 59, 999)
result = result.filter((e) => {
const entryDate = new Date(e.startedAt)
return entryDate >= startDay && entryDate <= endDay
})
} else {
const day = new Date(selectedDateFilter.value)
day.setHours(0, 0, 0, 0)
const nextDay = new Date(day)
nextDay.setDate(nextDay.getDate() + 1)
result = result.filter((e) => {
const entryDate = new Date(e.startedAt)
return entryDate >= day && entryDate < nextDay
})
}
}
return result
})
```
- [ ] **Step 4: Verify manually**
Run `make dev-nuxt`, navigate to time-tracking page:
1. Verify DateFilter appears in the filter bar
2. Click a single day — entries filter to that day
3. Click a second day — entries filter to the range
4. Click "Aujourd'hui" — filters to today
5. Click "Cette semaine" — filters to current week
6. Clear the filter — all entries show again
- [ ] **Step 5: Commit**
```bash
git add frontend/pages/time-tracking.vue
git commit -m "feat(frontend) : integrate date filter into time-tracking page"
```

View File

@@ -0,0 +1,86 @@
# Date Filter Component - Design Spec
## Summary
Add a reusable date filter component to the time-tracking page using `@vuepic/vue-datepicker`. Allows filtering by single day or date range via text input and mini calendar dropdown.
## Behavior
- **Single click** on a day = select that day
- **Second click** on another day = select range between the two dates
- **Text input**: type a date (`15/03/2026`) or a range (`15/03/2026 - 20/03/2026`)
- **Calendar dropdown**: opens on input click/focus
- **Quick shortcuts**: "Aujourd'hui" and "Cette semaine" buttons in calendar
- **No time picker**: filter by day granularity only
- **Format**: `dd/MM/yyyy` (French locale)
## Component: `DateFilter.vue`
Location: `frontend/components/ui/DateFilter.vue`
### Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `modelValue` | `Date \| [Date, Date] \| null` | `null` | Selected date or range |
| `placeholder` | `string` | `t('common.dateFilter')` | Input placeholder |
### Emits
| Event | Payload | Description |
|-------|---------|-------------|
| `update:modelValue` | `Date \| [Date, Date] \| null` | Date selection changed |
### Implementation
- Wraps `VueDatePicker` with project-consistent styling
- Uses `#dp-input` slot for custom input matching MalioSelect style
- Configures `range` mode with `multi-calendars: false`
- Sets `text-input` with `format: 'dd/MM/yyyy'`, `rangeSeparator: ' - '`
- Disables time picker (`enable-time-picker: false`)
- Applies project primary color (`#222783`) via CSS overrides
- Responsive width: `!w-44 sm:!w-52`
## Integration: Time Tracking Page
### Filter bar addition
Add `DateFilter` to the existing filter bar in `frontend/pages/time-tracking.vue`, alongside user/project/tag filters.
### Filtering logic
- Client-side filtering (same pattern as project and tag filters)
- When a single date is selected: show only entries matching that day
- When a range is selected: show entries within the range (inclusive)
- When null: show all entries (no date filter)
## Files Impacted
| File | Action | Description |
|------|--------|-------------|
| `frontend/components/ui/DateFilter.vue` | Create | Reusable date filter wrapper |
| `frontend/nuxt.config.ts` | Modify | Add `@vuepic/vue-datepicker` to `build.transpile` |
| `frontend/pages/time-tracking.vue` | Modify | Integrate DateFilter in filter bar + client-side filtering |
| `frontend/i18n/locales/fr.json` | Modify | Add French translations |
| `frontend/i18n/locales/en.json` | Modify | Add English translations |
| `package.json` | Modify | Add `@vuepic/vue-datepicker` dependency |
## i18n Keys
```json
{
"common": {
"dateFilter": "Date",
"today": "Aujourd'hui",
"thisWeek": "Cette semaine"
}
}
```
## Style
- Input height and borders match MalioSelect components
- Text size: `text-sm`
- Selected date highlight: project primary color `#222783`
- Calendar dropdown: subtle shadow, rounded corners matching project style
- Override default vue-datepicker CSS variables to match project theme

View File

@@ -27,90 +27,162 @@
{{ $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 class="flex items-center gap-2">
<!-- Edit button (only for open tickets submitted by current user) -->
<button
v-if="canEdit && !isEditing"
type="button"
class="flex h-8 items-center gap-1.5 rounded-lg px-3 text-sm font-medium text-primary-500 transition-colors hover:bg-primary-50"
@click="startEdit"
>
<Icon name="mdi:pencil-outline" size="16" />
{{ $t('common.edit') }}
</button>
<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>
</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>
<!-- Edit mode -->
<template v-if="isEditing">
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">
{{ $t('clientTicket.fields.title') }}
</label>
<input
v-model="editForm.title"
type="text"
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
</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>
<div class="mt-4">
<label class="mb-1 block text-sm font-medium text-neutral-700">
{{ $t('clientTicket.description') }}
</label>
<textarea
v-model="editForm.description"
rows="5"
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
</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>
<div v-if="ticket.type === 'bug'" class="mt-4">
<label class="mb-1 block text-sm font-medium text-neutral-700">
{{ $t('clientTicket.fields.url') }}
</label>
<input
v-model="editForm.url"
type="url"
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
:placeholder="$t('clientTicket.fields.urlPlaceholder')"
/>
</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>
<div class="mt-6 flex justify-end gap-3">
<button
type="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="cancelEdit"
>
{{ $t('common.cancel') }}
</button>
<button
type="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="isSaving"
@click="saveEdit"
>
{{ $t('common.save') }}
</button>
</div>
</template>
<!-- Documents -->
<TaskDocumentList
v-if="localDocuments.length"
:documents="localDocuments"
:is-admin="false"
@preview="openPreview"
/>
<!-- View mode -->
<template v-else>
<!-- Title -->
<h3 class="text-base font-bold text-neutral-900">{{ ticket.title }}</h3>
<!-- Document preview -->
<TaskDocumentPreview
:document="previewDoc"
:has-prev="previewIndex > 0"
:has-next="previewIndex < localDocuments.length - 1"
@close="previewDoc = null"
@prev="prevPreview"
@next="nextPreview"
/>
<!-- 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>
<!-- Upload zone -->
<TaskDocumentUpload
v-if="ticket"
:client-ticket-id="ticket.id"
@uploaded="refreshDocuments"
/>
<!-- 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>
<!-- Date -->
<p class="mt-6 text-xs text-neutral-400">
{{ $t('clientTicket.createdAt') }} : {{ formatDate(ticket.createdAt) }}
</p>
<!-- 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="localDocuments.length"
:documents="localDocuments"
:is-admin="canEdit"
@preview="openPreview"
@delete="handleDeleteDocument"
/>
<!-- Document preview -->
<TaskDocumentPreview
:document="previewDoc"
:has-prev="previewIndex > 0"
:has-next="previewIndex < localDocuments.length - 1"
@close="previewDoc = null"
@prev="prevPreview"
@next="nextPreview"
/>
<!-- Upload zone -->
<TaskDocumentUpload
v-if="ticket"
:client-ticket-id="ticket.id"
@uploaded="refreshDocuments"
/>
<!-- Date -->
<p class="mt-6 text-xs text-neutral-400">
{{ $t('clientTicket.createdAt') }} : {{ formatDate(ticket.createdAt) }}
</p>
</template>
</div>
</div>
</div>
@@ -122,6 +194,7 @@
import type { ClientTicket } from '~/services/dto/client-ticket'
import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents'
import { useClientTicketService } from '~/services/client-tickets'
const props = defineProps<{
modelValue: boolean
@@ -130,6 +203,7 @@ const props = defineProps<{
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'refresh'): void
}>()
const isOpen = computed({
@@ -138,12 +212,79 @@ const isOpen = computed({
})
function close() {
isEditing.value = false
isOpen.value = false
}
const { getByTicket } = useTaskDocumentService()
const auth = useAuthStore()
const { getByTicket, remove: removeDocument } = useTaskDocumentService()
const clientTicketService = useClientTicketService()
const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()
// Edit mode
const isEditing = ref(false)
const isSaving = ref(false)
const editForm = reactive({
title: '',
description: '',
url: '',
})
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
const canEdit = computed(() => {
if (!props.ticket) return false
if (isAdmin.value) return true
const status = props.ticket.status
if (status === 'done' || status === 'rejected') return false
const userId = auth.user?.id
if (!userId) return false
const submittedByIri = props.ticket.submittedBy
if (!submittedByIri) return false
return submittedByIri === `/api/users/${userId}`
})
function startEdit() {
if (!props.ticket) return
editForm.title = props.ticket.title
editForm.description = props.ticket.description
editForm.url = props.ticket.url ?? ''
isEditing.value = true
}
function cancelEdit() {
isEditing.value = false
}
async function saveEdit() {
if (!props.ticket) return
isSaving.value = true
try {
const data: Record<string, unknown> = {
title: editForm.title,
description: editForm.description,
}
if (props.ticket.type === 'bug') {
data.url = editForm.url || null
}
await clientTicketService.update(props.ticket.id, data as any)
isEditing.value = false
emit('refresh')
} finally {
isSaving.value = false
}
}
// Reset edit mode when ticket changes
watch(() => props.ticket?.id, () => {
isEditing.value = false
})
async function handleDeleteDocument(doc: TaskDocument) {
await removeDocument(doc.id)
await refreshDocuments()
}
async function refreshDocuments() {
if (!props.ticket) return
localDocuments.value = await getByTicket(props.ticket.id)

View File

@@ -0,0 +1,338 @@
<template>
<div>
<!-- Trigger button -->
<button
class="relative flex shrink-0 items-center gap-2 rounded-md bg-neutral-100 px-3 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-200 sm:px-4"
@click="open"
>
<Icon name="mdi:ticket-outline" class="size-4 sm:size-5" />
<span class="hidden sm:inline">{{ $t('clientTicket.adminTab') }}</span>
<span
v-if="totalCount > 0"
class="flex h-5 min-w-5 items-center justify-center rounded-full bg-primary-500 px-1 text-xs font-bold text-white"
>
{{ totalCount }}
</span>
</button>
<!-- Panel -->
<Teleport v-if="isOpen" to="body">
<Transition name="ct-panel" appear>
<div class="fixed inset-0 z-50 flex justify-end">
<!-- Backdrop -->
<div
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
@click="close"
/>
<!-- Slide panel -->
<div class="relative z-10 flex h-full w-full max-w-lg flex-col bg-white shadow-2xl">
<!-- Header -->
<div class="flex items-center justify-between border-b border-neutral-200 px-5 py-4">
<div>
<h2 class="text-base font-bold text-neutral-900">{{ $t('clientTicket.adminTab') }}</h2>
<p class="mt-0.5 text-xs text-neutral-400">{{ projectName }}</p>
</div>
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
@click="close"
>
<Icon name="mdi:close" size="20" />
</button>
</div>
<!-- Filters -->
<div class="flex items-center gap-3 border-b border-neutral-100 px-5 py-3">
<select
v-model="filterStatus"
class="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-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>
<!-- Content -->
<div class="flex-1 overflow-y-auto px-5 py-4">
<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="space-y-2">
<div
v-for="ticket in filteredTickets"
:key="ticket.id"
class="rounded-lg border border-neutral-200 bg-white"
>
<!-- Ticket row -->
<div
class="flex cursor-pointer items-start justify-between gap-3 p-3 transition-colors hover:bg-neutral-50"
@click="toggleExpand(ticket.id)"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-xs 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>
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold"
:class="statusBadgeClass(ticket.status)"
>
{{ $t(`clientTicket.status.${ticket.status}`) }}
</span>
</div>
<p class="mt-1 text-sm font-semibold text-neutral-900 leading-snug">{{ ticket.title }}</p>
<p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
</div>
<div class="flex items-center gap-1">
<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="16" />
</button>
<Icon
:name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'"
size="18"
class="text-neutral-400"
/>
</div>
</div>
<!-- Expanded details -->
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-3 py-3">
<p class="text-sm text-neutral-600 whitespace-pre-wrap">{{ ticket.description }}</p>
<div v-if="ticket.url" class="mt-2">
<a
:href="ticket.url"
target="_blank"
class="text-xs text-primary-500 underline hover:text-primary-600"
>
{{ ticket.url }}
</a>
</div>
<div v-if="ticket.statusComment" class="mt-2 rounded-lg bg-neutral-50 p-2 text-xs text-neutral-500">
{{ ticket.statusComment }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Status change modal -->
<Teleport v-if="statusModalOpen" to="body">
<Transition name="ct-modal" appear>
<div class="fixed inset-0 z-[60] 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-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-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>
</div>
</template>
<script setup lang="ts">
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
import { useClientTicketService } from '~/services/client-tickets'
const props = defineProps<{
projectId: number
projectName: string
}>()
const { t } = useI18n()
const clientTicketService = useClientTicketService()
const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()
const isOpen = ref(false)
const isLoading = ref(false)
const tickets = ref<ClientTicket[]>([])
const filterStatus = ref<string | null>(null)
const expandedId = ref<number | null>(null)
const totalCount = computed(() =>
tickets.value.filter(t => t.status === 'new' || t.status === 'in_progress').length
)
const filteredTickets = computed(() => {
if (!filterStatus.value) return tickets.value
return tickets.value.filter(t => t.status === filterStatus.value)
})
// Status change
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)
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' },
]
return allStatuses.filter(s => {
if (s.value === current) return false
if ((current === 'done' || current === 'rejected') && s.value === 'new') return false
return true
})
})
async function loadTickets() {
isLoading.value = true
try {
tickets.value = await clientTicketService.getAll({ project: props.projectId })
} finally {
isLoading.value = false
}
}
function open() {
isOpen.value = true
loadTickets()
}
function close() {
isOpen.value = false
expandedId.value = null
}
function toggleExpand(id: number) {
expandedId.value = expandedId.value === id ? null : id
}
function openStatusChange(ticket: ClientTicket) {
statusTarget.value = ticket
newStatus.value = null
statusComment.value = ''
rejectionError.value = false
statusModalOpen.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
}
}
</script>
<style scoped>
.ct-panel-enter-active,
.ct-panel-leave-active {
transition: opacity 0.2s ease;
}
.ct-panel-enter-active > div:last-child,
.ct-panel-leave-active > div:last-child {
transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.ct-panel-enter-from,
.ct-panel-leave-to {
opacity: 0;
}
.ct-panel-enter-from > div:last-child,
.ct-panel-leave-to > div:last-child {
transform: translateX(100%);
}
.ct-modal-enter-active,
.ct-modal-leave-active {
transition: opacity 0.15s ease;
}
.ct-modal-enter-from,
.ct-modal-leave-to {
opacity: 0;
}
</style>

View File

@@ -102,6 +102,14 @@
empty-option-label="Aucun groupe"
min-width="w-full"
/>
<MalioSelect
v-if="clientTicketOptions.length"
v-model="form.clientTicketId"
:options="clientTicketOptions"
label="Ticket client"
empty-option-label="Aucun ticket client"
min-width="w-full"
/>
</div>
<!-- Tags -->
@@ -245,8 +253,10 @@
<script setup lang="ts">
import type { Task, TaskWrite } from '~/services/dto/task'
import type { TaskDocument } from '~/services/dto/task-document'
import type { ClientTicket } from '~/services/dto/client-ticket'
import { useGiteaService } from '~/services/gitea'
import { useTaskDocumentService } from '~/services/task-documents'
import { useClientTicketService } from '~/services/client-tickets'
import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue'
import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskEffort } from '~/services/dto/task-effort'
@@ -306,6 +316,7 @@ const form = reactive({
assigneeId: null as number | null,
groupId: null as number | null,
tagIds: [] as number[],
clientTicketId: null as number | null,
})
const touched = reactive({
@@ -362,6 +373,7 @@ function populateForm(task: Task | null) {
form.assigneeId = task.assignee?.id ?? null
form.groupId = task.group?.id ?? null
form.tagIds = task.tags.map(t => t.id)
form.clientTicketId = task.clientTicket?.id ?? null
} else {
form.title = ''
form.description = ''
@@ -371,13 +383,19 @@ function populateForm(task: Task | null) {
form.assigneeId = null
form.groupId = null
form.tagIds = []
form.clientTicketId = null
}
touched.title = false
}
watch(() => props.modelValue, (open) => {
watch(() => props.modelValue, async (open) => {
if (open) {
populateForm(props.task)
try {
clientTickets.value = await clientTicketService.getAll({ project: props.projectId })
} catch {
clientTickets.value = []
}
}
})
@@ -400,8 +418,14 @@ watch(() => props.modelValue, async (open) => {
const { create, update, remove } = useTaskService()
const { remove: removeDocument, getByTask: getDocumentsByTask } = useTaskDocumentService()
const clientTicketService = useClientTicketService()
const { t } = useI18n()
const clientTickets = ref<ClientTicket[]>([])
const clientTicketOptions = computed(() =>
clientTickets.value.map(ct => ({ label: `CT-${String(ct.number).padStart(3, '0')}${ct.title}`, value: ct.id }))
)
const authStore = useAuthStore()
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
@@ -532,6 +556,7 @@ async function handleSubmit() {
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
project: `/api/projects/${props.projectId}`,
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
clientTicket: form.clientTicketId ? `/api/client_tickets/${form.clientTicketId}` : null,
}
if (isEditing.value && props.task) {

View File

@@ -0,0 +1,161 @@
<template>
<div class="date-filter">
<VueDatePicker
v-model="internalValue"
range
:enable-time-picker="false"
:locale="frLocale"
:format="formatDate"
auto-apply
:multi-calendars="false"
position="left"
@update:model-value="onUpdate"
>
<template #dp-input="{ value, onInput, onEnter, onTab, onClear, openMenu }">
<div class="relative">
<input
:value="value"
class="w-full cursor-pointer rounded-md border border-neutral-300 bg-white px-3 py-[7px] text-sm text-neutral-700 outline-none transition placeholder:text-neutral-400 focus:border-primary-500"
:placeholder="placeholder || t('common.dateFilter')"
readonly
@click="openMenu"
@input="onInput"
@keydown.enter="onEnter"
@keydown.tab="onTab"
/>
<button
v-if="value"
class="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600"
@click.stop="onClear"
>
<Icon name="mdi:close-circle" size="16" />
</button>
<Icon
v-else
name="mdi:calendar"
size="16"
class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400"
/>
</div>
</template>
<template #action-buttons>
<div class="flex gap-2 px-3 pb-2">
<button
class="rounded px-2 py-1 text-xs font-medium text-primary-500 hover:bg-primary-500/10 transition"
@click="selectToday"
>
{{ t('common.today') }}
</button>
<button
class="rounded px-2 py-1 text-xs font-medium text-primary-500 hover:bg-primary-500/10 transition"
@click="selectThisWeek"
>
{{ t('common.thisWeek') }}
</button>
</div>
</template>
</VueDatePicker>
</div>
</template>
<script setup lang="ts">
import { VueDatePicker } from '@vuepic/vue-datepicker'
import '@vuepic/vue-datepicker/dist/main.css'
import { fr as frLocale } from 'date-fns/locale'
const { t } = useI18n()
const props = defineProps<{
modelValue?: Date | [Date, Date] | null
placeholder?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: Date | [Date, Date] | null]
}>()
const internalValue = ref<Date[] | null>(null)
function formatDate(dates: Date[]): string {
if (!dates || dates.length === 0) return ''
if (dates.length === 1) return formatSingleDate(dates[0])
if (isSameDay(dates[0], dates[1])) return formatSingleDate(dates[0])
return `${formatSingleDate(dates[0])} - ${formatSingleDate(dates[1])}`
}
function formatSingleDate(d: Date): string {
const day = String(d.getDate()).padStart(2, '0')
const month = String(d.getMonth() + 1).padStart(2, '0')
const year = d.getFullYear()
return `${day}/${month}/${year}`
}
function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate()
}
function onUpdate(value: Date[] | null) {
if (!value || value.length === 0) {
emit('update:modelValue', null)
return
}
if (value.length === 2 && isSameDay(value[0], value[1])) {
emit('update:modelValue', value[0])
} else if (value.length === 2) {
emit('update:modelValue', [value[0], value[1]])
}
}
function selectToday() {
const today = new Date()
today.setHours(0, 0, 0, 0)
internalValue.value = [today, today]
emit('update:modelValue', today)
}
function selectThisWeek() {
const now = new Date()
const day = now.getDay()
const monday = new Date(now)
monday.setDate(now.getDate() - day + (day === 0 ? -6 : 1))
monday.setHours(0, 0, 0, 0)
const sunday = new Date(monday)
sunday.setDate(monday.getDate() + 6)
sunday.setHours(23, 59, 59, 999)
internalValue.value = [monday, sunday]
emit('update:modelValue', [monday, sunday])
}
watch(() => props.modelValue, (val) => {
if (val === null || val === undefined) {
internalValue.value = null
} else if (Array.isArray(val)) {
internalValue.value = [...val]
} else {
internalValue.value = [val, val]
}
}, { immediate: true })
</script>
<style>
.date-filter .dp__theme_light {
--dp-primary-color: #222783;
--dp-primary-text-color: #fff;
--dp-border-color: #d4d4d8;
--dp-menu-border-color: #d4d4d8;
--dp-border-color-hover: #222783;
--dp-hover-color: #f3f4f8;
--dp-font-size: 0.875rem;
}
.date-filter .dp__input_wrap {
width: auto;
}
.date-filter .dp__main {
font-family: inherit;
}
</style>

View File

@@ -166,7 +166,13 @@
},
"common": {
"cancel": "Annuler",
"loading": "Chargement..."
"save": "Enregistrer",
"edit": "Modifier",
"loading": "Chargement...",
"dateFilter": "Date",
"today": "Aujourd'hui",
"thisWeek": "Cette semaine",
"clear": "Effacer"
},
"gitea": {
"settings": {

View File

@@ -62,5 +62,8 @@ export default defineNuxtConfig({
},
typescript: {
strict: true
},
build: {
transpile: ['@vuepic/vue-datepicker']
}
})

View File

@@ -12,6 +12,7 @@
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
"@pinia/nuxt": "^0.11.3",
"@vuepic/vue-datepicker": "^12.1.0",
"chart.js": "^4.5.1",
"nuxt": "^4.3.1",
"nuxt-toast": "^1.4.0",
@@ -541,6 +542,12 @@
"postcss-selector-parser": "^7.0.0"
}
},
"node_modules/@date-fns/tz": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT"
},
"node_modules/@dxup/nuxt": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@dxup/nuxt/-/nuxt-0.3.2.tgz",
@@ -1094,6 +1101,68 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@floating-ui/vue": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.11.tgz",
"integrity": "sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.6",
"@floating-ui/utils": "^0.2.11",
"vue-demi": ">=0.13.0"
}
},
"node_modules/@floating-ui/vue/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -5259,6 +5328,12 @@
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
"license": "MIT"
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz",
@@ -5720,6 +5795,62 @@
"integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==",
"license": "MIT"
},
"node_modules/@vuepic/vue-datepicker": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/@vuepic/vue-datepicker/-/vue-datepicker-12.1.0.tgz",
"integrity": "sha512-QuWcO+CqIGYFoRNCagp9xUY9sMK/OHUlVIDxBYjw7HjCTWXfuE/r3l3loB00faEtb0Teo3DeBn26hT3tYA5pgg==",
"license": "MIT",
"dependencies": {
"@date-fns/tz": "^1.4.1",
"@floating-ui/vue": "^1.1.9",
"@vueuse/core": "^14.1.0",
"date-fns": "^4.1.0"
},
"engines": {
"node": ">=18.12.0"
},
"peerDependencies": {
"vue": ">=3.5.0"
}
},
"node_modules/@vueuse/core": {
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz",
"integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.2.1",
"@vueuse/shared": "14.2.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz",
"integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz",
"integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/abbrev": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
@@ -7126,6 +7257,16 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/db0": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz",

View File

@@ -16,6 +16,7 @@
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
"@pinia/nuxt": "^0.11.3",
"@vuepic/vue-datepicker": "^12.1.0",
"chart.js": "^4.5.1",
"nuxt": "^4.3.1",
"nuxt-toast": "^1.4.0",

View File

@@ -30,34 +30,56 @@
{{ $t('clientTicket.noTickets') }}
</div>
<div v-else class="mt-4 space-y-3">
<!-- Kanban board -->
<div v-else class="mt-4 flex flex-col gap-4 sm:flex-row sm:overflow-x-auto sm:pb-4">
<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)"
v-for="col in columns"
:key="col.status"
class="min-w-0 flex-1 sm:min-w-[280px]"
>
<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 class="mb-3 flex items-center gap-2">
<div class="h-2 w-2 rounded-full" :class="col.dotClass" />
<h3 class="text-sm font-bold text-neutral-700">{{ col.label }}</h3>
<span class="ml-auto rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-semibold text-neutral-500">
{{ col.tickets.length }}
</span>
</div>
<div
class="min-h-[60px] space-y-2 rounded-lg border-2 border-transparent p-1 transition-colors"
:class="dragOverStatus === col.status ? 'border-primary-300 bg-primary-50/50' : ''"
@dragover.prevent="onDragOver(col.status)"
@dragleave="onDragLeave"
@drop.prevent="onDrop(col.status)"
>
<div
v-for="ticket in col.tickets"
:key="ticket.id"
class="cursor-pointer rounded-lg border border-neutral-200 bg-white p-3 shadow-sm transition hover:shadow-md"
:class="isAdmin ? 'cursor-grab active:cursor-grabbing' : ''"
:draggable="isAdmin"
@dragstart="onDragStart(ticket)"
@dragend="onDragEnd"
@click="openDetail(ticket)"
>
<div class="flex items-center gap-2">
<span class="text-xs 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.5 text-sm font-semibold leading-snug text-neutral-900">{{ ticket.title }}</h4>
<p class="mt-1.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
</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
v-if="col.tickets.length === 0"
class="py-4 text-center text-xs text-neutral-400"
>
{{ $t('clientTicket.noTickets') }}
</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>
@@ -65,13 +87,49 @@
<ClientTicketDetailModal
v-model="detailOpen"
:ticket="selectedTicket"
@refresh="loadTickets"
/>
<!-- Reject comment modal -->
<Teleport v-if="rejectModalOpen" to="body">
<div class="fixed inset-0 z-[60] flex items-center justify-center p-4">
<div class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" @click="cancelReject" />
<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 class="mt-2 text-sm text-neutral-600">{{ $t('clientTicket.rejectionRequired') }}</p>
<textarea
v-model="rejectComment"
rows="3"
class="mt-3 w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
:placeholder="$t('clientTicket.rejectComment')"
/>
<div class="mt-4 flex justify-end gap-3">
<button
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
@click="cancelReject"
>
{{ $t('common.cancel') }}
</button>
<button
type="button"
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50"
:disabled="!rejectComment.trim()"
@click="confirmReject"
>
{{ $t('clientTicket.status.rejected') }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import type { ClientTicket } from '~/services/dto/client-ticket'
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
import { useClientTicketService } from '~/services/client-tickets'
import { useProjectService } from '~/services/projects'
definePageMeta({
layout: 'portal',
@@ -84,40 +142,143 @@ const projectId = computed(() => Number(route.params.id))
useHead({ title: t('portal.title') })
const clientTicketService = useClientTicketService()
const projectService = useProjectService()
const auth = useAuthStore()
const tickets = ref<ClientTicket[]>([])
const projectName = ref('')
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 ''
})
const isClient = computed(() => auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN'))
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
const { typeBadgeClass, formatDate } = useClientTicketHelpers()
const isClient = computed(() => auth.user?.roles?.includes('ROLE_CLIENT') ?? false)
const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()
const allStatuses: ClientTicketStatus[] = ['new', 'in_progress', 'done', 'rejected']
function statusDotClass(status: string): string {
switch (status) {
case 'new': return 'bg-blue-500'
case 'in_progress': return 'bg-yellow-500'
case 'done': return 'bg-green-500'
case 'rejected': return 'bg-red-500'
default: return 'bg-neutral-400'
}
}
const columns = computed(() => allStatuses.map(status => ({
status,
label: t(`clientTicket.status.${status}`),
dotClass: statusDotClass(status),
tickets: tickets.value.filter(tk => tk.status === status),
})))
// Drag & drop (admin only)
const draggedTicket = ref<ClientTicket | null>(null)
const dragOverStatus = ref<ClientTicketStatus | null>(null)
function onDragStart(ticket: ClientTicket) {
draggedTicket.value = ticket
}
function onDragEnd() {
draggedTicket.value = null
dragOverStatus.value = null
}
function onDragOver(status: ClientTicketStatus) {
if (!draggedTicket.value) return
dragOverStatus.value = status
}
function onDragLeave() {
dragOverStatus.value = null
}
async function onDrop(newStatus: ClientTicketStatus) {
dragOverStatus.value = null
const ticket = draggedTicket.value
draggedTicket.value = null
if (!ticket || ticket.status === newStatus) return
// Rejected requires a comment
if (newStatus === 'rejected') {
pendingRejectTicket.value = ticket
rejectComment.value = ''
rejectModalOpen.value = true
return
}
// Optimistic update
const oldStatus = ticket.status
ticket.status = newStatus
try {
await clientTicketService.updateStatus(ticket.id, { status: newStatus })
await loadTickets()
} catch {
ticket.status = oldStatus
}
}
// Reject modal
const rejectModalOpen = ref(false)
const rejectComment = ref('')
const pendingRejectTicket = ref<ClientTicket | null>(null)
function cancelReject() {
rejectModalOpen.value = false
pendingRejectTicket.value = null
rejectComment.value = ''
}
async function confirmReject() {
const ticket = pendingRejectTicket.value
if (!ticket || !rejectComment.value.trim()) return
const oldStatus = ticket.status
ticket.status = 'rejected'
rejectModalOpen.value = false
try {
await clientTicketService.updateStatus(ticket.id, {
status: 'rejected',
statusComment: rejectComment.value.trim(),
})
await loadTickets()
} catch {
ticket.status = oldStatus
}
pendingRejectTicket.value = null
rejectComment.value = ''
}
function openDetail(ticket: ClientTicket) {
selectedTicket.value = ticket
detailOpen.value = true
}
async function loadTickets() {
async function loadData() {
isLoading.value = true
try {
tickets.value = await clientTicketService.getAll({ project: projectId.value })
const [ticketList, project] = await Promise.all([
clientTicketService.getAll({ project: projectId.value }),
projectService.getById(projectId.value),
])
tickets.value = ticketList
projectName.value = project.name
} finally {
isLoading.value = false
}
}
async function loadTickets() {
tickets.value = await clientTicketService.getAll({ project: projectId.value })
}
onMounted(() => {
loadTickets()
loadData()
})
</script>

View File

@@ -3,13 +3,22 @@
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<div class="flex items-center justify-between gap-3">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
<button
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"
@click="openTaskCreate"
>
<span class="hidden sm:inline">+ Ajouter un ticket</span>
<span class="sm:hidden">+ Ticket</span>
</button>
<div class="flex items-center gap-2">
<button
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"
@click="openTaskCreate"
>
<span class="hidden sm:inline">+ Ajouter un ticket</span>
<span class="sm:hidden">+ Ticket</span>
</button>
<button
class="flex shrink-0 items-center rounded-md bg-neutral-200 px-3 py-2 text-neutral-600 hover:bg-neutral-300 sm:px-4"
title="Paramètres du projet"
@click="projectDrawerOpen = true"
>
<Icon name="heroicons:cog-6-tooth" class="size-4 sm:size-5" />
</button>
</div>
</div>
<div class="mt-4 flex flex-wrap gap-3">
@@ -120,6 +129,13 @@
@saved="onSaved"
/>
<ProjectDrawer
v-model="projectDrawerOpen"
:project="project"
:clients="clients"
@saved="onProjectSaved"
/>
</div>
</template>
@@ -132,7 +148,9 @@ import type { TaskPriority } from '~/services/dto/task-priority'
import type { TaskTag } from '~/services/dto/task-tag'
import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data'
import type { Client } from '~/services/dto/client'
import { useProjectService } from '~/services/projects'
import { useClientService } from '~/services/clients'
import { useTaskService } from '~/services/tasks'
import { useTaskStatusService } from '~/services/task-statuses'
import { useTaskEffortService } from '~/services/task-efforts'
@@ -147,6 +165,7 @@ const projectId = computed(() => Number(route.params.id))
useHead({ title: 'Projet' })
const projectService = useProjectService()
const clientService = useClientService()
const taskService = useTaskService()
const statusService = useTaskStatusService()
const effortService = useTaskEffortService()
@@ -163,6 +182,7 @@ const priorities = ref<TaskPriority[]>([])
const tags = ref<TaskTag[]>([])
const groups = ref<TaskGroup[]>([])
const users = ref<UserData[]>([])
const clients = ref<Client[]>([])
const isLoading = ref(true)
const selectedGroupId = ref<number | null>(null)
@@ -172,6 +192,7 @@ const selectedStatusId = ref<number | null>(null)
const dragOverStatusId = ref<number | null>(null)
const dragCounter = ref(0)
const taskDrawerOpen = ref(false)
const projectDrawerOpen = ref(false)
const selectedTask = ref<Task | null>(null)
const groupFilterOptions = computed(() =>
@@ -218,7 +239,7 @@ const backlogTasks = computed(() =>
async function loadData() {
isLoading.value = true
try {
const [p, t, s, e, pr, ty, g, u] = await Promise.all([
const [p, t, s, e, pr, ty, g, u, c] = await Promise.all([
projectService.getById(projectId.value),
taskService.getByProject(projectId.value),
statusService.getAll(),
@@ -227,6 +248,7 @@ async function loadData() {
tagService.getAll(),
groupService.getByProject(projectId.value),
userService.getAll(),
clientService.getAll(),
])
project.value = p
tasks.value = t
@@ -236,6 +258,7 @@ async function loadData() {
tags.value = ty
groups.value = g
users.value = u
clients.value = c
} finally {
isLoading.value = false
}
@@ -290,6 +313,10 @@ async function onSaved() {
await loadData()
}
async function onProjectSaved() {
await loadData()
}
onMounted(() => {
loadData()
})

View File

@@ -70,6 +70,8 @@
text-value="text-sm"
/>
</div>
<DateFilter v-model="selectedDateFilter" />
</div>
</div>
@@ -136,6 +138,7 @@ const startDate = ref(getMonday(new Date()))
const selectedUserId = ref<number | null>(authStore.user?.id ?? null)
const selectedTagId = ref<number | null>(null)
const selectedProjectId = ref<number | null>(null)
const selectedDateFilter = ref<Date | [Date, Date] | null>(null)
const entries = ref<TimeEntry[]>([])
const users = ref<UserData[]>([])
@@ -189,6 +192,28 @@ const filteredEntries = computed(() => {
if (selectedTagId.value) {
result = result.filter((e) => e.tags.some((t) => t.id === selectedTagId.value))
}
if (selectedDateFilter.value) {
if (Array.isArray(selectedDateFilter.value)) {
const [start, end] = selectedDateFilter.value
const startDay = new Date(start)
startDay.setHours(0, 0, 0, 0)
const endDay = new Date(end)
endDay.setHours(23, 59, 59, 999)
result = result.filter((e) => {
const entryDate = new Date(e.startedAt)
return entryDate >= startDay && entryDate <= endDay
})
} else {
const day = new Date(selectedDateFilter.value)
day.setHours(0, 0, 0, 0)
const nextDay = new Date(day)
nextDay.setDate(nextDay.getDate() + 1)
result = result.filter((e) => {
const entryDate = new Date(e.startedAt)
return entryDate >= day && entryDate < nextDay
})
}
}
return result
})

View File

@@ -30,11 +30,17 @@ export function useClientTicketService() {
})
}
async function update(id: number, data: Partial<ClientTicketWrite>): Promise<ClientTicket> {
return api.patch<ClientTicket>(`/client_tickets/${id}`, data 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 }
return { getAll, getById, create, update, updateStatus, remove }
}

View File

@@ -42,4 +42,5 @@ export type TaskWrite = {
project: string
tags: string[]
archived?: boolean
clientTicket?: string | null
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\Client;
use App\Entity\ClientTicket;
use App\Entity\Project;
use App\Entity\Task;
use App\Entity\TaskEffort;
@@ -28,7 +29,7 @@ class AppFixtures extends Fixture
public function load(ObjectManager $manager): void
{
// User admin
// Users
$admin = new User();
$admin->setUsername('admin');
$admin->setRoles(['ROLE_ADMIN']);
@@ -36,6 +37,24 @@ class AppFixtures extends Fixture
$admin->setApiToken('dev-mcp-token-for-testing-only-do-not-use-in-production');
$manager->persist($admin);
$userAlice = new User();
$userAlice->setUsername('alice');
$userAlice->setRoles(['ROLE_USER']);
$userAlice->setPassword($this->passwordHasher->hashPassword($userAlice, 'alice'));
$manager->persist($userAlice);
$userBob = new User();
$userBob->setUsername('bob');
$userBob->setRoles(['ROLE_USER']);
$userBob->setPassword($this->passwordHasher->hashPassword($userBob, 'bob'));
$manager->persist($userBob);
$userCharlie = new User();
$userCharlie->setUsername('charlie');
$userCharlie->setRoles(['ROLE_USER']);
$userCharlie->setPassword($this->passwordHasher->hashPassword($userCharlie, 'charlie'));
$manager->persist($userCharlie);
// Clients
$clientLiot = new Client();
$clientLiot->setName('LIOT');
@@ -251,7 +270,7 @@ class AppFixtures extends Fixture
$task2->setStatus($statusTodo);
$task2->setEffort($effortL);
$task2->setPriority($priorityHigh);
$task2->setAssignee($admin);
$task2->setAssignee($userAlice);
$task2->setGroup($groupFrontend);
$task2->setProject($projectSirh);
$task2->addTag($tagAuth);
@@ -275,7 +294,7 @@ class AppFixtures extends Fixture
$task4->setStatus($statusBlocked);
$task4->setEffort($effortXXL);
$task4->setPriority($priorityLow);
$task4->setAssignee($admin);
$task4->setAssignee($userBob);
$task4->setProject($projectSirh);
$task4->addTag($tagPassword);
$manager->persist($task4);
@@ -286,7 +305,7 @@ class AppFixtures extends Fixture
$task5->setStatus($statusReview);
$task5->setEffort($effortXXL);
$task5->setPriority($priorityMedium);
$task5->setAssignee($admin);
$task5->setAssignee($userCharlie);
$task5->setProject($projectSirh);
$task5->addTag($tagCalendar);
$manager->persist($task5);
@@ -322,7 +341,7 @@ class AppFixtures extends Fixture
$taskCrm2->setStatus($statusInProgress);
$taskCrm2->setEffort($effortM);
$taskCrm2->setPriority($priorityMedium);
$taskCrm2->setAssignee($admin);
$taskCrm2->setAssignee($userAlice);
$taskCrm2->setGroup($groupCrmUi);
$taskCrm2->setProject($projectCrm);
$manager->persist($taskCrm2);
@@ -344,7 +363,7 @@ class AppFixtures extends Fixture
$taskCrm4->setStatus($statusInProgress);
$taskCrm4->setEffort($effortXXL);
$taskCrm4->setPriority($priorityHigh);
$taskCrm4->setAssignee($admin);
$taskCrm4->setAssignee($userBob);
$taskCrm4->setGroup($groupCrmUi);
$taskCrm4->setProject($projectCrm);
$taskCrm4->addTag($tagCalendar);
@@ -381,7 +400,7 @@ class AppFixtures extends Fixture
$taskErp2->setStatus($statusInProgress);
$taskErp2->setEffort($effortM);
$taskErp2->setPriority($priorityHigh);
$taskErp2->setAssignee($admin);
$taskErp2->setAssignee($userCharlie);
$taskErp2->setGroup($groupErpStock);
$taskErp2->setProject($projectErp);
$manager->persist($taskErp2);
@@ -451,7 +470,7 @@ class AppFixtures extends Fixture
$taskSite2->setStatus($statusInProgress);
$taskSite2->setEffort($effortL);
$taskSite2->setPriority($priorityMedium);
$taskSite2->setAssignee($admin);
$taskSite2->setAssignee($userAlice);
$taskSite2->setGroup($groupSiteDesign);
$taskSite2->setProject($projectInterne);
$manager->persist($taskSite2);
@@ -543,6 +562,94 @@ class AppFixtures extends Fixture
$manager->persist($entry);
}
// =============================================
// Client Users
// =============================================
$clientUserLiot = new User();
$clientUserLiot->setUsername('client-liot');
$clientUserLiot->setRoles(['ROLE_CLIENT']);
$clientUserLiot->setPassword($this->passwordHasher->hashPassword($clientUserLiot, 'client'));
$clientUserLiot->setClient($clientLiot);
$clientUserLiot->addAllowedProject($projectSirh);
$manager->persist($clientUserLiot);
$clientUserAcme = new User();
$clientUserAcme->setUsername('client-acme');
$clientUserAcme->setRoles(['ROLE_CLIENT']);
$clientUserAcme->setPassword($this->passwordHasher->hashPassword($clientUserAcme, 'client'));
$clientUserAcme->setClient($clientAcme);
$clientUserAcme->addAllowedProject($projectCrm);
$manager->persist($clientUserAcme);
// =============================================
// Client Tickets
// =============================================
$ticket1 = new ClientTicket();
$ticket1->setNumber(1);
$ticket1->setType('bug');
$ticket1->setTitle('Erreur 500 sur la page de login');
$ticket1->setDescription('Quand je clique sur "Se connecter" avec un mot de passe vide, j\'obtiens une page blanche avec une erreur 500.');
$ticket1->setUrl('https://sirh.liot.fr/login');
$ticket1->setStatus('new');
$ticket1->setProject($projectSirh);
$ticket1->setSubmittedBy($clientUserLiot);
$ticket1->setCreatedAt(new DateTimeImmutable('-3 days'));
$ticket1->setUpdatedAt(new DateTimeImmutable('-3 days'));
$manager->persist($ticket1);
$ticket2 = new ClientTicket();
$ticket2->setNumber(2);
$ticket2->setType('improvement');
$ticket2->setTitle('Ajouter un export PDF des fiches employés');
$ticket2->setDescription('Il serait utile de pouvoir exporter les fiches employés au format PDF pour les archiver.');
$ticket2->setStatus('in_progress');
$ticket2->setProject($projectSirh);
$ticket2->setSubmittedBy($clientUserLiot);
$ticket2->setCreatedAt(new DateTimeImmutable('-7 days'));
$ticket2->setUpdatedAt(new DateTimeImmutable('-2 days'));
$manager->persist($ticket2);
$ticket3 = new ClientTicket();
$ticket3->setNumber(3);
$ticket3->setType('other');
$ticket3->setTitle('Demande de formation sur le module congés');
$ticket3->setDescription('Notre équipe RH souhaiterait une formation sur le nouveau module de gestion des congés.');
$ticket3->setStatus('done');
$ticket3->setStatusComment('Formation planifiée le 20/03. Ticket clos.');
$ticket3->setProject($projectSirh);
$ticket3->setSubmittedBy($clientUserLiot);
$ticket3->setCreatedAt(new DateTimeImmutable('-14 days'));
$ticket3->setUpdatedAt(new DateTimeImmutable('-5 days'));
$manager->persist($ticket3);
$ticket4 = new ClientTicket();
$ticket4->setNumber(1);
$ticket4->setType('bug');
$ticket4->setTitle('Doublons dans la liste des contacts');
$ticket4->setDescription('Certains contacts apparaissent en double après l\'import CSV. Le problème semble lié aux accents dans les noms.');
$ticket4->setStatus('new');
$ticket4->setProject($projectCrm);
$ticket4->setSubmittedBy($clientUserAcme);
$ticket4->setCreatedAt(new DateTimeImmutable('-1 day'));
$ticket4->setUpdatedAt(new DateTimeImmutable('-1 day'));
$manager->persist($ticket4);
$ticket5 = new ClientTicket();
$ticket5->setNumber(2);
$ticket5->setType('improvement');
$ticket5->setTitle('Filtre par date sur le pipeline de vente');
$ticket5->setDescription('Pouvoir filtrer le pipeline de vente par période (mois, trimestre, année).');
$ticket5->setStatus('rejected');
$ticket5->setStatusComment('Cette fonctionnalité est déjà prévue dans la prochaine version. Pas besoin de ticket spécifique.');
$ticket5->setProject($projectCrm);
$ticket5->setSubmittedBy($clientUserAcme);
$ticket5->setCreatedAt(new DateTimeImmutable('-10 days'));
$ticket5->setUpdatedAt(new DateTimeImmutable('-8 days'));
$manager->persist($ticket5);
// Link a task to a client ticket
$task3->setClientTicket($ticket1);
$manager->flush();
}
}

View File

@@ -36,7 +36,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
processor: ClientTicketNumberProcessor::class,
),
new Patch(
security: "is_granted('ROLE_ADMIN')",
security: "is_granted('ROLE_ADMIN') or (is_granted('ROLE_CLIENT') and object.getSubmittedBy() == user)",
processor: ClientTicketStatusProcessor::class,
),
new Delete(security: "is_granted('ROLE_ADMIN')"),

View File

@@ -10,6 +10,7 @@ use App\Entity\ClientTicket;
use App\Service\NotificationService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
@@ -25,6 +26,7 @@ final readonly class ClientTicketStatusProcessor implements ProcessorInterface
public function __construct(
private EntityManagerInterface $entityManager,
private NotificationService $notificationService,
private Security $security,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClientTicket
@@ -32,6 +34,13 @@ final readonly class ClientTicketStatusProcessor implements ProcessorInterface
assert($data instanceof ClientTicket);
$originalData = $context['previous_data'] ?? null;
// ROLE_CLIENT: can only edit content fields, not status
if (!$this->security->isGranted('ROLE_ADMIN') && $originalData instanceof ClientTicket) {
$data->setStatus($originalData->getStatus());
$data->setStatusComment($originalData->getStatusComment());
}
if ($originalData instanceof ClientTicket) {
$oldStatus = $originalData->getStatus();
$newStatus = $data->getStatus();