Migration modular monolith DDD (0.1 → 3.3) (#17)
Auto Tag Develop / tag (push) Successful in 9s
Auto Tag Develop / tag (push) Successful in 9s
## Migration modular monolith DDD — Lesstime (0.1 → 3.3) Cette MR regroupe l'intégralité de la refonte en monolithe modulaire (strangler progressif, additif). Elle remplace les MR stackées de Phase 1 (#12–#16), désormais incluses ici. **Ne pas merger avant validation fonctionnelle** : branche destinée à être testée telle quelle. ### Périmètre — 9 modules sous `src/Module/` | Phase | Module | Contenu | |------|--------|---------| | 0.1 | (socle) | infrastructure modulaire, `ModuleInterface`, mapping Doctrine par module | | 0.2 | (socle front) | auto-détection des layers Nuxt sous `frontend/modules/*` | | 1.1 | **Core** | Identité (User/Auth), Notifications, Notifier | | 1.2 | Core | RBAC fin (permissions `module.resource.action`, sidebar gated) | | 1.3 | Core | Audit log (`#[Auditable]`, listener, provider DBAL) | | 2.1 | **TimeTracking** | TimeEntry + MCP + export | | 2.2 | **ProjectManagement** | cœur métier Projets/Tâches + 38 MCP tools | | 2.3 | **Absence** | demandes, soldes, policies, justificatifs | | 2.4 | **Directory** | Clients (migrés) + **Prospects** (nouveau, conversion → Client) | | 2.5 | **Mail** | intégration IMAP OVH + liens tâches | | 2.6 | **Integration** | Gitea / BookStack / Zimbra / Share | | 3.1 | **Reporting** | rapports transverses (DBAL read-only, 0 import inter-module) | | 3.2 | **ClientPortal** | portail client (ROLE_CLIENT cloisonné, tickets, notifications) | | 3.3 | (finition) | nettoyage legacy — `src/Entity` vide, app 100% modulaire | ### Architecture - Découplage inter-modules par **contrats** (`UserInterface`, `ProjectInterface`, `TaskInterface`, `TaskTagInterface`, `ClientInterface`, `ClientTicketInterface`, `LeaveProfileInterface`) + `resolve_target_entities` 100% modulaire (aucune cible legacy). - Repositories : interface `Domain/Repository` + implémentation `Infrastructure/Doctrine`, bindées. - Reporting en DBAL read-only pur (aucun import d'entité d'un autre module). - Chaque migration de module : déplacement à comportement préservé (API publique et noms d'outils MCP inchangés), migrations **additives** uniquement (zéro destructif). ### Sécurité - ROLE_CLIENT cloisonné : un utilisateur client n'accède qu'à `/portal` et à ses propres tickets (filtrés par `allowedProjects`), interdit sur toute l'API interne. - Correctif : interdiction pour un client de créer un lien vers le partage SMB (upload uniquement). ### QA non-régression (branche reconstruite from scratch) - Migrations from scratch + fixtures : OK. - Compilation dev + prod : OK. - **180 tests PHPUnit verts**, php-cs-fixer clean, ~96 routes, **66 outils MCP** tous sous `App\Module\*`. - Smoke test runtime multi-rôles (admin / ROLE_USER / ROLE_CLIENT) : 44 vérifications HTTP, **0 écart**, cloisonnement client étanche. - Build Nuxt OK, 9 layers, 0 import legacy résiduel. ### Points à arbitrer (hors périmètre de cette migration) - Durcissement MCP/IDOR pré-existant (`userId` explicite sans scoping sur certains tools TimeTracking/Absence/TaskDocument) — ticket dédié recommandé. - Validation fonctionnelle de **Prospect** et **ClientPortal** (conçus depuis les specs disque). - **Harmonisation visuelle Malio finale** (3.3) — finition esthétique inter-modules laissée au PO. --- ## ⚠️ Déploiement / migration des données — à ne pas oublier ### 1. Resynchroniser les séquences PostgreSQL après tout import/restore de dump Si la prod (ou tout environnement) est **montée depuis un dump** (`pg_restore` / `COPY`), les lignes sont chargées avec leurs `id` explicites **sans avancer les séquences** → au premier `INSERT` : `duplicate key value violates unique constraint "..._pkey"` (constaté en local sur `notification`, `task`, `time_entry`…). À lancer **juste après chaque restore/import** : ```sql DO $$ DECLARE r RECORD; maxid BIGINT; seq TEXT; BEGIN FOR r IN SELECT table_name, column_name FROM information_schema.columns WHERE table_schema='public' LOOP seq := pg_get_serial_sequence(quote_ident(r.table_name), r.column_name); IF seq IS NOT NULL THEN EXECUTE format('SELECT COALESCE(MAX(%I),0) FROM %I', r.column_name, r.table_name) INTO maxid; PERFORM setval(seq, GREATEST(maxid,1), maxid > 0); END IF; END LOOP; END $$; ``` > Ne concerne **pas** une prod qui tourne déjà (séquences avancées organiquement) — uniquement le cas restore/import. Idempotent, sans risque. ### 2. Fix dénormalisation des collections typées-contrat (code, inclus dans la branche) Les relations **to-many** typées par une interface `Shared\Domain\Contract\*` (`TimeEntry::tags` → `TaskTagInterface`, `Task::collaborators` → `UserInterface`) étaient **indénormalisables par API Platform** (mono-valué OK via IRI, collection KO) → **tout POST/PATCH portant une telle collection renvoyait 400/500**. Corrigé par un dénormaliseur générique `ContractRelationDenormalizer` (réutilise `resolve_target_entities`, zéro couplage par-entité) + test fonctionnel de non-régression. --------- Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
@@ -1,69 +0,0 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="open" drawer-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ $t('absences.admin.adjust.title') }}</h2>
|
||||
</template>
|
||||
<div v-if="balance" class="flex flex-col gap-4">
|
||||
<p class="text-sm text-neutral-600">
|
||||
{{ balance.user.username }} · {{ balance.label }} · {{ balance.period }}
|
||||
</p>
|
||||
<MalioInputNumber v-model="acquired" :label="$t('absences.admin.adjust.acquired')" />
|
||||
<MalioInputNumber v-model="acquiring" :label="$t('absences.admin.adjust.acquiring')" />
|
||||
<MalioInputNumber v-model="taken" :label="$t('absences.admin.adjust.taken')" />
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<MalioButton :label="$t('common.cancel')" variant="tertiary" @click="open = false" />
|
||||
<MalioButton :label="$t('absences.admin.adjust.save')" :disabled="submitting" @click="submit" />
|
||||
</div>
|
||||
</div>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceBalance } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
balance: AbsenceBalance | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'adjusted': []
|
||||
}>()
|
||||
|
||||
const service = useAbsenceService()
|
||||
|
||||
const open = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
// MalioInputNumber works with string values (v-model is string | null).
|
||||
const acquired = ref('0')
|
||||
const acquiring = ref('0')
|
||||
const taken = ref('0')
|
||||
const submitting = ref(false)
|
||||
|
||||
watch(() => props.balance, (b) => {
|
||||
acquired.value = String(b?.acquired ?? 0)
|
||||
acquiring.value = String(b?.acquiring ?? 0)
|
||||
taken.value = String(b?.taken ?? 0)
|
||||
}, { immediate: true })
|
||||
|
||||
async function submit() {
|
||||
if (!props.balance) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await service.adjustBalance(props.balance.id, {
|
||||
acquired: Number(acquired.value) || 0,
|
||||
acquiring: Number(acquiring.value) || 0,
|
||||
taken: Number(taken.value) || 0,
|
||||
})
|
||||
emit('adjusted')
|
||||
open.value = false
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,120 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="balances.length === 0" class="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-6 text-center text-sm text-neutral-500">
|
||||
{{ $t('absences.noBalance') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="rounded-xl border border-neutral-200 bg-white p-5 shadow-sm">
|
||||
<!-- Primary balance, highlighted -->
|
||||
<div v-if="primary" class="flex items-start justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-neutral-500">{{ primary.label }}</p>
|
||||
<p class="mt-1 text-3xl font-bold text-neutral-900">
|
||||
{{ formatNumber(primary.available) }}
|
||||
<span class="text-lg font-normal text-neutral-400">/ {{ formatNumber(acquiredTotal(primary)) }}</span>
|
||||
</p>
|
||||
<p class="text-xs text-neutral-400">{{ $t('absences.remaining') }}</p>
|
||||
</div>
|
||||
<span
|
||||
class="rounded-full px-2.5 py-1 text-xs font-medium"
|
||||
:style="{ backgroundColor: typeColor(primary.type) + '22', color: typeColor(primary.type) }"
|
||||
>
|
||||
{{ primary.period }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="primary" class="mt-3 h-2 w-full overflow-hidden rounded-full bg-neutral-100">
|
||||
<div
|
||||
class="h-full rounded-full transition-all"
|
||||
:style="{ width: progress(primary) + '%', backgroundColor: typeColor(primary.type) }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Acquired (N-1) vs in-progress (N), as on a French payslip -->
|
||||
<div v-if="primary && primary.type === 'cp'" class="mt-3 grid grid-cols-2 gap-2">
|
||||
<div class="rounded-lg bg-neutral-50 px-3 py-2">
|
||||
<p class="text-xs text-neutral-400">{{ $t('absences.acquiredN1') }}</p>
|
||||
<p class="text-sm font-semibold text-neutral-800">{{ formatNumber(primary.acquired) }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-neutral-50 px-3 py-2">
|
||||
<p class="text-xs text-neutral-400">{{ $t('absences.acquiringN') }}</p>
|
||||
<p class="text-sm font-semibold text-neutral-800">{{ formatNumber(primary.acquiring) }}</p>
|
||||
<p class="text-[10px] leading-tight text-neutral-400">{{ $t('absences.acquiringHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="primary" class="mt-2 flex justify-between text-xs text-neutral-500">
|
||||
<span>{{ formatNumber(primary.taken) }} {{ $t('absences.taken') }}</span>
|
||||
<span v-if="primary.pending > 0" class="text-amber-600">
|
||||
{{ formatNumber(primary.pending) }} {{ $t('absences.pending') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Other balances, compact rows -->
|
||||
<div v-if="others.length" class="mt-4 flex flex-col divide-y divide-neutral-100 border-t border-neutral-100 pt-1">
|
||||
<div
|
||||
v-for="balance in others"
|
||||
:key="balance.id"
|
||||
class="flex items-center justify-between py-2 text-sm"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-neutral-600">
|
||||
<span class="h-2.5 w-2.5 flex-shrink-0 rounded-full" :style="{ backgroundColor: typeColor(balance.type) }" />
|
||||
{{ balance.label }}
|
||||
<span class="text-xs text-neutral-400">{{ balance.period }}</span>
|
||||
</span>
|
||||
<span class="text-neutral-900">
|
||||
<span class="font-semibold">{{ formatNumber(balance.available) }}</span>
|
||||
<span class="text-neutral-400"> / {{ formatNumber(acquiredTotal(balance)) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceBalance } from '~/services/dto/absence'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
balances: AbsenceBalance[]
|
||||
}>()
|
||||
|
||||
const { typeColor } = useAbsenceHelpers()
|
||||
|
||||
// Current paid-leave reference period, mirroring AbsenceBalanceService::periodFor.
|
||||
const currentCpPeriod = computed<string>(() => {
|
||||
const start = useAuthStore().user?.referencePeriodStart ?? '06-01'
|
||||
const now = new Date()
|
||||
const md = `${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
const startYear = md >= start ? now.getFullYear() : now.getFullYear() - 1
|
||||
return `${startYear}-${startYear + 1}`
|
||||
})
|
||||
|
||||
// The current "congés payés" balance is the headline; fall back to any CP, then any balance.
|
||||
const primary = computed<AbsenceBalance | null>(() => {
|
||||
const cps = props.balances.filter(b => b.type === 'cp')
|
||||
return cps.find(b => b.period === currentCpPeriod.value) ?? cps[0] ?? props.balances[0] ?? null
|
||||
})
|
||||
|
||||
const others = computed<AbsenceBalance[]>(() =>
|
||||
props.balances.filter(b => b.id !== primary.value?.id),
|
||||
)
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
// Valeur réelle avec décimales (ex. 8,75) : pas d'arrondi qui gonflerait le solde.
|
||||
return new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 2 }).format(n)
|
||||
}
|
||||
|
||||
// Total entitlement = acquired (N-1) + in-progress (N); falls back to the
|
||||
// backend-computed field when present.
|
||||
function acquiredTotal(balance: AbsenceBalance): number {
|
||||
return balance.acquiredTotal ?? balance.acquired + balance.acquiring
|
||||
}
|
||||
|
||||
function progress(balance: AbsenceBalance): number {
|
||||
const total = acquiredTotal(balance)
|
||||
if (total <= 0) return 0
|
||||
return Math.min(100, Math.max(0, (balance.taken / total) * 100))
|
||||
}
|
||||
</script>
|
||||
@@ -1,143 +0,0 @@
|
||||
<template>
|
||||
<div class="rounded-lg border border-neutral-200 bg-white">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-neutral-200 px-4 py-3">
|
||||
<button class="rounded-md p-1.5 text-neutral-500 hover:bg-neutral-100" @click="shiftMonth(-1)">
|
||||
<Icon name="mdi:chevron-left" size="22" />
|
||||
</button>
|
||||
<p class="text-lg font-semibold capitalize text-neutral-900">{{ monthLabel }}</p>
|
||||
<button class="rounded-md p-1.5 text-neutral-500 hover:bg-neutral-100" @click="shiftMonth(1)">
|
||||
<Icon name="mdi:chevron-right" size="22" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Weekday headers -->
|
||||
<div class="grid grid-cols-7 border-b border-neutral-100 text-center text-xs font-medium text-neutral-400">
|
||||
<div v-for="d in weekdays" :key="d" class="py-2">{{ d }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div class="grid grid-cols-7">
|
||||
<div
|
||||
v-for="cell in cells"
|
||||
:key="cell.key"
|
||||
class="min-h-[92px] border-b border-r border-neutral-100 p-1.5"
|
||||
:class="cell.holiday ? 'bg-amber-50' : (cell.inMonth ? 'bg-white' : 'bg-neutral-50')"
|
||||
:title="cell.holiday ?? undefined"
|
||||
>
|
||||
<div class="mb-1 flex items-center gap-1">
|
||||
<span v-if="cell.holiday" class="flex min-w-0 flex-1 items-center gap-1 text-[10px] font-medium text-amber-700">
|
||||
<Icon name="mdi:star-four-points-outline" size="11" class="flex-shrink-0" />
|
||||
<span class="truncate">{{ cell.holiday }}</span>
|
||||
</span>
|
||||
<span v-else class="flex-1" />
|
||||
<span class="flex-shrink-0 text-xs" :class="cell.isToday ? 'font-bold text-orange-500' : 'text-neutral-400'">
|
||||
{{ cell.day }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span
|
||||
v-for="abs in cell.absences"
|
||||
:key="abs.id"
|
||||
class="truncate rounded px-1 py-0.5 text-[11px] font-medium text-white"
|
||||
:style="{ backgroundColor: abs.status === 'pending' ? typeColor(abs.type) + 'aa' : typeColor(abs.type) }"
|
||||
:title="`${abs.user.username} · ${abs.label} (${statusLabel(abs.status)})`"
|
||||
>
|
||||
{{ abs.user.username }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceRequest } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
absences: AbsenceRequest[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'range-change': [from: string, to: string]
|
||||
}>()
|
||||
|
||||
const service = useAbsenceService()
|
||||
const { typeColor, statusLabel } = useAbsenceHelpers()
|
||||
|
||||
const holidays = ref<Record<string, string>>({})
|
||||
|
||||
const weekdays = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']
|
||||
|
||||
const cursor = ref(startOfMonth(new Date()))
|
||||
|
||||
const monthLabel = computed(() =>
|
||||
cursor.value.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }),
|
||||
)
|
||||
|
||||
type Cell = { key: string; day: number; date: Date; inMonth: boolean; isToday: boolean; holiday: string | null; absences: AbsenceRequest[] }
|
||||
|
||||
function startOfMonth(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1)
|
||||
}
|
||||
|
||||
function ymd(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// First Monday on/before the 1st of the month
|
||||
function gridStart(month: Date): Date {
|
||||
const first = startOfMonth(month)
|
||||
const dow = (first.getDay() + 6) % 7 // 0 = Monday
|
||||
const start = new Date(first)
|
||||
start.setDate(first.getDate() - dow)
|
||||
return start
|
||||
}
|
||||
|
||||
const visibleRange = computed(() => {
|
||||
const start = gridStart(cursor.value)
|
||||
const end = new Date(start)
|
||||
end.setDate(start.getDate() + 41) // 6 weeks grid
|
||||
return { start, end }
|
||||
})
|
||||
|
||||
const cells = computed<Cell[]>(() => {
|
||||
const { start } = visibleRange.value
|
||||
const today = ymd(new Date())
|
||||
const result: Cell[] = []
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const date = new Date(start)
|
||||
date.setDate(start.getDate() + i)
|
||||
const key = ymd(date)
|
||||
result.push({
|
||||
key,
|
||||
day: date.getDate(),
|
||||
date,
|
||||
inMonth: date.getMonth() === cursor.value.getMonth(),
|
||||
isToday: key === today,
|
||||
holiday: holidays.value[key] ?? null,
|
||||
absences: props.absences.filter(a => key >= a.startDate.slice(0, 10) && key <= a.endDate.slice(0, 10)),
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
async function emitRange() {
|
||||
const { start, end } = visibleRange.value
|
||||
emit('range-change', ymd(start), ymd(end))
|
||||
try {
|
||||
holidays.value = await service.getPublicHolidays(ymd(start), ymd(end))
|
||||
} catch {
|
||||
holidays.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
function shiftMonth(delta: number) {
|
||||
cursor.value = new Date(cursor.value.getFullYear(), cursor.value.getMonth() + delta, 1)
|
||||
emitRange()
|
||||
}
|
||||
|
||||
onMounted(emitRange)
|
||||
</script>
|
||||
@@ -1,73 +0,0 @@
|
||||
<template>
|
||||
<div class="absence-date-field">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ label }}</label>
|
||||
<MalioDate
|
||||
:model-value="modelValue"
|
||||
:min="min ?? undefined"
|
||||
:max="max ?? undefined"
|
||||
:error="error"
|
||||
:clearable="true"
|
||||
group-class="w-full"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
|
||||
<div v-if="showPills" class="mt-2 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="opt in pillOptions"
|
||||
:key="String(opt.value)"
|
||||
type="button"
|
||||
class="rounded-full border px-4 py-1.5 text-sm font-medium transition"
|
||||
:class="half === opt.value
|
||||
? 'border-primary-500 bg-primary-50 text-primary-600'
|
||||
: 'border-neutral-300 text-neutral-600 hover:border-neutral-400'"
|
||||
@click="$emit('update:half', opt.value)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HalfDay } from '~/services/dto/absence'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** ISO date string "YYYY-MM-DD" or null. */
|
||||
modelValue: string | null
|
||||
half: HalfDay | null
|
||||
label: string
|
||||
/** 'start' shows full/morning/afternoon, 'end' shows full/morning only. */
|
||||
mode?: 'start' | 'end'
|
||||
error?: string
|
||||
/** ISO date string "YYYY-MM-DD" or null. */
|
||||
min?: string | null
|
||||
max?: string | null
|
||||
showPills?: boolean
|
||||
}>(), {
|
||||
mode: 'start',
|
||||
error: '',
|
||||
min: null,
|
||||
max: null,
|
||||
showPills: true,
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string | null]
|
||||
'update:half': [value: HalfDay | null]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
type PillOption = { label: string; value: HalfDay | null }
|
||||
|
||||
const pillOptions = computed<PillOption[]>(() => {
|
||||
const base: PillOption[] = [
|
||||
{ label: t('absences.form.fullDay'), value: null },
|
||||
{ label: t('absences.halfDay.matin'), value: 'matin' },
|
||||
]
|
||||
if (props.mode === 'start') {
|
||||
base.push({ label: t('absences.halfDay.apres_midi'), value: 'apres_midi' })
|
||||
}
|
||||
return base
|
||||
})
|
||||
</script>
|
||||
@@ -1,193 +0,0 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="open" drawer-class="max-w-lg">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ $t('absences.detail.title') }}</h2>
|
||||
</template>
|
||||
<div v-if="request" class="flex flex-col gap-5">
|
||||
<!-- Hero -->
|
||||
<div class="overflow-hidden rounded-xl border border-neutral-200 shadow-sm">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4"
|
||||
:style="{ borderLeft: `4px solid ${typeColor(request.type)}` }"
|
||||
>
|
||||
<span
|
||||
class="mt-0.5 flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full"
|
||||
:style="{ backgroundColor: tint(request.type), color: typeColor(request.type) }"
|
||||
>
|
||||
<Icon name="mdi:calendar-account" size="22" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-lg font-semibold text-neutral-900">{{ request.label }}</p>
|
||||
<p class="mt-0.5 flex items-center gap-1.5 text-sm text-neutral-500">
|
||||
<Icon name="mdi:calendar-range" size="15" />
|
||||
{{ formatRange(request) }}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge
|
||||
class="flex-shrink-0"
|
||||
:label="statusLabel(request.status)"
|
||||
:variant="statusVariant(request.status)"
|
||||
:icon="statusIcon(request.status)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<dl class="grid grid-cols-2 divide-x divide-neutral-200 border-t border-neutral-200 bg-neutral-50">
|
||||
<div class="flex items-center gap-2.5 p-3">
|
||||
<span
|
||||
v-if="request.user.avatarUrl"
|
||||
class="h-9 w-9 flex-shrink-0 overflow-hidden rounded-full"
|
||||
>
|
||||
<img :src="request.user.avatarUrl" alt="" class="h-full w-full object-cover">
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-600"
|
||||
>
|
||||
{{ initials }}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<dt class="text-xs text-neutral-400">{{ $t('absences.table.employee') }}</dt>
|
||||
<dd class="truncate text-sm font-medium text-neutral-800">{{ request.user.username }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center p-3">
|
||||
<dt class="text-xs text-neutral-400">{{ $t('absences.table.days') }}</dt>
|
||||
<dd class="text-sm font-semibold text-neutral-900">{{ formatDays(request.countedDays) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Reason -->
|
||||
<div v-if="request.reason" class="rounded-lg border border-neutral-200 p-3">
|
||||
<p class="mb-1 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
<Icon name="mdi:comment-text-outline" size="14" />
|
||||
{{ $t('absences.form.reason') }}
|
||||
</p>
|
||||
<p class="whitespace-pre-line text-sm text-neutral-800">{{ request.reason }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Rejection -->
|
||||
<div
|
||||
v-if="request.status === 'rejected' && request.rejectionReason"
|
||||
class="rounded-lg border border-red-200 bg-red-50 p-3"
|
||||
>
|
||||
<p class="mb-1 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-red-500">
|
||||
<Icon name="mdi:close-circle-outline" size="14" />
|
||||
{{ $t('absences.detail.rejectionReason') }}
|
||||
</p>
|
||||
<p class="text-sm text-red-700">{{ request.rejectionReason }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Justification -->
|
||||
<a
|
||||
v-if="request.justificationUrl"
|
||||
:href="request.justificationUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="flex items-center gap-2 rounded-lg border border-neutral-200 p-3 text-sm font-medium text-neutral-700 transition hover:border-primary-300 hover:bg-primary-50"
|
||||
>
|
||||
<Icon name="mdi:file-document-outline" size="20" class="text-primary-500" />
|
||||
<span class="flex-1 truncate">{{ request.justificationFileName || $t('absences.detail.downloadJustification') }}</span>
|
||||
<Icon name="mdi:download" size="16" class="text-neutral-400" />
|
||||
</a>
|
||||
|
||||
<!-- Timeline -->
|
||||
<div>
|
||||
<p class="mb-3 text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
{{ $t('absences.detail.timeline') }}
|
||||
</p>
|
||||
<ol class="relative ml-1 border-l border-neutral-200 pl-5">
|
||||
<li class="mb-4 last:mb-0">
|
||||
<span class="absolute -left-[7px] mt-0.5 h-3.5 w-3.5 rounded-full border-2 border-white bg-primary-500 ring-1 ring-primary-200" />
|
||||
<p class="text-sm font-medium text-neutral-800">{{ $t('absences.detail.created') }}</p>
|
||||
<p class="text-xs text-neutral-400">{{ formatDateTime(request.createdAt) }}</p>
|
||||
</li>
|
||||
<li v-if="request.reviewedAt" class="last:mb-0">
|
||||
<span
|
||||
class="absolute -left-[7px] mt-0.5 h-3.5 w-3.5 rounded-full border-2 border-white ring-1"
|
||||
:class="request.status === 'rejected'
|
||||
? 'bg-red-500 ring-red-200'
|
||||
: 'bg-green-500 ring-green-200'"
|
||||
/>
|
||||
<p class="text-sm font-medium text-neutral-800">
|
||||
{{ statusLabel(request.status) }}
|
||||
<span v-if="request.reviewedBy" class="font-normal text-neutral-500">
|
||||
· {{ $t('absences.detail.reviewed', { name: request.reviewedBy.username }) }}
|
||||
</span>
|
||||
</p>
|
||||
<p class="text-xs text-neutral-400">{{ formatDateTime(request.reviewedAt) }}</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div v-if="canCancel" class="flex justify-end border-t border-neutral-100 pt-4">
|
||||
<MalioButton
|
||||
:label="$t('absences.detail.cancel')"
|
||||
variant="danger"
|
||||
icon-name="mdi:cancel"
|
||||
icon-position="left"
|
||||
:disabled="cancelling"
|
||||
@click="onCancel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceRequest } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
request: AbsenceRequest | null
|
||||
canCancel?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'cancelled': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const service = useAbsenceService()
|
||||
const { statusLabel, statusVariant, statusIcon, formatRange, formatDays, typeColor } = useAbsenceHelpers()
|
||||
|
||||
const open = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const cancelling = ref(false)
|
||||
|
||||
const initials = computed(() => {
|
||||
const name = props.request?.user.username ?? ''
|
||||
return name.slice(0, 2).toUpperCase() || '?'
|
||||
})
|
||||
|
||||
/** Type colour at ~12% opacity for soft backgrounds. */
|
||||
function tint(type: AbsenceRequest['type']): string {
|
||||
return `${typeColor(type)}1f`
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
return d.toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
async function onCancel() {
|
||||
if (!props.request) return
|
||||
if (!confirm(t('absences.detail.cancelConfirm'))) return
|
||||
cancelling.value = true
|
||||
try {
|
||||
await service.cancel(props.request.id)
|
||||
emit('cancelled')
|
||||
open.value = false
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,69 +0,0 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="open" drawer-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ $t('absences.review.rejectTitle') }}</h2>
|
||||
</template>
|
||||
<div v-if="request" class="flex flex-col gap-4">
|
||||
<p class="text-sm text-neutral-600">
|
||||
{{ request.user.username }} · {{ request.label }} · {{ formatRange(request) }}
|
||||
</p>
|
||||
<MalioInputTextArea
|
||||
v-model="reason"
|
||||
:label="$t('absences.review.rejectReasonLabel')"
|
||||
:placeholder="$t('absences.review.rejectReasonPlaceholder')"
|
||||
/>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<MalioButton :label="$t('common.cancel')" variant="tertiary" @click="open = false" />
|
||||
<MalioButton
|
||||
:label="$t('absences.review.confirm')"
|
||||
variant="danger"
|
||||
:disabled="!reason.trim() || submitting"
|
||||
@click="submit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceRequest } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
request: AbsenceRequest | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'rejected': []
|
||||
}>()
|
||||
|
||||
const service = useAbsenceService()
|
||||
const { formatRange } = useAbsenceHelpers()
|
||||
|
||||
const open = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const reason = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
watch(open, (v) => {
|
||||
if (v) reason.value = ''
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (!props.request || !reason.value.trim()) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await service.reject(props.request.id, reason.value.trim())
|
||||
emit('rejected')
|
||||
open.value = false
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,296 +0,0 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="open" drawer-class="max-w-xl">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ $t('absences.newRequest') }}</h2>
|
||||
</template>
|
||||
<div class="flex flex-col gap-5">
|
||||
<!-- Server-side error banner -->
|
||||
<div v-if="serverError" class="flex items-start gap-2 rounded-lg bg-red-50 p-3 text-sm text-red-700">
|
||||
<Icon name="mdi:alert-circle-outline" size="18" class="mt-0.5 flex-shrink-0" />
|
||||
<span>{{ serverError }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Step 1 — type (always visible) -->
|
||||
<MalioSelect
|
||||
v-model="form.type"
|
||||
:label="$t('absences.form.type')"
|
||||
:options="typeOptions"
|
||||
:empty-option-label="$t('absences.filters.allTypes')"
|
||||
:error="errors.type"
|
||||
group-class="w-full"
|
||||
/>
|
||||
|
||||
<!-- Step 2 — start date (revealed once a type is chosen) -->
|
||||
<AbsenceDateField
|
||||
v-if="showDates"
|
||||
v-model="form.startDate"
|
||||
v-model:half="form.startHalf"
|
||||
:label="$t('absences.form.startDate')"
|
||||
mode="start"
|
||||
:error="errors.startDate"
|
||||
:max="form.endDate"
|
||||
/>
|
||||
|
||||
<!-- Balance at start date -->
|
||||
<div v-if="preview && preview.available !== null" class="flex items-center justify-between border-t border-neutral-100 pt-3 text-sm">
|
||||
<span class="font-medium text-neutral-700">{{ $t('absences.form.balanceAt', { date: startDateLabel }) }}</span>
|
||||
<span class="text-neutral-900">{{ formatDays(preview.available) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 — end date (revealed once a start date is set) -->
|
||||
<AbsenceDateField
|
||||
v-if="showEnd"
|
||||
v-model="form.endDate"
|
||||
v-model:half="form.endHalf"
|
||||
:label="$t('absences.form.endDate')"
|
||||
mode="end"
|
||||
:error="errors.endDate"
|
||||
:min="form.startDate"
|
||||
:show-pills="!isSingleDay"
|
||||
/>
|
||||
|
||||
<!-- Duration & projected balance -->
|
||||
<div v-if="preview" class="flex flex-col gap-1 rounded-lg bg-neutral-50 p-3">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-neutral-600">{{ $t('absences.form.duration') }}</span>
|
||||
<span class="font-semibold text-neutral-900">{{ formatDays(preview.countedDays) }}</span>
|
||||
</div>
|
||||
<div v-if="preview.projectedAvailable !== null" class="flex items-center justify-between border-t border-neutral-200 pt-1 text-sm">
|
||||
<span class="font-medium text-neutral-700">{{ $t('absences.form.balanceAfterValidation') }}</span>
|
||||
<span :class="preview.projectedAvailable < 0 ? 'font-semibold text-amber-600' : 'text-neutral-900'">
|
||||
{{ formatDays(preview.projectedAvailable) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="preview && preview.projectedAvailable !== null && preview.projectedAvailable < 0"
|
||||
class="rounded-lg bg-amber-50 p-3 text-sm text-amber-700"
|
||||
>
|
||||
{{ $t('absences.form.negativeWarning') }}
|
||||
</div>
|
||||
|
||||
<!-- Step 4 — justification (only when the policy requires it) -->
|
||||
<MalioInputUpload
|
||||
v-if="showJustification"
|
||||
:model-value="form.file?.name ?? null"
|
||||
:label="`${$t('absences.form.justification')} *`"
|
||||
accept="application/pdf,image/png,image/jpeg,image/webp"
|
||||
:error="errors.justification"
|
||||
@file-selected="onFileSelected"
|
||||
/>
|
||||
|
||||
<!-- Comment (optional) -->
|
||||
<div v-if="showComment" class="flex items-start gap-2">
|
||||
<span class="mt-1 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-600">
|
||||
{{ initials }}
|
||||
</span>
|
||||
<MalioInputTextArea
|
||||
v-model="form.reason"
|
||||
group-class="flex-1"
|
||||
:placeholder="$t('absences.form.commentPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<MalioButton :label="$t('common.cancel')" variant="tertiary" @click="open = false" />
|
||||
<MalioButton
|
||||
:label="$t('absences.form.submit')"
|
||||
:disabled="submitting"
|
||||
@click="submit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsencePolicy, AbsencePreviewResult, AbsenceType, HalfDay } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
policies: AbsencePolicy[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'created': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { formatDays, formatDate } = useAbsenceHelpers()
|
||||
const service = useAbsenceService()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const open = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
type FormState = {
|
||||
type: AbsenceType | null
|
||||
// ISO date strings "YYYY-MM-DD" (lexicographic order == chronological order).
|
||||
startDate: string | null
|
||||
startHalf: HalfDay | null
|
||||
endDate: string | null
|
||||
endHalf: HalfDay | null
|
||||
reason: string
|
||||
file: File | null
|
||||
}
|
||||
|
||||
const form = reactive<FormState>({
|
||||
type: null,
|
||||
startDate: null,
|
||||
startHalf: null,
|
||||
endDate: null,
|
||||
endHalf: null,
|
||||
reason: '',
|
||||
file: null,
|
||||
})
|
||||
|
||||
const errors = reactive<{ type: string; startDate: string; endDate: string; justification: string }>({
|
||||
type: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
justification: '',
|
||||
})
|
||||
|
||||
const serverError = ref('')
|
||||
const preview = ref<AbsencePreviewResult | null>(null)
|
||||
const submitting = ref(false)
|
||||
|
||||
const typeOptions = computed(() =>
|
||||
props.policies
|
||||
.filter(p => p.active)
|
||||
.map(p => ({ label: p.label, value: p.type })),
|
||||
)
|
||||
|
||||
const selectedPolicy = computed(() => props.policies.find(p => p.type === form.type) ?? null)
|
||||
const justificationRequired = computed(() => selectedPolicy.value?.justificationRequired ?? false)
|
||||
|
||||
const showDates = computed(() => form.type !== null)
|
||||
const showEnd = computed(() => form.startDate !== null)
|
||||
const showJustification = computed(() => form.type !== null && justificationRequired.value)
|
||||
const showComment = computed(() => form.startDate !== null)
|
||||
|
||||
const isSingleDay = computed(() =>
|
||||
form.startDate !== null
|
||||
&& form.endDate !== null
|
||||
&& form.startDate === form.endDate,
|
||||
)
|
||||
|
||||
const startDateLabel = computed(() => formatDate(form.startDate))
|
||||
|
||||
const initials = computed(() => {
|
||||
const name = auth.user?.username ?? ''
|
||||
return name.slice(0, 2).toUpperCase() || '?'
|
||||
})
|
||||
|
||||
function onFileSelected(file: File) {
|
||||
form.file = file
|
||||
errors.justification = ''
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
// On a single-day request the end half-day mirrors the start.
|
||||
const endHalf = isSingleDay.value ? form.startHalf : form.endHalf
|
||||
return {
|
||||
type: form.type as AbsenceType,
|
||||
startDate: form.startDate as string,
|
||||
endDate: form.endDate as string,
|
||||
startHalfDay: form.startHalf,
|
||||
endHalfDay: endHalf,
|
||||
reason: form.reason || null,
|
||||
}
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
errors.type = form.type ? '' : t('absences.form.errors.typeRequired')
|
||||
errors.startDate = form.startDate ? '' : t('absences.form.errors.startRequired')
|
||||
|
||||
if (form.endDate === null) {
|
||||
errors.endDate = t('absences.form.errors.endRequired')
|
||||
} else if (form.startDate && form.endDate < form.startDate) {
|
||||
errors.endDate = t('absences.form.errors.endBeforeStart')
|
||||
} else if (form.type && form.startDate && (preview.value?.countedDays ?? 0) <= 0) {
|
||||
errors.endDate = t('absences.form.errors.zeroDays')
|
||||
} else {
|
||||
errors.endDate = ''
|
||||
}
|
||||
|
||||
errors.justification = justificationRequired.value && !form.file
|
||||
? t('absences.form.errors.justificationRequired')
|
||||
: ''
|
||||
|
||||
return !errors.type && !errors.startDate && !errors.endDate && !errors.justification
|
||||
}
|
||||
|
||||
// Clear field errors as soon as the user corrects them.
|
||||
watch(() => form.type, (v) => { if (v) errors.type = '' })
|
||||
watch(() => form.startDate, (v) => { if (v) errors.startDate = '' })
|
||||
watch(() => [form.endDate, form.startDate], () => {
|
||||
if (form.endDate && (!form.startDate || form.endDate >= form.startDate)) errors.endDate = ''
|
||||
})
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(
|
||||
() => [form.type, form.startDate, form.endDate, form.startHalf, form.endHalf],
|
||||
() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
if (!form.type || !form.startDate || !form.endDate) {
|
||||
preview.value = null
|
||||
return
|
||||
}
|
||||
debounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
preview.value = await service.preview(buildPayload())
|
||||
} catch {
|
||||
preview.value = null
|
||||
}
|
||||
}, 300)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
async function submit() {
|
||||
serverError.value = ''
|
||||
if (!validate()) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const created = await service.create(buildPayload())
|
||||
if (form.file) {
|
||||
await service.uploadJustification(created.id, form.file)
|
||||
}
|
||||
emit('created')
|
||||
open.value = false
|
||||
resetForm()
|
||||
} catch (e) {
|
||||
serverError.value = (e instanceof Error && e.message) ? e.message : t('absences.form.serverError')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.type = null
|
||||
form.startDate = null
|
||||
form.startHalf = null
|
||||
form.endDate = null
|
||||
form.endHalf = null
|
||||
form.reason = ''
|
||||
form.file = null
|
||||
errors.type = ''
|
||||
errors.startDate = ''
|
||||
errors.endDate = ''
|
||||
errors.justification = ''
|
||||
serverError.value = ''
|
||||
preview.value = null
|
||||
}
|
||||
|
||||
watch(open, (v) => {
|
||||
if (v) resetForm()
|
||||
})
|
||||
</script>
|
||||
@@ -1,143 +0,0 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="open" drawer-class="max-w-lg">
|
||||
<template #header>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold">{{ $t('absences.admin.employees.drawer.title') }}</h2>
|
||||
<p v-if="user" class="text-sm text-neutral-500">{{ user.username }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<form v-if="user" class="grid grid-cols-1 gap-4 sm:grid-cols-2" @submit.prevent="save">
|
||||
<!-- Dates en pleine largeur (1 par ligne) : le popover du calendrier
|
||||
a besoin de toute la largeur pour s'afficher correctement. -->
|
||||
<div class="sm:col-span-2">
|
||||
<MalioDate
|
||||
v-model="form.hireDate"
|
||||
:label="$t('absences.admin.employees.fields.hireDate')"
|
||||
group-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<MalioDate
|
||||
v-model="form.endDate"
|
||||
:label="$t('absences.admin.employees.fields.endDate')"
|
||||
group-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<MalioSelect
|
||||
v-model="form.contractType"
|
||||
:label="$t('absences.admin.employees.fields.contractType')"
|
||||
:options="contractOptions"
|
||||
empty-option-label="—"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.workTimeRatio"
|
||||
:label="$t('absences.admin.employees.fields.workTimeRatio')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.annualLeaveDays"
|
||||
:label="$t('absences.admin.employees.fields.annualLeaveDays')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.referencePeriodStart"
|
||||
:label="$t('absences.admin.employees.fields.referencePeriodStart')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.initialLeaveBalance"
|
||||
:label="$t('absences.admin.employees.fields.initialLeaveBalance')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<div class="col-span-full mt-2 flex justify-end">
|
||||
<MalioButton
|
||||
:label="$t('absences.admin.employees.drawer.save')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="submitting"
|
||||
@click="save"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ContractType, UserData } from '~/services/dto/user-data'
|
||||
import { useUserService } from '~/services/users'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
user: UserData | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'saved': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { update } = useUserService()
|
||||
|
||||
const open = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
const contractOptions = [
|
||||
{ label: t('absences.admin.employees.contract.cdi'), value: 'CDI' },
|
||||
{ label: t('absences.admin.employees.contract.cdd'), value: 'CDD' },
|
||||
{ label: t('absences.admin.employees.contract.stage'), value: 'STAGE' },
|
||||
{ label: t('absences.admin.employees.contract.alternance'), value: 'ALTERNANCE' },
|
||||
{ label: t('absences.admin.employees.contract.autre'), value: 'AUTRE' },
|
||||
]
|
||||
|
||||
const form = reactive({
|
||||
hireDate: null as string | null,
|
||||
endDate: null as string | null,
|
||||
contractType: null as ContractType | null,
|
||||
workTimeRatio: '1.0',
|
||||
annualLeaveDays: '25',
|
||||
referencePeriodStart: '06-01',
|
||||
initialLeaveBalance: '0',
|
||||
})
|
||||
|
||||
function hydrate(u: UserData | null) {
|
||||
if (!u) return
|
||||
form.hireDate = u.hireDate ? u.hireDate.slice(0, 10) : null
|
||||
form.endDate = u.endDate ? u.endDate.slice(0, 10) : null
|
||||
form.contractType = u.contractType ?? null
|
||||
form.workTimeRatio = String(u.workTimeRatio ?? 1)
|
||||
form.annualLeaveDays = String(u.annualLeaveDays ?? 25)
|
||||
form.referencePeriodStart = u.referencePeriodStart ?? '06-01'
|
||||
form.initialLeaveBalance = String(u.initialLeaveBalance ?? 0)
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (isOpen) => {
|
||||
if (isOpen) hydrate(props.user)
|
||||
})
|
||||
|
||||
async function save() {
|
||||
if (!props.user) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await update(props.user.id, {
|
||||
isEmployee: true,
|
||||
hireDate: form.hireDate || null,
|
||||
endDate: form.endDate || null,
|
||||
contractType: form.contractType,
|
||||
workTimeRatio: Number(form.workTimeRatio) || 1,
|
||||
annualLeaveDays: Number(form.annualLeaveDays) || 0,
|
||||
referencePeriodStart: form.referencePeriodStart || '06-01',
|
||||
initialLeaveBalance: Number(form.initialLeaveBalance) || 0,
|
||||
})
|
||||
emit('saved')
|
||||
open.value = false
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -51,8 +51,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsencePolicy } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import type { AbsencePolicy } from '~/modules/absence/services/dto/absence'
|
||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
||||
|
||||
const service = useAbsenceService()
|
||||
const rows = ref<AbsencePolicy[]>([])
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('admin.audit.title') }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-4">
|
||||
<MalioSelect
|
||||
v-model="entityTypeFilter"
|
||||
:options="entityTypeOptions"
|
||||
:label="$t('admin.audit.filterEntityType')"
|
||||
:empty-option-label="$t('admin.audit.filterEntityTypeAll')"
|
||||
group-class="w-64"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="actionFilter"
|
||||
:options="actionOptions"
|
||||
:label="$t('admin.audit.filterAction')"
|
||||
:empty-option-label="$t('admin.audit.filterActionAll')"
|
||||
group-class="w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:items="rows"
|
||||
:loading="isLoading"
|
||||
:empty-message="$t('admin.audit.empty')"
|
||||
>
|
||||
<template #cell-performedAt="{ item }">
|
||||
{{ formatDate(item.performedAt) }}
|
||||
</template>
|
||||
<template #cell-entityType="{ item }">
|
||||
{{ entityTypeLabel(item.entityType) }}
|
||||
</template>
|
||||
<template #cell-action="{ item }">
|
||||
{{ actionLabel(item.action) }}
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<span class="text-sm text-neutral-500">{{ $t('admin.audit.page', { page }) }}</span>
|
||||
<div class="flex gap-2">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('admin.audit.previous')"
|
||||
:disabled="page <= 1 || isLoading"
|
||||
@click="goToPage(page - 1)"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('admin.audit.next')"
|
||||
:disabled="!hasNextPage || isLoading"
|
||||
@click="goToPage(page + 1)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AuditLogAction, AuditLogItem } from '~/modules/core/services/audit-logs'
|
||||
import { useAuditLogService } from '~/modules/core/services/audit-logs'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
const { t, te } = useI18n()
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'performedAt', label: t('admin.audit.date'), primary: true },
|
||||
{ key: 'performedBy', label: t('admin.audit.performedBy') },
|
||||
{ key: 'entityType', label: t('admin.audit.entityType') },
|
||||
{ key: 'action', label: t('admin.audit.action') },
|
||||
{ key: 'entityId', label: t('admin.audit.entityId') },
|
||||
])
|
||||
|
||||
const actionOptions = computed<{ value: AuditLogAction, label: string }[]>(() => [
|
||||
{ value: 'create', label: t('audit.action.create') },
|
||||
{ value: 'update', label: t('audit.action.update') },
|
||||
{ value: 'delete', label: t('audit.action.delete') },
|
||||
])
|
||||
|
||||
const auditLogService = useAuditLogService()
|
||||
|
||||
const rows = ref<AuditLogItem[]>([])
|
||||
const entityTypes = ref<string[]>([])
|
||||
const totalItems = ref(0)
|
||||
const page = ref(1)
|
||||
const isLoading = ref(true)
|
||||
const entityTypeFilter = ref<string | null>(null)
|
||||
const actionFilter = ref<AuditLogAction | null>(null)
|
||||
|
||||
const entityTypeOptions = computed<{ value: string, label: string }[]>(() =>
|
||||
entityTypes.value.map((value) => ({ value, label: entityTypeLabel(value) })),
|
||||
)
|
||||
|
||||
// PAGE_SIZE must match the API default page size. The full-page guard keeps the
|
||||
// "next" button accurate even on the last (partial) page.
|
||||
const hasNextPage = computed(() => rows.value.length >= PAGE_SIZE && page.value * PAGE_SIZE < totalItems.value)
|
||||
|
||||
function entityTypeLabel(value: string): string {
|
||||
const key = `audit.entity.${value}`
|
||||
return te(key) ? t(key) : value
|
||||
}
|
||||
|
||||
function actionLabel(action: AuditLogAction): string {
|
||||
return t(`audit.action.${action}`)
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
async function loadItems() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const result = await auditLogService.list({
|
||||
page: page.value,
|
||||
entityType: entityTypeFilter.value ?? undefined,
|
||||
action: actionFilter.value ?? undefined,
|
||||
})
|
||||
rows.value = result.items
|
||||
totalItems.value = result.totalItems
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEntityTypes() {
|
||||
entityTypes.value = await auditLogService.entityTypes()
|
||||
}
|
||||
|
||||
function goToPage(target: number) {
|
||||
if (target < 1) {
|
||||
return
|
||||
}
|
||||
page.value = target
|
||||
loadItems()
|
||||
}
|
||||
|
||||
watch([entityTypeFilter, actionFilter], () => {
|
||||
page.value = 1
|
||||
loadItems()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadItems()
|
||||
loadEntityTypes()
|
||||
})
|
||||
</script>
|
||||
@@ -51,7 +51,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBookStackService } from '~/services/bookstack'
|
||||
import { useBookStackService } from '~/modules/integration/services/bookstack'
|
||||
|
||||
const { getSettings, saveSettings, testConnection } = useBookStackService()
|
||||
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import { useClientService } from '~/services/clients'
|
||||
import type { Client } from '~/modules/directory/services/dto/client'
|
||||
import { useClientService } from '~/modules/directory/services/clients'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
import { useTaskEffortService } from '~/services/task-efforts'
|
||||
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
|
||||
import { useTaskEffortService } from '~/modules/project-management/services/task-efforts'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
import { useGiteaService } from '~/modules/integration/services/gitea'
|
||||
|
||||
const { getSettings, saveSettings, testConnection } = useGiteaService()
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMailService } from '~/services/mail'
|
||||
import { useMailService } from '~/modules/mail/services/mail'
|
||||
|
||||
const { getConfiguration, updateConfiguration, testConfiguration } = useMailService()
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
|
||||
import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('admin.roles.title') }}</h2>
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('admin.roles.addRole')"
|
||||
@click="openCreate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:loading="isLoading"
|
||||
:empty-message="$t('admin.roles.empty')"
|
||||
@row-click="openEdit"
|
||||
>
|
||||
<template #cell-isSystem="{ item }">
|
||||
<span
|
||||
v-if="item.isSystem"
|
||||
class="rounded-full bg-primary-100 px-2 py-0.5 text-xs font-semibold text-primary-600"
|
||||
>
|
||||
{{ $t('admin.roles.system') }}
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-permissions="{ item }">
|
||||
<span class="text-neutral-600">{{ item.permissions.length }}</span>
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<MalioButtonIcon
|
||||
v-if="!item.isSystem"
|
||||
icon="mdi:delete-outline"
|
||||
:aria-label="$t('common.delete')"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
button-class="text-neutral-400 hover:text-red-500"
|
||||
@click.stop="handleDelete(item.id)"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<RoleDrawer
|
||||
v-model="drawerOpen"
|
||||
:item="selectedItem"
|
||||
:permissions="permissions"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Role } from '~/modules/core/services/roles'
|
||||
import { useRoleService } from '~/modules/core/services/roles'
|
||||
import type { Permission } from '~/modules/core/services/permissions'
|
||||
import { usePermissionService } from '~/modules/core/services/permissions'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'label', label: t('admin.roles.label'), primary: true },
|
||||
{ key: 'code', label: t('admin.roles.code') },
|
||||
{ key: 'permissions', label: t('admin.roles.permissions') },
|
||||
{ key: 'isSystem', label: '' },
|
||||
])
|
||||
|
||||
const roleService = useRoleService()
|
||||
const permissionService = usePermissionService()
|
||||
|
||||
const items = ref<Role[]>([])
|
||||
const permissions = ref<Permission[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedItem = ref<Role | null>(null)
|
||||
|
||||
async function loadItems() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
items.value = await roleService.list()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPermissions() {
|
||||
permissions.value = await permissionService.list()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedItem.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: Role) {
|
||||
selectedItem.value = item
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await roleService.remove(id)
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadItems()
|
||||
loadPermissions()
|
||||
})
|
||||
</script>
|
||||
@@ -70,7 +70,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useShareSettingsService } from '~/services/share-settings'
|
||||
import { useShareSettingsService } from '~/modules/integration/services/share-settings'
|
||||
|
||||
const { getSettings, saveSettings, testConnection } = useShareSettingsService()
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import { useTaskTagService } from '~/services/task-tags'
|
||||
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
|
||||
import { useTaskTagService } from '~/modules/project-management/services/task-tags'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Workflow } from '~/services/dto/workflow'
|
||||
import { useWorkflowService } from '~/services/workflows'
|
||||
import type { Workflow } from '~/modules/project-management/services/dto/workflow'
|
||||
import { useWorkflowService } from '~/modules/project-management/services/workflows'
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useZimbraService } from '~/services/zimbra'
|
||||
import { useZimbraService } from '~/modules/integration/services/zimbra'
|
||||
|
||||
const { getSettings, saveSettings, testConnection } = useZimbraService()
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">
|
||||
{{ isEditing ? $t('admin.roles.editRole') : $t('admin.roles.addRole') }}
|
||||
</h2>
|
||||
</template>
|
||||
<form class="flex flex-col gap-3" @submit.prevent="handleSubmit">
|
||||
<MalioInputText
|
||||
v-model="form.code"
|
||||
:label="$t('admin.roles.code')"
|
||||
input-class="w-full"
|
||||
:disabled="isEditing"
|
||||
:hint="isEditing ? $t('admin.roles.codeImmutable') : $t('admin.roles.codeHint')"
|
||||
:error="touched.code && !codeValid ? $t('admin.roles.codeInvalid') : ''"
|
||||
@blur="touched.code = true"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
:label="$t('admin.roles.label')"
|
||||
input-class="w-full"
|
||||
:error="touched.label && !form.label.trim() ? $t('admin.roles.labelRequired') : ''"
|
||||
@blur="touched.label = true"
|
||||
/>
|
||||
<MalioInputTextArea
|
||||
v-model="form.description"
|
||||
:label="$t('admin.roles.description')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<div class="mt-2">
|
||||
<label class="text-sm font-semibold text-neutral-700">
|
||||
{{ $t('admin.roles.permissions') }}
|
||||
</label>
|
||||
<p v-if="permissions.length === 0" class="mt-2 text-xs text-neutral-400">
|
||||
{{ $t('admin.roles.noPermissions') }}
|
||||
</p>
|
||||
<div
|
||||
v-for="group in groupedPermissions"
|
||||
:key="group.module"
|
||||
class="mt-3 rounded-lg border border-neutral-200 p-3"
|
||||
>
|
||||
<p class="mb-2 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
||||
{{ group.module }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label
|
||||
v-for="perm in group.permissions"
|
||||
:key="perm.id"
|
||||
class="flex items-start gap-2 text-sm text-neutral-700"
|
||||
>
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
type="checkbox"
|
||||
:value="perm['@id']"
|
||||
class="mt-0.5 rounded border-neutral-300"
|
||||
/>
|
||||
<span>
|
||||
{{ perm.label }}
|
||||
<span class="block text-xs text-neutral-400">{{ perm.code }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<MalioButton
|
||||
:label="$t('common.save')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Role, RoleWrite } from '~/modules/core/services/roles'
|
||||
import { useRoleService } from '~/modules/core/services/roles'
|
||||
import type { Permission } from '~/modules/core/services/permissions'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
item: Role | null
|
||||
permissions: Permission[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const isEditing = computed(() => !!props.item)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
code: '',
|
||||
label: '',
|
||||
description: '',
|
||||
permissions: [] as string[],
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
code: false,
|
||||
label: false,
|
||||
})
|
||||
|
||||
const codeValid = computed(() => /^[a-z][a-z0-9_]*$/.test(form.code))
|
||||
|
||||
const groupedPermissions = computed(() => {
|
||||
const byModule = new Map<string, Permission[]>()
|
||||
for (const perm of props.permissions) {
|
||||
const list = byModule.get(perm.module) ?? []
|
||||
list.push(perm)
|
||||
byModule.set(perm.module, list)
|
||||
}
|
||||
return [...byModule.entries()]
|
||||
.map(([module, permissions]) => ({ module, permissions }))
|
||||
.sort((a, b) => a.module.localeCompare(b.module))
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.item) {
|
||||
form.code = props.item.code
|
||||
form.label = props.item.label
|
||||
form.description = props.item.description ?? ''
|
||||
form.permissions = props.item.permissions
|
||||
.map((p) => p['@id'])
|
||||
.filter((iri): iri is string => !!iri)
|
||||
} else {
|
||||
form.code = ''
|
||||
form.label = ''
|
||||
form.description = ''
|
||||
form.permissions = []
|
||||
}
|
||||
touched.code = false
|
||||
touched.label = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = useRoleService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.code = true
|
||||
touched.label = true
|
||||
if (!form.label.trim()) {
|
||||
return
|
||||
}
|
||||
if (!isEditing.value && !codeValid.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
if (isEditing.value && props.item) {
|
||||
const payload: Partial<RoleWrite> = {
|
||||
label: form.label.trim(),
|
||||
description: form.description.trim() || null,
|
||||
permissions: form.permissions,
|
||||
}
|
||||
await update(props.item.id, payload)
|
||||
} else {
|
||||
const payload: RoleWrite = {
|
||||
code: form.code.trim(),
|
||||
label: form.label.trim(),
|
||||
description: form.description.trim() || null,
|
||||
permissions: form.permissions,
|
||||
}
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -96,11 +96,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Workflow, StatusCategory } from '~/services/dto/workflow'
|
||||
import { STATUS_CATEGORY_COLOR } from '~/services/dto/workflow'
|
||||
import type { TaskStatusWrite } from '~/services/dto/task-status'
|
||||
import { useWorkflowService } from '~/services/workflows'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
import type { Workflow, StatusCategory } from '~/modules/project-management/services/dto/workflow'
|
||||
import { STATUS_CATEGORY_COLOR } from '~/modules/project-management/services/dto/workflow'
|
||||
import type { TaskStatusWrite } from '~/modules/project-management/services/dto/task-status'
|
||||
import { useWorkflowService } from '~/modules/project-management/services/workflows'
|
||||
import { useTaskStatusService } from '~/modules/project-management/services/task-statuses'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('clients.editClient') : $t('clients.addClient') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
label="Nom"
|
||||
input-class="w-full"
|
||||
:error="touched.name && !form.name.trim() ? 'Le nom est requis' : ''"
|
||||
@blur="touched.name = true"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.email"
|
||||
label="Email"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.phone"
|
||||
label="Téléphone"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.street"
|
||||
label="Rue"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.city"
|
||||
label="Ville"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.postalCode"
|
||||
label="Code Postal"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Client, ClientWrite } from '~/services/dto/client'
|
||||
import { useClientService } from '~/services/clients'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
client: Client | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const isEditing = computed(() => !!props.client)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
street: '',
|
||||
city: '',
|
||||
postalCode: '',
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
name: false,
|
||||
email: false,
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.client) {
|
||||
form.name = props.client.name ?? ''
|
||||
form.email = props.client.email ?? ''
|
||||
form.phone = props.client.phone ?? ''
|
||||
form.street = props.client.street ?? ''
|
||||
form.city = props.client.city ?? ''
|
||||
form.postalCode = props.client.postalCode ?? ''
|
||||
} else {
|
||||
form.name = ''
|
||||
form.email = ''
|
||||
form.phone = ''
|
||||
form.street = ''
|
||||
form.city = ''
|
||||
form.postalCode = ''
|
||||
}
|
||||
touched.name = false
|
||||
touched.email = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = useClientService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.name = true
|
||||
if (!form.name.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: ClientWrite = {
|
||||
name: form.name.trim(),
|
||||
email: form.email.trim() || null,
|
||||
phone: form.phone.trim() || null,
|
||||
street: form.street.trim() || null,
|
||||
city: form.city.trim() || null,
|
||||
postalCode: form.postalCode.trim() || null,
|
||||
}
|
||||
|
||||
if (isEditing.value && props.client) {
|
||||
await update(props.client.id, payload)
|
||||
} else {
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,121 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
/** Ouverture de la visionneuse. */
|
||||
modelValue: boolean
|
||||
/** Nom du fichier affiché dans la barre. */
|
||||
filename: string
|
||||
/** Type MIME — détermine le rendu (image vs PDF). */
|
||||
mimeType: string
|
||||
/** Object URL du Blob de la pièce jointe. null tant que le contenu charge. */
|
||||
url: string | null
|
||||
/** Téléchargement en cours du contenu. */
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
download: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const isImage = computed(() => props.mimeType.startsWith('image/'))
|
||||
const isPdf = computed(() => props.mimeType === 'application/pdf')
|
||||
|
||||
function close(): void {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape') close()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) {
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
} else {
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="mail-preview" appear>
|
||||
<div class="fixed inset-0 z-50 flex flex-col bg-slate-900/80 backdrop-blur-sm">
|
||||
<!-- Barre supérieure -->
|
||||
<div class="flex flex-shrink-0 items-center justify-between gap-4 px-4 py-3 text-white">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Icon
|
||||
:name="isImage ? 'material-symbols:image-outline' : 'material-symbols:picture-as-pdf-outline'"
|
||||
size="18"
|
||||
class="flex-shrink-0 text-white/70"
|
||||
/>
|
||||
<span class="truncate text-sm font-medium">{{ filename }}</span>
|
||||
</div>
|
||||
<div class="flex flex-shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-white/90 transition-colors hover:bg-white/10"
|
||||
@click="emit('download')"
|
||||
>
|
||||
<Icon name="material-symbols:download" size="18" />
|
||||
<span class="hidden sm:inline">{{ t('mail.actions.download') }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1.5 text-white/90 transition-colors hover:bg-white/10"
|
||||
:aria-label="t('mail.preview.close')"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="mdi:close" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contenu -->
|
||||
<div class="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4" @click.self="close">
|
||||
<div v-if="loading" class="flex flex-col items-center gap-3 text-white/70">
|
||||
<Icon name="material-symbols:progress-activity" size="32" class="animate-spin" />
|
||||
<span class="text-sm">{{ t('mail.preview.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<img
|
||||
v-else-if="isImage && url"
|
||||
:src="url"
|
||||
:alt="filename"
|
||||
class="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
|
||||
>
|
||||
|
||||
<iframe
|
||||
v-else-if="isPdf && url"
|
||||
:src="url"
|
||||
:title="filename"
|
||||
class="h-full w-full max-w-5xl rounded-lg bg-white shadow-2xl"
|
||||
/>
|
||||
|
||||
<div v-else class="flex flex-col items-center gap-3 text-white/70">
|
||||
<Icon name="material-symbols:visibility-off-outline" size="32" />
|
||||
<span class="text-sm">{{ t('mail.preview.unavailable') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-preview-enter-active,
|
||||
.mail-preview-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.mail-preview-enter-from,
|
||||
.mail-preview-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,144 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailMessageDetailDto } from '~/services/dto/mail'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useMailService } from '~/services/mail'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useUserService } from '~/services/users'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
messageId: number
|
||||
messageDetail: MailMessageDetailDto | null
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
created: [task: Task]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const auth = useAuthStore()
|
||||
const mailService = useMailService()
|
||||
const projectService = useProjectService()
|
||||
const taskGroupService = useTaskGroupService()
|
||||
const userService = useUserService()
|
||||
|
||||
const projectId = ref<number | null>(null)
|
||||
const taskGroupId = ref<number | null>(null)
|
||||
const assigneeId = ref<number | null>(null)
|
||||
const statusId = ref<number | null>(null)
|
||||
const isSubmitting = ref(false)
|
||||
const touchedProject = ref(false)
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const groups = ref<TaskGroup[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
const loadingGroups = ref(false)
|
||||
|
||||
const projectOptions = computed(() => projects.value.map(p => ({ label: p.name, value: p.id })))
|
||||
const groupOptions = computed(() => groups.value.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })))
|
||||
const userOptions = computed(() => users.value.map(u => ({ label: u.username, value: u.id })))
|
||||
|
||||
const selectedProject = computed(() => projects.value.find(p => p.id === projectId.value) ?? null)
|
||||
const statusOptions = computed(() =>
|
||||
(selectedProject.value?.workflow?.statuses ?? []).map(s => ({ label: s.label, value: s.id })),
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
const [projs, us] = await Promise.all([
|
||||
projectService.getAll({ archived: false }),
|
||||
userService.getAll(),
|
||||
])
|
||||
projects.value = projs
|
||||
users.value = us
|
||||
})
|
||||
|
||||
watch(projectId, async (pid) => {
|
||||
taskGroupId.value = null
|
||||
statusId.value = selectedProject.value?.workflow?.statuses?.[0]?.id ?? null
|
||||
groups.value = []
|
||||
if (!pid) return
|
||||
loadingGroups.value = true
|
||||
try {
|
||||
groups.value = await taskGroupService.getByProject(pid)
|
||||
} finally {
|
||||
loadingGroups.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
projectId.value = null
|
||||
taskGroupId.value = null
|
||||
statusId.value = null
|
||||
assigneeId.value = auth.user?.id ?? null
|
||||
touchedProject.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function close(): void {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
touchedProject.value = true
|
||||
if (!projectId.value) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const task = await mailService.createTaskFromMail(props.messageId, {
|
||||
projectId: projectId.value,
|
||||
taskGroupId: taskGroupId.value ?? undefined,
|
||||
assigneeId: assigneeId.value ?? undefined,
|
||||
statusId: statusId.value ?? undefined,
|
||||
})
|
||||
emit('created', task)
|
||||
close()
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppModal
|
||||
:model-value="modelValue"
|
||||
width="lg"
|
||||
:title="t('mail.createTaskModal.title')"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="space-y-5">
|
||||
<div v-if="messageDetail" class="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3 text-sm">
|
||||
<p class="truncate font-medium text-neutral-800">{{ messageDetail.header.subject ?? t('mail.noSubject') }}</p>
|
||||
<p class="mt-0.5 truncate text-xs text-neutral-500">{{ messageDetail.header.fromName ?? messageDetail.header.fromEmail }}</p>
|
||||
<p class="mt-2 text-xs italic text-neutral-400">{{ t('mail.createTaskModal.titleHint') }}</p>
|
||||
<p class="text-xs italic text-neutral-400">{{ t('mail.createTaskModal.descriptionHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MalioSelect v-model="projectId" :options="projectOptions" :label="t('mail.createTaskModal.projectLabel')" :empty-option-label="t('mail.createTaskModal.projectPlaceholder')" group-class="w-full" />
|
||||
<p v-if="touchedProject && !projectId" class="mt-1 text-xs text-red-500">{{ t('mail.createTaskModal.projectLabel').replace(' *', '') }} requis</p>
|
||||
</div>
|
||||
|
||||
<div v-if="projectId">
|
||||
<MalioSelect v-model="taskGroupId" :options="groupOptions" :label="t('mail.createTaskModal.groupLabel')" :empty-option-label="t('mail.createTaskModal.groupPlaceholder')" group-class="w-full" :disabled="loadingGroups" />
|
||||
</div>
|
||||
|
||||
<div v-if="projectId">
|
||||
<MalioSelect v-model="statusId" :options="statusOptions" :label="t('mail.createTaskModal.statusLabel')" group-class="w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MalioSelect v-model="assigneeId" :options="userOptions" :label="t('mail.createTaskModal.assigneeLabel')" :empty-option-label="t('mail.createTaskModal.assigneePlaceholder')" group-class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton variant="tertiary" label="Annuler" button-class="w-auto px-4" @click="close" />
|
||||
<MalioButton :label="t('mail.createTaskModal.submit')" button-class="w-auto px-6" :disabled="isSubmitting" @click="handleSubmit" />
|
||||
</template>
|
||||
</AppModal>
|
||||
</template>
|
||||
@@ -1,123 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailFolderDto } from '~/services/dto/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Arbre de dossiers (getter folderTree du store) */
|
||||
folders: readonly MailFolderDto[]
|
||||
/** Chemin du dossier actuellement sélectionné */
|
||||
selectedPath: string | null
|
||||
/** Niveau de profondeur pour l'indentation (usage récursif interne) */
|
||||
depth?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [path: string]
|
||||
}>()
|
||||
|
||||
const { getFolderLabel, getFolderIcon } = useSystemFolderLabel()
|
||||
const { t } = useI18n()
|
||||
|
||||
const currentDepth = computed(() => props.depth ?? 0)
|
||||
|
||||
// Dossiers dépliés (repliés par défaut → seuls les dossiers racine sont visibles).
|
||||
const expanded = ref<Set<string>>(new Set())
|
||||
|
||||
function isExpanded(path: string): boolean {
|
||||
return expanded.value.has(path)
|
||||
}
|
||||
|
||||
function toggleExpanded(path: string): void {
|
||||
const next = new Set(expanded.value)
|
||||
if (next.has(path)) {
|
||||
next.delete(path)
|
||||
} else {
|
||||
next.add(path)
|
||||
}
|
||||
expanded.value = next
|
||||
}
|
||||
|
||||
function hasChildren(folder: MailFolderDto): boolean {
|
||||
return !!folder.children && folder.children.length > 0
|
||||
}
|
||||
|
||||
function handleSelect(path: string): void {
|
||||
emit('select', path)
|
||||
}
|
||||
|
||||
function paddingStyle(): Record<string, string> {
|
||||
const depth = currentDepth.value
|
||||
return { paddingLeft: `${0.5 + depth * 0.75}rem` }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="folders.length === 0 && currentDepth === 0"
|
||||
class="px-3 py-4 text-sm text-neutral-400 italic"
|
||||
>
|
||||
{{ t('mail.empty.folder') }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-for="folder in folders" :key="folder.path">
|
||||
<div
|
||||
class="flex items-center gap-1 rounded-md pr-2 py-1.5 text-sm transition-colors"
|
||||
:class="
|
||||
selectedPath === folder.path
|
||||
? 'bg-primary-100 text-primary-700 font-medium'
|
||||
: 'text-neutral-700 hover:bg-neutral-100'
|
||||
"
|
||||
:style="paddingStyle()"
|
||||
>
|
||||
<button
|
||||
v-if="hasChildren(folder)"
|
||||
type="button"
|
||||
class="flex-shrink-0 rounded p-0.5 hover:bg-neutral-200"
|
||||
:aria-label="isExpanded(folder.path) ? t('mail.folderTree.collapse') : t('mail.folderTree.expand')"
|
||||
@click.stop="toggleExpanded(folder.path)"
|
||||
>
|
||||
<Icon
|
||||
:name="isExpanded(folder.path) ? 'material-symbols:keyboard-arrow-down' : 'material-symbols:chevron-right'"
|
||||
size="16"
|
||||
class="text-neutral-400"
|
||||
/>
|
||||
</button>
|
||||
<span v-else class="inline-block w-[22px] flex-shrink-0" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-1 items-center gap-2 text-left min-w-0"
|
||||
@click="handleSelect(folder.path)"
|
||||
>
|
||||
<Icon
|
||||
:name="getFolderIcon(folder.path)"
|
||||
size="16"
|
||||
class="flex-shrink-0"
|
||||
:class="selectedPath === folder.path ? 'text-primary-600' : 'text-neutral-400'"
|
||||
/>
|
||||
|
||||
<span class="flex-1 truncate">
|
||||
{{ getFolderLabel(folder.path, folder.displayName) }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="folder.unreadCount > 0"
|
||||
class="ml-auto flex-shrink-0 rounded-full bg-primary-500 px-1.5 py-0.5 text-xs font-bold text-white"
|
||||
>
|
||||
{{ folder.unreadCount > 99 ? '99+' : folder.unreadCount }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<MailFolderTree
|
||||
v-if="hasChildren(folder) && isExpanded(folder.path)"
|
||||
:folders="folder.children"
|
||||
:selected-path="selectedPath"
|
||||
:depth="currentDepth + 1"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,266 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import { useMailService } from '~/services/mail'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
/** ID BDD du message à lier */
|
||||
messageId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
/** Émis après liaison réussie — payload = id de la tâche liée */
|
||||
linked: [taskId: number]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const mailService = useMailService()
|
||||
const taskService = useTaskService()
|
||||
const projectService = useProjectService()
|
||||
|
||||
// ─── État recherche ───────────────────────────────────────────────────────
|
||||
|
||||
const searchQuery = ref('')
|
||||
const filterProjectId = ref<number | null>(null)
|
||||
const results = ref<Task[]>([])
|
||||
const selectedTask = ref<Task | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
// ─── Projets pour le filtre ───────────────────────────────────────────────
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
|
||||
const projectFilterOptions = computed(() =>
|
||||
projects.value.map(p => ({ label: p.name, value: p.id })),
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
projects.value = await projectService.getAll({ archived: false })
|
||||
})
|
||||
|
||||
// ─── Debounce recherche ───────────────────────────────────────────────────
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch([searchQuery, filterProjectId], () => {
|
||||
selectedTask.value = null
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
void runSearch()
|
||||
}, 300)
|
||||
})
|
||||
|
||||
async function runSearch(): Promise<void> {
|
||||
const q = searchQuery.value.trim()
|
||||
if (!q && !filterProjectId.value) {
|
||||
results.value = []
|
||||
return
|
||||
}
|
||||
isLoading.value = true
|
||||
try {
|
||||
const params: Record<string, string | number | boolean | string[]> = {
|
||||
archived: false,
|
||||
}
|
||||
if (q) params['title'] = q
|
||||
if (filterProjectId.value) params['project'] = `/api/projects/${filterProjectId.value}`
|
||||
results.value = await taskService.getFiltered(params)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Reset à l'ouverture ──────────────────────────────────────────────────
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
searchQuery.value = ''
|
||||
filterProjectId.value = null
|
||||
results.value = []
|
||||
selectedTask.value = null
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
})
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
function close(): void {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function selectTask(task: Task): void {
|
||||
selectedTask.value = task
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
if (!selectedTask.value) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await mailService.linkTask(props.messageId, selectedTask.value.id)
|
||||
emit('linked', selectedTask.value.id)
|
||||
close()
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="mail-modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="close"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="relative z-10 w-full max-w-lg rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 overflow-hidden"
|
||||
style="max-height: min(90vh, 640px)"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-neutral-100 bg-neutral-50/80 px-6 py-4">
|
||||
<h2 class="text-base font-bold text-neutral-900">
|
||||
{{ t('mail.linkTaskModal.title') }}
|
||||
</h2>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Corps -->
|
||||
<div class="overflow-y-auto px-6 py-5 space-y-4">
|
||||
<!-- Filtre projet -->
|
||||
<MalioSelect
|
||||
v-model="filterProjectId"
|
||||
:options="projectFilterOptions"
|
||||
:label="t('mail.linkTaskModal.projectFilter')"
|
||||
:empty-option-label="t('mail.linkTaskModal.projectAll')"
|
||||
group-class="w-full"
|
||||
/>
|
||||
|
||||
<!-- Recherche tâche -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||
{{ t('mail.linkTaskModal.title') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('mail.linkTaskModal.searchPlaceholder')"
|
||||
class="w-full rounded-md 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>
|
||||
|
||||
<!-- Résultats -->
|
||||
<div class="max-h-64 overflow-y-auto rounded-md border border-neutral-200">
|
||||
<!-- Chargement -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-6 text-sm text-neutral-400"
|
||||
>
|
||||
<Icon name="material-symbols:progress-activity" size="18" class="mr-2 animate-spin" />
|
||||
{{ t('mail.linkTaskModal.loading') }}
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div
|
||||
v-else-if="!isLoading && results.length === 0 && (searchQuery.trim() || filterProjectId)"
|
||||
class="py-6 text-center text-sm text-neutral-400 italic"
|
||||
>
|
||||
{{ t('mail.linkTaskModal.empty') }}
|
||||
</div>
|
||||
|
||||
<!-- Liste résultats -->
|
||||
<button
|
||||
v-for="task in results"
|
||||
:key="task.id"
|
||||
type="button"
|
||||
class="flex w-full items-start gap-3 px-4 py-3 text-left text-sm transition-colors hover:bg-neutral-50"
|
||||
:class="selectedTask?.id === task.id
|
||||
? 'bg-primary-50 border-l-2 border-primary-500'
|
||||
: 'border-l-2 border-transparent'"
|
||||
@click="selectTask(task)"
|
||||
>
|
||||
<Icon
|
||||
name="material-symbols:task-outline"
|
||||
size="16"
|
||||
class="mt-0.5 flex-shrink-0 text-neutral-400"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-medium text-neutral-800">
|
||||
{{ task.title }}
|
||||
</p>
|
||||
<p
|
||||
v-if="task.project"
|
||||
class="truncate text-xs text-neutral-500"
|
||||
>
|
||||
{{ task.project.name }}
|
||||
<span v-if="task.project.code && task.number">
|
||||
— {{ task.project.code }}-{{ task.number }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<Icon
|
||||
v-if="selectedTask?.id === task.id"
|
||||
name="material-symbols:check-circle"
|
||||
size="16"
|
||||
class="flex-shrink-0 text-primary-500"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-end gap-3 border-t border-neutral-100 px-6 py-4">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Annuler"
|
||||
button-class="w-auto px-4"
|
||||
@click="close"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('mail.linkTaskModal.submit')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="!selectedTask || isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-modal-enter-active,
|
||||
.mail-modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mail-modal-enter-active > div:last-child,
|
||||
.mail-modal-leave-active > div:last-child {
|
||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mail-modal-enter-from,
|
||||
.mail-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mail-modal-enter-from > div:last-child {
|
||||
transform: scale(0.95) translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,151 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailMessageHeaderDto } from '~/services/dto/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
messages: readonly MailMessageHeaderDto[]
|
||||
selectedId: number | null
|
||||
loading: boolean
|
||||
hasMore: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [id: number]
|
||||
loadMore: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const sentinelRef = ref<HTMLDivElement | null>(null)
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (!sentinelRef.value) return
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry?.isIntersecting && props.hasMore && !props.loading) {
|
||||
emit('loadMore')
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
)
|
||||
observer.observe(sentinelRef.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect()
|
||||
observer = null
|
||||
})
|
||||
|
||||
/**
|
||||
* Formate une date ISO en date relative (il y a X minutes/heures/jours).
|
||||
* Utilise Intl.RelativeTimeFormat avec la locale fr.
|
||||
*/
|
||||
function formatRelative(isoDate: string | null): string {
|
||||
if (!isoDate) return ''
|
||||
const date = new Date(isoDate)
|
||||
const now = new Date()
|
||||
const diffMs = date.getTime() - now.getTime()
|
||||
const diffSeconds = Math.round(diffMs / 1000)
|
||||
const diffMinutes = Math.round(diffSeconds / 60)
|
||||
const diffHours = Math.round(diffMinutes / 60)
|
||||
const diffDays = Math.round(diffHours / 24)
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat('fr', { numeric: 'auto' })
|
||||
|
||||
if (Math.abs(diffMinutes) < 1) return rtf.format(diffSeconds, 'second')
|
||||
if (Math.abs(diffHours) < 1) return rtf.format(diffMinutes, 'minute')
|
||||
if (Math.abs(diffDays) < 1) return rtf.format(diffHours, 'hour')
|
||||
if (Math.abs(diffDays) < 30) return rtf.format(diffDays, 'day')
|
||||
|
||||
return date.toLocaleDateString('fr', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
function getSenderLabel(msg: MailMessageHeaderDto): string {
|
||||
return msg.fromName ?? msg.fromEmail ?? ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<div
|
||||
v-if="!loading && messages.length === 0"
|
||||
class="flex flex-1 items-center justify-center text-sm text-neutral-400 italic px-4 text-center"
|
||||
>
|
||||
{{ t('mail.empty.list') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 overflow-y-auto divide-y divide-neutral-100">
|
||||
<button
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
type="button"
|
||||
class="flex w-full gap-3 px-3 py-3 text-left transition-colors hover:bg-neutral-50 focus:outline-none"
|
||||
:class="[
|
||||
selectedId === msg.id ? 'bg-primary-50 border-l-2 border-primary-500' : '',
|
||||
!msg.isRead ? 'bg-white' : 'bg-neutral-50/50',
|
||||
]"
|
||||
@click="emit('select', msg.id)"
|
||||
>
|
||||
<div class="mt-1.5 flex-shrink-0">
|
||||
<span
|
||||
class="block h-2 w-2 rounded-full"
|
||||
:class="msg.isRead ? 'bg-transparent' : 'bg-primary-500'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span
|
||||
class="truncate text-sm"
|
||||
:class="msg.isRead ? 'text-neutral-600 font-normal' : 'text-neutral-900 font-semibold'"
|
||||
>
|
||||
{{ getSenderLabel(msg) }}
|
||||
</span>
|
||||
<span class="flex-shrink-0 text-xs text-neutral-400">
|
||||
{{ formatRelative(msg.sentAt ?? msg.receivedAt) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="truncate text-sm"
|
||||
:class="msg.isRead ? 'text-neutral-500' : 'text-neutral-800 font-medium'"
|
||||
>
|
||||
{{ msg.subject ?? t('mail.noSubject') }}
|
||||
</p>
|
||||
|
||||
<div class="mt-0.5 flex items-center gap-1.5">
|
||||
<Icon
|
||||
v-if="msg.isFlagged"
|
||||
name="material-symbols:star"
|
||||
size="14"
|
||||
class="text-amber-400 flex-shrink-0"
|
||||
/>
|
||||
<Icon
|
||||
v-if="msg.hasAttachments"
|
||||
name="material-symbols:attach-file"
|
||||
size="14"
|
||||
class="text-neutral-400 flex-shrink-0"
|
||||
/>
|
||||
<Icon
|
||||
v-if="msg.linkedTaskIds.length > 0"
|
||||
name="material-symbols:task-outline"
|
||||
size="14"
|
||||
class="text-primary-400 flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div ref="sentinelRef" class="h-px" />
|
||||
|
||||
<div v-if="loading && messages.length > 0" class="flex items-center justify-center py-4">
|
||||
<Icon name="material-symbols:progress-activity" size="20" class="animate-spin text-neutral-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && messages.length === 0" class="flex flex-1 items-center justify-center">
|
||||
<Icon name="material-symbols:progress-activity" size="24" class="animate-spin text-neutral-400" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,278 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailMessageDetailDto, MailAddressDto, MailAttachmentDto } from '~/services/dto/mail'
|
||||
import { sanitizeMailHtml } from '~/utils/sanitizeMailHtml'
|
||||
import { useMailService } from '~/services/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Détail complet du message. null = aucun message sélectionné. */
|
||||
detail: MailMessageDetailDto | null
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
createTask: [mailId: number]
|
||||
linkTask: [mailId: number]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const mailService = useMailService()
|
||||
|
||||
const showImages = ref(false)
|
||||
|
||||
const sanitizedBody = computed((): string => {
|
||||
if (!props.detail?.bodyHtml) return ''
|
||||
return sanitizeMailHtml(props.detail.bodyHtml, { allowImages: showImages.value })
|
||||
})
|
||||
|
||||
// ─── Pièces jointes : aperçu / téléchargement ──────────────────────────────
|
||||
|
||||
function isImage(mime: string): boolean {
|
||||
return mime.startsWith('image/')
|
||||
}
|
||||
|
||||
function isPdf(mime: string): boolean {
|
||||
return mime === 'application/pdf'
|
||||
}
|
||||
|
||||
function isPreviewable(mime: string): boolean {
|
||||
return isImage(mime) || isPdf(mime)
|
||||
}
|
||||
|
||||
function attachmentIcon(mime: string): string {
|
||||
if (isImage(mime)) return 'material-symbols:image-outline'
|
||||
if (isPdf(mime)) return 'material-symbols:picture-as-pdf-outline'
|
||||
return 'material-symbols:attach-file'
|
||||
}
|
||||
|
||||
const previewOpen = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewAtt = ref<MailAttachmentDto | null>(null)
|
||||
const previewUrl = ref<string | null>(null)
|
||||
let previewBlob: Blob | null = null
|
||||
|
||||
function revokePreview(): void {
|
||||
if (previewUrl.value) {
|
||||
URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = null
|
||||
}
|
||||
previewBlob = null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.detail?.header.id,
|
||||
() => {
|
||||
showImages.value = false
|
||||
previewOpen.value = false
|
||||
revokePreview()
|
||||
},
|
||||
)
|
||||
|
||||
watch(previewOpen, (open) => {
|
||||
if (!open) revokePreview()
|
||||
})
|
||||
|
||||
onBeforeUnmount(revokePreview)
|
||||
|
||||
async function handleAttachmentClick(att: MailAttachmentDto): Promise<void> {
|
||||
if (!isPreviewable(att.mimeType)) {
|
||||
await handleDownload(att.downloadId, att.filename)
|
||||
return
|
||||
}
|
||||
|
||||
previewAtt.value = att
|
||||
previewUrl.value = null
|
||||
previewLoading.value = true
|
||||
previewOpen.value = true
|
||||
|
||||
try {
|
||||
const { data } = await mailService.downloadAttachment(att.downloadId)
|
||||
previewBlob = data
|
||||
previewUrl.value = URL.createObjectURL(data)
|
||||
} catch {
|
||||
// useApi affiche déjà le toast — on referme la visionneuse.
|
||||
previewOpen.value = false
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFromPreview(): void {
|
||||
const att = previewAtt.value
|
||||
if (!att) return
|
||||
if (previewBlob) {
|
||||
triggerBlobDownload(previewBlob, att.filename)
|
||||
} else {
|
||||
void handleDownload(att.downloadId, att.filename)
|
||||
}
|
||||
}
|
||||
|
||||
function triggerBlobDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function handleDownload(downloadId: string, filename: string): Promise<void> {
|
||||
try {
|
||||
const { data } = await mailService.downloadAttachment(downloadId)
|
||||
triggerBlobDownload(data, filename)
|
||||
} catch {
|
||||
// L'erreur est gérée par useApi (toast automatique)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleString('fr', {
|
||||
dateStyle: 'long',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
function joinAddresses(addresses: MailAddressDto[]): string {
|
||||
return addresses
|
||||
.map((a) => (a.name ? `${a.name} <${a.email}>` : a.email))
|
||||
.join(', ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<div
|
||||
v-if="!detail && !loading"
|
||||
class="flex flex-1 items-center justify-center text-sm text-neutral-400 italic px-8 text-center"
|
||||
>
|
||||
{{ t('mail.empty.viewer') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="flex flex-1 items-center justify-center">
|
||||
<Icon name="material-symbols:progress-activity" size="28" class="animate-spin text-neutral-400" />
|
||||
</div>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<div class="flex-shrink-0 border-b border-neutral-200 px-4 py-3 space-y-1.5">
|
||||
<h2 class="text-base font-semibold text-neutral-900 break-words">
|
||||
{{ detail.header.subject ?? t('mail.noSubject') }}
|
||||
</h2>
|
||||
|
||||
<dl class="text-xs text-neutral-500 space-y-0.5">
|
||||
<div class="flex gap-1.5">
|
||||
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.from') }}</dt>
|
||||
<dd class="break-all">
|
||||
{{
|
||||
detail.header.fromName
|
||||
? `${detail.header.fromName} <${detail.header.fromEmail}>`
|
||||
: (detail.header.fromEmail ?? '')
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="detail.header.toRecipients.length > 0" class="flex gap-1.5">
|
||||
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.to') }}</dt>
|
||||
<dd class="break-all">{{ joinAddresses(detail.header.toRecipients) }}</dd>
|
||||
</div>
|
||||
<div v-if="detail.header.ccRecipients.length > 0" class="flex gap-1.5">
|
||||
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.cc') }}</dt>
|
||||
<dd class="break-all">{{ joinAddresses(detail.header.ccRecipients) }}</dd>
|
||||
</div>
|
||||
<div class="flex gap-1.5">
|
||||
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.date') }}</dt>
|
||||
<dd>{{ formatDate(detail.header.sentAt ?? detail.header.receivedAt) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 pt-1">
|
||||
<MalioButton
|
||||
:label="t('mail.actions.createTask')"
|
||||
variant="primary"
|
||||
icon-name="material-symbols:add-task-outline"
|
||||
icon-position="left"
|
||||
:icon-size="13"
|
||||
button-class="text-xs px-2.5 py-1"
|
||||
@click="emit('createTask', detail.header.id)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('mail.actions.linkTask')"
|
||||
variant="secondary"
|
||||
icon-name="material-symbols:link"
|
||||
icon-position="left"
|
||||
:icon-size="13"
|
||||
button-class="text-xs px-2.5 py-1"
|
||||
@click="emit('linkTask', detail.header.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-4 py-3">
|
||||
<div
|
||||
v-if="!showImages && detail.bodyHtml"
|
||||
class="mb-3 flex items-center gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm"
|
||||
>
|
||||
<Icon name="material-symbols:image-outline" size="16" class="text-amber-500 flex-shrink-0" />
|
||||
<span class="flex-1 text-amber-700">
|
||||
{{ t('mail.remoteImagesBlocked') }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-medium text-amber-700 underline hover:text-amber-900 transition-colors"
|
||||
@click="showImages = true"
|
||||
>
|
||||
{{ t('mail.actions.showImages') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="detail.bodyHtml"
|
||||
class="prose prose-sm max-w-none text-neutral-800"
|
||||
v-html="sanitizedBody"
|
||||
/>
|
||||
|
||||
<pre
|
||||
v-else-if="detail.bodyText"
|
||||
class="whitespace-pre-wrap font-sans text-sm text-neutral-700"
|
||||
>{{ detail.bodyText }}</pre>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="detail.attachments.length > 0"
|
||||
class="flex-shrink-0 border-t border-neutral-200 px-4 py-3"
|
||||
>
|
||||
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
{{ t('mail.attachments') }} ({{ detail.attachments.length }})
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="att in detail.attachments"
|
||||
:key="att.downloadId"
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded border border-neutral-200 bg-neutral-50 px-2.5 py-1.5 text-xs text-neutral-700 transition-colors hover:bg-neutral-100 hover:border-neutral-300"
|
||||
:title="isPreviewable(att.mimeType) ? t('mail.preview.open') : t('mail.actions.download')"
|
||||
@click="handleAttachmentClick(att)"
|
||||
>
|
||||
<Icon :name="attachmentIcon(att.mimeType)" size="14" class="flex-shrink-0 text-neutral-400" />
|
||||
<span class="max-w-[180px] truncate">{{ att.filename }}</span>
|
||||
<span class="text-neutral-400">({{ Math.round(att.size / 1024) }} Ko)</span>
|
||||
<Icon
|
||||
v-if="isPreviewable(att.mimeType)"
|
||||
name="material-symbols:visibility-outline"
|
||||
size="13"
|
||||
class="flex-shrink-0 text-neutral-400"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MailAttachmentPreview
|
||||
v-if="previewAtt"
|
||||
v-model="previewOpen"
|
||||
:filename="previewAtt.filename"
|
||||
:mime-type="previewAtt.mimeType"
|
||||
:url="previewUrl"
|
||||
:loading="previewLoading"
|
||||
@download="downloadFromPreview"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,24 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useMailStore } from '~/stores/mail'
|
||||
|
||||
const store = useMailStore()
|
||||
const { syncing } = storeToRefs(store)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
async function handleRefresh(): Promise<void> {
|
||||
await store.triggerSync()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MalioButton
|
||||
:label="t('mail.actions.refresh')"
|
||||
variant="secondary"
|
||||
icon-name="material-symbols:refresh"
|
||||
icon-position="left"
|
||||
:icon-size="16"
|
||||
:disabled="syncing"
|
||||
@click="handleRefresh"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,322 +0,0 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('projects.editProject') : $t('projects.addProject') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="codeProxy"
|
||||
label="Code"
|
||||
input-class="w-full"
|
||||
:max-length="10"
|
||||
:disabled="isEditing"
|
||||
:error="touched.code && !form.code ? 'Le code est requis' : touched.code && !/^[A-Z]{2,10}$/.test(form.code) ? '2 à 10 lettres majuscules' : ''"
|
||||
@blur="touched.code = true"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
label="Titre"
|
||||
input-class="w-full"
|
||||
:error="touched.name && !form.name.trim() ? 'Le titre est requis' : ''"
|
||||
@blur="touched.name = true"
|
||||
/>
|
||||
<MalioInputTextArea
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
:size="3"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.clientId"
|
||||
:options="clientOptions"
|
||||
label="Client"
|
||||
empty-option-label="Aucun client"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<ColorPicker v-model="form.color" />
|
||||
</div>
|
||||
|
||||
<div v-if="giteaRepos.length" class="mt-4">
|
||||
<MalioSelect
|
||||
v-model="form.giteaRepoFullName"
|
||||
:options="giteaRepoOptions"
|
||||
label="Dépôt Gitea"
|
||||
empty-option-label="Aucun dépôt"
|
||||
group-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="bookstackShelves.length" class="mt-4">
|
||||
<MalioSelect
|
||||
v-model="form.bookstackShelfId"
|
||||
:options="bookstackShelfOptions"
|
||||
label="Étagère BookStack"
|
||||
empty-option-label="Aucune étagère"
|
||||
group-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4 flex items-center justify-between">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:icon-name="project.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-arrow-down-outline'"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleArchiveToggle"
|
||||
>
|
||||
{{ project.archived ? 'Désarchiver' : 'Archiver' }}
|
||||
</MalioButton>
|
||||
<MalioButton
|
||||
v-if="project.taskCount === 0"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="confirmDeleteOpen = true"
|
||||
>
|
||||
{{ $t('common.delete') }}
|
||||
</MalioButton>
|
||||
</div>
|
||||
|
||||
<div v-if="props.project" class="mt-4 rounded border border-neutral-200 p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs uppercase text-neutral-500">{{ $t('workflows.title') }}</p>
|
||||
<p class="text-sm font-semibold text-neutral-900">{{ props.project.workflow?.name }}</p>
|
||||
</div>
|
||||
<MalioButton
|
||||
v-if="canManageWorkflows"
|
||||
type="button"
|
||||
icon-name="mdi:swap-horizontal"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-3 py-1 text-xs"
|
||||
:label="$t('workflows.switchTitle')"
|
||||
@click="switchModalOpen = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDeleteProjectModal
|
||||
v-model="confirmDeleteOpen"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
|
||||
<ProjectWorkflowSwitchModal
|
||||
v-if="props.project"
|
||||
v-model="switchModalOpen"
|
||||
:project="props.project"
|
||||
@switched="onWorkflowSwitched"
|
||||
/>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Project, ProjectWrite } from '~/services/dto/project'
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import type { GiteaRepository } from '~/services/dto/gitea'
|
||||
import type { BookStackShelf } from '~/services/dto/bookstack'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
import { useBookStackService } from '~/services/bookstack'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
project: Project | null
|
||||
clients: Client[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const isEditing = computed(() => !!props.project)
|
||||
const isSubmitting = ref(false)
|
||||
const confirmDeleteOpen = ref(false)
|
||||
const switchModalOpen = ref(false)
|
||||
|
||||
const auth = useAuthStore()
|
||||
const canManageWorkflows = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
|
||||
function onWorkflowSwitched() {
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const { listRepositories } = useGiteaService()
|
||||
const giteaRepos = ref<GiteaRepository[]>([])
|
||||
|
||||
const giteaRepoOptions = computed(() =>
|
||||
giteaRepos.value.map(r => ({ label: r.fullName, value: r.fullName }))
|
||||
)
|
||||
|
||||
const { listShelves } = useBookStackService()
|
||||
const bookstackShelves = ref<BookStackShelf[]>([])
|
||||
|
||||
const bookstackShelfOptions = computed(() =>
|
||||
bookstackShelves.value.map(s => ({ label: s.name, value: s.id }))
|
||||
)
|
||||
|
||||
const form = reactive({
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
color: '#222783',
|
||||
clientId: null as number | null,
|
||||
giteaRepoFullName: null as string | null,
|
||||
bookstackShelfId: null as number | null,
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
code: false,
|
||||
name: false,
|
||||
})
|
||||
|
||||
// Source unique de vérité : on sanitise dans le setter (majuscules, lettres
|
||||
// uniquement, max 10) plutôt que via @input — sinon course entre la mutation
|
||||
// manuelle et l'émission update:modelValue de MalioInputText, qui laissait
|
||||
// form.code en minuscules et bloquait la création.
|
||||
const codeProxy = computed({
|
||||
get: () => form.code,
|
||||
set: (value: string) => {
|
||||
form.code = (value ?? '').toUpperCase().replace(/[^A-Z]/g, '').slice(0, 10)
|
||||
},
|
||||
})
|
||||
|
||||
const clientOptions = computed(() =>
|
||||
props.clients.map(c => ({ label: c.name, value: c.id }))
|
||||
)
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.project) {
|
||||
form.code = props.project.code ?? ''
|
||||
form.name = props.project.name ?? ''
|
||||
form.description = props.project.description ?? ''
|
||||
form.color = props.project.color ?? '#222783'
|
||||
form.clientId = props.project.client?.id ?? null
|
||||
form.giteaRepoFullName = props.project?.giteaOwner && props.project?.giteaRepo
|
||||
? `${props.project.giteaOwner}/${props.project.giteaRepo}`
|
||||
: null
|
||||
form.bookstackShelfId = props.project.bookstackShelfId ?? null
|
||||
} else {
|
||||
form.code = ''
|
||||
form.name = ''
|
||||
form.description = ''
|
||||
form.color = '#222783'
|
||||
form.clientId = null
|
||||
form.giteaRepoFullName = null
|
||||
form.bookstackShelfId = null
|
||||
}
|
||||
touched.code = false
|
||||
touched.name = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update, remove } = useProjectService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.name = true
|
||||
touched.code = true
|
||||
if (!form.name.trim()) return
|
||||
if (!isEditing.value && !/^[A-Z]{2,10}$/.test(form.code)) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: ProjectWrite = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || null,
|
||||
color: form.color,
|
||||
client: form.clientId ? `/api/clients/${form.clientId}` : null,
|
||||
}
|
||||
|
||||
if (form.giteaRepoFullName) {
|
||||
const [owner, repo] = form.giteaRepoFullName.split('/')
|
||||
payload.giteaOwner = owner
|
||||
payload.giteaRepo = repo
|
||||
} else {
|
||||
payload.giteaOwner = null
|
||||
payload.giteaRepo = null
|
||||
}
|
||||
|
||||
if (form.bookstackShelfId) {
|
||||
const shelf = bookstackShelves.value.find(s => s.id === form.bookstackShelfId)
|
||||
payload.bookstackShelfId = form.bookstackShelfId
|
||||
payload.bookstackShelfName = shelf?.name ?? null
|
||||
} else {
|
||||
payload.bookstackShelfId = null
|
||||
payload.bookstackShelfName = null
|
||||
}
|
||||
|
||||
if (isEditing.value && props.project) {
|
||||
await update(props.project.id, payload)
|
||||
} else {
|
||||
payload.code = form.code
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!props.project) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await remove(props.project.id)
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
confirmDeleteOpen.value = false
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchiveToggle() {
|
||||
if (!props.project) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const newArchived = !props.project.archived
|
||||
await update(props.project.id, { archived: newArchived }, {
|
||||
toastSuccessKey: newArchived ? 'projects.archived' : 'projects.unarchived',
|
||||
})
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
giteaRepos.value = await listRepositories()
|
||||
} catch {
|
||||
// Gitea not configured, ignore
|
||||
}
|
||||
try {
|
||||
bookstackShelves.value = await listShelves()
|
||||
} catch {
|
||||
// BookStack not configured, ignore
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,169 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Groupes</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
button-class="w-auto px-3"
|
||||
:label="showArchived ? $t('archive.hideArchived') : $t('archive.showArchived')"
|
||||
@click="showArchived = !showArchived"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="!showArchived"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
label="Ajouter un groupe"
|
||||
@click="openCreate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:loading="isLoading"
|
||||
empty-message="Aucun groupe trouvé."
|
||||
:deletable="!showArchived"
|
||||
@row-click="openEdit"
|
||||
@delete="(item) => handleDelete(item.id)"
|
||||
>
|
||||
<template #cell-color="{ item }">
|
||||
<span
|
||||
class="inline-block h-6 w-6 rounded-full"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-description="{ item }">
|
||||
{{ stripRichText(item.description) || '—' }}
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<MalioButton
|
||||
v-if="!showArchived && canArchiveGroup(item)"
|
||||
variant="secondary"
|
||||
:label="$t('archive.archiveButton')"
|
||||
button-class="w-auto px-3"
|
||||
@click.stop="handleArchive(item)"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="showArchived"
|
||||
variant="secondary"
|
||||
:label="$t('archive.unarchiveButton')"
|
||||
button-class="w-auto px-3"
|
||||
@click.stop="handleUnarchive(item)"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<TaskGroupDrawer
|
||||
v-model="drawerOpen"
|
||||
:group="selectedItem"
|
||||
:project-id="projectId"
|
||||
:tasks="[...activeTasks, ...archivedTasks]"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { stripRichText } from '~/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updated'): void
|
||||
}>()
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'title', label: 'Titre', primary: true },
|
||||
{ key: 'color', label: 'Couleur' },
|
||||
{ key: 'description', label: 'Description', class: 'max-w-xs truncate text-neutral-700' },
|
||||
]
|
||||
|
||||
const groupService = useTaskGroupService()
|
||||
const taskService = useTaskService()
|
||||
|
||||
const allGroups = ref<TaskGroup[]>([])
|
||||
const activeTasks = ref<Task[]>([])
|
||||
const archivedTasks = ref<Task[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedItem = ref<TaskGroup | null>(null)
|
||||
const showArchived = ref(false)
|
||||
|
||||
const items = computed(() =>
|
||||
allGroups.value.filter(g => showArchived.value ? g.archived : !g.archived)
|
||||
)
|
||||
|
||||
function canArchiveGroup(group: TaskGroup): boolean {
|
||||
const groupTasks = activeTasks.value.filter(t => t.group?.id === group.id)
|
||||
if (groupTasks.length === 0) return false
|
||||
return groupTasks.every(t => t.status?.isFinal === true)
|
||||
}
|
||||
|
||||
async function loadItems() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [g, t, at] = await Promise.all([
|
||||
groupService.getByProject(props.projectId),
|
||||
taskService.getByProject(props.projectId),
|
||||
taskService.getByProject(props.projectId, true),
|
||||
])
|
||||
allGroups.value = g
|
||||
activeTasks.value = t
|
||||
archivedTasks.value = at
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedItem.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: TaskGroup) {
|
||||
selectedItem.value = item
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await groupService.remove(id)
|
||||
await loadItems()
|
||||
emit('updated')
|
||||
}
|
||||
|
||||
async function handleArchive(group: TaskGroup) {
|
||||
const groupTasks = activeTasks.value.filter(t => t.group?.id === group.id)
|
||||
await Promise.all(groupTasks.map(t => taskService.update(t.id, { archived: true })))
|
||||
await groupService.update(group.id, { archived: true })
|
||||
await loadItems()
|
||||
emit('updated')
|
||||
}
|
||||
|
||||
async function handleUnarchive(group: TaskGroup) {
|
||||
const groupTasks = archivedTasks.value.filter(t => t.group?.id === group.id)
|
||||
await Promise.all(groupTasks.map(t => taskService.update(t.id, { archived: false })))
|
||||
await groupService.update(group.id, { archived: false })
|
||||
await loadItems()
|
||||
emit('updated')
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadItems()
|
||||
emit('updated')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadItems()
|
||||
})
|
||||
</script>
|
||||
@@ -1,209 +0,0 @@
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/30" @click="close" />
|
||||
<div class="relative z-10 w-full max-w-2xl rounded-lg bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('workflows.switchTitle') }}</h3>
|
||||
|
||||
<div class="mt-5 flex flex-col gap-5">
|
||||
<MalioSelect
|
||||
v-model="targetWorkflowId"
|
||||
:options="targetOptions"
|
||||
:label="$t('workflows.switchTargetLabel')"
|
||||
empty-option-label="—"
|
||||
group-class="!w-full"
|
||||
/>
|
||||
|
||||
<div v-if="targetWorkflow" class="flex flex-col gap-2">
|
||||
<h4 class="text-sm font-bold text-neutral-900">{{ $t('workflows.switchMappingTitle') }}</h4>
|
||||
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b text-left text-xs text-neutral-500">
|
||||
<th class="py-2 pr-3">{{ $t('workflows.switchSourceCol') }}</th>
|
||||
<th class="py-2 pr-3">{{ $t('workflows.switchTargetCol') }}</th>
|
||||
<th class="py-2 text-right">{{ $t('workflows.switchTaskCountCol') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in mappingRows" :key="row.sourceId ?? 'backlog'" class="border-b last:border-0">
|
||||
<td class="py-2 pr-3">
|
||||
<span
|
||||
v-if="row.source"
|
||||
class="mr-2 inline-block h-3 w-3 rounded-full align-middle"
|
||||
:style="{ backgroundColor: row.source.color }"
|
||||
/>
|
||||
{{ row.source?.label ?? $t('myTasks.backlog') }}
|
||||
<span class="ml-1 text-xs text-neutral-400">
|
||||
({{ row.source?.category ? $t(`workflows.categories.${row.source.category}`) : '—' }})
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 pr-3">
|
||||
<select
|
||||
v-model="row.targetId"
|
||||
class="h-9 w-full rounded border border-neutral-300 px-2 text-sm"
|
||||
>
|
||||
<option :value="null">{{ $t('workflows.switchToBacklog') }}</option>
|
||||
<option
|
||||
v-for="s in targetWorkflow.statuses"
|
||||
:key="s.id"
|
||||
:value="s.id"
|
||||
>
|
||||
{{ s.label }}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="py-2 text-right text-neutral-700">{{ row.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Annuler"
|
||||
button-class="w-auto px-4"
|
||||
@click="close"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="$t('workflows.switchConfirm')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="!canConfirm || isSubmitting"
|
||||
@click="confirm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { Workflow } from '~/services/dto/workflow'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import { useWorkflowService } from '~/services/workflows'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
project: Project
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'switched'): void
|
||||
}>()
|
||||
|
||||
const workflows = ref<Workflow[]>([])
|
||||
const projectTasks = ref<Task[]>([])
|
||||
const targetWorkflowId = ref<number | null>(null)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const workflowService = useWorkflowService()
|
||||
const taskService = useTaskService()
|
||||
|
||||
const targetOptions = computed(() =>
|
||||
workflows.value
|
||||
.filter(w => w.id !== props.project.workflow.id)
|
||||
.map(w => ({ label: w.name, value: w.id })),
|
||||
)
|
||||
|
||||
const targetWorkflow = computed<Workflow | null>(() =>
|
||||
workflows.value.find(w => w.id === targetWorkflowId.value) ?? null,
|
||||
)
|
||||
|
||||
type Row = {
|
||||
sourceId: number | null
|
||||
source: TaskStatus | null
|
||||
targetId: number | null
|
||||
count: number
|
||||
}
|
||||
|
||||
const mappingRows = ref<Row[]>([])
|
||||
|
||||
function smartPrefill(source: TaskStatus | null, target: Workflow): number | null {
|
||||
if (!source) return null
|
||||
const sameCat = target.statuses
|
||||
.filter(s => s.category === source.category)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
return sameCat[0]?.id ?? null
|
||||
}
|
||||
|
||||
watch(targetWorkflow, (tw) => {
|
||||
if (!tw) {
|
||||
mappingRows.value = []
|
||||
return
|
||||
}
|
||||
const usedStatusIds = new Map<number | null, number>()
|
||||
for (const t of projectTasks.value) {
|
||||
const key = t.status?.id ?? null
|
||||
usedStatusIds.set(key, (usedStatusIds.get(key) ?? 0) + 1)
|
||||
}
|
||||
mappingRows.value = [...usedStatusIds.entries()].map(([sourceId, count]) => {
|
||||
const source = props.project.workflow.statuses.find(s => s.id === sourceId) ?? null
|
||||
return {
|
||||
sourceId,
|
||||
source,
|
||||
targetId: smartPrefill(source, tw),
|
||||
count,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const canConfirm = computed(() => {
|
||||
if (!targetWorkflow.value) return false
|
||||
return mappingRows.value.every(r => r.sourceId === null || r.targetId !== undefined)
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
if (!open) return
|
||||
targetWorkflowId.value = null
|
||||
const [allWorkflows, tasks] = await Promise.all([
|
||||
workflowService.getAll(),
|
||||
taskService.getFiltered({ project: `/api/projects/${props.project.id}`, archived: false }),
|
||||
])
|
||||
workflows.value = allWorkflows
|
||||
projectTasks.value = tasks
|
||||
})
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (!targetWorkflow.value) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const mapping: Record<string, number | null> = {}
|
||||
for (const r of mappingRows.value) {
|
||||
if (r.sourceId !== null) {
|
||||
mapping[String(r.sourceId)] = r.targetId
|
||||
}
|
||||
}
|
||||
await workflowService.switchOnProject(props.project.id, {
|
||||
workflowId: targetWorkflow.value.id,
|
||||
mapping,
|
||||
})
|
||||
emit('switched')
|
||||
close()
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -167,8 +167,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FileEntry } from '~/services/dto/share'
|
||||
import { useShareService } from '~/services/share'
|
||||
import type { FileEntry } from '~/modules/integration/services/dto/share'
|
||||
import { useShareService } from '~/modules/integration/services/share'
|
||||
import { formatFileSize } from '~/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
|
||||
defineProps<{
|
||||
statuses: TaskStatus[]
|
||||
x: number
|
||||
y: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
pick: [status: TaskStatus]
|
||||
cancel: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 z-[60]" @click="emit('cancel')" />
|
||||
<div
|
||||
class="fixed z-[61] min-w-44 rounded-lg border border-neutral-200 bg-white py-1 shadow-xl"
|
||||
:style="{ left: x + 'px', top: y + 'px' }"
|
||||
>
|
||||
<button
|
||||
v-for="s in statuses"
|
||||
:key="s.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-neutral-50"
|
||||
@click="emit('pick', s)"
|
||||
>
|
||||
<span class="h-3 w-3 shrink-0 rounded-full" :style="{ backgroundColor: s.color }" />
|
||||
{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -1,159 +0,0 @@
|
||||
<template>
|
||||
<div class="mt-5">
|
||||
<p class="mb-2 text-sm font-medium text-neutral-700">{{ $t('bookstack.links.title') }}</p>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<MalioInputText
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('bookstack.links.searchPlaceholder')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<!-- Dropdown results -->
|
||||
<div
|
||||
v-if="searchResults.length > 0"
|
||||
class="absolute z-30 mt-1 w-full rounded-md border border-neutral-200 bg-white shadow-lg"
|
||||
>
|
||||
<button
|
||||
v-for="result in searchResults"
|
||||
:key="`${result.type}-${result.id}`"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-neutral-50"
|
||||
@click="handleAdd(result)"
|
||||
>
|
||||
<Icon
|
||||
:name="result.type === 'page' ? 'mdi:file-document-outline' : 'mdi:book-outline'"
|
||||
size="16"
|
||||
class="shrink-0 text-neutral-400"
|
||||
/>
|
||||
<span class="truncate">{{ result.name }}</span>
|
||||
<span class="ml-auto shrink-0 text-xs text-neutral-400">{{ result.type }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="searchQuery.length >= 2 && !isSearching && searchResults.length === 0 && hasSearched" class="mt-1 text-xs text-neutral-400">
|
||||
{{ $t('bookstack.links.noResults') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Linked documents -->
|
||||
<div v-if="links.length > 0" class="mt-3 space-y-1">
|
||||
<div
|
||||
v-for="link in links"
|
||||
:key="link.id"
|
||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-neutral-50"
|
||||
>
|
||||
<Icon
|
||||
:name="link.bookstackType === 'page' ? 'mdi:file-document-outline' : 'mdi:book-outline'"
|
||||
size="16"
|
||||
class="shrink-0 text-neutral-400"
|
||||
/>
|
||||
<a
|
||||
:href="link.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="truncate text-primary-500 hover:underline"
|
||||
>
|
||||
{{ link.title }}
|
||||
</a>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Supprimer le lien"
|
||||
variant="ghost"
|
||||
icon-size="16"
|
||||
button-class="ml-auto shrink-0 text-neutral-300 hover:text-red-500"
|
||||
@click="handleRemove(link.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else-if="!isLoading" class="mt-2 text-xs text-neutral-400">
|
||||
{{ $t('bookstack.links.empty') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { BookStackLink, BookStackSearchResult } from '~/services/dto/bookstack'
|
||||
import { useBookStackService } from '~/services/bookstack'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
}>()
|
||||
|
||||
const { getLinks, addLink, removeLink, search } = useBookStackService()
|
||||
|
||||
const links = ref<BookStackLink[]>([])
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref<BookStackSearchResult[]>([])
|
||||
const isLoading = ref(true)
|
||||
const isSearching = ref(false)
|
||||
const hasSearched = ref(false)
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(searchQuery, (query) => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
hasSearched.value = false
|
||||
searchResults.value = []
|
||||
|
||||
if (query.trim().length < 2) {
|
||||
return
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(async () => {
|
||||
isSearching.value = true
|
||||
try {
|
||||
searchResults.value = await search(props.taskId, query.trim())
|
||||
} catch {
|
||||
searchResults.value = []
|
||||
} finally {
|
||||
isSearching.value = false
|
||||
hasSearched.value = true
|
||||
}
|
||||
}, 300)
|
||||
})
|
||||
|
||||
async function handleAdd(result: BookStackSearchResult) {
|
||||
searchQuery.value = ''
|
||||
searchResults.value = []
|
||||
hasSearched.value = false
|
||||
|
||||
// Check if already linked
|
||||
if (links.value.some(l => l.bookstackId === result.id && l.bookstackType === result.type)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await addLink(props.taskId, {
|
||||
bookstackId: result.id,
|
||||
bookstackType: result.type,
|
||||
title: result.name,
|
||||
url: result.url,
|
||||
})
|
||||
links.value.unshift(created)
|
||||
} catch {
|
||||
// Error handled by useApi toast
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(linkId: number) {
|
||||
try {
|
||||
await removeLink(props.taskId, linkId)
|
||||
links.value = links.value.filter(l => l.id !== linkId)
|
||||
} catch {
|
||||
// Error handled by useApi toast
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
links.value = await getLinks(props.taskId)
|
||||
} catch {
|
||||
// Error handled by useApi toast
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,179 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2 rounded-[10px] bg-white px-3 py-2 shadow-sm">
|
||||
<!-- Select all checkbox -->
|
||||
<div
|
||||
class="flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded border-2 transition-colors"
|
||||
:class="allSelected ? 'border-primary-500 bg-primary-500' : someSelected ? 'border-primary-500 bg-primary-500' : 'border-neutral-300 hover:border-primary-400'"
|
||||
@click="emit('toggle-all')"
|
||||
>
|
||||
<Icon v-if="allSelected" name="mdi:check" size="12" class="text-white" />
|
||||
<Icon v-else-if="someSelected" name="mdi:minus" size="12" class="text-white" />
|
||||
</div>
|
||||
<span class="text-xs font-medium text-neutral-500">
|
||||
{{ selectedCount }}/{{ totalCount }}
|
||||
</span>
|
||||
|
||||
<div v-if="selectedCount > 0" class="ml-2 flex items-center gap-1">
|
||||
<!-- Bulk status (scoped to single project's workflow) -->
|
||||
<MalioSelect
|
||||
v-if="!isMultiProject"
|
||||
:model-value="null"
|
||||
:options="statusOptions"
|
||||
label="Status"
|
||||
empty-option-label="Status"
|
||||
group-class="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'status', v)"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="rounded border border-neutral-200 px-2 py-1 text-xs text-neutral-400"
|
||||
title="Sélection multi-projets — le statut dépend du workflow de chaque projet"
|
||||
>
|
||||
Status —
|
||||
</span>
|
||||
<!-- Bulk user -->
|
||||
<MalioSelect
|
||||
:model-value="null"
|
||||
:options="userOptions"
|
||||
label="User"
|
||||
empty-option-label="User"
|
||||
group-class="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'assignee', v)"
|
||||
/>
|
||||
<!-- Bulk priority -->
|
||||
<MalioSelect
|
||||
:model-value="null"
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
empty-option-label="Priorité"
|
||||
group-class="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'priority', v)"
|
||||
/>
|
||||
<!-- Bulk effort -->
|
||||
<MalioSelect
|
||||
:model-value="null"
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
empty-option-label="Effort"
|
||||
group-class="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'effort', v)"
|
||||
/>
|
||||
<!-- Bulk group -->
|
||||
<MalioSelect
|
||||
v-if="groupOptions.length > 0"
|
||||
:model-value="null"
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Groupe"
|
||||
group-class="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'group', v)"
|
||||
/>
|
||||
|
||||
<!-- Archive (only when current filter targets a final status) -->
|
||||
<MalioButtonIcon
|
||||
v-if="canArchive"
|
||||
icon="mdi:archive-outline"
|
||||
aria-label="Archiver"
|
||||
variant="ghost"
|
||||
icon-size="22"
|
||||
button-class="self-end text-neutral-500 hover:bg-primary-50 hover:text-primary-500"
|
||||
@click="emit('bulk-archive')"
|
||||
/>
|
||||
|
||||
<!-- Delete -->
|
||||
<MalioButtonIcon
|
||||
icon="mdi:delete-outline"
|
||||
aria-label="Supprimer"
|
||||
variant="ghost"
|
||||
icon-size="22"
|
||||
button-class="self-end text-neutral-500 hover:bg-red-50 hover:text-red-500"
|
||||
@click="emit('bulk-delete')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
selectedCount: number
|
||||
totalCount: number
|
||||
allSelected: boolean
|
||||
someSelected: boolean
|
||||
statuses: TaskStatus[]
|
||||
users: UserData[]
|
||||
priorities: TaskPriority[]
|
||||
efforts: TaskEffort[]
|
||||
groups: TaskGroup[]
|
||||
selectedTasks?: Task[]
|
||||
projects?: Project[]
|
||||
canArchive?: boolean
|
||||
}>(), {
|
||||
selectedTasks: () => [],
|
||||
projects: () => [],
|
||||
canArchive: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle-all'): void
|
||||
(e: 'bulk-update', field: string, value: number): void
|
||||
(e: 'bulk-archive'): void
|
||||
(e: 'bulk-delete'): void
|
||||
}>()
|
||||
|
||||
const distinctProjectIds = computed(() => {
|
||||
const ids = new Set<number>()
|
||||
for (const t of props.selectedTasks) {
|
||||
if (t.project) ids.add(t.project.id)
|
||||
}
|
||||
return ids
|
||||
})
|
||||
|
||||
const isMultiProject = computed(() => distinctProjectIds.value.size > 1)
|
||||
|
||||
const statusOptions = computed<{ label: string, value: number }[]>(() => {
|
||||
// Si on connait les projets et qu'on est sur un seul, on scope au workflow de ce projet
|
||||
if (distinctProjectIds.value.size === 1 && props.projects.length > 0) {
|
||||
const projectId = [...distinctProjectIds.value][0]
|
||||
const project = props.projects.find(p => p.id === projectId)
|
||||
if (project?.workflow?.statuses) {
|
||||
return project.workflow.statuses.map(s => ({ label: s.label, value: s.id }))
|
||||
}
|
||||
}
|
||||
// Fallback : statuts globaux fournis en props (ex. depuis projects/[id])
|
||||
return props.statuses.map(s => ({ label: s.label, value: s.id }))
|
||||
})
|
||||
|
||||
const userOptions = computed(() =>
|
||||
props.users.map(u => ({ label: u.username, value: u.id })),
|
||||
)
|
||||
|
||||
const priorityOptions = computed(() =>
|
||||
props.priorities.map(p => ({ label: p.label, value: p.id })),
|
||||
)
|
||||
|
||||
const effortOptions = computed(() =>
|
||||
props.efforts.map(e => ({ label: e.label, value: e.id })),
|
||||
)
|
||||
|
||||
const groupOptions = computed(() =>
|
||||
props.groups.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })),
|
||||
)
|
||||
</script>
|
||||
@@ -1,157 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="cursor-pointer rounded-lg border border-neutral-200 bg-white p-3 shadow-sm transition hover:shadow-md"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-1">
|
||||
<span
|
||||
v-if="task.project && task.number"
|
||||
class="text-xs font-semibold"
|
||||
:class="showProjectColor ? '' : 'text-neutral-400'"
|
||||
:style="showProjectColor && task.project.color ? { color: task.project.color } : {}"
|
||||
>{{ task.project.code }}{{ task.number }}</span>
|
||||
<Icon
|
||||
v-if="task.priority?.label === 'Haute'"
|
||||
name="mdi:flag-variant"
|
||||
class="h-3.5 w-3.5 text-red-600"
|
||||
/>
|
||||
</div>
|
||||
<h4 class="line-clamp-2 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
</div>
|
||||
<MalioButtonIcon
|
||||
:icon="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'"
|
||||
:aria-label="isTimerOnTask ? 'Arrêter le timer' : 'Démarrer le timer'"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
:button-class="isTimerOnTask ? 'shrink-0 text-[#F18619] hover:text-[#d97314]' : 'shrink-0 text-neutral-400 hover:text-primary-500'"
|
||||
@click.stop="isTimerOnTask ? timerStore.stop() : onPlay()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
v-if="showStatusBadge && task.status"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: task.status.color }"
|
||||
>
|
||||
{{ task.status.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.priority"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: task.priority.color }"
|
||||
>
|
||||
{{ task.priority.label }}
|
||||
</span>
|
||||
<span
|
||||
v-for="tag in task.tags"
|
||||
:key="tag.id"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
<!-- Deadline badge -->
|
||||
<span
|
||||
v-if="task.deadline"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: deadlineColor }"
|
||||
:title="task.deadline"
|
||||
>
|
||||
{{ formatDeadline(task.deadline) }}
|
||||
</span>
|
||||
<!-- Calendar sync icon -->
|
||||
<Icon
|
||||
v-if="task.syncToCalendar"
|
||||
:name="task.calendarSyncError ? 'mdi:alert-circle' : 'mdi:calendar-check'"
|
||||
:class="task.calendarSyncError ? 'text-red-500' : 'text-green-500'"
|
||||
size="14"
|
||||
/>
|
||||
<!-- Recurrence icon -->
|
||||
<Icon
|
||||
v-if="task.recurrence"
|
||||
name="mdi:repeat"
|
||||
class="text-blue-500"
|
||||
size="14"
|
||||
/>
|
||||
<Icon
|
||||
v-if="task.collaborators?.length"
|
||||
name="mdi:account-group"
|
||||
class="ml-auto h-4 w-4 text-neutral-400"
|
||||
:title="task.collaborators.map(c => c.username).join(', ')"
|
||||
/>
|
||||
<UserAvatar
|
||||
v-if="task.assignee"
|
||||
:user="task.assignee"
|
||||
size="xs"
|
||||
:class="task.collaborators?.length ? '' : 'ml-auto'"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="ml-auto flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
|
||||
>
|
||||
<Icon name="mdi:account-outline" size="14" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
task: Task
|
||||
showProjectColor?: boolean
|
||||
showStatusBadge?: boolean
|
||||
}>(), {
|
||||
showProjectColor: false,
|
||||
showStatusBadge: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
}>()
|
||||
|
||||
const timerStore = useTimerStore()
|
||||
|
||||
const isTimerOnTask = computed(() => {
|
||||
const entry = timerStore.activeEntry
|
||||
if (!entry?.task) return false
|
||||
const entryTaskId = typeof entry.task === 'string'
|
||||
? entry.task
|
||||
: (entry.task['@id'] ?? entry.task.id)
|
||||
const taskId = props.task['@id'] ?? props.task.id
|
||||
return entryTaskId === taskId || entryTaskId === `/api/tasks/${props.task.id}`
|
||||
})
|
||||
|
||||
function onPlay() {
|
||||
timerStore.startFromTask(props.task)
|
||||
}
|
||||
|
||||
const deadlineColor = computed(() => {
|
||||
if (!props.task.deadline) return ''
|
||||
const daysLeft = (new Date(props.task.deadline).getTime() - Date.now()) / 86400000
|
||||
if (daysLeft < 0) return '#DC2626'
|
||||
if (daysLeft < 2) return '#F59E0B'
|
||||
return '#9CA3AF'
|
||||
})
|
||||
|
||||
function formatDeadline(d: string): string {
|
||||
return new Date(d).toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function onDragStart(event: DragEvent) {
|
||||
event.dataTransfer!.effectAllowed = 'move'
|
||||
event.dataTransfer!.setData('text/plain', String(props.task.id))
|
||||
;(event.target as HTMLElement).classList.add('opacity-50')
|
||||
}
|
||||
|
||||
function onDragEnd(event: DragEvent) {
|
||||
;(event.target as HTMLElement).classList.remove('opacity-50')
|
||||
}
|
||||
</script>
|
||||
@@ -1,92 +0,0 @@
|
||||
<template>
|
||||
<div v-if="documents.length" class="mt-3">
|
||||
<p class="mb-2 text-sm font-medium text-neutral-700">
|
||||
{{ $t('taskDocuments.title') }} ({{ documents.length }})
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<div
|
||||
v-for="doc in documents"
|
||||
:key="doc.id"
|
||||
class="group relative flex cursor-pointer items-center gap-2 rounded-lg border border-neutral-200 p-2 transition-colors hover:bg-neutral-50"
|
||||
@click="$emit('preview', doc)"
|
||||
>
|
||||
<!-- Thumbnail or icon -->
|
||||
<div class="relative h-10 w-10 shrink-0">
|
||||
<div class="flex h-10 w-10 items-center justify-center overflow-hidden rounded">
|
||||
<img
|
||||
v-if="isImage(doc.mimeType)"
|
||||
:src="getDownloadUrl(doc.id)"
|
||||
:alt="doc.originalName"
|
||||
class="h-10 w-10 object-cover"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
:name="getIconForMime(doc.mimeType)"
|
||||
class="h-6 w-6 text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<!-- Pastille : document lié depuis le partage SMB -->
|
||||
<span
|
||||
v-if="doc.sharePath"
|
||||
class="absolute -bottom-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary-500 ring-2 ring-white"
|
||||
:title="$t('taskDocuments.shareLinkBadge')"
|
||||
>
|
||||
<Icon name="heroicons:link" class="h-2.5 w-2.5 text-white" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- File info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs font-medium text-neutral-700">{{ doc.originalName }}</p>
|
||||
<p class="text-xs text-neutral-400">
|
||||
<span v-if="doc.sharePath" class="font-medium text-primary-500">{{ $t('taskDocuments.shareLinkLabel') }}</span>
|
||||
<span v-if="doc.sharePath"> · </span>{{ formatFileSize(doc.size) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Delete button -->
|
||||
<MalioButtonIcon
|
||||
v-if="isAdmin"
|
||||
icon="heroicons:x-mark"
|
||||
aria-label="Supprimer"
|
||||
variant="ghost"
|
||||
icon-size="16"
|
||||
button-class="absolute right-1 top-1 hidden text-neutral-400 hover:bg-red-50 hover:text-red-500 group-hover:block"
|
||||
@click.stop="$emit('delete', doc)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskDocument } from '~/services/dto/task-document'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
import { formatFileSize } from '~/utils/format'
|
||||
|
||||
defineProps<{
|
||||
documents: TaskDocument[]
|
||||
isAdmin: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
preview: [doc: TaskDocument]
|
||||
delete: [doc: TaskDocument]
|
||||
}>()
|
||||
|
||||
const { getDownloadUrl } = useTaskDocumentService()
|
||||
|
||||
function isImage(mimeType: string): boolean {
|
||||
return mimeType.startsWith('image/')
|
||||
}
|
||||
|
||||
function getIconForMime(mimeType: string): string {
|
||||
if (mimeType === 'text/markdown') return 'mdi:language-markdown'
|
||||
if (mimeType === 'application/pdf') return 'heroicons:document-text'
|
||||
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return 'heroicons:table-cells'
|
||||
if (mimeType.includes('word') || mimeType.includes('document')) return 'heroicons:document'
|
||||
if (mimeType.includes('zip') || mimeType.includes('archive') || mimeType.includes('tar') || mimeType.includes('rar')) return 'heroicons:archive-box'
|
||||
return 'heroicons:paper-clip'
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -1,200 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade" appear>
|
||||
<div
|
||||
v-if="document"
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80"
|
||||
@click.self="$emit('close')"
|
||||
@keydown.escape="$emit('close')"
|
||||
@keydown.left="$emit('prev')"
|
||||
@keydown.right="$emit('next')"
|
||||
tabindex="0"
|
||||
ref="overlayRef"
|
||||
>
|
||||
<!-- Close button -->
|
||||
<MalioButtonIcon
|
||||
icon="heroicons:x-mark"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
icon-size="24"
|
||||
button-class="absolute right-4 top-4 rounded-full bg-black/50 text-white hover:bg-black/70"
|
||||
@click="$emit('close')"
|
||||
/>
|
||||
|
||||
<!-- Navigation arrows -->
|
||||
<MalioButtonIcon
|
||||
v-if="hasPrev"
|
||||
icon="heroicons:chevron-left"
|
||||
aria-label="Précédent"
|
||||
variant="ghost"
|
||||
icon-size="24"
|
||||
button-class="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70"
|
||||
@click="$emit('prev')"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
v-if="hasNext"
|
||||
icon="heroicons:chevron-right"
|
||||
aria-label="Suivant"
|
||||
variant="ghost"
|
||||
icon-size="24"
|
||||
button-class="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70"
|
||||
@click="$emit('next')"
|
||||
/>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex max-h-[90vh] max-w-[90vw] flex-col items-center">
|
||||
<!-- Image preview -->
|
||||
<img
|
||||
v-if="isImage"
|
||||
:src="downloadUrl"
|
||||
:alt="document.originalName"
|
||||
class="max-h-[85vh] max-w-[90vw] object-contain"
|
||||
/>
|
||||
|
||||
<!-- PDF preview -->
|
||||
<iframe
|
||||
v-else-if="isPdf"
|
||||
:src="downloadUrl"
|
||||
class="h-[85vh] w-[80vw] rounded-lg bg-white"
|
||||
/>
|
||||
|
||||
<!-- Text / Markdown preview -->
|
||||
<div
|
||||
v-else-if="isText"
|
||||
class="flex max-h-[85vh] w-[85vw] max-w-3xl flex-col overflow-hidden rounded-xl bg-white"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2 border-b border-neutral-200 px-4 py-3">
|
||||
<p class="truncate text-sm font-medium text-neutral-700">{{ document.originalName }}</p>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-neutral-100 px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-200"
|
||||
@click="copyContent"
|
||||
>
|
||||
<Icon
|
||||
:name="copied ? 'heroicons:check' : 'mdi:content-copy'"
|
||||
class="h-4 w-4"
|
||||
:class="copied ? 'text-green-600' : ''"
|
||||
/>
|
||||
{{ copied ? $t('taskDocuments.copied') : $t('taskDocuments.copy') }}
|
||||
</button>
|
||||
<a
|
||||
:href="downloadUrl"
|
||||
download
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
{{ $t('taskDocuments.download') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-auto p-4">
|
||||
<div v-if="loadingText" class="flex justify-center py-10">
|
||||
<Icon name="heroicons:arrow-path" class="h-6 w-6 animate-spin text-neutral-400" />
|
||||
</div>
|
||||
<pre
|
||||
v-else
|
||||
class="whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-neutral-800"
|
||||
>{{ textContent }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generic file -->
|
||||
<div v-else class="flex flex-col items-center gap-4 rounded-xl bg-white p-10">
|
||||
<Icon name="heroicons:document" class="h-16 w-16 text-neutral-400" />
|
||||
<p class="max-w-xs truncate text-lg font-medium text-neutral-700">{{ document.originalName }}</p>
|
||||
<p class="text-sm text-neutral-400">{{ formatFileSize(document.size) }}</p>
|
||||
<a
|
||||
:href="downloadUrl"
|
||||
download
|
||||
class="mt-2 rounded-lg bg-blue-600 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
{{ $t('taskDocuments.download') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- File name footer -->
|
||||
<p v-if="!isText" class="mt-3 text-sm text-white/70">{{ document.originalName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskDocument } from '~/services/dto/task-document'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
import { formatFileSize } from '~/utils/format'
|
||||
import { copyToClipboard } from '~/utils/clipboard'
|
||||
|
||||
const props = defineProps<{
|
||||
document: TaskDocument | null
|
||||
hasPrev: boolean
|
||||
hasNext: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
prev: []
|
||||
next: []
|
||||
}>()
|
||||
|
||||
const overlayRef = ref<HTMLElement | null>(null)
|
||||
const textContent = ref('')
|
||||
const loadingText = ref(false)
|
||||
const copied = ref(false)
|
||||
|
||||
const { getDownloadUrl, getContent } = useTaskDocumentService()
|
||||
const { t } = useI18n()
|
||||
|
||||
const TEXT_MIME_TYPES = ['text/markdown', 'text/plain', 'text/csv', 'application/json', 'application/xml', 'text/xml']
|
||||
|
||||
function isTextDocument(doc: TaskDocument | null): boolean {
|
||||
if (!doc) return false
|
||||
if (TEXT_MIME_TYPES.includes(doc.mimeType)) return true
|
||||
return /\.(md|markdown|txt|csv|json|xml)$/i.test(doc.originalName)
|
||||
}
|
||||
|
||||
const downloadUrl = computed(() => props.document ? getDownloadUrl(props.document.id) : '')
|
||||
const isImage = computed(() => props.document?.mimeType.startsWith('image/') ?? false)
|
||||
const isPdf = computed(() => props.document?.mimeType === 'application/pdf')
|
||||
const isText = computed(() => isTextDocument(props.document))
|
||||
|
||||
async function copyContent() {
|
||||
if (await copyToClipboard(textContent.value)) {
|
||||
copied.value = true
|
||||
useToast().success(t('taskDocuments.copied'))
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
// Focus overlay for keyboard events, and load text content for text/markdown documents
|
||||
watch(() => props.document, async (doc) => {
|
||||
textContent.value = ''
|
||||
copied.value = false
|
||||
if (!doc) return
|
||||
|
||||
nextTick(() => overlayRef.value?.focus())
|
||||
|
||||
if (isTextDocument(doc)) {
|
||||
loadingText.value = true
|
||||
try {
|
||||
textContent.value = await getContent(doc.id)
|
||||
} catch {
|
||||
textContent.value = ''
|
||||
} finally {
|
||||
loadingText.value = false
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,156 +0,0 @@
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="modal" appear>
|
||||
<div class="fixed inset-0 z-[70] flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/30" @click.stop="close" />
|
||||
<div class="relative z-10 flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg bg-white shadow-xl">
|
||||
<!-- En-tête -->
|
||||
<div class="flex items-center justify-between border-b border-neutral-200 px-6 py-4">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('taskDocuments.linkShareTitle') }}</h3>
|
||||
<MalioButtonIcon
|
||||
icon="heroicons:x-mark"
|
||||
:aria-label="$t('common.cancel')"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
button-class="text-neutral-400 hover:text-neutral-700"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Fil d'Ariane -->
|
||||
<nav class="flex flex-wrap items-center gap-1 border-b border-neutral-100 px-6 py-2 text-sm text-neutral-500">
|
||||
<button class="hover:text-primary-500" @click="openPath('')">{{ $t('sharedFiles.root') }}</button>
|
||||
<template v-for="crumb in breadcrumb" :key="crumb.path">
|
||||
<span>/</span>
|
||||
<button class="hover:text-primary-500" @click="openPath(crumb.path)">{{ crumb.name }}</button>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- Contenu -->
|
||||
<div class="min-h-[12rem] flex-1 overflow-auto px-2 py-2">
|
||||
<div v-if="loading" class="flex justify-center py-12">
|
||||
<Icon name="heroicons:arrow-path" class="h-6 w-6 animate-spin text-neutral-400" />
|
||||
</div>
|
||||
<p v-else-if="error" class="px-4 py-12 text-center text-sm text-red-600">{{ error }}</p>
|
||||
<p v-else-if="entries.length === 0" class="px-4 py-12 text-center text-sm text-neutral-400">{{ $t('sharedFiles.empty') }}</p>
|
||||
<ul v-else class="text-sm">
|
||||
<li
|
||||
v-for="entry in entries"
|
||||
:key="entry.path"
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-3 py-2 hover:bg-neutral-50"
|
||||
:class="{ 'opacity-60': linking }"
|
||||
@click="onEntryClick(entry)"
|
||||
>
|
||||
<Icon :name="entry.isDir ? 'mdi:folder-outline' : iconForMime(entry.mimeType)" class="h-5 w-5 shrink-0 text-neutral-400" />
|
||||
<span class="flex-1 truncate">{{ entry.name }}</span>
|
||||
<span class="shrink-0 text-xs text-neutral-400">{{ entry.isDir ? '' : formatFileSize(entry.size) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p class="border-t border-neutral-100 px-6 py-3 text-xs text-neutral-400">{{ $t('taskDocuments.linkShareHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Breadcrumb, FileEntry } from '~/services/dto/share'
|
||||
import { useShareService } from '~/services/share'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
import { formatFileSize } from '~/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
taskId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'linked'): void
|
||||
}>()
|
||||
|
||||
const { browse } = useShareService()
|
||||
const { linkShare } = useTaskDocumentService()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const currentPath = ref('')
|
||||
const breadcrumb = ref<Breadcrumb[]>([])
|
||||
const entries = ref<FileEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const linking = ref(false)
|
||||
|
||||
async function load(path: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await browse(path)
|
||||
currentPath.value = result.path
|
||||
breadcrumb.value = result.breadcrumb
|
||||
entries.value = result.entries
|
||||
} catch (e: unknown) {
|
||||
error.value = (e as Error)?.message ?? t('sharedFiles.previewError')
|
||||
entries.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openPath(path: string) {
|
||||
load(path)
|
||||
}
|
||||
|
||||
async function onEntryClick(entry: FileEntry) {
|
||||
if (linking.value) return
|
||||
if (entry.isDir) {
|
||||
load(entry.path)
|
||||
return
|
||||
}
|
||||
|
||||
linking.value = true
|
||||
try {
|
||||
await linkShare(props.taskId, entry.path)
|
||||
toast.success({ title: '', message: t('taskDocuments.linkShareSuccess') })
|
||||
emit('linked')
|
||||
close()
|
||||
} catch {
|
||||
toast.error({ title: 'Erreur', message: t('taskDocuments.linkShareError') })
|
||||
} finally {
|
||||
linking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function iconForMime(mime: string): string {
|
||||
if (mime.startsWith('image/')) return 'mdi:file-image-outline'
|
||||
if (mime === 'application/pdf') return 'mdi:file-pdf-box'
|
||||
if (mime.includes('wordprocessingml') || mime === 'application/msword') return 'mdi:file-word-outline'
|
||||
if (mime.includes('spreadsheetml') || mime === 'application/vnd.ms-excel') return 'mdi:file-excel-outline'
|
||||
if (mime.startsWith('text/')) return 'mdi:file-document-outline'
|
||||
return 'mdi:file-outline'
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
entries.value = []
|
||||
load('')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,135 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative mt-4 rounded-lg border-2 border-dashed transition-colors"
|
||||
:class="isDragging ? 'border-blue-400 bg-blue-50' : 'border-neutral-300 hover:border-neutral-400'"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleDrop"
|
||||
@click="fileInput?.click()"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
|
||||
<div class="flex cursor-pointer flex-col items-center gap-2 px-4 py-6 text-center">
|
||||
<Icon name="heroicons:cloud-arrow-up" class="h-8 w-8 text-neutral-400" />
|
||||
<p class="text-sm text-neutral-500">{{ $t('taskDocuments.dropzone') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Upload progress -->
|
||||
<div v-if="uploads.length" class="space-y-2 border-t border-neutral-200 px-4 py-3">
|
||||
<div v-for="upload in uploads" :key="upload.name" class="flex items-center gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm text-neutral-700">{{ upload.name }}</p>
|
||||
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-neutral-200">
|
||||
<div
|
||||
class="h-full rounded-full transition-all"
|
||||
:class="[
|
||||
upload.error ? 'bg-red-500' : upload.uploading ? 'animate-pulse bg-blue-400' : 'bg-green-500',
|
||||
]"
|
||||
:style="{ width: upload.uploading ? '70%' : `${upload.progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Icon
|
||||
v-if="upload.error"
|
||||
name="heroicons:exclamation-circle"
|
||||
class="h-5 w-5 shrink-0 text-red-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
uploaded: []
|
||||
}>()
|
||||
|
||||
const { upload: uploadFile } = useTaskDocumentService()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const isDragging = ref(false)
|
||||
|
||||
type UploadState = {
|
||||
name: string
|
||||
progress: number
|
||||
uploading: boolean
|
||||
error: boolean
|
||||
}
|
||||
|
||||
const uploads = ref<UploadState[]>([])
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
isDragging.value = false
|
||||
const files = event.dataTransfer?.files
|
||||
if (files?.length) {
|
||||
processFiles(Array.from(files))
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (input.files?.length) {
|
||||
processFiles(Array.from(input.files))
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function processFiles(files: File[]) {
|
||||
const maxSize = 50 * 1024 * 1024
|
||||
|
||||
for (const file of files) {
|
||||
if (file.size > maxSize) {
|
||||
toast.error({
|
||||
title: 'Erreur',
|
||||
message: t('taskDocuments.maxSizeError'),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const state: UploadState = reactive({
|
||||
name: file.name,
|
||||
progress: 30,
|
||||
uploading: true,
|
||||
error: false,
|
||||
})
|
||||
uploads.value.push(state)
|
||||
|
||||
try {
|
||||
if (props.taskId) {
|
||||
await uploadFile(props.taskId, file)
|
||||
}
|
||||
state.uploading = false
|
||||
state.progress = 100
|
||||
} catch {
|
||||
state.uploading = false
|
||||
state.error = true
|
||||
state.progress = 100
|
||||
toast.error({
|
||||
title: 'Erreur',
|
||||
message: t('taskDocuments.uploadError'),
|
||||
})
|
||||
}
|
||||
|
||||
emit('uploaded')
|
||||
}
|
||||
|
||||
// Clean up completed uploads after a delay
|
||||
setTimeout(() => {
|
||||
uploads.value = uploads.value.filter(u => u.error)
|
||||
}, 1500)
|
||||
}
|
||||
</script>
|
||||
@@ -1,92 +0,0 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('taskEfforts.editEffort') : $t('taskEfforts.addEffort') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
label="Libellé"
|
||||
input-class="w-full"
|
||||
:error="touched.label && !form.label.trim() ? 'Le libellé est requis' : ''"
|
||||
@blur="touched.label = true"
|
||||
/>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskEffort, TaskEffortWrite } from '~/services/dto/task-effort'
|
||||
import { useTaskEffortService } from '~/services/task-efforts'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
item: TaskEffort | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const isEditing = computed(() => !!props.item)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
label: '',
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
label: false,
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.item) {
|
||||
form.label = props.item.label ?? ''
|
||||
} else {
|
||||
form.label = ''
|
||||
}
|
||||
touched.label = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = useTaskEffortService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.label = true
|
||||
if (!form.label.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: TaskEffortWrite = {
|
||||
label: form.label.trim(),
|
||||
}
|
||||
|
||||
if (isEditing.value && props.item) {
|
||||
await update(props.item.id, payload)
|
||||
} else {
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,422 +0,0 @@
|
||||
<template>
|
||||
<div class="mt-5 rounded-lg border border-neutral-200 bg-neutral-50">
|
||||
<!-- Header with tabs -->
|
||||
<div class="flex items-center justify-between border-b border-neutral-200 bg-neutral-100/60 px-4 py-2">
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-semibold transition-colors"
|
||||
:class="activeTab === 'branches'
|
||||
? 'bg-white text-neutral-900 shadow-sm ring-1 ring-neutral-200'
|
||||
: 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="activeTab = 'branches'"
|
||||
>
|
||||
<Icon name="mdi:source-branch" size="14" class="mr-1 inline-block align-[-2px]" />
|
||||
{{ $t('gitea.branch.title') }}
|
||||
<span
|
||||
v-if="branches.length"
|
||||
class="ml-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-neutral-200 px-1 text-[10px] font-bold text-neutral-600"
|
||||
>{{ branches.length }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-semibold transition-colors"
|
||||
:class="activeTab === 'prs'
|
||||
? 'bg-white text-neutral-900 shadow-sm ring-1 ring-neutral-200'
|
||||
: 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="activeTab = 'prs'"
|
||||
>
|
||||
<Icon name="mdi:source-pull" size="14" class="mr-1 inline-block align-[-2px]" />
|
||||
{{ $t('gitea.pr.title') }}
|
||||
<span
|
||||
v-if="pullRequests.length"
|
||||
class="ml-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-bold"
|
||||
:class="hasOpenPr ? 'bg-green-100 text-green-700' : 'bg-neutral-200 text-neutral-600'"
|
||||
>{{ pullRequests.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-1">
|
||||
<MalioButtonIcon
|
||||
v-if="activeTab === 'branches'"
|
||||
icon="mdi:content-copy"
|
||||
:aria-label="$t('gitea.branch.copy')"
|
||||
variant="ghost"
|
||||
icon-size="14"
|
||||
@click="handleCopy"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="activeTab === 'branches'"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-2.5 py-1.5 text-xs"
|
||||
:label="$t('gitea.branch.create')"
|
||||
@click="showCreateForm = !showCreateForm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-if="error" class="px-4 py-3">
|
||||
<p class="text-xs text-red-500">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Create branch form (inline) -->
|
||||
<Transition name="slide-down">
|
||||
<div v-if="showCreateForm && activeTab === 'branches'" class="relative z-20 border-b border-neutral-200 bg-white px-4 py-3">
|
||||
<div class="grid grid-cols-[1fr_1fr_auto] items-end gap-3">
|
||||
<MalioSelect
|
||||
v-model="branchForm.type"
|
||||
:options="typeOptions"
|
||||
:label="$t('gitea.branch.type')"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="branchForm.baseBranch"
|
||||
:label="$t('gitea.branch.baseBranch')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="isCreating ? '...' : $t('gitea.branch.create')"
|
||||
button-class="w-auto px-4 mb-[2px] text-xs"
|
||||
:disabled="isCreating"
|
||||
@click="handleCreate"
|
||||
/>
|
||||
</div>
|
||||
<code class="mt-2 block rounded bg-neutral-50 px-2 py-1 text-[11px] text-neutral-500">
|
||||
{{ branchPreview }}
|
||||
</code>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Content area with scroll -->
|
||||
<div class="max-h-64 overflow-y-auto overscroll-contain">
|
||||
<!-- Loading -->
|
||||
<div v-if="(activeTab === 'branches' && isLoading) || (activeTab === 'prs' && isLoadingPrs)" class="flex items-center justify-center py-8">
|
||||
<Icon name="mdi:loading" size="20" class="animate-spin text-neutral-300" />
|
||||
</div>
|
||||
|
||||
<!-- BRANCHES TAB -->
|
||||
<template v-if="activeTab === 'branches' && !isLoading">
|
||||
<div v-if="branches.length" class="divide-y divide-neutral-100">
|
||||
<div
|
||||
v-for="branch in branches"
|
||||
:key="branch.name"
|
||||
class="group"
|
||||
>
|
||||
<!-- Branch header (clickable to expand) -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-4 py-2.5 text-left transition-colors hover:bg-white"
|
||||
@click="toggleBranch(branch.name)"
|
||||
>
|
||||
<Icon
|
||||
name="mdi:chevron-right"
|
||||
size="14"
|
||||
class="shrink-0 text-neutral-400 transition-transform"
|
||||
:class="{ 'rotate-90': expandedBranches.has(branch.name) }"
|
||||
/>
|
||||
<Icon name="mdi:source-branch" size="14" class="shrink-0 text-primary-500" />
|
||||
<span class="min-w-0 truncate text-xs font-medium text-primary-600">
|
||||
{{ branch.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="branch.commits.length"
|
||||
class="ml-auto shrink-0 rounded bg-neutral-200/60 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500"
|
||||
>
|
||||
{{ branch.commits.length }} commit{{ branch.commits.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
<a
|
||||
:href="branchUrl(branch.name)"
|
||||
target="_blank"
|
||||
class="shrink-0 text-neutral-400 opacity-0 transition-opacity hover:text-primary-500 group-hover:opacity-100"
|
||||
@click.stop
|
||||
>
|
||||
<Icon name="mdi:open-in-new" size="12" />
|
||||
</a>
|
||||
</button>
|
||||
|
||||
<!-- Commits (collapsible) -->
|
||||
<Transition name="expand">
|
||||
<div v-if="expandedBranches.has(branch.name) && branch.commits.length" class="border-t border-neutral-100 bg-white">
|
||||
<div
|
||||
v-for="(commit, idx) in branch.commits.slice(0, 10)"
|
||||
:key="commit.sha"
|
||||
class="flex items-center gap-2 px-4 py-1.5"
|
||||
:class="idx !== Math.min(branch.commits.length, 10) - 1 ? 'border-b border-neutral-50' : ''"
|
||||
>
|
||||
<span class="shrink-0 pl-5 font-mono text-[10px] text-primary-400">{{ commit.sha.slice(0, 7) }}</span>
|
||||
<span class="min-w-0 truncate text-[11px] text-neutral-700">{{ commitFirstLine(commit.message) }}</span>
|
||||
<span class="ml-auto shrink-0 text-[10px] text-neutral-400">{{ commit.author }}</span>
|
||||
<span class="shrink-0 text-[10px] text-neutral-300">{{ formatDate(commit.date) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="branch.commits.length > 10"
|
||||
class="border-t border-neutral-50 px-4 py-1.5 text-center text-[10px] text-neutral-400"
|
||||
>
|
||||
+{{ branch.commits.length - 10 }} commits
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else-if="!error" class="py-6 text-center text-xs text-neutral-400">
|
||||
{{ $t('gitea.branch.noBranches') }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- PULL REQUESTS TAB -->
|
||||
<template v-if="activeTab === 'prs' && !isLoadingPrs">
|
||||
<div v-if="pullRequests.length" class="divide-y divide-neutral-100">
|
||||
<div
|
||||
v-for="pr in pullRequests"
|
||||
:key="pr.number"
|
||||
class="group flex items-start gap-3 px-4 py-3 transition-colors hover:bg-white"
|
||||
>
|
||||
<!-- Status pill -->
|
||||
<span
|
||||
class="mt-0.5 shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-white"
|
||||
:class="prStatusClass(pr)"
|
||||
>
|
||||
{{ prStatusLabel(pr) }}
|
||||
</span>
|
||||
|
||||
<!-- PR content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<a
|
||||
:href="pr.url"
|
||||
target="_blank"
|
||||
class="text-xs font-medium text-neutral-800 hover:text-primary-500 hover:underline"
|
||||
>
|
||||
<span class="text-neutral-400">#{{ pr.number }}</span>
|
||||
{{ pr.title }}
|
||||
</a>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<span class="text-[10px] text-neutral-400">{{ pr.author }}</span>
|
||||
<span v-if="pr.headBranch" class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-[10px] text-neutral-500">
|
||||
{{ pr.headBranch }}
|
||||
</span>
|
||||
<!-- CI statuses -->
|
||||
<template v-if="pr.ciStatuses.length">
|
||||
<a
|
||||
v-for="ci in pr.ciStatuses"
|
||||
:key="ci.context"
|
||||
:href="ci.target_url"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[10px] font-medium transition-opacity hover:opacity-80"
|
||||
:class="ciStatusClass(ci.status)"
|
||||
>
|
||||
<Icon :name="ciStatusIcon(ci.status)" size="10" />
|
||||
{{ ci.context }}
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else-if="branches.length && !error" class="py-6 text-center text-xs text-neutral-400">
|
||||
{{ $t('gitea.pr.noPrs') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { GiteaBranch, GiteaPullRequest } from '~/services/dto/gitea'
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
import { copyToClipboard } from '~/utils/clipboard'
|
||||
|
||||
const { t } = useI18n()
|
||||
const props = defineProps<{
|
||||
task: Task
|
||||
giteaUrl: string
|
||||
}>()
|
||||
|
||||
const { listBranches, createBranch, listPullRequests, getBranchName } = useGiteaService()
|
||||
|
||||
const activeTab = ref<'branches' | 'prs'>('branches')
|
||||
const branches = ref<GiteaBranch[]>([])
|
||||
const pullRequests = ref<GiteaPullRequest[]>([])
|
||||
const isLoading = ref(true)
|
||||
const isLoadingPrs = ref(true)
|
||||
const isCreating = ref(false)
|
||||
const error = ref('')
|
||||
const showCreateForm = ref(false)
|
||||
const expandedBranches = ref(new Set<string>())
|
||||
|
||||
const branchForm = reactive({
|
||||
type: 'feature',
|
||||
baseBranch: 'develop',
|
||||
})
|
||||
|
||||
const typeOptions = [
|
||||
{ label: t('gitea.branch.types.feature'), value: 'feature' },
|
||||
{ label: t('gitea.branch.types.fix'), value: 'fix' },
|
||||
{ label: t('gitea.branch.types.refactor'), value: 'refactor' },
|
||||
{ label: t('gitea.branch.types.hotfix'), value: 'hotfix' },
|
||||
{ label: t('gitea.branch.types.chore'), value: 'chore' },
|
||||
]
|
||||
|
||||
const hasOpenPr = computed(() => pullRequests.value.some(pr => pr.state === 'open' && !pr.merged))
|
||||
|
||||
const branchPreview = computed(() => {
|
||||
if (!props.task.project?.code || !props.task.number) return ''
|
||||
const slug = props.task.title
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 50)
|
||||
return `${branchForm.type}/${props.task.project.code}-${props.task.number}-${slug}`
|
||||
})
|
||||
|
||||
function toggleBranch(name: string) {
|
||||
if (expandedBranches.value.has(name)) {
|
||||
expandedBranches.value.delete(name)
|
||||
} else {
|
||||
expandedBranches.value.add(name)
|
||||
}
|
||||
}
|
||||
|
||||
function branchUrl(name: string): string {
|
||||
const project = props.task.project
|
||||
if (!project?.giteaOwner || !project?.giteaRepo) return '#'
|
||||
return `${props.giteaUrl}/${project.giteaOwner}/${project.giteaRepo}/src/branch/${encodeURIComponent(name)}`
|
||||
}
|
||||
|
||||
function commitFirstLine(message: string): string {
|
||||
return message.split('\n')[0]
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - d.getTime()
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays === 0) return "aujourd'hui"
|
||||
if (diffDays === 1) return 'hier'
|
||||
if (diffDays < 7) return `il y a ${diffDays}j`
|
||||
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
function prStatusClass(pr: GiteaPullRequest): string {
|
||||
if (pr.merged) return 'bg-purple-500'
|
||||
if (pr.state === 'open') return 'bg-green-500'
|
||||
return 'bg-red-500'
|
||||
}
|
||||
|
||||
function prStatusLabel(pr: GiteaPullRequest): string {
|
||||
if (pr.merged) return t('gitea.pr.merged')
|
||||
if (pr.state === 'open') return t('gitea.pr.open')
|
||||
return t('gitea.pr.closed')
|
||||
}
|
||||
|
||||
function ciStatusClass(status: string): string {
|
||||
if (status === 'success') return 'bg-green-100 text-green-700'
|
||||
if (status === 'failure' || status === 'error') return 'bg-red-100 text-red-700'
|
||||
return 'bg-yellow-100 text-yellow-700'
|
||||
}
|
||||
|
||||
function ciStatusIcon(status: string): string {
|
||||
if (status === 'success') return 'mdi:check-circle'
|
||||
if (status === 'failure' || status === 'error') return 'mdi:close-circle'
|
||||
return 'mdi:clock-outline'
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (!props.task.id) return
|
||||
|
||||
isLoading.value = true
|
||||
isLoadingPrs.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
branches.value = await listBranches(props.task.id)
|
||||
// Auto-expand first branch
|
||||
if (branches.value.length === 1) {
|
||||
expandedBranches.value.add(branches.value[0].name)
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e?.data?.detail || e?.data?.['hydra:description'] || t('gitea.error')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
try {
|
||||
pullRequests.value = await listPullRequests(props.task.id)
|
||||
} catch {
|
||||
// PR errors don't block branch display
|
||||
} finally {
|
||||
isLoadingPrs.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
isCreating.value = true
|
||||
try {
|
||||
await createBranch(props.task.id, {
|
||||
type: branchForm.type,
|
||||
baseBranch: branchForm.baseBranch,
|
||||
})
|
||||
showCreateForm.value = false
|
||||
await loadData()
|
||||
} finally {
|
||||
isCreating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
const result = await getBranchName(props.task.id, branchForm.type)
|
||||
await copyToClipboard(result.name)
|
||||
const { success } = useToast()
|
||||
success(t('gitea.branch.copied'))
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slide-down-enter-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.slide-down-leave-active {
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
|
||||
.slide-down-enter-from,
|
||||
.slide-down-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: all 0.15s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expand-enter-from,
|
||||
.expand-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.expand-enter-to,
|
||||
.expand-leave-from {
|
||||
max-height: 500px;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,181 +0,0 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('taskGroups.editGroup') : $t('taskGroups.addGroup') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.title"
|
||||
label="Titre"
|
||||
input-class="w-full"
|
||||
:error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''"
|
||||
@blur="touched.title = true"
|
||||
/>
|
||||
<MalioInputRichText
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
min-height="120px"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<ColorPicker v-model="form.color" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isEditing && !canArchive && !canUnarchive && nonFinalTasksCount > 0"
|
||||
class="mt-4 rounded-md bg-amber-50 px-4 py-3 text-sm text-amber-700"
|
||||
>
|
||||
{{ $t('archive.groupNonFinalTasks', { count: nonFinalTasksCount }) }}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
|
||||
<MalioButton
|
||||
v-if="canArchive"
|
||||
variant="secondary"
|
||||
:label="$t('archive.archiveButton')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleArchive"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="canUnarchive"
|
||||
variant="secondary"
|
||||
:label="$t('archive.unarchiveButton')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleUnarchive"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskGroup, TaskGroupWrite } from '~/services/dto/task-group'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
group: TaskGroup | null
|
||||
projectId: number
|
||||
tasks?: Task[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const isEditing = computed(() => !!props.group)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
color: '#222783',
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
title: false,
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.group) {
|
||||
form.title = props.group.title ?? ''
|
||||
form.description = props.group.description ?? ''
|
||||
form.color = props.group.color ?? '#222783'
|
||||
} else {
|
||||
form.title = ''
|
||||
form.description = ''
|
||||
form.color = '#222783'
|
||||
}
|
||||
touched.title = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = useTaskGroupService()
|
||||
const taskService = useTaskService()
|
||||
|
||||
const groupTasks = computed(() =>
|
||||
(props.tasks ?? []).filter(t => t.group?.id === props.group?.id)
|
||||
)
|
||||
|
||||
const nonFinalTasksCount = computed(() =>
|
||||
groupTasks.value.filter(t => t.status?.isFinal !== true).length
|
||||
)
|
||||
|
||||
const canArchive = computed(() => {
|
||||
if (!isEditing.value || !props.group || props.group.archived) return false
|
||||
if (groupTasks.value.length === 0) return false
|
||||
return nonFinalTasksCount.value === 0
|
||||
})
|
||||
|
||||
const canUnarchive = computed(() => {
|
||||
return isEditing.value && !!props.group?.archived
|
||||
})
|
||||
|
||||
async function handleArchive() {
|
||||
if (!props.group) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await Promise.all(groupTasks.value.map(t => taskService.update(t.id, { archived: true })))
|
||||
await update(props.group.id, { archived: true })
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnarchive() {
|
||||
if (!props.group) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await Promise.all(groupTasks.value.map(t => taskService.update(t.id, { archived: false })))
|
||||
await update(props.group.id, { archived: false })
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.title = true
|
||||
if (!form.title.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: TaskGroupWrite = {
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim() || null,
|
||||
color: form.color,
|
||||
project: `/api/projects/${props.projectId}`,
|
||||
}
|
||||
|
||||
if (isEditing.value && props.group) {
|
||||
await update(props.group.id, payload)
|
||||
} else {
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,152 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex cursor-pointer items-stretch gap-3 rounded-[10px] bg-white px-3 py-2.5 transition-colors hover:shadow-sm sm:px-4"
|
||||
:class="selected ? 'ring-2 ring-primary-500' : ''"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<!-- Content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<!-- Row 1: checkbox + code + flag -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div
|
||||
class="flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded border-2 transition-colors"
|
||||
:class="selected ? 'border-primary-500 bg-primary-500' : 'border-neutral-300 hover:border-primary-400'"
|
||||
@click.stop="emit('toggle-select', task.id)"
|
||||
>
|
||||
<Icon v-if="selected" name="mdi:check" size="12" class="text-white" />
|
||||
</div>
|
||||
<span
|
||||
v-if="task.project && task.number"
|
||||
class="text-xs font-semibold"
|
||||
:class="showProjectColor ? '' : 'text-neutral-400'"
|
||||
:style="showProjectColor && task.project.color ? { color: task.project.color } : {}"
|
||||
>
|
||||
{{ task.project.code }}-{{ task.number }}
|
||||
</span>
|
||||
<Icon
|
||||
v-if="task.priority?.label === 'Haute'"
|
||||
name="mdi:flag-variant"
|
||||
class="h-3.5 w-3.5 text-red-600"
|
||||
/>
|
||||
</div>
|
||||
<!-- Row 2: title -->
|
||||
<h4 class="mt-1 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
<!-- Row 3: tags + status + deadline/calendar/recurrence -->
|
||||
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
v-for="tag in task.tags"
|
||||
:key="tag.id"
|
||||
class="rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.status"
|
||||
class="text-xs font-semibold uppercase text-neutral-400"
|
||||
>
|
||||
{{ task.status.label }}
|
||||
</span>
|
||||
<span v-else class="text-xs font-semibold uppercase text-neutral-300">
|
||||
Backlog
|
||||
</span>
|
||||
<!-- Deadline badge -->
|
||||
<span
|
||||
v-if="task.deadline"
|
||||
class="rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
|
||||
:style="{ backgroundColor: deadlineColor }"
|
||||
:title="task.deadline"
|
||||
>
|
||||
{{ formatDeadline(task.deadline) }}
|
||||
</span>
|
||||
<!-- Calendar sync icon -->
|
||||
<Icon
|
||||
v-if="task.syncToCalendar"
|
||||
:name="task.calendarSyncError ? 'mdi:alert-circle' : 'mdi:calendar-check'"
|
||||
:class="task.calendarSyncError ? 'text-red-500' : 'text-green-500'"
|
||||
size="13"
|
||||
/>
|
||||
<!-- Recurrence icon -->
|
||||
<Icon
|
||||
v-if="task.recurrence"
|
||||
name="mdi:repeat"
|
||||
class="text-blue-500"
|
||||
size="13"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: timer top, avatar bottom -->
|
||||
<div class="flex shrink-0 flex-col items-end justify-between self-stretch gap-1">
|
||||
<MalioButtonIcon
|
||||
:icon="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'"
|
||||
:aria-label="isTimerOnTask ? 'Arrêter le timer' : 'Démarrer le timer'"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
:button-class="isTimerOnTask ? 'shrink-0 text-[#F18619] hover:text-[#d97314]' : 'shrink-0 text-neutral-400 hover:text-primary-500'"
|
||||
@click.stop="isTimerOnTask ? timerStore.stop() : timerStore.startFromTask(task)"
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<Icon
|
||||
v-if="task.collaborators?.length"
|
||||
name="mdi:account-group"
|
||||
class="h-4 w-4 text-neutral-400"
|
||||
:title="task.collaborators.map(c => c.username).join(', ')"
|
||||
/>
|
||||
<UserAvatar
|
||||
v-if="task.assignee"
|
||||
:user="task.assignee"
|
||||
size="xs"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
|
||||
>
|
||||
<Icon name="mdi:account-outline" size="14" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
task: Task
|
||||
showProjectColor?: boolean
|
||||
selected?: boolean
|
||||
}>(), {
|
||||
showProjectColor: false,
|
||||
selected: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
(e: 'toggle-select', taskId: number): void
|
||||
}>()
|
||||
|
||||
const timerStore = useTimerStore()
|
||||
|
||||
const deadlineColor = computed(() => {
|
||||
if (!props.task.deadline) return ''
|
||||
const daysLeft = (new Date(props.task.deadline).getTime() - Date.now()) / 86400000
|
||||
if (daysLeft < 0) return '#DC2626'
|
||||
if (daysLeft < 2) return '#F59E0B'
|
||||
return '#9CA3AF'
|
||||
})
|
||||
|
||||
function formatDeadline(d: string): string {
|
||||
return new Date(d).toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
const isTimerOnTask = computed(() => {
|
||||
const entry = timerStore.activeEntry
|
||||
if (!entry?.task) return false
|
||||
const entryTaskId = typeof entry.task === 'string'
|
||||
? entry.task
|
||||
: (entry.task['@id'] ?? entry.task.id)
|
||||
const taskId = props.task['@id'] ?? props.task.id
|
||||
return entryTaskId === taskId || entryTaskId === `/api/tasks/${props.task.id}`
|
||||
})
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,99 +0,0 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('taskPriorities.editPriority') : $t('taskPriorities.addPriority') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
label="Libellé"
|
||||
input-class="w-full"
|
||||
:error="touched.label && !form.label.trim() ? 'Le libellé est requis' : ''"
|
||||
@blur="touched.label = true"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<ColorPicker v-model="form.color" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskPriority, TaskPriorityWrite } from '~/services/dto/task-priority'
|
||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
item: TaskPriority | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const isEditing = computed(() => !!props.item)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
label: '',
|
||||
color: '#222783',
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
label: false,
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.item) {
|
||||
form.label = props.item.label ?? ''
|
||||
form.color = props.item.color ?? '#222783'
|
||||
} else {
|
||||
form.label = ''
|
||||
form.color = '#222783'
|
||||
}
|
||||
touched.label = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = useTaskPriorityService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.label = true
|
||||
if (!form.label.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: TaskPriorityWrite = {
|
||||
label: form.label.trim(),
|
||||
color: form.color,
|
||||
}
|
||||
|
||||
if (isEditing.value && props.item) {
|
||||
await update(props.item.id, payload)
|
||||
} else {
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,99 +0,0 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('taskTags.editTag') : $t('taskTags.addTag') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
label="Libellé"
|
||||
input-class="w-full"
|
||||
:error="touched.label && !form.label.trim() ? 'Le libellé est requis' : ''"
|
||||
@blur="touched.label = true"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<ColorPicker v-model="form.color" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskTag, TaskTagWrite } from '~/services/dto/task-tag'
|
||||
import { useTaskTagService } from '~/services/task-tags'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
item: TaskTag | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const isEditing = computed(() => !!props.item)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
label: '',
|
||||
color: '#222783',
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
label: false,
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.item) {
|
||||
form.label = props.item.label ?? ''
|
||||
form.color = props.item.color ?? '#222783'
|
||||
} else {
|
||||
form.label = ''
|
||||
form.color = '#222783'
|
||||
}
|
||||
touched.label = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = useTaskTagService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.label = true
|
||||
if (!form.label.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: TaskTagWrite = {
|
||||
label: form.label.trim(),
|
||||
color: form.color,
|
||||
}
|
||||
|
||||
if (isEditing.value && props.item) {
|
||||
await update(props.item.id, payload)
|
||||
} else {
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,269 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
ref="blockEl"
|
||||
class="absolute z-10 cursor-pointer rounded-md text-xs shadow-sm select-none"
|
||||
:style="blockStyle"
|
||||
:class="{ 'opacity-40': isDragSource }"
|
||||
@contextmenu.prevent="emit('contextmenu', $event, entry)"
|
||||
@mousedown="onMouseDown"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Resize handle top (outside block) -->
|
||||
<div
|
||||
class="absolute left-0 right-0 h-3 cursor-n-resize group"
|
||||
style="bottom: 100%"
|
||||
@mousedown.stop.prevent="onResizeTopStart"
|
||||
>
|
||||
<div class="absolute bottom-0 left-1/2 -translate-x-1/2 h-[3px] w-8 rounded-full bg-black/0 group-hover:bg-black/20 transition" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col h-full overflow-hidden px-1.5 py-1">
|
||||
<!-- Top: title + project -->
|
||||
<div class="min-w-0">
|
||||
<div v-if="sizeLevel >= 1" class="font-bold truncate leading-tight" style="color: #0A2168">{{ entry.title || $t('common.untitled') }}</div>
|
||||
<div v-if="sizeLevel >= 2 && entry.project" class="truncate text-[10px] font-semibold opacity-80 leading-tight">{{ entry.project.name }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="flex-1" />
|
||||
|
||||
<!-- Bottom: tags left, duration right -->
|
||||
<div v-if="sizeLevel >= 3" class="flex items-end justify-between gap-1 min-w-0">
|
||||
<div v-if="showTags && entry.tags.length" class="flex flex-wrap items-center gap-0.5 overflow-hidden min-w-0">
|
||||
<span
|
||||
v-for="tag in visibleTags"
|
||||
:key="tag.id"
|
||||
class="inline-flex items-center rounded-full px-1.5 py-0.5 text-[9px] font-bold text-white truncate max-w-[5rem]"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="hiddenTagCount > 0"
|
||||
class="inline-flex items-center rounded-full bg-black/20 px-1 py-0.5 text-[9px] font-bold text-white"
|
||||
>
|
||||
+{{ hiddenTagCount }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
|
||||
</div>
|
||||
<div v-else-if="sizeLevel === 2" class="flex items-end justify-end">
|
||||
<span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resize handle bottom (outside block) -->
|
||||
<div
|
||||
class="absolute left-0 right-0 h-3 cursor-s-resize group"
|
||||
style="top: 100%"
|
||||
@mousedown.stop.prevent="onResizeBottomStart"
|
||||
>
|
||||
<div class="absolute top-0 left-1/2 -translate-x-1/2 h-[3px] w-8 rounded-full bg-black/0 group-hover:bg-black/20 transition" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
|
||||
const props = defineProps<{
|
||||
entry: TimeEntry
|
||||
hourHeight: number
|
||||
dayStartHour: number
|
||||
isDragSource?: boolean
|
||||
columnIndex?: number
|
||||
totalColumns?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click', entry: TimeEntry): void
|
||||
(e: 'contextmenu', event: MouseEvent, entry: TimeEntry): void
|
||||
(e: 'resize', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): void
|
||||
(e: 'moveStart', payload: { entry: TimeEntry; offsetY: number }): void
|
||||
}>()
|
||||
|
||||
const blockEl = ref<HTMLElement | null>(null)
|
||||
|
||||
const startDate = computed(() => new Date(props.entry.startedAt))
|
||||
const endDate = computed(() => props.entry.stoppedAt ? new Date(props.entry.stoppedAt) : new Date())
|
||||
|
||||
const resizeTopDeltaMinutes = ref(0)
|
||||
const resizeBottomDeltaMinutes = ref(0)
|
||||
|
||||
const duration = computed(() => {
|
||||
const mins = Math.floor((endDate.value.getTime() + resizeBottomDeltaMinutes.value * 60000
|
||||
- startDate.value.getTime() - resizeTopDeltaMinutes.value * 60000) / 60000)
|
||||
const h = Math.floor(mins / 60)
|
||||
const m = mins % 60
|
||||
return m > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${h}h`
|
||||
})
|
||||
|
||||
const heightPx = computed(() => {
|
||||
const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value
|
||||
const endMinutes = endDate.value.getHours() * 60 + endDate.value.getMinutes() + resizeBottomDeltaMinutes.value
|
||||
return Math.max(((endMinutes - startMinutes) / 60) * props.hourHeight, 20)
|
||||
})
|
||||
|
||||
// Responsive content levels based on block height
|
||||
// 3 = full (title + project + types + duration)
|
||||
// 2 = medium (title + duration)
|
||||
// 1 = small (title only)
|
||||
// 0 = tiny (colored bar only)
|
||||
const sizeLevel = computed(() => {
|
||||
const h = heightPx.value
|
||||
if (h >= 50) return 3
|
||||
if (h >= 35) return 2
|
||||
if (h >= 20) return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
const showTags = computed(() => (props.totalColumns ?? 1) <= 2)
|
||||
|
||||
const maxVisibleTags = computed(() => {
|
||||
const total = props.totalColumns ?? 1
|
||||
if (total >= 2) return 1
|
||||
return 2
|
||||
})
|
||||
|
||||
const visibleTags = computed(() => props.entry.tags.slice(0, maxVisibleTags.value))
|
||||
const hiddenTagCount = computed(() => Math.max(0, props.entry.tags.length - maxVisibleTags.value))
|
||||
|
||||
const hasProject = computed(() => !!props.entry.project)
|
||||
|
||||
const blockStyle = computed(() => {
|
||||
const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value
|
||||
const topPx = ((startMinutes - props.dayStartHour * 60) / 60) * props.hourHeight
|
||||
|
||||
const col = props.columnIndex ?? 0
|
||||
const total = props.totalColumns ?? 1
|
||||
const gapPx = 2
|
||||
const leftPercent = (col / total) * 100
|
||||
const widthPercent = (1 / total) * 100
|
||||
|
||||
const base: Record<string, string> = {
|
||||
top: `${topPx}px`,
|
||||
height: `${heightPx.value}px`,
|
||||
left: `calc(${leftPercent}% + ${gapPx}px)`,
|
||||
width: `calc(${widthPercent}% - ${gapPx * 2}px)`,
|
||||
}
|
||||
|
||||
if (hasProject.value) {
|
||||
const hex = props.entry.project!.color.replace('#', '')
|
||||
const r = parseInt(hex.substring(0, 2), 16)
|
||||
const g = parseInt(hex.substring(2, 4), 16)
|
||||
const b = parseInt(hex.substring(4, 6), 16)
|
||||
base.backgroundColor = `rgb(${Math.round(r + (255 - r) * 0.6)}, ${Math.round(g + (255 - g) * 0.6)}, ${Math.round(b + (255 - b) * 0.6)})`
|
||||
base.color = `rgb(${r}, ${g}, ${b})`
|
||||
} else {
|
||||
base.backgroundColor = '#e5e7eb'
|
||||
base.backgroundImage = 'repeating-conic-gradient(#d1d5db 0% 25%, #f3f4f6 0% 50%)'
|
||||
base.backgroundSize = '12px 12px'
|
||||
base.color = '#6b7280'
|
||||
}
|
||||
|
||||
return base
|
||||
})
|
||||
|
||||
// --- Click / Drag detection ---
|
||||
let mouseDownPos = { x: 0, y: 0 }
|
||||
let mouseDownHandled = false
|
||||
|
||||
function onMouseDown(event: MouseEvent) {
|
||||
if (event.button !== 0) return
|
||||
if ((event.target as HTMLElement).closest('.cursor-s-resize, .cursor-n-resize')) return
|
||||
|
||||
mouseDownPos = { x: event.clientX, y: event.clientY }
|
||||
mouseDownHandled = false
|
||||
|
||||
document.addEventListener('mousemove', onMouseMoveDetect)
|
||||
document.addEventListener('mouseup', onMouseUpDetect)
|
||||
}
|
||||
|
||||
function onMouseMoveDetect(event: MouseEvent) {
|
||||
const dx = event.clientX - mouseDownPos.x
|
||||
const dy = event.clientY - mouseDownPos.y
|
||||
if (Math.abs(dx) + Math.abs(dy) > 5 && !mouseDownHandled) {
|
||||
mouseDownHandled = true
|
||||
document.removeEventListener('mousemove', onMouseMoveDetect)
|
||||
document.removeEventListener('mouseup', onMouseUpDetect)
|
||||
|
||||
const rect = blockEl.value!.getBoundingClientRect()
|
||||
emit('moveStart', {
|
||||
entry: props.entry,
|
||||
offsetY: mouseDownPos.y - rect.top,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseUpDetect() {
|
||||
document.removeEventListener('mousemove', onMouseMoveDetect)
|
||||
document.removeEventListener('mouseup', onMouseUpDetect)
|
||||
if (!mouseDownHandled) {
|
||||
emit('click', props.entry)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Resize bottom (change stoppedAt) ---
|
||||
function onResizeBottomStart(event: MouseEvent) {
|
||||
const startY = event.clientY
|
||||
resizeBottomDeltaMinutes.value = 0
|
||||
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = 's-resize'
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
const delta = e.clientY - startY
|
||||
resizeBottomDeltaMinutes.value = Math.round((delta / props.hourHeight) * 60 / 15) * 15
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
|
||||
const finalDelta = resizeBottomDeltaMinutes.value
|
||||
resizeBottomDeltaMinutes.value = 0
|
||||
|
||||
if (finalDelta !== 0) {
|
||||
const newEnd = new Date(endDate.value.getTime() + finalDelta * 60000)
|
||||
emit('resize', props.entry, props.entry.startedAt, newEnd.toISOString())
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
// --- Resize top (change startedAt) ---
|
||||
function onResizeTopStart(event: MouseEvent) {
|
||||
const startY = event.clientY
|
||||
resizeTopDeltaMinutes.value = 0
|
||||
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = 'n-resize'
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
const delta = e.clientY - startY
|
||||
resizeTopDeltaMinutes.value = Math.round((delta / props.hourHeight) * 60 / 15) * 15
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
|
||||
const finalDelta = resizeTopDeltaMinutes.value
|
||||
resizeTopDeltaMinutes.value = 0
|
||||
|
||||
if (finalDelta !== 0) {
|
||||
const newStart = new Date(startDate.value.getTime() + finalDelta * 60000)
|
||||
emit('resize', props.entry, newStart.toISOString(), props.entry.stoppedAt ?? endDate.value.toISOString())
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
</script>
|
||||
@@ -1,89 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible"
|
||||
ref="menuEl"
|
||||
class="fixed z-50 min-w-36 rounded-md border border-neutral-200 bg-white py-1 shadow-lg"
|
||||
:style="{ top: `${y}px`, left: `${x}px` }"
|
||||
>
|
||||
<button
|
||||
v-if="entry"
|
||||
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100"
|
||||
@click="onCopy"
|
||||
>
|
||||
<Icon name="mdi:content-copy" size="16" />
|
||||
Copier
|
||||
</button>
|
||||
<button
|
||||
v-if="canPaste"
|
||||
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100"
|
||||
@click="onPaste"
|
||||
>
|
||||
<Icon name="mdi:content-paste" size="16" />
|
||||
Coller
|
||||
</button>
|
||||
<button
|
||||
v-if="entry"
|
||||
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50"
|
||||
@click="onDelete"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="16" />
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
x: number
|
||||
y: number
|
||||
entry?: TimeEntry | null
|
||||
canPaste: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'copy', entry: TimeEntry): void
|
||||
(e: 'paste'): void
|
||||
(e: 'delete', entry: TimeEntry): void
|
||||
}>()
|
||||
|
||||
const menuEl = ref<HTMLElement | null>(null)
|
||||
|
||||
function onCopy() {
|
||||
if (props.entry) emit('copy', props.entry)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onPaste() {
|
||||
emit('paste')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (props.entry) emit('delete', props.entry)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onClickOutside(event: MouseEvent) {
|
||||
if (menuEl.value && !menuEl.value.contains(event.target as Node)) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.visible, (v) => {
|
||||
if (v) {
|
||||
setTimeout(() => document.addEventListener('click', onClickOutside), 0)
|
||||
} else {
|
||||
document.removeEventListener('click', onClickOutside)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onClickOutside)
|
||||
})
|
||||
</script>
|
||||
@@ -1,293 +0,0 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry') }}</h2>
|
||||
</template>
|
||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
|
||||
<input
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
|
||||
placeholder="Que fais-tu ?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioInputRichText
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
min-height="120px"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Date</label>
|
||||
<input
|
||||
v-model="form.date"
|
||||
type="date"
|
||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Début</label>
|
||||
<input
|
||||
v-model="form.startTime"
|
||||
type="time"
|
||||
step="60"
|
||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm tabular-nums focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Fin</label>
|
||||
<input
|
||||
v-model="form.endTime"
|
||||
type="time"
|
||||
step="60"
|
||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm tabular-nums focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="durationLabel"
|
||||
class="rounded-md bg-neutral-100 px-3 py-2 text-center text-sm font-semibold text-neutral-600 tabular-nums"
|
||||
>
|
||||
{{ durationLabel }}
|
||||
</div>
|
||||
|
||||
<MalioSelect
|
||||
v-model="form.userId"
|
||||
:options="userOptions"
|
||||
label="Utilisateur"
|
||||
group-class="w-full"
|
||||
/>
|
||||
|
||||
<MalioSelect
|
||||
v-model="form.projectId"
|
||||
:options="projectOptions"
|
||||
label="Projet"
|
||||
empty-option-label="— Aucun —"
|
||||
group-class="w-full"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-semibold text-neutral-700">Tags</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition"
|
||||
:class="form.tagIds.includes(tag.id)
|
||||
? 'text-white'
|
||||
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
|
||||
:style="form.tagIds.includes(tag.id) ? { backgroundColor: tag.color } : {}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="hidden"
|
||||
:value="tag.id"
|
||||
:checked="form.tagIds.includes(tag.id)"
|
||||
@change="toggleTag(tag.id)"
|
||||
/>
|
||||
{{ tag.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
|
||||
<MalioButton
|
||||
v-if="isEditing"
|
||||
variant="danger"
|
||||
label="Supprimer"
|
||||
button-class="w-auto px-4"
|
||||
@click="onDelete"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<MalioButton
|
||||
v-if="isEditing"
|
||||
variant="secondary"
|
||||
label="Dupliquer"
|
||||
button-class="w-auto px-4"
|
||||
@click="onDuplicate"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-4"
|
||||
@click="onSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry, TimeEntryWrite } from '~/services/dto/time-entry'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import { useTimeEntryService } from '~/services/time-entries'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
entry?: TimeEntry | null
|
||||
prefillStartedAt?: string | null
|
||||
users: UserData[]
|
||||
projects: Project[]
|
||||
tags: TaskTag[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const isEditing = computed(() => !!props.entry)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
date: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
userId: authStore.user?.id ?? null as number | null,
|
||||
projectId: null as number | null,
|
||||
tagIds: [] as number[],
|
||||
})
|
||||
|
||||
const userOptions = computed(() =>
|
||||
props.users.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
const projectOptions = computed(() =>
|
||||
props.projects.map(p => ({ label: p.name, value: p.id }))
|
||||
)
|
||||
|
||||
const durationLabel = computed(() => {
|
||||
if (!form.startTime || !form.endTime) return ''
|
||||
const [sh, sm] = form.startTime.split(':').map(Number) as [number, number]
|
||||
const [eh, em] = form.endTime.split(':').map(Number) as [number, number]
|
||||
const diff = (eh * 60 + em) - (sh * 60 + sm)
|
||||
if (diff <= 0) return ''
|
||||
const h = Math.floor(diff / 60)
|
||||
const m = diff % 60
|
||||
return m > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${h}h`
|
||||
})
|
||||
|
||||
function toggleTag(id: number) {
|
||||
const idx = form.tagIds.indexOf(id)
|
||||
if (idx >= 0) {
|
||||
form.tagIds.splice(idx, 1)
|
||||
} else {
|
||||
form.tagIds.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
function toLocalDate(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
const offset = d.getTimezoneOffset()
|
||||
const local = new Date(d.getTime() - offset * 60000)
|
||||
return local.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function toLocalTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
const offset = d.getTimezoneOffset()
|
||||
const local = new Date(d.getTime() - offset * 60000)
|
||||
return local.toISOString().slice(11, 16)
|
||||
}
|
||||
|
||||
function toISO(date: string, time: string): string {
|
||||
return new Date(`${date}T${time}`).toISOString()
|
||||
}
|
||||
|
||||
function populateForm(entry: TimeEntry | null | undefined) {
|
||||
if (entry) {
|
||||
form.title = entry.title ?? ''
|
||||
form.description = entry.description ?? ''
|
||||
form.date = toLocalDate(entry.startedAt)
|
||||
form.startTime = toLocalTime(entry.startedAt)
|
||||
form.endTime = entry.stoppedAt ? toLocalTime(entry.stoppedAt) : ''
|
||||
form.userId = entry.user?.id ?? authStore.user?.id ?? null
|
||||
form.projectId = entry.project?.id ?? null
|
||||
form.tagIds = entry.tags?.map(t => t.id) ?? []
|
||||
} else {
|
||||
form.title = ''
|
||||
form.description = ''
|
||||
form.date = props.prefillStartedAt ? toLocalDate(props.prefillStartedAt) : new Date().toISOString().slice(0, 10)
|
||||
form.startTime = props.prefillStartedAt ? toLocalTime(props.prefillStartedAt) : ''
|
||||
form.endTime = ''
|
||||
form.userId = authStore.user?.id ?? null
|
||||
form.projectId = null
|
||||
form.tagIds = []
|
||||
}
|
||||
}
|
||||
|
||||
watch([() => props.modelValue, () => props.entry] as const, ([open, entry]) => {
|
||||
if (open) {
|
||||
populateForm(entry)
|
||||
}
|
||||
})
|
||||
|
||||
async function onDuplicate() {
|
||||
if (!form.date || !form.startTime || !form.endTime) return
|
||||
|
||||
const { create } = useTimeEntryService()
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
title: form.title || null,
|
||||
description: form.description || null,
|
||||
startedAt: toISO(form.date, form.startTime),
|
||||
stoppedAt: form.endTime ? toISO(form.date, form.endTime) : null,
|
||||
user: `/api/users/${form.userId}`,
|
||||
project: form.projectId ? `/api/projects/${form.projectId}` : null,
|
||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||
}
|
||||
|
||||
await create(payload as TimeEntryWrite)
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
if (!props.entry) return
|
||||
const { remove } = useTimeEntryService()
|
||||
await remove(props.entry.id)
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.date || !form.startTime || !form.endTime) return
|
||||
|
||||
const { create, update } = useTimeEntryService()
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
title: form.title || null,
|
||||
description: form.description || null,
|
||||
startedAt: toISO(form.date, form.startTime),
|
||||
stoppedAt: form.endTime ? toISO(form.date, form.endTime) : null,
|
||||
user: `/api/users/${form.userId}`,
|
||||
project: form.projectId ? `/api/projects/${form.projectId}` : null,
|
||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||
}
|
||||
|
||||
if (isEditing.value && props.entry) {
|
||||
await update(props.entry.id, payload)
|
||||
} else {
|
||||
await create(payload as TimeEntryWrite)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -1,106 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div v-if="entries.length === 0" class="rounded-lg border border-neutral-200 bg-neutral-50 py-12 text-center text-sm text-neutral-400">
|
||||
{{ $t('timeEntries.noEntries') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="entry in sortedEntries"
|
||||
:key="entry.id"
|
||||
class="group flex items-center gap-2 sm:gap-4 rounded-lg border border-neutral-200 bg-white px-3 sm:px-4 py-3 cursor-pointer transition hover:border-neutral-300 hover:shadow-sm"
|
||||
@click="emit('editEntry', entry)"
|
||||
>
|
||||
<!-- Color bar -->
|
||||
<div
|
||||
class="h-10 w-1 shrink-0 rounded-full"
|
||||
:style="{ backgroundColor: entry.project?.color ?? '#94a3b8' }"
|
||||
/>
|
||||
|
||||
<!-- Main info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-semibold text-neutral-900">
|
||||
{{ entry.title || $t('common.untitled') }}
|
||||
</div>
|
||||
<div v-if="entry.tags.length" class="mt-1 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="tag in entry.tags"
|
||||
:key="tag.id"
|
||||
class="rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">
|
||||
<span v-if="entry.project">{{ entry.project.name }}</span>
|
||||
<span v-if="entry.project && stripRichText(entry.description)" class="text-neutral-300">·</span>
|
||||
<span v-if="stripRichText(entry.description)" class="truncate">{{ stripRichText(entry.description) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Time info -->
|
||||
<div class="shrink-0 text-right">
|
||||
<div class="text-sm font-semibold tabular-nums text-neutral-900">
|
||||
{{ formatDuration(entry) }}
|
||||
</div>
|
||||
<div class="text-xs tabular-nums text-neutral-400">
|
||||
{{ formatTime(entry.startedAt) }} – {{ entry.stoppedAt ? formatTime(entry.stoppedAt) : '...' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date -->
|
||||
<div class="hidden shrink-0 text-xs text-neutral-400 sm:block">
|
||||
{{ formatDate(entry.startedAt) }}
|
||||
</div>
|
||||
|
||||
<!-- Delete action -->
|
||||
<MalioButtonIcon
|
||||
icon="mdi:delete-outline"
|
||||
:aria-label="$t('common.delete')"
|
||||
variant="ghost"
|
||||
icon-size="18"
|
||||
button-class="shrink-0 text-neutral-300 opacity-0 hover:bg-red-50 hover:text-red-500 group-hover:opacity-100"
|
||||
@click.stop="emit('deleteEntry', entry)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
import { stripRichText } from '~/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
entries: TimeEntry[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'editEntry', entry: TimeEntry): void
|
||||
(e: 'deleteEntry', entry: TimeEntry): void
|
||||
}>()
|
||||
|
||||
const sortedEntries = computed(() => {
|
||||
return [...props.entries].sort((a, b) => {
|
||||
return new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
|
||||
})
|
||||
})
|
||||
|
||||
function formatDuration(entry: TimeEntry): string {
|
||||
const start = new Date(entry.startedAt).getTime()
|
||||
const end = entry.stoppedAt ? new Date(entry.stoppedAt).getTime() : Date.now()
|
||||
const diff = end - start
|
||||
const h = Math.floor(diff / 3600000)
|
||||
const m = Math.floor((diff % 3600000) / 60000)
|
||||
return m > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${h}h`
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
}
|
||||
</script>
|
||||
@@ -1,608 +0,0 @@
|
||||
<template>
|
||||
<div ref="calendarEl" class="relative flex h-full flex-col rounded-lg border border-neutral-200 bg-white">
|
||||
<!-- Grid body with sticky header -->
|
||||
<div ref="gridBodyEl" class="relative min-h-0 flex-1 overflow-y-auto">
|
||||
<!-- Day headers (sticky inside scroll container) -->
|
||||
<div class="sticky top-0 z-20 flex border-b border-neutral-200 bg-white rounded-t-lg">
|
||||
<div class="w-16 shrink-0 border-r border-neutral-200" />
|
||||
<div
|
||||
v-for="day in days"
|
||||
:key="'header-' + day.dateStr"
|
||||
class="flex-1 border-r border-neutral-100 py-2 text-center"
|
||||
:class="{ 'bg-orange-50': day.holiday }"
|
||||
>
|
||||
<div class="text-lg font-bold" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-900'">
|
||||
{{ day.dayNum }}
|
||||
</div>
|
||||
<div class="text-xs" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-500'">
|
||||
{{ day.label }}
|
||||
</div>
|
||||
<div
|
||||
v-if="day.holiday"
|
||||
class="flex items-center justify-center gap-0.5 truncate px-1 text-[10px] font-medium text-amber-600"
|
||||
:title="day.holiday"
|
||||
>
|
||||
<Icon name="mdi:star-four-points-outline" size="10" class="flex-shrink-0" />
|
||||
<span class="truncate">{{ day.holiday }}</span>
|
||||
</div>
|
||||
<div class="text-[10px] text-neutral-400">{{ day.totalFormatted }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Columns -->
|
||||
<div class="relative flex">
|
||||
<!-- Hour labels -->
|
||||
<div class="w-16 shrink-0">
|
||||
<div
|
||||
v-for="hour in hours"
|
||||
:key="hour"
|
||||
class="flex items-start justify-end border-r border-neutral-200 pr-2 text-xs text-neutral-400"
|
||||
:style="{ height: `${hourHeight}px` }"
|
||||
>
|
||||
{{ String(hour).padStart(2, '0') }} : 00
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Day columns -->
|
||||
<div
|
||||
v-for="(day, dayIndex) in days"
|
||||
:key="day.dateStr"
|
||||
:ref="(el) => { dayColumnEls[dayIndex] = el as HTMLElement }"
|
||||
class="relative flex-1 border-r border-neutral-100"
|
||||
:class="{ 'bg-orange-50': day.holiday }"
|
||||
@click="onClickGrid($event, day)"
|
||||
@contextmenu.prevent="onContextMenuGrid($event, day)"
|
||||
>
|
||||
<!-- Hour row lines -->
|
||||
<div
|
||||
v-for="hour in hours"
|
||||
:key="hour"
|
||||
class="border-b border-neutral-100"
|
||||
:style="{ height: `${hourHeight}px` }"
|
||||
/>
|
||||
|
||||
<!-- Time entry blocks with overlap columns -->
|
||||
<TimeEntryBlock
|
||||
v-for="layout in layoutForDay(day.dateStr)"
|
||||
:key="layout.entry.id"
|
||||
:entry="layout.entry"
|
||||
:hour-height="hourHeight"
|
||||
:day-start-hour="0"
|
||||
:is-drag-source="dragState?.entryId === layout.entry.id"
|
||||
:column-index="layout.columnIndex"
|
||||
:total-columns="layout.totalColumns"
|
||||
@click="emit('editEntry', $event)"
|
||||
@contextmenu="(ev, ent) => emit('contextmenu', ev, ent)"
|
||||
@resize="(ent, newStart, newStop) => emit('resizeEntry', ent, newStart, newStop)"
|
||||
@move-start="(payload) => onMoveStart(payload, dayIndex)"
|
||||
/>
|
||||
|
||||
<!-- Overflow indicators for dense groups -->
|
||||
<div
|
||||
v-for="overflow in overflowsForDay(day.dateStr)"
|
||||
:key="`overflow-${overflow.topPx}`"
|
||||
class="absolute right-1 z-20 rounded bg-neutral-700 px-1.5 py-0.5 text-[10px] font-semibold text-white cursor-pointer hover:bg-neutral-600 transition"
|
||||
:style="{ top: `${overflow.topPx}px` }"
|
||||
@click.stop="openOverflowPopover(dayIndex, overflow)"
|
||||
>
|
||||
+{{ overflow.count }}
|
||||
</div>
|
||||
|
||||
<!-- Overflow popover -->
|
||||
<div
|
||||
v-if="overflowPopover && overflowPopover.dayIndex === dayIndex"
|
||||
class="absolute z-30 w-48 rounded-lg border border-neutral-200 bg-white p-2 shadow-xl"
|
||||
:style="{ top: `${overflowPopover.topPx}px`, right: '4px' }"
|
||||
>
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<span class="text-xs font-semibold text-neutral-600">{{ overflowPopover.entries.length }} entrées masquées</span>
|
||||
<button class="text-neutral-400 hover:text-neutral-600 text-xs" @click="overflowPopover = null">×</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="entry in overflowPopover.entries"
|
||||
:key="entry.id"
|
||||
class="flex items-center gap-2 rounded px-1.5 py-1 cursor-pointer hover:bg-neutral-50 transition"
|
||||
@click.stop="emit('editEntry', entry); overflowPopover = null"
|
||||
>
|
||||
<div
|
||||
class="h-3 w-3 shrink-0 rounded-sm"
|
||||
:style="{ backgroundColor: entry.project?.color ?? '#94a3b8' }"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-xs font-medium text-neutral-800">{{ entry.title || $t('common.untitled') }}</div>
|
||||
<div class="text-[10px] text-neutral-500">
|
||||
{{ formatTime(entry.startedAt) }} – {{ entry.stoppedAt ? formatTime(entry.stoppedAt) : '...' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current time indicator -->
|
||||
<div
|
||||
v-if="isToday(day.date)"
|
||||
class="absolute left-0 right-0 z-10 pointer-events-none"
|
||||
:style="{ top: `${currentTimeTopPx}px` }"
|
||||
>
|
||||
<div class="relative flex items-center">
|
||||
<div class="absolute -left-[5px] h-[10px] w-[10px] rounded-full bg-orange-500" />
|
||||
<div class="h-[2px] w-full bg-orange-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Drag ghost preview -->
|
||||
<div
|
||||
v-if="dragState && dragState.targetDayIndex === dayIndex"
|
||||
class="absolute left-1 right-1 rounded-md px-2 py-1 text-xs text-white shadow-lg pointer-events-none ring-2 ring-white/60 transition-[top] duration-75"
|
||||
:style="{
|
||||
top: `${dragState.ghostTopPx}px`,
|
||||
height: `${dragState.ghostHeightPx}px`,
|
||||
backgroundColor: dragState.color,
|
||||
opacity: 0.75,
|
||||
}"
|
||||
>
|
||||
<div class="font-semibold truncate">{{ dragState.title }}</div>
|
||||
<div class="text-[10px] opacity-90">{{ dragState.timeLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- end columns flex -->
|
||||
</div><!-- end gridBodyEl -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
|
||||
const { t } = useI18n()
|
||||
const absenceService = useAbsenceService()
|
||||
|
||||
const props = defineProps<{
|
||||
entries: TimeEntry[]
|
||||
startDate: Date
|
||||
viewMode: 'week' | 'day'
|
||||
stickyOffset?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'editEntry', entry: TimeEntry): void
|
||||
(e: 'createEntry', startedAt: string): void
|
||||
(e: 'moveEntry', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): void
|
||||
(e: 'resizeEntry', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): void
|
||||
(e: 'contextmenu', event: MouseEvent, entry: TimeEntry | null): void
|
||||
}>()
|
||||
|
||||
const hourHeight = 60
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i)
|
||||
const dayLabels = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam']
|
||||
|
||||
const calendarEl = ref<HTMLElement | null>(null)
|
||||
const gridBodyEl = ref<HTMLElement | null>(null)
|
||||
const dayColumnEls = ref<HTMLElement[]>([])
|
||||
const stickyOffset = computed(() => props.stickyOffset ?? 0)
|
||||
|
||||
// --- Current time indicator ---
|
||||
const nowMinutes = ref(0)
|
||||
let nowTimer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
function updateNowMinutes() {
|
||||
const now = new Date()
|
||||
nowMinutes.value = now.getHours() * 60 + now.getMinutes()
|
||||
}
|
||||
|
||||
const currentTimeTopPx = computed(() => (nowMinutes.value / 60) * hourHeight)
|
||||
|
||||
updateNowMinutes()
|
||||
|
||||
onMounted(() => {
|
||||
nowTimer = setInterval(updateNowMinutes, 60_000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(nowTimer)
|
||||
})
|
||||
|
||||
function getScrollParent(): HTMLElement | null {
|
||||
let el = calendarEl.value?.parentElement
|
||||
while (el) {
|
||||
if (el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'visible') return el
|
||||
el = el.parentElement
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Scroll to current hour on mount
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
if (!gridBodyEl.value) return
|
||||
const now = new Date()
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes()
|
||||
const scrollTarget = (currentMinutes / 60) * hourHeight - gridBodyEl.value.clientHeight / 3
|
||||
gridBodyEl.value.scrollTop = Math.max(0, scrollTarget)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Public holidays (computed server-side, shared with the absence calendar) ---
|
||||
const holidays = ref<Record<string, string>>({})
|
||||
|
||||
async function loadHolidays() {
|
||||
const count = props.viewMode === 'week' ? 7 : 1
|
||||
const start = new Date(props.startDate)
|
||||
const end = new Date(start)
|
||||
end.setDate(end.getDate() + count - 1)
|
||||
try {
|
||||
holidays.value = await absenceService.getPublicHolidays(toDateStr(start), toDateStr(end))
|
||||
} catch {
|
||||
holidays.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => [props.startDate, props.viewMode], loadHolidays, { immediate: true })
|
||||
|
||||
// --- Days computation ---
|
||||
const days = computed(() => {
|
||||
const count = props.viewMode === 'week' ? 7 : 1
|
||||
const result = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const d = new Date(props.startDate)
|
||||
d.setDate(d.getDate() + i)
|
||||
const dateStr = toDateStr(d)
|
||||
const dayEntries = props.entries.filter((e) => toDateStr(new Date(e.startedAt)) === dateStr)
|
||||
const totalMs = dayEntries.reduce((sum, e) => {
|
||||
if (!e.stoppedAt) return sum
|
||||
return sum + (new Date(e.stoppedAt).getTime() - new Date(e.startedAt).getTime())
|
||||
}, 0)
|
||||
const totalH = Math.floor(totalMs / 3600000)
|
||||
const totalM = Math.floor((totalMs % 3600000) / 60000)
|
||||
const totalS = Math.floor((totalMs % 60000) / 1000)
|
||||
|
||||
result.push({
|
||||
date: new Date(d),
|
||||
dateStr,
|
||||
dayNum: d.getDate(),
|
||||
label: dayLabels[d.getDay()],
|
||||
holiday: holidays.value[dateStr] ?? null,
|
||||
totalFormatted: `${String(totalH).padStart(2, '0')}:${String(totalM).padStart(2, '0')}:${String(totalS).padStart(2, '0')}`,
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
function toDateStr(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function isToday(d: Date): boolean {
|
||||
return toDateStr(d) === toDateStr(new Date())
|
||||
}
|
||||
|
||||
function entriesForDay(dateStr: string): TimeEntry[] {
|
||||
return props.entries.filter((e) => toDateStr(new Date(e.startedAt)) === dateStr)
|
||||
}
|
||||
|
||||
// --- Overlap layout computation ---
|
||||
const MAX_VISIBLE_COLUMNS = 4
|
||||
|
||||
interface EntryLayout {
|
||||
entry: TimeEntry
|
||||
columnIndex: number
|
||||
totalColumns: number
|
||||
}
|
||||
|
||||
interface OverflowIndicator {
|
||||
topPx: number
|
||||
count: number
|
||||
hiddenEntries: TimeEntry[]
|
||||
}
|
||||
|
||||
function getEntryMinutes(entry: TimeEntry): { start: number; end: number } {
|
||||
const s = new Date(entry.startedAt)
|
||||
const startMin = s.getHours() * 60 + s.getMinutes()
|
||||
const e = entry.stoppedAt ? new Date(entry.stoppedAt) : new Date()
|
||||
const endMin = e.getHours() * 60 + e.getMinutes()
|
||||
return { start: startMin, end: Math.max(endMin, startMin + 15) }
|
||||
}
|
||||
|
||||
function computeOverlapLayout(dayEntries: TimeEntry[]): { layouts: EntryLayout[]; overflows: OverflowIndicator[] } {
|
||||
if (dayEntries.length === 0) return { layouts: [], overflows: [] }
|
||||
|
||||
// Sort by start time, then by duration (longest first)
|
||||
const sorted = [...dayEntries].sort((a, b) => {
|
||||
const aM = getEntryMinutes(a)
|
||||
const bM = getEntryMinutes(b)
|
||||
if (aM.start !== bM.start) return aM.start - bM.start
|
||||
return (bM.end - bM.start) - (aM.end - aM.start)
|
||||
})
|
||||
|
||||
// Group overlapping entries into clusters
|
||||
const clusters: TimeEntry[][] = []
|
||||
let currentCluster: TimeEntry[] = []
|
||||
let clusterEnd = 0
|
||||
|
||||
for (const entry of sorted) {
|
||||
const { start, end } = getEntryMinutes(entry)
|
||||
if (currentCluster.length === 0 || start < clusterEnd) {
|
||||
currentCluster.push(entry)
|
||||
clusterEnd = Math.max(clusterEnd, end)
|
||||
} else {
|
||||
clusters.push(currentCluster)
|
||||
currentCluster = [entry]
|
||||
clusterEnd = end
|
||||
}
|
||||
}
|
||||
if (currentCluster.length > 0) clusters.push(currentCluster)
|
||||
|
||||
const layouts: EntryLayout[] = []
|
||||
const overflows: OverflowIndicator[] = []
|
||||
|
||||
for (const cluster of clusters) {
|
||||
// Assign columns within this cluster
|
||||
const colEnds: number[] = []
|
||||
|
||||
const clusterAssignments: { entry: TimeEntry; col: number }[] = []
|
||||
|
||||
for (const entry of cluster) {
|
||||
const { start, end } = getEntryMinutes(entry)
|
||||
// Find first column where this entry fits
|
||||
let placed = false
|
||||
for (let c = 0; c < colEnds.length; c++) {
|
||||
if (colEnds[c]! <= start) {
|
||||
colEnds[c] = end
|
||||
clusterAssignments.push({ entry, col: c })
|
||||
placed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!placed) {
|
||||
clusterAssignments.push({ entry, col: colEnds.length })
|
||||
colEnds.push(end)
|
||||
}
|
||||
}
|
||||
|
||||
const totalColumns = Math.min(colEnds.length, MAX_VISIBLE_COLUMNS)
|
||||
let hasOverflow = false
|
||||
|
||||
for (const { entry, col } of clusterAssignments) {
|
||||
if (col < MAX_VISIBLE_COLUMNS) {
|
||||
layouts.push({
|
||||
entry,
|
||||
columnIndex: col,
|
||||
totalColumns,
|
||||
})
|
||||
} else {
|
||||
hasOverflow = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOverflow) {
|
||||
const hidden = clusterAssignments.filter((a) => a.col >= MAX_VISIBLE_COLUMNS)
|
||||
const firstEntry = cluster[0]!
|
||||
const { start } = getEntryMinutes(firstEntry)
|
||||
overflows.push({
|
||||
topPx: (start / 60) * hourHeight,
|
||||
count: hidden.length,
|
||||
hiddenEntries: hidden.map((a) => a.entry),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { layouts, overflows }
|
||||
}
|
||||
|
||||
const layoutCache = computed(() => {
|
||||
const cache = new Map<string, { layouts: EntryLayout[]; overflows: OverflowIndicator[] }>()
|
||||
for (const day of days.value) {
|
||||
const dayEntries = entriesForDay(day.dateStr)
|
||||
cache.set(day.dateStr, computeOverlapLayout(dayEntries))
|
||||
}
|
||||
return cache
|
||||
})
|
||||
|
||||
function layoutForDay(dateStr: string): EntryLayout[] {
|
||||
return layoutCache.value.get(dateStr)?.layouts ?? []
|
||||
}
|
||||
|
||||
function overflowsForDay(dateStr: string): OverflowIndicator[] {
|
||||
return layoutCache.value.get(dateStr)?.overflows ?? []
|
||||
}
|
||||
|
||||
// --- Overflow popover ---
|
||||
interface OverflowPopoverState {
|
||||
dayIndex: number
|
||||
topPx: number
|
||||
entries: TimeEntry[]
|
||||
}
|
||||
|
||||
const overflowPopover = ref<OverflowPopoverState | null>(null)
|
||||
|
||||
function openOverflowPopover(dayIndex: number, overflow: OverflowIndicator) {
|
||||
overflowPopover.value = {
|
||||
dayIndex,
|
||||
topPx: overflow.topPx,
|
||||
entries: overflow.hiddenEntries,
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function getSnappedMinutesFromY(y: number): number {
|
||||
return Math.max(0, Math.min(23 * 60 + 45, Math.round((y / hourHeight) * 60 / 15) * 15))
|
||||
}
|
||||
|
||||
function formatMinutes(totalMinutes: number): string {
|
||||
const h = Math.floor(totalMinutes / 60)
|
||||
const m = totalMinutes % 60
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// --- Click to create ---
|
||||
let dragEndTime = 0
|
||||
|
||||
function onClickGrid(event: MouseEvent, day: { date: Date; dateStr: string }) {
|
||||
// Suppress click right after drag end
|
||||
if (Date.now() - dragEndTime < 200) return
|
||||
|
||||
const target = event.currentTarget as HTMLElement
|
||||
const rect = target.getBoundingClientRect()
|
||||
const y = event.clientY - rect.top
|
||||
const minutes = getSnappedMinutesFromY(y)
|
||||
const h = Math.floor(minutes / 60)
|
||||
const m = minutes % 60
|
||||
const d = new Date(day.date)
|
||||
d.setHours(h, m, 0, 0)
|
||||
emit('createEntry', d.toISOString())
|
||||
}
|
||||
|
||||
function onContextMenuGrid(event: MouseEvent, _day: { date: Date; dateStr: string }) {
|
||||
emit('contextmenu', event, null)
|
||||
}
|
||||
|
||||
// --- Drag to move ---
|
||||
interface DragState {
|
||||
entryId: number
|
||||
entry: TimeEntry
|
||||
title: string
|
||||
color: string
|
||||
durationMinutes: number
|
||||
ghostHeightPx: number
|
||||
offsetY: number
|
||||
targetDayIndex: number
|
||||
ghostTopPx: number
|
||||
snappedMinutes: number
|
||||
timeLabel: string
|
||||
}
|
||||
|
||||
const dragState = ref<DragState | null>(null)
|
||||
let autoScrollActive = false
|
||||
let lastMouseEvent: MouseEvent | null = null
|
||||
|
||||
function onMoveStart(payload: { entry: TimeEntry; offsetY: number }, sourceDayIndex: number) {
|
||||
const entry = payload.entry
|
||||
const startMinutes = new Date(entry.startedAt).getHours() * 60 + new Date(entry.startedAt).getMinutes()
|
||||
const endMinutes = entry.stoppedAt
|
||||
? new Date(entry.stoppedAt).getHours() * 60 + new Date(entry.stoppedAt).getMinutes()
|
||||
: startMinutes + 60
|
||||
const durationMinutes = endMinutes - startMinutes
|
||||
|
||||
dragState.value = {
|
||||
entryId: entry.id,
|
||||
entry,
|
||||
title: entry.title || t('common.untitled'),
|
||||
color: entry.project?.color ?? '#94a3b8',
|
||||
durationMinutes,
|
||||
ghostHeightPx: Math.max((durationMinutes / 60) * hourHeight, 20),
|
||||
offsetY: payload.offsetY,
|
||||
targetDayIndex: sourceDayIndex,
|
||||
ghostTopPx: (startMinutes / 60) * hourHeight,
|
||||
snappedMinutes: startMinutes,
|
||||
timeLabel: `${formatMinutes(startMinutes)} – ${formatMinutes(endMinutes)}`,
|
||||
}
|
||||
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = 'grabbing'
|
||||
document.addEventListener('mousemove', onDragMove)
|
||||
document.addEventListener('mouseup', onDragEnd)
|
||||
}
|
||||
|
||||
function updateDragPosition(event: MouseEvent) {
|
||||
if (!dragState.value) return
|
||||
|
||||
// Find which column the cursor is over
|
||||
let targetDayIndex = dragState.value.targetDayIndex
|
||||
for (let i = 0; i < dayColumnEls.value.length; i++) {
|
||||
const el = dayColumnEls.value[i]
|
||||
if (!el) continue
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (event.clientX >= rect.left && event.clientX <= rect.right) {
|
||||
targetDayIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate Y position in the target column
|
||||
const targetCol = dayColumnEls.value[targetDayIndex]
|
||||
if (!targetCol) return
|
||||
const colRect = targetCol.getBoundingClientRect()
|
||||
const y = event.clientY - colRect.top - dragState.value.offsetY
|
||||
const snappedMinutes = getSnappedMinutesFromY(y)
|
||||
const endMinutes = snappedMinutes + dragState.value.durationMinutes
|
||||
|
||||
dragState.value.targetDayIndex = targetDayIndex
|
||||
dragState.value.snappedMinutes = snappedMinutes
|
||||
dragState.value.ghostTopPx = (snappedMinutes / 60) * hourHeight
|
||||
dragState.value.timeLabel = `${formatMinutes(snappedMinutes)} – ${formatMinutes(endMinutes)}`
|
||||
}
|
||||
|
||||
function onDragMove(event: MouseEvent) {
|
||||
if (!dragState.value) return
|
||||
event.preventDefault()
|
||||
lastMouseEvent = event
|
||||
updateDragPosition(event)
|
||||
|
||||
// Start auto-scroll if not running
|
||||
if (!autoScrollActive) {
|
||||
autoScrollActive = true
|
||||
requestAnimationFrame(autoScrollLoop)
|
||||
}
|
||||
}
|
||||
|
||||
function autoScrollLoop() {
|
||||
const scrollParent = getScrollParent()
|
||||
if (!autoScrollActive || !lastMouseEvent || !scrollParent || !dragState.value) {
|
||||
autoScrollActive = false
|
||||
return
|
||||
}
|
||||
|
||||
const rect = scrollParent.getBoundingClientRect()
|
||||
const edgeSize = 60
|
||||
const maxSpeed = 10
|
||||
|
||||
const distFromTop = lastMouseEvent.clientY - rect.top
|
||||
const distFromBottom = rect.bottom - lastMouseEvent.clientY
|
||||
|
||||
let scrolled = false
|
||||
if (distFromTop < edgeSize && distFromTop > 0) {
|
||||
scrollParent.scrollTop -= maxSpeed * (1 - distFromTop / edgeSize)
|
||||
scrolled = true
|
||||
} else if (distFromBottom < edgeSize && distFromBottom > 0) {
|
||||
scrollParent.scrollTop += maxSpeed * (1 - distFromBottom / edgeSize)
|
||||
scrolled = true
|
||||
}
|
||||
|
||||
// Update ghost position if we scrolled (scroll changes coordinate mapping)
|
||||
if (scrolled && lastMouseEvent) {
|
||||
updateDragPosition(lastMouseEvent)
|
||||
}
|
||||
|
||||
requestAnimationFrame(autoScrollLoop)
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
document.removeEventListener('mousemove', onDragMove)
|
||||
document.removeEventListener('mouseup', onDragEnd)
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
autoScrollActive = false
|
||||
lastMouseEvent = null
|
||||
|
||||
if (!dragState.value) return
|
||||
|
||||
const state = dragState.value
|
||||
const targetDay = days.value[state.targetDayIndex]
|
||||
|
||||
if (targetDay) {
|
||||
const h = Math.floor(state.snappedMinutes / 60)
|
||||
const m = state.snappedMinutes % 60
|
||||
const newStart = new Date(targetDay.date)
|
||||
newStart.setHours(h, m, 0, 0)
|
||||
const newStop = new Date(newStart.getTime() + state.durationMinutes * 60000)
|
||||
|
||||
emit('moveEntry', state.entry, newStart.toISOString(), newStop.toISOString())
|
||||
}
|
||||
|
||||
dragState.value = null
|
||||
dragEndTime = Date.now()
|
||||
}
|
||||
</script>
|
||||
@@ -1,208 +0,0 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen" drawer-class="max-w-lg">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ $t('timeEntries.exportTitle') }}</h2>
|
||||
</template>
|
||||
<div class="flex flex-col gap-6 p-4">
|
||||
<!-- Period presets -->
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-semibold text-neutral-700">Période</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<MalioRadioButton
|
||||
v-model="periodMode"
|
||||
name="exportPeriod"
|
||||
value="currentMonth"
|
||||
:label="$t('timeEntries.exportCurrentMonth')"
|
||||
/>
|
||||
<MalioRadioButton
|
||||
v-model="periodMode"
|
||||
name="exportPeriod"
|
||||
value="lastMonth"
|
||||
:label="$t('timeEntries.exportLastMonth')"
|
||||
/>
|
||||
<MalioRadioButton
|
||||
v-model="periodMode"
|
||||
name="exportPeriod"
|
||||
value="custom"
|
||||
:label="$t('timeEntries.exportCustomPeriod')"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="periodMode === 'custom'" class="mt-3 flex items-center gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="mb-1 block text-xs text-neutral-500">{{ $t('timeEntries.exportFrom') }}</label>
|
||||
<input
|
||||
v-model="customFrom"
|
||||
type="date"
|
||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="mb-1 block text-xs text-neutral-500">{{ $t('timeEntries.exportTo') }}</label>
|
||||
<input
|
||||
v-model="customTo"
|
||||
type="date"
|
||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User filter (admin only) -->
|
||||
<div v-if="isAdmin" class="[&>div]:!mt-0">
|
||||
<MalioSelectCheckbox
|
||||
v-model="selectedUserIds"
|
||||
:options="userOptions"
|
||||
:label="$t('timeEntries.exportUsers')"
|
||||
:display-tag="true"
|
||||
:display-select-all="true"
|
||||
group-class="!w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Client filter -->
|
||||
<div class="[&>div]:!mt-0">
|
||||
<MalioSelect
|
||||
v-model="selectedClientId"
|
||||
:options="clientOptions"
|
||||
:label="$t('timeEntries.exportClient')"
|
||||
:empty-option-label="$t('timeEntries.exportAllClients')"
|
||||
group-class="!w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Project filter -->
|
||||
<div class="[&>div]:!mt-0">
|
||||
<MalioSelectCheckbox
|
||||
v-model="selectedProjectIds"
|
||||
:options="filteredProjectOptions"
|
||||
:label="$t('timeEntries.exportProjects')"
|
||||
:display-tag="true"
|
||||
:display-select-all="true"
|
||||
group-class="!w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tag filter -->
|
||||
<div class="[&>div]:!mt-0">
|
||||
<MalioSelectCheckbox
|
||||
v-model="selectedTagIds"
|
||||
:options="tagOptions"
|
||||
:label="$t('timeEntries.exportTags')"
|
||||
:display-tag="true"
|
||||
:display-select-all="true"
|
||||
group-class="!w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Export button -->
|
||||
<MalioButton
|
||||
:label="$t('timeEntries.export')"
|
||||
icon-name="mdi:download"
|
||||
icon-position="left"
|
||||
button-class="w-full"
|
||||
@click="doExport"
|
||||
/>
|
||||
</div>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import type { Client } from '~/services/dto/client'
|
||||
|
||||
const props = defineProps<{
|
||||
users: UserData[]
|
||||
projects: Project[]
|
||||
tags: TaskTag[]
|
||||
clients: Client[]
|
||||
}>()
|
||||
|
||||
const isOpen = defineModel<boolean>({ default: false })
|
||||
const emit = defineEmits<{
|
||||
(e: 'export', params: {
|
||||
after: string
|
||||
before: string
|
||||
users?: number[]
|
||||
projects?: number[]
|
||||
client?: number
|
||||
tags?: number[]
|
||||
}): void
|
||||
}>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
|
||||
const periodMode = ref<'currentMonth' | 'lastMonth' | 'custom'>('currentMonth')
|
||||
const customFrom = ref('')
|
||||
const customTo = ref('')
|
||||
const selectedUserIds = ref<number[]>([])
|
||||
const selectedClientId = ref<number | null>(null)
|
||||
const selectedProjectIds = ref<number[]>([])
|
||||
const selectedTagIds = ref<number[]>([])
|
||||
|
||||
const userOptions = computed(() =>
|
||||
props.users.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
const clientOptions = computed(() =>
|
||||
props.clients.map(c => ({ label: c.name, value: c.id }))
|
||||
)
|
||||
|
||||
const filteredProjectOptions = computed(() => {
|
||||
let list = props.projects
|
||||
if (selectedClientId.value) {
|
||||
list = list.filter(p => p.client?.id === selectedClientId.value)
|
||||
}
|
||||
return list.map(p => ({ label: p.name, value: p.id }))
|
||||
})
|
||||
|
||||
const tagOptions = computed(() =>
|
||||
props.tags.map(t => ({ label: t.label, value: t.id }))
|
||||
)
|
||||
|
||||
// Reset project selection when client changes
|
||||
watch(selectedClientId, () => {
|
||||
selectedProjectIds.value = []
|
||||
})
|
||||
|
||||
function getDateRange(): { after: string; before: string } {
|
||||
const now = new Date()
|
||||
if (periodMode.value === 'currentMonth') {
|
||||
const first = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const last = new Date(now.getFullYear(), now.getMonth() + 1, 1)
|
||||
return {
|
||||
after: first.toISOString().slice(0, 10),
|
||||
before: last.toISOString().slice(0, 10),
|
||||
}
|
||||
}
|
||||
if (periodMode.value === 'lastMonth') {
|
||||
const first = new Date(now.getFullYear(), now.getMonth() - 1, 1)
|
||||
const last = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
return {
|
||||
after: first.toISOString().slice(0, 10),
|
||||
before: last.toISOString().slice(0, 10),
|
||||
}
|
||||
}
|
||||
return {
|
||||
after: customFrom.value,
|
||||
before: customTo.value,
|
||||
}
|
||||
}
|
||||
|
||||
function doExport() {
|
||||
const { after, before } = getDateRange()
|
||||
if (!after || !before) return
|
||||
|
||||
emit('export', {
|
||||
after,
|
||||
before,
|
||||
users: selectedUserIds.value.length ? selectedUserIds.value : undefined,
|
||||
projects: selectedProjectIds.value.length ? selectedProjectIds.value : undefined,
|
||||
client: selectedClientId.value ?? undefined,
|
||||
tags: selectedTagIds.value.length ? selectedTagIds.value : undefined,
|
||||
})
|
||||
isOpen.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -106,30 +106,43 @@ const touched = reactive({
|
||||
password: false,
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.item) {
|
||||
form.username = props.item.username ?? ''
|
||||
form.firstName = props.item.firstName ?? ''
|
||||
form.lastName = props.item.lastName ?? ''
|
||||
form.password = ''
|
||||
form.roles = [...props.item.roles]
|
||||
form.isEmployee = props.item.isEmployee ?? false
|
||||
} else {
|
||||
form.username = ''
|
||||
form.firstName = ''
|
||||
form.lastName = ''
|
||||
form.password = ''
|
||||
form.roles = ['ROLE_USER']
|
||||
form.isEmployee = false
|
||||
const { create, update, getById } = useUserService()
|
||||
|
||||
function applyUser(user: UserData) {
|
||||
form.username = user.username ?? ''
|
||||
form.firstName = user.firstName ?? ''
|
||||
form.lastName = user.lastName ?? ''
|
||||
form.password = ''
|
||||
form.roles = [...user.roles]
|
||||
form.isEmployee = user.isEmployee ?? false
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
touched.username = false
|
||||
touched.password = false
|
||||
|
||||
if (props.item) {
|
||||
applyUser(props.item)
|
||||
try {
|
||||
const full = await getById(props.item.id)
|
||||
applyUser(full)
|
||||
} catch {
|
||||
// Keep the list data if the detailed fetch fails.
|
||||
}
|
||||
touched.username = false
|
||||
touched.password = false
|
||||
} else {
|
||||
form.username = ''
|
||||
form.firstName = ''
|
||||
form.lastName = ''
|
||||
form.password = ''
|
||||
form.roles = ['ROLE_USER']
|
||||
form.isEmployee = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = useUserService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.username = true
|
||||
touched.password = true
|
||||
|
||||
Reference in New Issue
Block a user