feat : date filter, project drawer, and misc frontend improvements
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
10
config/packages/http_discovery.yaml
Normal file
10
config/packages/http_discovery.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
Psr\Http\Message\RequestFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\ResponseFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\ServerRequestFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\StreamFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\UploadedFileFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\UriFactoryInterface: '@http_discovery.psr17_factory'
|
||||
|
||||
http_discovery.psr17_factory:
|
||||
class: Http\Discovery\Psr17Factory
|
||||
385
docs/superpowers/plans/2026-03-15-date-filter.md
Normal file
385
docs/superpowers/plans/2026-03-15-date-filter.md
Normal file
@@ -0,0 +1,385 @@
|
||||
# Date Filter Component Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a reusable date filter component to the time-tracking page using `@vuepic/vue-datepicker`, enabling filtering by single day or date range.
|
||||
|
||||
**Architecture:** A wrapper component `DateFilter.vue` encapsulates `VueDatePicker` with project-consistent styling. It integrates into the existing filter bar on the time-tracking page. Filtering is client-side, matching the existing project/tag filter pattern.
|
||||
|
||||
**Tech Stack:** Vue 3, @vuepic/vue-datepicker, Tailwind CSS, @nuxtjs/i18n
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Setup and Component
|
||||
|
||||
### Task 1: Install @vuepic/vue-datepicker and configure Nuxt
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/package.json`
|
||||
- Modify: `frontend/nuxt.config.ts:1-66`
|
||||
|
||||
- [ ] **Step 1: Install the package**
|
||||
|
||||
Run inside the PHP container (where Node is available):
|
||||
|
||||
```bash
|
||||
cd /home/r-dev/Lesstime/frontend && npm install @vuepic/vue-datepicker
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add transpile config to nuxt.config.ts**
|
||||
|
||||
In `frontend/nuxt.config.ts`, add `build.transpile` after the `typescript` block:
|
||||
|
||||
```typescript
|
||||
export default defineNuxtConfig({
|
||||
// ... existing config ...
|
||||
typescript: {
|
||||
strict: true
|
||||
},
|
||||
build: {
|
||||
transpile: ['@vuepic/vue-datepicker']
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/package.json frontend/package-lock.json frontend/nuxt.config.ts
|
||||
git commit -m "feat(frontend) : add @vuepic/vue-datepicker dependency"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add i18n translations
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/i18n/locales/fr.json:167-170`
|
||||
|
||||
- [ ] **Step 1: Add date filter translations to fr.json**
|
||||
|
||||
In `frontend/i18n/locales/fr.json`, add keys inside the existing `"common"` block:
|
||||
|
||||
```json
|
||||
"common": {
|
||||
"cancel": "Annuler",
|
||||
"loading": "Chargement...",
|
||||
"dateFilter": "Date",
|
||||
"today": "Aujourd'hui",
|
||||
"thisWeek": "Cette semaine",
|
||||
"clear": "Effacer"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/i18n/locales/fr.json
|
||||
git commit -m "feat(frontend) : add date filter i18n translations"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create DateFilter.vue component
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/components/ui/DateFilter.vue`
|
||||
|
||||
- [ ] **Step 1: Create the component**
|
||||
|
||||
Create `frontend/components/ui/DateFilter.vue`:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="date-filter">
|
||||
<VueDatePicker
|
||||
v-model="internalValue"
|
||||
:range="isRange"
|
||||
:enable-time-picker="false"
|
||||
:text-input="textInputConfig"
|
||||
:locale="'fr'"
|
||||
:format="formatDate"
|
||||
:preview-format="formatDate"
|
||||
auto-apply
|
||||
:multi-calendars="false"
|
||||
position="left"
|
||||
@update:model-value="onUpdate"
|
||||
@cleared="onClear"
|
||||
>
|
||||
<template #dp-input="{ value, onInput, onEnter, onTab, onClear, openMenu }">
|
||||
<div class="relative">
|
||||
<input
|
||||
:value="value"
|
||||
class="w-full cursor-pointer rounded-md border border-neutral-300 bg-white px-3 py-[7px] text-sm text-neutral-700 outline-none transition placeholder:text-neutral-400 focus:border-primary-500"
|
||||
:placeholder="placeholder || t('common.dateFilter')"
|
||||
readonly
|
||||
@click="openMenu"
|
||||
@input="onInput"
|
||||
@keydown.enter="onEnter"
|
||||
@keydown.tab="onTab"
|
||||
/>
|
||||
<button
|
||||
v-if="value"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600"
|
||||
@click.stop="onClear"
|
||||
>
|
||||
<Icon name="mdi:close-circle" size="16" />
|
||||
</button>
|
||||
<Icon
|
||||
v-else
|
||||
name="mdi:calendar"
|
||||
size="16"
|
||||
class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #action-buttons>
|
||||
<div class="flex gap-2 px-3 pb-2">
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs font-medium text-primary-500 hover:bg-primary-500/10 transition"
|
||||
@click="selectToday"
|
||||
>
|
||||
{{ t('common.today') }}
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs font-medium text-primary-500 hover:bg-primary-500/10 transition"
|
||||
@click="selectThisWeek"
|
||||
>
|
||||
{{ t('common.thisWeek') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</VueDatePicker>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import VueDatePicker from '@vuepic/vue-datepicker'
|
||||
import '@vuepic/vue-datepicker/dist/main.css'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: Date | [Date, Date] | null
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: Date | [Date, Date] | null]
|
||||
}>()
|
||||
|
||||
const isRange = ref(false)
|
||||
const internalValue = ref<Date | Date[] | null>(null)
|
||||
const firstClick = ref<Date | null>(null)
|
||||
|
||||
const textInputConfig = {
|
||||
enterSubmit: true,
|
||||
tabSubmit: true,
|
||||
format: 'dd/MM/yyyy',
|
||||
rangeSeparator: ' - ',
|
||||
}
|
||||
|
||||
function formatDate(date: Date | Date[]): string {
|
||||
if (Array.isArray(date)) {
|
||||
return date.map(d => formatSingleDate(d)).join(' - ')
|
||||
}
|
||||
return formatSingleDate(date)
|
||||
}
|
||||
|
||||
function formatSingleDate(d: Date): string {
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const year = d.getFullYear()
|
||||
return `${day}/${month}/${year}`
|
||||
}
|
||||
|
||||
function onUpdate(value: Date | Date[] | null) {
|
||||
if (value === null) {
|
||||
firstClick.value = null
|
||||
isRange.value = false
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(value) && value.length === 2) {
|
||||
emit('update:modelValue', [value[0], value[1]])
|
||||
return
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
if (firstClick.value === null) {
|
||||
// First click — select single day, store for potential range
|
||||
firstClick.value = value
|
||||
emit('update:modelValue', value)
|
||||
// Enable range mode for next click
|
||||
nextTick(() => {
|
||||
isRange.value = true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onClear() {
|
||||
internalValue.value = null
|
||||
firstClick.value = null
|
||||
isRange.value = false
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
|
||||
function selectToday() {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
isRange.value = false
|
||||
firstClick.value = null
|
||||
internalValue.value = today
|
||||
emit('update:modelValue', today)
|
||||
}
|
||||
|
||||
function selectThisWeek() {
|
||||
const now = new Date()
|
||||
const day = now.getDay()
|
||||
const monday = new Date(now)
|
||||
monday.setDate(now.getDate() - day + (day === 0 ? -6 : 1))
|
||||
monday.setHours(0, 0, 0, 0)
|
||||
const sunday = new Date(monday)
|
||||
sunday.setDate(monday.getDate() + 6)
|
||||
sunday.setHours(23, 59, 59, 999)
|
||||
isRange.value = true
|
||||
firstClick.value = null
|
||||
internalValue.value = [monday, sunday]
|
||||
emit('update:modelValue', [monday, sunday])
|
||||
}
|
||||
|
||||
// Sync external modelValue to internal state
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val === null || val === undefined) {
|
||||
internalValue.value = null
|
||||
firstClick.value = null
|
||||
isRange.value = false
|
||||
} else if (Array.isArray(val)) {
|
||||
isRange.value = true
|
||||
internalValue.value = [...val]
|
||||
} else {
|
||||
isRange.value = false
|
||||
internalValue.value = val
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.date-filter .dp__theme_light {
|
||||
--dp-primary-color: #222783;
|
||||
--dp-primary-text-color: #fff;
|
||||
--dp-border-color: #d4d4d8;
|
||||
--dp-menu-border-color: #d4d4d8;
|
||||
--dp-border-color-hover: #222783;
|
||||
--dp-hover-color: #f3f4f8;
|
||||
--dp-font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.date-filter .dp__input_wrap {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.date-filter .dp__main {
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the component renders**
|
||||
|
||||
Run `make dev-nuxt` and navigate to the time-tracking page (integration comes in Task 4). Check that no build errors occur.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/components/ui/DateFilter.vue
|
||||
git commit -m "feat(frontend) : create DateFilter reusable component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Integration
|
||||
|
||||
### Task 4: Integrate DateFilter into time-tracking page
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/pages/time-tracking.vue:15-73` (template filter bar)
|
||||
- Modify: `frontend/pages/time-tracking.vue:138` (add ref)
|
||||
- Modify: `frontend/pages/time-tracking.vue:184-193` (filteredEntries computed)
|
||||
|
||||
- [ ] **Step 1: Add the date filter ref**
|
||||
|
||||
In `frontend/pages/time-tracking.vue`, after line 138 (`selectedProjectId`), add:
|
||||
|
||||
```typescript
|
||||
const selectedDateFilter = ref<Date | [Date, Date] | null>(null)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add DateFilter to the template filter bar**
|
||||
|
||||
In the filter bar `<div>` (line 15), after the tag MalioSelect block (after line 72), add:
|
||||
|
||||
```vue
|
||||
<DateFilter
|
||||
v-model="selectedDateFilter"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add date filtering to filteredEntries computed**
|
||||
|
||||
In `frontend/pages/time-tracking.vue`, update the `filteredEntries` computed (around line 184) to include date filtering:
|
||||
|
||||
```typescript
|
||||
const filteredEntries = computed(() => {
|
||||
let result = entries.value
|
||||
if (selectedProjectId.value) {
|
||||
result = result.filter((e) => e.project?.id === selectedProjectId.value)
|
||||
}
|
||||
if (selectedTagId.value) {
|
||||
result = result.filter((e) => e.tags.some((t) => t.id === selectedTagId.value))
|
||||
}
|
||||
if (selectedDateFilter.value) {
|
||||
if (Array.isArray(selectedDateFilter.value)) {
|
||||
const [start, end] = selectedDateFilter.value
|
||||
const startDay = new Date(start)
|
||||
startDay.setHours(0, 0, 0, 0)
|
||||
const endDay = new Date(end)
|
||||
endDay.setHours(23, 59, 59, 999)
|
||||
result = result.filter((e) => {
|
||||
const entryDate = new Date(e.startedAt)
|
||||
return entryDate >= startDay && entryDate <= endDay
|
||||
})
|
||||
} else {
|
||||
const day = new Date(selectedDateFilter.value)
|
||||
day.setHours(0, 0, 0, 0)
|
||||
const nextDay = new Date(day)
|
||||
nextDay.setDate(nextDay.getDate() + 1)
|
||||
result = result.filter((e) => {
|
||||
const entryDate = new Date(e.startedAt)
|
||||
return entryDate >= day && entryDate < nextDay
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify manually**
|
||||
|
||||
Run `make dev-nuxt`, navigate to time-tracking page:
|
||||
1. Verify DateFilter appears in the filter bar
|
||||
2. Click a single day — entries filter to that day
|
||||
3. Click a second day — entries filter to the range
|
||||
4. Click "Aujourd'hui" — filters to today
|
||||
5. Click "Cette semaine" — filters to current week
|
||||
6. Clear the filter — all entries show again
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/pages/time-tracking.vue
|
||||
git commit -m "feat(frontend) : integrate date filter into time-tracking page"
|
||||
```
|
||||
86
docs/superpowers/specs/2026-03-15-date-filter-design.md
Normal file
86
docs/superpowers/specs/2026-03-15-date-filter-design.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Date Filter Component - Design Spec
|
||||
|
||||
## Summary
|
||||
|
||||
Add a reusable date filter component to the time-tracking page using `@vuepic/vue-datepicker`. Allows filtering by single day or date range via text input and mini calendar dropdown.
|
||||
|
||||
## Behavior
|
||||
|
||||
- **Single click** on a day = select that day
|
||||
- **Second click** on another day = select range between the two dates
|
||||
- **Text input**: type a date (`15/03/2026`) or a range (`15/03/2026 - 20/03/2026`)
|
||||
- **Calendar dropdown**: opens on input click/focus
|
||||
- **Quick shortcuts**: "Aujourd'hui" and "Cette semaine" buttons in calendar
|
||||
- **No time picker**: filter by day granularity only
|
||||
- **Format**: `dd/MM/yyyy` (French locale)
|
||||
|
||||
## Component: `DateFilter.vue`
|
||||
|
||||
Location: `frontend/components/ui/DateFilter.vue`
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `modelValue` | `Date \| [Date, Date] \| null` | `null` | Selected date or range |
|
||||
| `placeholder` | `string` | `t('common.dateFilter')` | Input placeholder |
|
||||
|
||||
### Emits
|
||||
|
||||
| Event | Payload | Description |
|
||||
|-------|---------|-------------|
|
||||
| `update:modelValue` | `Date \| [Date, Date] \| null` | Date selection changed |
|
||||
|
||||
### Implementation
|
||||
|
||||
- Wraps `VueDatePicker` with project-consistent styling
|
||||
- Uses `#dp-input` slot for custom input matching MalioSelect style
|
||||
- Configures `range` mode with `multi-calendars: false`
|
||||
- Sets `text-input` with `format: 'dd/MM/yyyy'`, `rangeSeparator: ' - '`
|
||||
- Disables time picker (`enable-time-picker: false`)
|
||||
- Applies project primary color (`#222783`) via CSS overrides
|
||||
- Responsive width: `!w-44 sm:!w-52`
|
||||
|
||||
## Integration: Time Tracking Page
|
||||
|
||||
### Filter bar addition
|
||||
|
||||
Add `DateFilter` to the existing filter bar in `frontend/pages/time-tracking.vue`, alongside user/project/tag filters.
|
||||
|
||||
### Filtering logic
|
||||
|
||||
- Client-side filtering (same pattern as project and tag filters)
|
||||
- When a single date is selected: show only entries matching that day
|
||||
- When a range is selected: show entries within the range (inclusive)
|
||||
- When null: show all entries (no date filter)
|
||||
|
||||
## Files Impacted
|
||||
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `frontend/components/ui/DateFilter.vue` | Create | Reusable date filter wrapper |
|
||||
| `frontend/nuxt.config.ts` | Modify | Add `@vuepic/vue-datepicker` to `build.transpile` |
|
||||
| `frontend/pages/time-tracking.vue` | Modify | Integrate DateFilter in filter bar + client-side filtering |
|
||||
| `frontend/i18n/locales/fr.json` | Modify | Add French translations |
|
||||
| `frontend/i18n/locales/en.json` | Modify | Add English translations |
|
||||
| `package.json` | Modify | Add `@vuepic/vue-datepicker` dependency |
|
||||
|
||||
## i18n Keys
|
||||
|
||||
```json
|
||||
{
|
||||
"common": {
|
||||
"dateFilter": "Date",
|
||||
"today": "Aujourd'hui",
|
||||
"thisWeek": "Cette semaine"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Style
|
||||
|
||||
- Input height and borders match MalioSelect components
|
||||
- Text size: `text-sm`
|
||||
- Selected date highlight: project primary color `#222783`
|
||||
- Calendar dropdown: subtle shadow, rounded corners matching project style
|
||||
- Override default vue-datepicker CSS variables to match project theme
|
||||
161
frontend/components/ui/DateFilter.vue
Normal file
161
frontend/components/ui/DateFilter.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div class="date-filter">
|
||||
<VueDatePicker
|
||||
v-model="internalValue"
|
||||
range
|
||||
:enable-time-picker="false"
|
||||
:locale="frLocale"
|
||||
:format="formatDate"
|
||||
auto-apply
|
||||
:multi-calendars="false"
|
||||
position="left"
|
||||
@update:model-value="onUpdate"
|
||||
>
|
||||
<template #dp-input="{ value, onInput, onEnter, onTab, onClear, openMenu }">
|
||||
<div class="relative">
|
||||
<input
|
||||
:value="value"
|
||||
class="w-full cursor-pointer rounded-md border border-neutral-300 bg-white px-3 py-[7px] text-sm text-neutral-700 outline-none transition placeholder:text-neutral-400 focus:border-primary-500"
|
||||
:placeholder="placeholder || t('common.dateFilter')"
|
||||
readonly
|
||||
@click="openMenu"
|
||||
@input="onInput"
|
||||
@keydown.enter="onEnter"
|
||||
@keydown.tab="onTab"
|
||||
/>
|
||||
<button
|
||||
v-if="value"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600"
|
||||
@click.stop="onClear"
|
||||
>
|
||||
<Icon name="mdi:close-circle" size="16" />
|
||||
</button>
|
||||
<Icon
|
||||
v-else
|
||||
name="mdi:calendar"
|
||||
size="16"
|
||||
class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #action-buttons>
|
||||
<div class="flex gap-2 px-3 pb-2">
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs font-medium text-primary-500 hover:bg-primary-500/10 transition"
|
||||
@click="selectToday"
|
||||
>
|
||||
{{ t('common.today') }}
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs font-medium text-primary-500 hover:bg-primary-500/10 transition"
|
||||
@click="selectThisWeek"
|
||||
>
|
||||
{{ t('common.thisWeek') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</VueDatePicker>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { VueDatePicker } from '@vuepic/vue-datepicker'
|
||||
import '@vuepic/vue-datepicker/dist/main.css'
|
||||
import { fr as frLocale } from 'date-fns/locale'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: Date | [Date, Date] | null
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: Date | [Date, Date] | null]
|
||||
}>()
|
||||
|
||||
const internalValue = ref<Date[] | null>(null)
|
||||
|
||||
function formatDate(dates: Date[]): string {
|
||||
if (!dates || dates.length === 0) return ''
|
||||
if (dates.length === 1) return formatSingleDate(dates[0])
|
||||
if (isSameDay(dates[0], dates[1])) return formatSingleDate(dates[0])
|
||||
return `${formatSingleDate(dates[0])} - ${formatSingleDate(dates[1])}`
|
||||
}
|
||||
|
||||
function formatSingleDate(d: Date): string {
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const year = d.getFullYear()
|
||||
return `${day}/${month}/${year}`
|
||||
}
|
||||
|
||||
function isSameDay(a: Date, b: Date): boolean {
|
||||
return a.getFullYear() === b.getFullYear()
|
||||
&& a.getMonth() === b.getMonth()
|
||||
&& a.getDate() === b.getDate()
|
||||
}
|
||||
|
||||
function onUpdate(value: Date[] | null) {
|
||||
if (!value || value.length === 0) {
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
if (value.length === 2 && isSameDay(value[0], value[1])) {
|
||||
emit('update:modelValue', value[0])
|
||||
} else if (value.length === 2) {
|
||||
emit('update:modelValue', [value[0], value[1]])
|
||||
}
|
||||
}
|
||||
|
||||
function selectToday() {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
internalValue.value = [today, today]
|
||||
emit('update:modelValue', today)
|
||||
}
|
||||
|
||||
function selectThisWeek() {
|
||||
const now = new Date()
|
||||
const day = now.getDay()
|
||||
const monday = new Date(now)
|
||||
monday.setDate(now.getDate() - day + (day === 0 ? -6 : 1))
|
||||
monday.setHours(0, 0, 0, 0)
|
||||
const sunday = new Date(monday)
|
||||
sunday.setDate(monday.getDate() + 6)
|
||||
sunday.setHours(23, 59, 59, 999)
|
||||
internalValue.value = [monday, sunday]
|
||||
emit('update:modelValue', [monday, sunday])
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val === null || val === undefined) {
|
||||
internalValue.value = null
|
||||
} else if (Array.isArray(val)) {
|
||||
internalValue.value = [...val]
|
||||
} else {
|
||||
internalValue.value = [val, val]
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.date-filter .dp__theme_light {
|
||||
--dp-primary-color: #222783;
|
||||
--dp-primary-text-color: #fff;
|
||||
--dp-border-color: #d4d4d8;
|
||||
--dp-menu-border-color: #d4d4d8;
|
||||
--dp-border-color-hover: #222783;
|
||||
--dp-hover-color: #f3f4f8;
|
||||
--dp-font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.date-filter .dp__input_wrap {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.date-filter .dp__main {
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -166,7 +166,11 @@
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Annuler",
|
||||
"loading": "Chargement..."
|
||||
"loading": "Chargement...",
|
||||
"dateFilter": "Date",
|
||||
"today": "Aujourd'hui",
|
||||
"thisWeek": "Cette semaine",
|
||||
"clear": "Effacer"
|
||||
},
|
||||
"gitea": {
|
||||
"settings": {
|
||||
|
||||
@@ -62,5 +62,8 @@ export default defineNuxtConfig({
|
||||
},
|
||||
typescript: {
|
||||
strict: true
|
||||
},
|
||||
build: {
|
||||
transpile: ['@vuepic/vue-datepicker']
|
||||
}
|
||||
})
|
||||
|
||||
141
frontend/package-lock.json
generated
141
frontend/package-lock.json
generated
@@ -12,6 +12,7 @@
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"@vuepic/vue-datepicker": "^12.1.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"nuxt": "^4.3.1",
|
||||
"nuxt-toast": "^1.4.0",
|
||||
@@ -541,6 +542,12 @@
|
||||
"postcss-selector-parser": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@date-fns/tz": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
|
||||
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@dxup/nuxt": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@dxup/nuxt/-/nuxt-0.3.2.tgz",
|
||||
@@ -1094,6 +1101,68 @@
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.5",
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@floating-ui/vue": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.11.tgz",
|
||||
"integrity": "sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.6",
|
||||
"@floating-ui/utils": "^0.2.11",
|
||||
"vue-demi": ">=0.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/vue/node_modules/vue-demi": {
|
||||
"version": "0.14.10",
|
||||
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
|
||||
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"vue-demi-fix": "bin/vue-demi-fix.js",
|
||||
"vue-demi-switch": "bin/vue-demi-switch.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue/composition-api": "^1.0.0-rc.1",
|
||||
"vue": "^3.0.0-0 || ^2.6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vue/composition-api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@@ -5259,6 +5328,12 @@
|
||||
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz",
|
||||
@@ -5720,6 +5795,62 @@
|
||||
"integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vuepic/vue-datepicker": {
|
||||
"version": "12.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vuepic/vue-datepicker/-/vue-datepicker-12.1.0.tgz",
|
||||
"integrity": "sha512-QuWcO+CqIGYFoRNCagp9xUY9sMK/OHUlVIDxBYjw7HjCTWXfuE/r3l3loB00faEtb0Teo3DeBn26hT3tYA5pgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@date-fns/tz": "^1.4.1",
|
||||
"@floating-ui/vue": "^1.1.9",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"date-fns": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">=3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/core": {
|
||||
"version": "14.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz",
|
||||
"integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"@vueuse/metadata": "14.2.1",
|
||||
"@vueuse/shared": "14.2.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/metadata": {
|
||||
"version": "14.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz",
|
||||
"integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/shared": {
|
||||
"version": "14.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz",
|
||||
"integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
|
||||
@@ -7126,6 +7257,16 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/db0": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"@vuepic/vue-datepicker": "^12.1.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"nuxt": "^4.3.1",
|
||||
"nuxt-toast": "^1.4.0",
|
||||
|
||||
@@ -3,13 +3,22 @@
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
|
||||
<button
|
||||
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm"
|
||||
@click="openTaskCreate"
|
||||
>
|
||||
<span class="hidden sm:inline">+ Ajouter un ticket</span>
|
||||
<span class="sm:hidden">+ Ticket</span>
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm"
|
||||
@click="openTaskCreate"
|
||||
>
|
||||
<span class="hidden sm:inline">+ Ajouter un ticket</span>
|
||||
<span class="sm:hidden">+ Ticket</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex shrink-0 items-center rounded-md bg-neutral-200 px-3 py-2 text-neutral-600 hover:bg-neutral-300 sm:px-4"
|
||||
title="Paramètres du projet"
|
||||
@click="projectDrawerOpen = true"
|
||||
>
|
||||
<Icon name="heroicons:cog-6-tooth" class="size-4 sm:size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
@@ -120,6 +129,13 @@
|
||||
@saved="onSaved"
|
||||
/>
|
||||
|
||||
<ProjectDrawer
|
||||
v-model="projectDrawerOpen"
|
||||
:project="project"
|
||||
:clients="clients"
|
||||
@saved="onProjectSaved"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -132,7 +148,9 @@ import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useClientService } from '~/services/clients'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
import { useTaskEffortService } from '~/services/task-efforts'
|
||||
@@ -147,6 +165,7 @@ const projectId = computed(() => Number(route.params.id))
|
||||
useHead({ title: 'Projet' })
|
||||
|
||||
const projectService = useProjectService()
|
||||
const clientService = useClientService()
|
||||
const taskService = useTaskService()
|
||||
const statusService = useTaskStatusService()
|
||||
const effortService = useTaskEffortService()
|
||||
@@ -163,6 +182,7 @@ const priorities = ref<TaskPriority[]>([])
|
||||
const tags = ref<TaskTag[]>([])
|
||||
const groups = ref<TaskGroup[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
const clients = ref<Client[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
const selectedGroupId = ref<number | null>(null)
|
||||
@@ -172,6 +192,7 @@ const selectedStatusId = ref<number | null>(null)
|
||||
const dragOverStatusId = ref<number | null>(null)
|
||||
const dragCounter = ref(0)
|
||||
const taskDrawerOpen = ref(false)
|
||||
const projectDrawerOpen = ref(false)
|
||||
const selectedTask = ref<Task | null>(null)
|
||||
|
||||
const groupFilterOptions = computed(() =>
|
||||
@@ -218,7 +239,7 @@ const backlogTasks = computed(() =>
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [p, t, s, e, pr, ty, g, u] = await Promise.all([
|
||||
const [p, t, s, e, pr, ty, g, u, c] = await Promise.all([
|
||||
projectService.getById(projectId.value),
|
||||
taskService.getByProject(projectId.value),
|
||||
statusService.getAll(),
|
||||
@@ -227,6 +248,7 @@ async function loadData() {
|
||||
tagService.getAll(),
|
||||
groupService.getByProject(projectId.value),
|
||||
userService.getAll(),
|
||||
clientService.getAll(),
|
||||
])
|
||||
project.value = p
|
||||
tasks.value = t
|
||||
@@ -236,6 +258,7 @@ async function loadData() {
|
||||
tags.value = ty
|
||||
groups.value = g
|
||||
users.value = u
|
||||
clients.value = c
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -290,6 +313,10 @@ async function onSaved() {
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function onProjectSaved() {
|
||||
await loadData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
@@ -70,6 +70,8 @@
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DateFilter v-model="selectedDateFilter" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -136,6 +138,7 @@ const startDate = ref(getMonday(new Date()))
|
||||
const selectedUserId = ref<number | null>(authStore.user?.id ?? null)
|
||||
const selectedTagId = ref<number | null>(null)
|
||||
const selectedProjectId = ref<number | null>(null)
|
||||
const selectedDateFilter = ref<Date | [Date, Date] | null>(null)
|
||||
|
||||
const entries = ref<TimeEntry[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
@@ -189,6 +192,28 @@ const filteredEntries = computed(() => {
|
||||
if (selectedTagId.value) {
|
||||
result = result.filter((e) => e.tags.some((t) => t.id === selectedTagId.value))
|
||||
}
|
||||
if (selectedDateFilter.value) {
|
||||
if (Array.isArray(selectedDateFilter.value)) {
|
||||
const [start, end] = selectedDateFilter.value
|
||||
const startDay = new Date(start)
|
||||
startDay.setHours(0, 0, 0, 0)
|
||||
const endDay = new Date(end)
|
||||
endDay.setHours(23, 59, 59, 999)
|
||||
result = result.filter((e) => {
|
||||
const entryDate = new Date(e.startedAt)
|
||||
return entryDate >= startDay && entryDate <= endDay
|
||||
})
|
||||
} else {
|
||||
const day = new Date(selectedDateFilter.value)
|
||||
day.setHours(0, 0, 0, 0)
|
||||
const nextDay = new Date(day)
|
||||
nextDay.setDate(nextDay.getDate() + 1)
|
||||
result = result.filter((e) => {
|
||||
const entryDate = new Date(e.startedAt)
|
||||
return entryDate >= day && entryDate < nextDay
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user