Compare commits
10 Commits
6c910e7fcc
...
318b6198da
| Author | SHA1 | Date | |
|---|---|---|---|
| 318b6198da | |||
| 4e3e854aa2 | |||
| 49cd971e3e | |||
| ffe4a0117c | |||
| d2f6d84d03 | |||
| 2a874046d3 | |||
| f09ef67117 | |||
| 046ee396d3 | |||
| 0ba487cfa9 | |||
| a2fc8e6e52 |
@@ -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
|
||||||
@@ -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"
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -27,90 +27,162 @@
|
|||||||
{{ $t('portal.ticketDetail') }}
|
{{ $t('portal.ticketDetail') }}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="flex items-center gap-2">
|
||||||
type="button"
|
<!-- Edit button (only for open tickets submitted by current user) -->
|
||||||
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"
|
<button
|
||||||
@click="close"
|
v-if="canEdit && !isEditing"
|
||||||
>
|
type="button"
|
||||||
<Icon name="mdi:close" size="20" />
|
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"
|
||||||
</button>
|
@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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<div v-if="ticket" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
|
<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 -->
|
<!-- Edit mode -->
|
||||||
<div class="mt-3 flex items-center gap-2">
|
<template v-if="isEditing">
|
||||||
<span
|
<div>
|
||||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||||
:class="typeBadgeClass(ticket.type)"
|
{{ $t('clientTicket.fields.title') }}
|
||||||
>
|
</label>
|
||||||
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
<input
|
||||||
</span>
|
v-model="editForm.title"
|
||||||
<span
|
type="text"
|
||||||
class="rounded-full px-3 py-1 text-xs font-semibold"
|
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"
|
||||||
:class="statusBadgeClass(ticket.status)"
|
/>
|
||||||
>
|
</div>
|
||||||
{{ $t(`clientTicket.status.${ticket.status}`) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description -->
|
<div class="mt-4">
|
||||||
<div class="mt-4">
|
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||||
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p>
|
{{ $t('clientTicket.description') }}
|
||||||
<p class="mt-1 whitespace-pre-wrap text-sm text-neutral-600">{{ ticket.description }}</p>
|
</label>
|
||||||
</div>
|
<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.type === 'bug'" class="mt-4">
|
||||||
<div v-if="ticket.url" class="mt-4">
|
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||||
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.url') }}</p>
|
{{ $t('clientTicket.fields.url') }}
|
||||||
<a
|
</label>
|
||||||
:href="ticket.url"
|
<input
|
||||||
target="_blank"
|
v-model="editForm.url"
|
||||||
class="mt-1 text-sm text-primary-500 underline hover:text-primary-600"
|
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"
|
||||||
{{ ticket.url }}
|
:placeholder="$t('clientTicket.fields.urlPlaceholder')"
|
||||||
</a>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status comment -->
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
<div v-if="ticket.statusComment" class="mt-4">
|
<button
|
||||||
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.statusComment') }}</p>
|
type="button"
|
||||||
<p class="mt-1 whitespace-pre-wrap rounded-lg bg-neutral-50 p-3 text-sm text-neutral-600">{{ ticket.statusComment }}</p>
|
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||||
</div>
|
@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 -->
|
<!-- View mode -->
|
||||||
<TaskDocumentList
|
<template v-else>
|
||||||
v-if="localDocuments.length"
|
<!-- Title -->
|
||||||
:documents="localDocuments"
|
<h3 class="text-base font-bold text-neutral-900">{{ ticket.title }}</h3>
|
||||||
:is-admin="false"
|
|
||||||
@preview="openPreview"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Document preview -->
|
<!-- Badges -->
|
||||||
<TaskDocumentPreview
|
<div class="mt-3 flex items-center gap-2">
|
||||||
:document="previewDoc"
|
<span
|
||||||
:has-prev="previewIndex > 0"
|
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||||
:has-next="previewIndex < localDocuments.length - 1"
|
:class="typeBadgeClass(ticket.type)"
|
||||||
@close="previewDoc = null"
|
>
|
||||||
@prev="prevPreview"
|
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
||||||
@next="nextPreview"
|
</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 -->
|
<!-- Description -->
|
||||||
<TaskDocumentUpload
|
<div class="mt-4">
|
||||||
v-if="ticket"
|
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p>
|
||||||
:client-ticket-id="ticket.id"
|
<p class="mt-1 whitespace-pre-wrap text-sm text-neutral-600">{{ ticket.description }}</p>
|
||||||
@uploaded="refreshDocuments"
|
</div>
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Date -->
|
<!-- URL (if bug) -->
|
||||||
<p class="mt-6 text-xs text-neutral-400">
|
<div v-if="ticket.url" class="mt-4">
|
||||||
{{ $t('clientTicket.createdAt') }} : {{ formatDate(ticket.createdAt) }}
|
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.url') }}</p>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,6 +194,7 @@
|
|||||||
import type { ClientTicket } from '~/services/dto/client-ticket'
|
import type { ClientTicket } from '~/services/dto/client-ticket'
|
||||||
import type { TaskDocument } from '~/services/dto/task-document'
|
import type { TaskDocument } from '~/services/dto/task-document'
|
||||||
import { useTaskDocumentService } from '~/services/task-documents'
|
import { useTaskDocumentService } from '~/services/task-documents'
|
||||||
|
import { useClientTicketService } from '~/services/client-tickets'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
@@ -130,6 +203,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:modelValue', value: boolean): void
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
(e: 'refresh'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isOpen = computed({
|
const isOpen = computed({
|
||||||
@@ -138,12 +212,79 @@ const isOpen = computed({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
|
isEditing.value = false
|
||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const { getByTicket } = useTaskDocumentService()
|
const auth = useAuthStore()
|
||||||
|
const { getByTicket, remove: removeDocument } = useTaskDocumentService()
|
||||||
|
const clientTicketService = useClientTicketService()
|
||||||
const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()
|
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() {
|
async function refreshDocuments() {
|
||||||
if (!props.ticket) return
|
if (!props.ticket) return
|
||||||
localDocuments.value = await getByTicket(props.ticket.id)
|
localDocuments.value = await getByTicket(props.ticket.id)
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -102,6 +102,14 @@
|
|||||||
empty-option-label="Aucun groupe"
|
empty-option-label="Aucun groupe"
|
||||||
min-width="w-full"
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Tags -->
|
<!-- Tags -->
|
||||||
@@ -245,8 +253,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Task, TaskWrite } from '~/services/dto/task'
|
import type { Task, TaskWrite } from '~/services/dto/task'
|
||||||
import type { TaskDocument } from '~/services/dto/task-document'
|
import type { TaskDocument } from '~/services/dto/task-document'
|
||||||
|
import type { ClientTicket } from '~/services/dto/client-ticket'
|
||||||
import { useGiteaService } from '~/services/gitea'
|
import { useGiteaService } from '~/services/gitea'
|
||||||
import { useTaskDocumentService } from '~/services/task-documents'
|
import { useTaskDocumentService } from '~/services/task-documents'
|
||||||
|
import { useClientTicketService } from '~/services/client-tickets'
|
||||||
import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue'
|
import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue'
|
||||||
import type { TaskStatus } from '~/services/dto/task-status'
|
import type { TaskStatus } from '~/services/dto/task-status'
|
||||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||||
@@ -306,6 +316,7 @@ const form = reactive({
|
|||||||
assigneeId: null as number | null,
|
assigneeId: null as number | null,
|
||||||
groupId: null as number | null,
|
groupId: null as number | null,
|
||||||
tagIds: [] as number[],
|
tagIds: [] as number[],
|
||||||
|
clientTicketId: null as number | null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const touched = reactive({
|
const touched = reactive({
|
||||||
@@ -362,6 +373,7 @@ function populateForm(task: Task | null) {
|
|||||||
form.assigneeId = task.assignee?.id ?? null
|
form.assigneeId = task.assignee?.id ?? null
|
||||||
form.groupId = task.group?.id ?? null
|
form.groupId = task.group?.id ?? null
|
||||||
form.tagIds = task.tags.map(t => t.id)
|
form.tagIds = task.tags.map(t => t.id)
|
||||||
|
form.clientTicketId = task.clientTicket?.id ?? null
|
||||||
} else {
|
} else {
|
||||||
form.title = ''
|
form.title = ''
|
||||||
form.description = ''
|
form.description = ''
|
||||||
@@ -371,13 +383,19 @@ function populateForm(task: Task | null) {
|
|||||||
form.assigneeId = null
|
form.assigneeId = null
|
||||||
form.groupId = null
|
form.groupId = null
|
||||||
form.tagIds = []
|
form.tagIds = []
|
||||||
|
form.clientTicketId = null
|
||||||
}
|
}
|
||||||
touched.title = false
|
touched.title = false
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.modelValue, (open) => {
|
watch(() => props.modelValue, async (open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
populateForm(props.task)
|
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 { create, update, remove } = useTaskService()
|
||||||
const { remove: removeDocument, getByTask: getDocumentsByTask } = useTaskDocumentService()
|
const { remove: removeDocument, getByTask: getDocumentsByTask } = useTaskDocumentService()
|
||||||
|
const clientTicketService = useClientTicketService()
|
||||||
const { t } = useI18n()
|
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 authStore = useAuthStore()
|
||||||
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
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,
|
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
|
||||||
project: `/api/projects/${props.projectId}`,
|
project: `/api/projects/${props.projectId}`,
|
||||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||||
|
clientTicket: form.clientTicketId ? `/api/client_tickets/${form.clientTicketId}` : null,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEditing.value && props.task) {
|
if (isEditing.value && props.task) {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -166,7 +166,13 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"loading": "Chargement..."
|
"save": "Enregistrer",
|
||||||
|
"edit": "Modifier",
|
||||||
|
"loading": "Chargement...",
|
||||||
|
"dateFilter": "Date",
|
||||||
|
"today": "Aujourd'hui",
|
||||||
|
"thisWeek": "Cette semaine",
|
||||||
|
"clear": "Effacer"
|
||||||
},
|
},
|
||||||
"gitea": {
|
"gitea": {
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|||||||
@@ -62,5 +62,8 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
typescript: {
|
typescript: {
|
||||||
strict: true
|
strict: true
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
transpile: ['@vuepic/vue-datepicker']
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
+141
@@ -12,6 +12,7 @@
|
|||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
"@pinia/nuxt": "^0.11.3",
|
"@pinia/nuxt": "^0.11.3",
|
||||||
|
"@vuepic/vue-datepicker": "^12.1.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"nuxt": "^4.3.1",
|
"nuxt": "^4.3.1",
|
||||||
"nuxt-toast": "^1.4.0",
|
"nuxt-toast": "^1.4.0",
|
||||||
@@ -541,6 +542,12 @@
|
|||||||
"postcss-selector-parser": "^7.0.0"
|
"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": {
|
"node_modules/@dxup/nuxt": {
|
||||||
"version": "0.3.2",
|
"version": "0.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/@dxup/nuxt/-/nuxt-0.3.2.tgz",
|
"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": "^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": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@@ -5259,6 +5328,12 @@
|
|||||||
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
|
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@typescript-eslint/project-service": {
|
||||||
"version": "8.56.1",
|
"version": "8.56.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz",
|
||||||
@@ -5720,6 +5795,62 @@
|
|||||||
"integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==",
|
"integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/abbrev": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
|
||||||
@@ -7126,6 +7257,16 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/db0": {
|
||||||
"version": "0.3.4",
|
"version": "0.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
"@pinia/nuxt": "^0.11.3",
|
"@pinia/nuxt": "^0.11.3",
|
||||||
|
"@vuepic/vue-datepicker": "^12.1.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"nuxt": "^4.3.1",
|
"nuxt": "^4.3.1",
|
||||||
"nuxt-toast": "^1.4.0",
|
"nuxt-toast": "^1.4.0",
|
||||||
|
|||||||
@@ -30,34 +30,56 @@
|
|||||||
{{ $t('clientTicket.noTickets') }}
|
{{ $t('clientTicket.noTickets') }}
|
||||||
</div>
|
</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
|
<div
|
||||||
v-for="ticket in tickets"
|
v-for="col in columns"
|
||||||
:key="ticket.id"
|
:key="col.status"
|
||||||
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"
|
class="min-w-0 flex-1 sm:min-w-[280px]"
|
||||||
@click="openDetail(ticket)"
|
|
||||||
>
|
>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="mb-3 flex items-center gap-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="h-2 w-2 rounded-full" :class="col.dotClass" />
|
||||||
<span class="text-sm font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</span>
|
<h3 class="text-sm font-bold text-neutral-700">{{ col.label }}</h3>
|
||||||
<span
|
<span class="ml-auto rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-semibold text-neutral-500">
|
||||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
{{ col.tickets.length }}
|
||||||
:class="typeBadgeClass(ticket.type)"
|
</span>
|
||||||
>
|
</div>
|
||||||
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
<div
|
||||||
</span>
|
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>
|
</div>
|
||||||
<h4 class="mt-1 text-sm font-semibold text-neutral-900">{{ ticket.title }}</h4>
|
<p
|
||||||
<p class="mt-1 text-xs text-neutral-400">
|
v-if="col.tickets.length === 0"
|
||||||
{{ formatDate(ticket.createdAt) }}
|
class="py-4 text-center text-xs text-neutral-400"
|
||||||
|
>
|
||||||
|
{{ $t('clientTicket.noTickets') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -65,13 +87,49 @@
|
|||||||
<ClientTicketDetailModal
|
<ClientTicketDetailModal
|
||||||
v-model="detailOpen"
|
v-model="detailOpen"
|
||||||
:ticket="selectedTicket"
|
: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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useClientTicketService } from '~/services/client-tickets'
|
||||||
|
import { useProjectService } from '~/services/projects'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'portal',
|
layout: 'portal',
|
||||||
@@ -84,40 +142,143 @@ const projectId = computed(() => Number(route.params.id))
|
|||||||
useHead({ title: t('portal.title') })
|
useHead({ title: t('portal.title') })
|
||||||
|
|
||||||
const clientTicketService = useClientTicketService()
|
const clientTicketService = useClientTicketService()
|
||||||
|
const projectService = useProjectService()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const tickets = ref<ClientTicket[]>([])
|
const tickets = ref<ClientTicket[]>([])
|
||||||
|
const projectName = ref('')
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
const detailOpen = ref(false)
|
const detailOpen = ref(false)
|
||||||
const selectedTicket = ref<ClientTicket | null>(null)
|
const selectedTicket = ref<ClientTicket | null>(null)
|
||||||
|
|
||||||
const projectName = computed(() => {
|
const isClient = computed(() => auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN'))
|
||||||
const me = auth.user as any
|
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||||
if (me?.allowedProjects) {
|
const { typeBadgeClass, formatDate } = useClientTicketHelpers()
|
||||||
const project = me.allowedProjects.find((p: any) => p.id === projectId.value)
|
|
||||||
return project?.name ?? ''
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const isClient = computed(() => auth.user?.roles?.includes('ROLE_CLIENT') ?? false)
|
const allStatuses: ClientTicketStatus[] = ['new', 'in_progress', 'done', 'rejected']
|
||||||
const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()
|
|
||||||
|
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) {
|
function openDetail(ticket: ClientTicket) {
|
||||||
selectedTicket.value = ticket
|
selectedTicket.value = ticket
|
||||||
detailOpen.value = true
|
detailOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTickets() {
|
async function loadData() {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
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 {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadTickets() {
|
||||||
|
tickets.value = await clientTicketService.getAll({ project: projectId.value })
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadTickets()
|
loadData()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,13 +3,22 @@
|
|||||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
<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="flex items-center justify-between gap-3">
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
|
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
|
||||||
<button
|
<div class="flex items-center gap-2">
|
||||||
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"
|
<button
|
||||||
@click="openTaskCreate"
|
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>
|
<span class="hidden sm:inline">+ Ajouter un ticket</span>
|
||||||
</button>
|
<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>
|
||||||
|
|
||||||
<div class="mt-4 flex flex-wrap gap-3">
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
@@ -120,6 +129,13 @@
|
|||||||
@saved="onSaved"
|
@saved="onSaved"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ProjectDrawer
|
||||||
|
v-model="projectDrawerOpen"
|
||||||
|
:project="project"
|
||||||
|
:clients="clients"
|
||||||
|
@saved="onProjectSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -132,7 +148,9 @@ import type { TaskPriority } from '~/services/dto/task-priority'
|
|||||||
import type { TaskTag } from '~/services/dto/task-tag'
|
import type { TaskTag } from '~/services/dto/task-tag'
|
||||||
import type { TaskGroup } from '~/services/dto/task-group'
|
import type { TaskGroup } from '~/services/dto/task-group'
|
||||||
import type { UserData } from '~/services/dto/user-data'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
|
import type { Client } from '~/services/dto/client'
|
||||||
import { useProjectService } from '~/services/projects'
|
import { useProjectService } from '~/services/projects'
|
||||||
|
import { useClientService } from '~/services/clients'
|
||||||
import { useTaskService } from '~/services/tasks'
|
import { useTaskService } from '~/services/tasks'
|
||||||
import { useTaskStatusService } from '~/services/task-statuses'
|
import { useTaskStatusService } from '~/services/task-statuses'
|
||||||
import { useTaskEffortService } from '~/services/task-efforts'
|
import { useTaskEffortService } from '~/services/task-efforts'
|
||||||
@@ -147,6 +165,7 @@ const projectId = computed(() => Number(route.params.id))
|
|||||||
useHead({ title: 'Projet' })
|
useHead({ title: 'Projet' })
|
||||||
|
|
||||||
const projectService = useProjectService()
|
const projectService = useProjectService()
|
||||||
|
const clientService = useClientService()
|
||||||
const taskService = useTaskService()
|
const taskService = useTaskService()
|
||||||
const statusService = useTaskStatusService()
|
const statusService = useTaskStatusService()
|
||||||
const effortService = useTaskEffortService()
|
const effortService = useTaskEffortService()
|
||||||
@@ -163,6 +182,7 @@ const priorities = ref<TaskPriority[]>([])
|
|||||||
const tags = ref<TaskTag[]>([])
|
const tags = ref<TaskTag[]>([])
|
||||||
const groups = ref<TaskGroup[]>([])
|
const groups = ref<TaskGroup[]>([])
|
||||||
const users = ref<UserData[]>([])
|
const users = ref<UserData[]>([])
|
||||||
|
const clients = ref<Client[]>([])
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
|
|
||||||
const selectedGroupId = ref<number | null>(null)
|
const selectedGroupId = ref<number | null>(null)
|
||||||
@@ -172,6 +192,7 @@ const selectedStatusId = ref<number | null>(null)
|
|||||||
const dragOverStatusId = ref<number | null>(null)
|
const dragOverStatusId = ref<number | null>(null)
|
||||||
const dragCounter = ref(0)
|
const dragCounter = ref(0)
|
||||||
const taskDrawerOpen = ref(false)
|
const taskDrawerOpen = ref(false)
|
||||||
|
const projectDrawerOpen = ref(false)
|
||||||
const selectedTask = ref<Task | null>(null)
|
const selectedTask = ref<Task | null>(null)
|
||||||
|
|
||||||
const groupFilterOptions = computed(() =>
|
const groupFilterOptions = computed(() =>
|
||||||
@@ -218,7 +239,7 @@ const backlogTasks = computed(() =>
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
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),
|
projectService.getById(projectId.value),
|
||||||
taskService.getByProject(projectId.value),
|
taskService.getByProject(projectId.value),
|
||||||
statusService.getAll(),
|
statusService.getAll(),
|
||||||
@@ -227,6 +248,7 @@ async function loadData() {
|
|||||||
tagService.getAll(),
|
tagService.getAll(),
|
||||||
groupService.getByProject(projectId.value),
|
groupService.getByProject(projectId.value),
|
||||||
userService.getAll(),
|
userService.getAll(),
|
||||||
|
clientService.getAll(),
|
||||||
])
|
])
|
||||||
project.value = p
|
project.value = p
|
||||||
tasks.value = t
|
tasks.value = t
|
||||||
@@ -236,6 +258,7 @@ async function loadData() {
|
|||||||
tags.value = ty
|
tags.value = ty
|
||||||
groups.value = g
|
groups.value = g
|
||||||
users.value = u
|
users.value = u
|
||||||
|
clients.value = c
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -290,6 +313,10 @@ async function onSaved() {
|
|||||||
await loadData()
|
await loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onProjectSaved() {
|
||||||
|
await loadData()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -70,6 +70,8 @@
|
|||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DateFilter v-model="selectedDateFilter" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -136,6 +138,7 @@ const startDate = ref(getMonday(new Date()))
|
|||||||
const selectedUserId = ref<number | null>(authStore.user?.id ?? null)
|
const selectedUserId = ref<number | null>(authStore.user?.id ?? null)
|
||||||
const selectedTagId = ref<number | null>(null)
|
const selectedTagId = ref<number | null>(null)
|
||||||
const selectedProjectId = ref<number | null>(null)
|
const selectedProjectId = ref<number | null>(null)
|
||||||
|
const selectedDateFilter = ref<Date | [Date, Date] | null>(null)
|
||||||
|
|
||||||
const entries = ref<TimeEntry[]>([])
|
const entries = ref<TimeEntry[]>([])
|
||||||
const users = ref<UserData[]>([])
|
const users = ref<UserData[]>([])
|
||||||
@@ -189,6 +192,28 @@ const filteredEntries = computed(() => {
|
|||||||
if (selectedTagId.value) {
|
if (selectedTagId.value) {
|
||||||
result = result.filter((e) => e.tags.some((t) => t.id === 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
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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> {
|
async function remove(id: number): Promise<void> {
|
||||||
await api.delete(`/client_tickets/${id}`, {}, {
|
await api.delete(`/client_tickets/${id}`, {}, {
|
||||||
toastSuccessKey: 'clientTicket.deleted',
|
toastSuccessKey: 'clientTicket.deleted',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return { getAll, getById, create, updateStatus, remove }
|
return { getAll, getById, create, update, updateStatus, remove }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,4 +42,5 @@ export type TaskWrite = {
|
|||||||
project: string
|
project: string
|
||||||
tags: string[]
|
tags: string[]
|
||||||
archived?: boolean
|
archived?: boolean
|
||||||
|
clientTicket?: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\DataFixtures;
|
namespace App\DataFixtures;
|
||||||
|
|
||||||
use App\Entity\Client;
|
use App\Entity\Client;
|
||||||
|
use App\Entity\ClientTicket;
|
||||||
use App\Entity\Project;
|
use App\Entity\Project;
|
||||||
use App\Entity\Task;
|
use App\Entity\Task;
|
||||||
use App\Entity\TaskEffort;
|
use App\Entity\TaskEffort;
|
||||||
@@ -28,7 +29,7 @@ class AppFixtures extends Fixture
|
|||||||
|
|
||||||
public function load(ObjectManager $manager): void
|
public function load(ObjectManager $manager): void
|
||||||
{
|
{
|
||||||
// User admin
|
// Users
|
||||||
$admin = new User();
|
$admin = new User();
|
||||||
$admin->setUsername('admin');
|
$admin->setUsername('admin');
|
||||||
$admin->setRoles(['ROLE_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');
|
$admin->setApiToken('dev-mcp-token-for-testing-only-do-not-use-in-production');
|
||||||
$manager->persist($admin);
|
$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
|
// Clients
|
||||||
$clientLiot = new Client();
|
$clientLiot = new Client();
|
||||||
$clientLiot->setName('LIOT');
|
$clientLiot->setName('LIOT');
|
||||||
@@ -251,7 +270,7 @@ class AppFixtures extends Fixture
|
|||||||
$task2->setStatus($statusTodo);
|
$task2->setStatus($statusTodo);
|
||||||
$task2->setEffort($effortL);
|
$task2->setEffort($effortL);
|
||||||
$task2->setPriority($priorityHigh);
|
$task2->setPriority($priorityHigh);
|
||||||
$task2->setAssignee($admin);
|
$task2->setAssignee($userAlice);
|
||||||
$task2->setGroup($groupFrontend);
|
$task2->setGroup($groupFrontend);
|
||||||
$task2->setProject($projectSirh);
|
$task2->setProject($projectSirh);
|
||||||
$task2->addTag($tagAuth);
|
$task2->addTag($tagAuth);
|
||||||
@@ -275,7 +294,7 @@ class AppFixtures extends Fixture
|
|||||||
$task4->setStatus($statusBlocked);
|
$task4->setStatus($statusBlocked);
|
||||||
$task4->setEffort($effortXXL);
|
$task4->setEffort($effortXXL);
|
||||||
$task4->setPriority($priorityLow);
|
$task4->setPriority($priorityLow);
|
||||||
$task4->setAssignee($admin);
|
$task4->setAssignee($userBob);
|
||||||
$task4->setProject($projectSirh);
|
$task4->setProject($projectSirh);
|
||||||
$task4->addTag($tagPassword);
|
$task4->addTag($tagPassword);
|
||||||
$manager->persist($task4);
|
$manager->persist($task4);
|
||||||
@@ -286,7 +305,7 @@ class AppFixtures extends Fixture
|
|||||||
$task5->setStatus($statusReview);
|
$task5->setStatus($statusReview);
|
||||||
$task5->setEffort($effortXXL);
|
$task5->setEffort($effortXXL);
|
||||||
$task5->setPriority($priorityMedium);
|
$task5->setPriority($priorityMedium);
|
||||||
$task5->setAssignee($admin);
|
$task5->setAssignee($userCharlie);
|
||||||
$task5->setProject($projectSirh);
|
$task5->setProject($projectSirh);
|
||||||
$task5->addTag($tagCalendar);
|
$task5->addTag($tagCalendar);
|
||||||
$manager->persist($task5);
|
$manager->persist($task5);
|
||||||
@@ -322,7 +341,7 @@ class AppFixtures extends Fixture
|
|||||||
$taskCrm2->setStatus($statusInProgress);
|
$taskCrm2->setStatus($statusInProgress);
|
||||||
$taskCrm2->setEffort($effortM);
|
$taskCrm2->setEffort($effortM);
|
||||||
$taskCrm2->setPriority($priorityMedium);
|
$taskCrm2->setPriority($priorityMedium);
|
||||||
$taskCrm2->setAssignee($admin);
|
$taskCrm2->setAssignee($userAlice);
|
||||||
$taskCrm2->setGroup($groupCrmUi);
|
$taskCrm2->setGroup($groupCrmUi);
|
||||||
$taskCrm2->setProject($projectCrm);
|
$taskCrm2->setProject($projectCrm);
|
||||||
$manager->persist($taskCrm2);
|
$manager->persist($taskCrm2);
|
||||||
@@ -344,7 +363,7 @@ class AppFixtures extends Fixture
|
|||||||
$taskCrm4->setStatus($statusInProgress);
|
$taskCrm4->setStatus($statusInProgress);
|
||||||
$taskCrm4->setEffort($effortXXL);
|
$taskCrm4->setEffort($effortXXL);
|
||||||
$taskCrm4->setPriority($priorityHigh);
|
$taskCrm4->setPriority($priorityHigh);
|
||||||
$taskCrm4->setAssignee($admin);
|
$taskCrm4->setAssignee($userBob);
|
||||||
$taskCrm4->setGroup($groupCrmUi);
|
$taskCrm4->setGroup($groupCrmUi);
|
||||||
$taskCrm4->setProject($projectCrm);
|
$taskCrm4->setProject($projectCrm);
|
||||||
$taskCrm4->addTag($tagCalendar);
|
$taskCrm4->addTag($tagCalendar);
|
||||||
@@ -381,7 +400,7 @@ class AppFixtures extends Fixture
|
|||||||
$taskErp2->setStatus($statusInProgress);
|
$taskErp2->setStatus($statusInProgress);
|
||||||
$taskErp2->setEffort($effortM);
|
$taskErp2->setEffort($effortM);
|
||||||
$taskErp2->setPriority($priorityHigh);
|
$taskErp2->setPriority($priorityHigh);
|
||||||
$taskErp2->setAssignee($admin);
|
$taskErp2->setAssignee($userCharlie);
|
||||||
$taskErp2->setGroup($groupErpStock);
|
$taskErp2->setGroup($groupErpStock);
|
||||||
$taskErp2->setProject($projectErp);
|
$taskErp2->setProject($projectErp);
|
||||||
$manager->persist($taskErp2);
|
$manager->persist($taskErp2);
|
||||||
@@ -451,7 +470,7 @@ class AppFixtures extends Fixture
|
|||||||
$taskSite2->setStatus($statusInProgress);
|
$taskSite2->setStatus($statusInProgress);
|
||||||
$taskSite2->setEffort($effortL);
|
$taskSite2->setEffort($effortL);
|
||||||
$taskSite2->setPriority($priorityMedium);
|
$taskSite2->setPriority($priorityMedium);
|
||||||
$taskSite2->setAssignee($admin);
|
$taskSite2->setAssignee($userAlice);
|
||||||
$taskSite2->setGroup($groupSiteDesign);
|
$taskSite2->setGroup($groupSiteDesign);
|
||||||
$taskSite2->setProject($projectInterne);
|
$taskSite2->setProject($projectInterne);
|
||||||
$manager->persist($taskSite2);
|
$manager->persist($taskSite2);
|
||||||
@@ -543,6 +562,94 @@ class AppFixtures extends Fixture
|
|||||||
$manager->persist($entry);
|
$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();
|
$manager->flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
processor: ClientTicketNumberProcessor::class,
|
processor: ClientTicketNumberProcessor::class,
|
||||||
),
|
),
|
||||||
new Patch(
|
new Patch(
|
||||||
security: "is_granted('ROLE_ADMIN')",
|
security: "is_granted('ROLE_ADMIN') or (is_granted('ROLE_CLIENT') and object.getSubmittedBy() == user)",
|
||||||
processor: ClientTicketStatusProcessor::class,
|
processor: ClientTicketStatusProcessor::class,
|
||||||
),
|
),
|
||||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Entity\ClientTicket;
|
|||||||
use App\Service\NotificationService;
|
use App\Service\NotificationService;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,6 +26,7 @@ final readonly class ClientTicketStatusProcessor implements ProcessorInterface
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private EntityManagerInterface $entityManager,
|
private EntityManagerInterface $entityManager,
|
||||||
private NotificationService $notificationService,
|
private NotificationService $notificationService,
|
||||||
|
private Security $security,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClientTicket
|
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);
|
assert($data instanceof ClientTicket);
|
||||||
|
|
||||||
$originalData = $context['previous_data'] ?? null;
|
$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) {
|
if ($originalData instanceof ClientTicket) {
|
||||||
$oldStatus = $originalData->getStatus();
|
$oldStatus = $originalData->getStatus();
|
||||||
$newStatus = $data->getStatus();
|
$newStatus = $data->getStatus();
|
||||||
|
|||||||
Reference in New Issue
Block a user