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,170 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-neutral-900">{{ $t('absences.title') }}</h1>
|
||||
<MalioButton
|
||||
:label="$t('absences.newRequest')"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
@click="requestDrawerOpen = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AbsenceBalanceCards :balances="balances" />
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="filters.status"
|
||||
:label="$t('absences.table.status')"
|
||||
:options="statusOptions"
|
||||
:empty-option-label="$t('absences.filters.allStatuses')"
|
||||
group-class="w-52"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="filters.type"
|
||||
:label="$t('absences.table.type')"
|
||||
:options="typeOptions"
|
||||
:empty-option-label="$t('absences.filters.allTypes')"
|
||||
group-class="w-52"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="filters.year"
|
||||
:label="$t('absences.table.year')"
|
||||
:options="yearOptions"
|
||||
:empty-option-label="$t('absences.filters.allYears')"
|
||||
group-class="w-40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="rows"
|
||||
:total-items="rows.length"
|
||||
:row-clickable="true"
|
||||
:empty-message="$t('absences.noRequests')"
|
||||
@row-click="openDetail"
|
||||
>
|
||||
<template #cell-status="{ item }">
|
||||
<StatusBadge
|
||||
:label="statusLabel((item as Row).status)"
|
||||
:variant="statusVariant((item as Row).status)"
|
||||
:icon="statusIcon((item as Row).status)"
|
||||
/>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
|
||||
<AbsenceRequestDrawer
|
||||
v-model="requestDrawerOpen"
|
||||
:policies="policies"
|
||||
@created="reload"
|
||||
/>
|
||||
<AbsenceDetailDrawer
|
||||
v-model="detailDrawerOpen"
|
||||
:request="selected"
|
||||
:can-cancel="selected?.status === 'pending'"
|
||||
@cancelled="reload"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceBalance, AbsencePolicy, AbsenceRequest, AbsenceStatus, AbsenceType } from '~/services/dto/absence'
|
||||
import { useAbsenceService, type AbsenceRequestFilters } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
type Row = AbsenceRequest & { typeLabelText: string; periodText: string; daysText: string; createdAtText: string }
|
||||
|
||||
definePageMeta({ middleware: ['employee'] })
|
||||
|
||||
const { t } = useI18n()
|
||||
const service = useAbsenceService()
|
||||
const { statusLabel, statusVariant, statusIcon, formatRange, formatDays, formatDate } = useAbsenceHelpers()
|
||||
|
||||
useHead({ title: t('absences.title') })
|
||||
|
||||
const balances = ref<AbsenceBalance[]>([])
|
||||
const requests = ref<AbsenceRequest[]>([])
|
||||
const policies = ref<AbsencePolicy[]>([])
|
||||
|
||||
const requestDrawerOpen = ref(false)
|
||||
const detailDrawerOpen = ref(false)
|
||||
const selected = ref<AbsenceRequest | null>(null)
|
||||
|
||||
// Empty option of MalioSelect has value null, so filters default to null.
|
||||
const filters = reactive<{ status: AbsenceStatus | null; type: AbsenceType | null; year: number | null }>({
|
||||
status: null,
|
||||
type: null,
|
||||
year: null,
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ key: 'typeLabelText', label: t('absences.table.type') },
|
||||
{ key: 'periodText', label: t('absences.table.period') },
|
||||
{ key: 'daysText', label: t('absences.table.days') },
|
||||
{ key: 'status', label: t('absences.table.status') },
|
||||
{ key: 'createdAtText', label: t('absences.table.requestedAt') },
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ label: t('absences.status.pending'), value: 'pending' },
|
||||
{ label: t('absences.status.approved'), value: 'approved' },
|
||||
{ label: t('absences.status.rejected'), value: 'rejected' },
|
||||
{ label: t('absences.status.cancelled'), value: 'cancelled' },
|
||||
]
|
||||
|
||||
const typeOptions = computed(() => policies.value.map(p => ({ label: p.label, value: p.type })))
|
||||
|
||||
const yearOptions = computed(() => {
|
||||
const current = new Date().getFullYear()
|
||||
return [current + 1, current, current - 1, current - 2].map(y => ({ label: String(y), value: y }))
|
||||
})
|
||||
|
||||
const rows = computed<Row[]>(() =>
|
||||
requests.value.map(r => ({
|
||||
...r,
|
||||
typeLabelText: r.label,
|
||||
periodText: formatRange(r),
|
||||
daysText: formatDays(r.countedDays),
|
||||
createdAtText: formatDate(r.createdAt),
|
||||
})),
|
||||
)
|
||||
|
||||
function openDetail(item: Record<string, unknown>) {
|
||||
selected.value = item as Row
|
||||
detailDrawerOpen.value = true
|
||||
}
|
||||
|
||||
async function loadRequests() {
|
||||
// Scope to the current user: the collection endpoint returns every user's
|
||||
// requests for admins, which would leak the whole team into "Mes absences".
|
||||
const userId = useAuthStore().user?.id
|
||||
if (!userId) {
|
||||
requests.value = []
|
||||
return
|
||||
}
|
||||
const f: AbsenceRequestFilters = { user: userId }
|
||||
if (filters.status) f.status = filters.status
|
||||
if (filters.type) f.type = filters.type
|
||||
if (filters.year) f.year = filters.year
|
||||
requests.value = await service.getRequests(f)
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
// Scope balances to the current user: the collection endpoint returns every
|
||||
// user's balance for admins, which would pollute the personal "Mes absences" view.
|
||||
const userId = useAuthStore().user?.id
|
||||
const [bal] = await Promise.all([
|
||||
userId ? service.getBalances({ user: userId }) : Promise.resolve([]),
|
||||
loadRequests(),
|
||||
])
|
||||
balances.value = bal
|
||||
}
|
||||
|
||||
watch(() => [filters.status, filters.type, filters.year], loadRequests)
|
||||
|
||||
onMounted(async () => {
|
||||
policies.value = await service.getPolicies()
|
||||
await reload()
|
||||
})
|
||||
</script>
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="mt-6 border-b border-neutral-200 overflow-x-auto">
|
||||
<nav class="flex gap-4 sm:gap-6">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
v-for="tab in visibleTabs"
|
||||
:key="tab.key"
|
||||
class="whitespace-nowrap px-1 pb-3 text-sm font-semibold transition"
|
||||
:class="activeTab === tab.key
|
||||
@@ -27,6 +27,8 @@
|
||||
<AdminPriorityTab v-if="activeTab === 'priorities'" />
|
||||
<AdminTagTab v-if="activeTab === 'tags'" />
|
||||
<AdminUserTab v-if="activeTab === 'users'" />
|
||||
<AdminRoleTab v-if="activeTab === 'roles' && canViewRoles" />
|
||||
<AdminAuditTab v-if="activeTab === 'audit' && canViewAudit" />
|
||||
<AdminGiteaTab v-if="activeTab === 'gitea'" />
|
||||
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
|
||||
<AdminZimbraTab v-if="activeTab === 'zimbra'" />
|
||||
@@ -41,6 +43,12 @@
|
||||
definePageMeta({ middleware: ['admin'] })
|
||||
useHead({ title: 'Administration' })
|
||||
|
||||
const { can } = usePermissions()
|
||||
const { t } = useI18n()
|
||||
|
||||
const canViewRoles = computed(() => can('core.roles.view'))
|
||||
const canViewAudit = computed(() => can('core.audit_log.view'))
|
||||
|
||||
const tabs = [
|
||||
{ key: 'clients', label: 'Clients' },
|
||||
{ key: 'workflows', label: 'Workflows' },
|
||||
@@ -48,6 +56,8 @@ const tabs = [
|
||||
{ key: 'priorities', label: 'Priorités' },
|
||||
{ key: 'tags', label: 'Tags' },
|
||||
{ key: 'users', label: 'Utilisateurs' },
|
||||
{ key: 'roles', label: t('admin.roles.title'), permission: 'core.roles.view' },
|
||||
{ key: 'audit', label: t('admin.audit.title'), permission: 'core.audit_log.view' },
|
||||
{ key: 'gitea', label: 'Gitea' },
|
||||
{ key: 'bookstack', label: 'BookStack' },
|
||||
{ key: 'zimbra', label: 'Zimbra' },
|
||||
@@ -58,5 +68,9 @@ const tabs = [
|
||||
|
||||
type TabKey = typeof tabs[number]['key']
|
||||
|
||||
const visibleTabs = computed(() =>
|
||||
tabs.filter((tab) => !('permission' in tab) || can(tab.permission)),
|
||||
)
|
||||
|
||||
const activeTab = ref<TabKey>('clients')
|
||||
</script>
|
||||
|
||||
@@ -81,8 +81,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Breadcrumb, FileEntry } from '~/services/dto/share'
|
||||
import { useShareService } from '~/services/share'
|
||||
import type { Breadcrumb, FileEntry } from '~/modules/integration/services/dto/share'
|
||||
import { useShareService } from '~/modules/integration/services/share'
|
||||
import { formatFileSize } from '~/utils/format'
|
||||
|
||||
useHead({ title: 'Documents' })
|
||||
|
||||
+10
-10
@@ -1,16 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { Doughnut, Bar, Line } from 'vue-chartjs'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
|
||||
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
|
||||
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
|
||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||
import { useTimeEntryService } from '~/services/time-entries'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useTaskService } from '~/modules/project-management/services/tasks'
|
||||
import { useTaskStatusService } from '~/modules/project-management/services/task-statuses'
|
||||
import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities'
|
||||
import { useTimeEntryService } from '~/modules/time-tracking/services/time-entries'
|
||||
import { useProjectService } from '~/modules/project-management/services/projects'
|
||||
import { useUserService } from '~/services/users'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
<template>
|
||||
<div class="mx-auto w-full max-w-lg">
|
||||
<span
|
||||
class="flex items-center justify-center bg-white text-xl font-bold uppercase text-primary-500 p-4"
|
||||
>
|
||||
<img src="/malio.png" alt="Logo" class="w-[150px]"/>
|
||||
</span>
|
||||
<form
|
||||
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<MalioInputText
|
||||
label="Nom d'utilisateur"
|
||||
autocomplete="username"
|
||||
group-class="mt-0"
|
||||
inputClass="w-full"
|
||||
v-model="username"
|
||||
/>
|
||||
|
||||
<MalioInputPassword
|
||||
v-model="password"
|
||||
label="Mot de passe"
|
||||
autocomplete="current-password"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<MalioButton
|
||||
label="Se connecter"
|
||||
button-class="w-full"
|
||||
type="submit"
|
||||
:disabled="isSubmitting"
|
||||
/>
|
||||
<p class="font-bold">v{{ version }}</p>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({layout: 'auth'})
|
||||
useHead({
|
||||
title: 'Connexion'
|
||||
})
|
||||
|
||||
const auth = useAuthStore()
|
||||
const {version} = useAppVersion()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
async function handleSubmit() {
|
||||
if (isSubmitting.value) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await auth.login(username.value, password.value)
|
||||
await navigateTo('/')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,162 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import { useMailStore } from '~/stores/mail'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
useHead({ title: t('mail.title') })
|
||||
|
||||
// ─── Store ────────────────────────────────────────────────────────────────
|
||||
|
||||
const store = useMailStore()
|
||||
const {
|
||||
folderTree,
|
||||
selectedFolderPath,
|
||||
messages,
|
||||
messagesLoading,
|
||||
hasMoreMessages,
|
||||
selectedMessageId,
|
||||
selectedMessageDetail,
|
||||
detailLoading,
|
||||
} = storeToRefs(store)
|
||||
|
||||
// ─── Init : charge les dossiers + deep-link ───────────────────────────────
|
||||
|
||||
onMounted(async () => {
|
||||
if (folderTree.value.length === 0) {
|
||||
await store.fetchFolders()
|
||||
}
|
||||
|
||||
if (!selectedFolderPath.value && folderTree.value.length > 0) {
|
||||
const inbox = folderTree.value.find((f) => f.path.toUpperCase() === 'INBOX')
|
||||
const first = folderTree.value[0]
|
||||
const target = inbox?.path ?? first?.path
|
||||
if (target) {
|
||||
await store.selectFolder(target)
|
||||
}
|
||||
}
|
||||
|
||||
const messageIdParam = route.query.messageId
|
||||
if (messageIdParam) {
|
||||
const id = parseInt(String(messageIdParam), 10)
|
||||
if (!isNaN(id)) {
|
||||
await store.selectMessage(id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Handlers ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleFolderSelect(path: string): Promise<void> {
|
||||
await store.selectFolder(path)
|
||||
if (route.query.messageId) {
|
||||
const nextQuery = { ...route.query }
|
||||
delete nextQuery.messageId
|
||||
router.replace({ query: nextQuery })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMessageSelect(id: number): Promise<void> {
|
||||
await store.selectMessage(id)
|
||||
}
|
||||
|
||||
function handleLoadMore(): void {
|
||||
store.fetchMessages(true)
|
||||
}
|
||||
|
||||
// ─── Modals Phase 6 ────────────────────────────────────────────────────────
|
||||
|
||||
const showCreateTaskModal = ref(false)
|
||||
const showLinkTaskModal = ref(false)
|
||||
const activeMailIdForModal = ref<number | null>(null)
|
||||
|
||||
function handleCreateTask(mailId: number): void {
|
||||
activeMailIdForModal.value = mailId
|
||||
showCreateTaskModal.value = true
|
||||
}
|
||||
|
||||
function handleLinkTask(mailId: number): void {
|
||||
activeMailIdForModal.value = mailId
|
||||
showLinkTaskModal.value = true
|
||||
}
|
||||
|
||||
function handleTaskCreated(_task: Task): void {
|
||||
showCreateTaskModal.value = false
|
||||
// La tâche est créée et liée côté backend — toast géré par useMailService.createTaskFromMail
|
||||
}
|
||||
|
||||
function handleTaskLinked(_taskId: number): void {
|
||||
showLinkTaskModal.value = false
|
||||
// Toast géré par useMailService.linkTask
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<div class="flex flex-shrink-0 items-center justify-between border-b border-neutral-200 bg-white px-4 py-3">
|
||||
<h1 class="text-lg font-semibold text-neutral-900">
|
||||
{{ t('mail.title') }}
|
||||
</h1>
|
||||
<MailRefreshButton />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<aside class="w-[220px] flex-shrink-0 overflow-y-auto border-r border-neutral-200 bg-neutral-50 py-2">
|
||||
<p class="mb-1 px-3 text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||
{{ t('mail.folders') }}
|
||||
</p>
|
||||
<MailFolderTree
|
||||
:folders="folderTree"
|
||||
:selected-path="selectedFolderPath"
|
||||
@select="handleFolderSelect"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<div class="flex w-[320px] flex-shrink-0 flex-col overflow-hidden border-r border-neutral-200 bg-white">
|
||||
<div class="flex flex-shrink-0 items-center justify-between border-b border-neutral-100 px-3 py-2">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||
{{ t('mail.messages') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<MailMessageList
|
||||
:messages="messages"
|
||||
:selected-id="selectedMessageId"
|
||||
:loading="messagesLoading"
|
||||
:has-more="hasMoreMessages"
|
||||
@select="handleMessageSelect"
|
||||
@load-more="handleLoadMore"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-hidden bg-white">
|
||||
<MailMessageViewer
|
||||
:detail="selectedMessageDetail"
|
||||
:loading="detailLoading"
|
||||
@create-task="handleCreateTask"
|
||||
@link-task="handleLinkTask"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal créer tâche depuis mail -->
|
||||
<MailCreateTaskModal
|
||||
v-if="activeMailIdForModal !== null"
|
||||
v-model="showCreateTaskModal"
|
||||
:message-id="activeMailIdForModal"
|
||||
:message-detail="selectedMessageDetail"
|
||||
@created="handleTaskCreated"
|
||||
/>
|
||||
|
||||
<!-- Modal lier mail à tâche -->
|
||||
<MailLinkTaskModal
|
||||
v-if="activeMailIdForModal !== null"
|
||||
v-model="showLinkTaskModal"
|
||||
:message-id="activeMailIdForModal"
|
||||
@linked="handleTaskLinked"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,558 +0,0 @@
|
||||
<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 { TaskTag } from '~/services/dto/task-tag'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { StatusCategory } from '~/services/dto/workflow'
|
||||
import { STATUS_CATEGORY_LABEL, STATUS_CATEGORY_COLOR, contrastText } from '~/services/dto/workflow'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
import { useTaskEffortService } from '~/services/task-efforts'
|
||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||
import { useTaskTagService } from '~/services/task-tags'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useUserService } from '~/services/users'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
useHead({ title: t('myTasks.title') })
|
||||
|
||||
const taskService = useTaskService()
|
||||
const statusService = useTaskStatusService()
|
||||
const effortService = useTaskEffortService()
|
||||
const priorityService = useTaskPriorityService()
|
||||
const tagService = useTaskTagService()
|
||||
const groupService = useTaskGroupService()
|
||||
const userService = useUserService()
|
||||
const projectService = useProjectService()
|
||||
|
||||
const tasks = ref<Task[]>([])
|
||||
const statuses = ref<TaskStatus[]>([])
|
||||
const efforts = ref<TaskEffort[]>([])
|
||||
const priorities = ref<TaskPriority[]>([])
|
||||
const tags = ref<TaskTag[]>([])
|
||||
const groups = ref<TaskGroup[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
const projects = ref<Project[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
// Filters
|
||||
const selectedProjectId = ref<number | null>(null)
|
||||
const selectedGroupId = ref<number | null>(null)
|
||||
const selectedTagId = ref<number | null>(null)
|
||||
const selectedPriorityId = ref<number | null>(null)
|
||||
const selectedEffortId = ref<number | null>(null)
|
||||
const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
|
||||
|
||||
// Sort
|
||||
const SORT_DEADLINE = 1
|
||||
const SORT_SCHEDULED = 2
|
||||
const sortById = ref<number | null>(null)
|
||||
|
||||
// View toggle
|
||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||
|
||||
// Bulk selection
|
||||
const selectedTaskIds = reactive(new Set<number>())
|
||||
const selectedTasksArray = computed(() => tasks.value.filter(t => selectedTaskIds.has(t.id)))
|
||||
|
||||
// Modal
|
||||
const taskModalOpen = ref(false)
|
||||
const selectedTask = ref<Task | null>(null)
|
||||
|
||||
// Timer
|
||||
const timerStore = useTimerStore()
|
||||
|
||||
// Toast
|
||||
const toast = useToast()
|
||||
|
||||
// Drag & drop
|
||||
const dragOverCategory = ref<StatusCategory | null>(null)
|
||||
const pendingPicker = ref<{ statuses: TaskStatus[], task: Task, x: number, y: number } | null>(null)
|
||||
|
||||
function statusesForTaskCategory(task: Task, category: StatusCategory): TaskStatus[] {
|
||||
// GET /tasks n'embarque que l'IRI du workflow ; on résout depuis la liste projects chargée (qui embarque workflow.statuses).
|
||||
const project = projects.value.find(p => p.id === task.project?.id)
|
||||
const wf = project?.workflow
|
||||
if (!wf || typeof wf === 'string') return []
|
||||
return wf.statuses.filter(s => s.category === category)
|
||||
}
|
||||
|
||||
async function applyStatus(task: Task, status: TaskStatus): Promise<void> {
|
||||
await taskService.update(task.id, { status: `/api/task_statuses/${status.id}` })
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
function onDrop(category: StatusCategory, event: DragEvent): void {
|
||||
dragOverCategory.value = null
|
||||
const taskId = Number(event.dataTransfer?.getData('text/plain'))
|
||||
const task = tasks.value.find(t => t.id === taskId)
|
||||
if (!task) return
|
||||
const candidates = statusesForTaskCategory(task, category)
|
||||
if (candidates.length === 0) {
|
||||
toast.error({ message: t('myTasks.dropRefused') })
|
||||
return
|
||||
}
|
||||
if (candidates.length === 1) {
|
||||
void applyStatus(task, candidates[0])
|
||||
return
|
||||
}
|
||||
pendingPicker.value = { statuses: candidates, task, x: event.clientX, y: event.clientY }
|
||||
}
|
||||
|
||||
function onPickerChoice(status: TaskStatus): void {
|
||||
if (pendingPicker.value) void applyStatus(pendingPicker.value.task, status)
|
||||
pendingPicker.value = null
|
||||
}
|
||||
|
||||
function isTimerOnTask(task: Task): boolean {
|
||||
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 = task['@id'] ?? task.id
|
||||
return entryTaskId === taskId || entryTaskId === `/api/tasks/${task.id}`
|
||||
}
|
||||
|
||||
// Filter options
|
||||
const projectOptions = computed(() =>
|
||||
projects.value.map(p => ({ label: p.name, value: p.id }))
|
||||
)
|
||||
|
||||
const groupOptions = computed(() => {
|
||||
let g = groups.value.filter(g => !g.archived)
|
||||
if (selectedProjectId.value) {
|
||||
g = g.filter(g => g.project?.id === selectedProjectId.value)
|
||||
}
|
||||
return g.map(g => ({ label: g.title, value: g.id }))
|
||||
})
|
||||
|
||||
const tagOptions = computed(() =>
|
||||
tags.value.map(t => ({ label: t.label, value: t.id }))
|
||||
)
|
||||
|
||||
const priorityOptions = computed(() =>
|
||||
priorities.value.map(p => ({ label: p.label, value: p.id }))
|
||||
)
|
||||
|
||||
const effortOptions = computed(() =>
|
||||
efforts.value.map(e => ({ label: e.label, value: e.id }))
|
||||
)
|
||||
|
||||
const assigneeOptions = computed(() =>
|
||||
users.value.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
const sortOptions = computed(() => [
|
||||
{ label: t('myTasks.sortDeadline'), value: SORT_DEADLINE },
|
||||
{ label: t('myTasks.sortScheduledStart'), value: SORT_SCHEDULED },
|
||||
])
|
||||
|
||||
// Kanban helpers (grouped by canonical status category)
|
||||
const CATEGORIES: StatusCategory[] = ['todo', 'in_progress', 'blocked', 'review', 'done']
|
||||
|
||||
function tasksByCategory(category: StatusCategory): Task[] {
|
||||
return tasks.value.filter(t => t.status?.category === category)
|
||||
}
|
||||
|
||||
const backlogTasks = computed(() =>
|
||||
tasks.value.filter(t => !t.status)
|
||||
)
|
||||
|
||||
// Data loading
|
||||
async function loadReferenceData() {
|
||||
const [s, e, pr, tg, g, u, p] = await Promise.all([
|
||||
statusService.getAll(),
|
||||
effortService.getAll(),
|
||||
priorityService.getAll(),
|
||||
tagService.getAll(),
|
||||
groupService.getAll(),
|
||||
userService.getAll(),
|
||||
projectService.getAll(),
|
||||
])
|
||||
statuses.value = s
|
||||
efforts.value = e
|
||||
priorities.value = pr
|
||||
tags.value = tg
|
||||
groups.value = g
|
||||
users.value = u
|
||||
projects.value = p
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
const baseParams: Record<string, string | number | boolean | string[]> = {
|
||||
archived: false,
|
||||
}
|
||||
if (selectedProjectId.value) {
|
||||
baseParams.project = `/api/projects/${selectedProjectId.value}`
|
||||
}
|
||||
if (selectedGroupId.value) {
|
||||
baseParams.group = `/api/task_groups/${selectedGroupId.value}`
|
||||
}
|
||||
if (selectedPriorityId.value) {
|
||||
baseParams.priority = `/api/task_priorities/${selectedPriorityId.value}`
|
||||
}
|
||||
if (selectedEffortId.value) {
|
||||
baseParams.effort = `/api/task_efforts/${selectedEffortId.value}`
|
||||
}
|
||||
if (selectedTagId.value) {
|
||||
baseParams['tags[]'] = `/api/task_tags/${selectedTagId.value}`
|
||||
}
|
||||
if (sortById.value === SORT_DEADLINE) {
|
||||
baseParams['order[deadline]'] = 'asc'
|
||||
} else if (sortById.value === SORT_SCHEDULED) {
|
||||
baseParams['order[scheduledStart]'] = 'asc'
|
||||
}
|
||||
|
||||
if (selectedAssigneeId.value) {
|
||||
const userIri = `/api/users/${selectedAssigneeId.value}`
|
||||
const [assigneeTasks, collabTasks] = await Promise.all([
|
||||
taskService.getFiltered({ ...baseParams, assignee: userIri }),
|
||||
taskService.getFiltered({ ...baseParams, 'collaborators[]': userIri }),
|
||||
])
|
||||
const map = new Map<number, Task>()
|
||||
for (const t of assigneeTasks) map.set(t.id, t)
|
||||
for (const t of collabTasks) map.set(t.id, t)
|
||||
tasks.value = [...map.values()].sort((a, b) => b.id - a.id)
|
||||
} else {
|
||||
tasks.value = await taskService.getFiltered(baseParams)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
await Promise.all([loadReferenceData(), loadTasks()])
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Watch filters and sort to reload tasks
|
||||
watch(
|
||||
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId, sortById],
|
||||
() => { loadTasks() },
|
||||
)
|
||||
|
||||
// Reset group when project changes
|
||||
watch(selectedProjectId, () => {
|
||||
selectedGroupId.value = null
|
||||
}, { flush: 'sync' })
|
||||
|
||||
// Modal
|
||||
function openTaskCreate() {
|
||||
selectedTask.value = null
|
||||
taskModalOpen.value = true
|
||||
router.replace({ query: {} })
|
||||
}
|
||||
|
||||
function openTaskEdit(task: Task) {
|
||||
selectedTask.value = task
|
||||
taskModalOpen.value = true
|
||||
if (task.project?.code && task.number) {
|
||||
router.replace({ query: { task: `${task.project.code}-${task.number}` } })
|
||||
}
|
||||
}
|
||||
|
||||
watch(taskModalOpen, (open) => {
|
||||
if (!open) {
|
||||
router.replace({ query: {} })
|
||||
}
|
||||
})
|
||||
|
||||
async function onSaved() {
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
function toggleTaskSelect(taskId: number) {
|
||||
if (selectedTaskIds.has(taskId)) {
|
||||
selectedTaskIds.delete(taskId)
|
||||
} else {
|
||||
selectedTaskIds.add(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAll(taskList: Task[]) {
|
||||
if (selectedTaskIds.size === taskList.length) {
|
||||
selectedTaskIds.clear()
|
||||
} else {
|
||||
taskList.forEach(t => selectedTaskIds.add(t.id))
|
||||
}
|
||||
}
|
||||
|
||||
async function onBulkUpdate(field: string, value: number) {
|
||||
const ids = [...selectedTaskIds]
|
||||
if (ids.length === 0) return
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (field === 'status') payload.status = `/api/task_statuses/${value}`
|
||||
else if (field === 'assignee') payload.assignee = `/api/users/${value}`
|
||||
else if (field === 'priority') payload.priority = `/api/task_priorities/${value}`
|
||||
else if (field === 'effort') payload.effort = `/api/task_efforts/${value}`
|
||||
else if (field === 'group') payload.group = `/api/task_groups/${value}`
|
||||
await Promise.all(ids.map(id => taskService.update(id, payload)))
|
||||
selectedTaskIds.clear()
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
async function onBulkArchive() {
|
||||
const ids = [...selectedTaskIds]
|
||||
if (ids.length === 0) return
|
||||
await Promise.all(ids.map(id => taskService.update(id, { archived: true })))
|
||||
selectedTaskIds.clear()
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
async function onBulkDelete() {
|
||||
const ids = [...selectedTaskIds]
|
||||
if (ids.length === 0) return
|
||||
await Promise.all(ids.map(id => taskService.remove(id)))
|
||||
selectedTaskIds.clear()
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAll()
|
||||
const taskParam = route.query.task as string | undefined
|
||||
if (taskParam) {
|
||||
const dashIndex = taskParam.lastIndexOf('-')
|
||||
if (dashIndex > 0) {
|
||||
const code = taskParam.slice(0, dashIndex)
|
||||
const num = Number(taskParam.slice(dashIndex + 1))
|
||||
if (num) {
|
||||
const task = tasks.value.find(t => t.project?.code === code && t.number === num)
|
||||
if (task) {
|
||||
openTaskEdit(task)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-w-0">
|
||||
<!-- Header + Filters -->
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('myTasks.title') }}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-3"
|
||||
@click="openTaskCreate"
|
||||
>
|
||||
{{ $t('myTasks.createTask') }}
|
||||
</MalioButton>
|
||||
<button
|
||||
class="flex h-[40px] w-[40px] items-center justify-center rounded-md border transition-colors"
|
||||
:class="viewMode === 'list'
|
||||
? 'border-primary-500 bg-primary-500 text-white'
|
||||
: 'border-primary-500 text-primary-500 hover:bg-primary-50'"
|
||||
:title="viewMode === 'list' ? $t('myTasks.viewKanban') : $t('myTasks.viewList')"
|
||||
@click="viewMode = viewMode === 'kanban' ? 'list' : 'kanban'"
|
||||
>
|
||||
<Icon name="mdi:format-list-bulleted" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="selectedProjectId"
|
||||
:options="projectOptions"
|
||||
label="Projet"
|
||||
:empty-option-label="$t('myTasks.allProjects')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedGroupId"
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
:empty-option-label="$t('myTasks.allGroups')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedTagId"
|
||||
:options="tagOptions"
|
||||
label="Type"
|
||||
:empty-option-label="$t('myTasks.allTypes')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedPriorityId"
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
:empty-option-label="$t('myTasks.allPriorities')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedEffortId"
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
:empty-option-label="$t('myTasks.allEfforts')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedAssigneeId"
|
||||
:options="assigneeOptions"
|
||||
label="Assigné"
|
||||
:empty-option-label="$t('myTasks.allAssignees')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="sortById"
|
||||
:options="sortOptions"
|
||||
:label="$t('myTasks.sortBy')"
|
||||
:empty-option-label="$t('myTasks.sortDefault')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kanban View — grouped by canonical category -->
|
||||
<div v-if="viewMode === 'kanban'">
|
||||
<div class="mt-6 flex h-[calc(100vh-260px)] gap-3 overflow-x-auto pb-4">
|
||||
<div
|
||||
v-for="cat in CATEGORIES"
|
||||
:key="cat"
|
||||
class="flex w-72 shrink-0 flex-col rounded-lg bg-neutral-50 transition"
|
||||
:class="dragOverCategory === cat ? 'ring-2 ring-primary-400' : ''"
|
||||
@dragover.prevent="dragOverCategory = cat"
|
||||
@dragleave="dragOverCategory = null"
|
||||
@drop="onDrop(cat, $event)"
|
||||
>
|
||||
<div
|
||||
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold"
|
||||
:style="{ backgroundColor: STATUS_CATEGORY_COLOR[cat], color: contrastText(STATUS_CATEGORY_COLOR[cat]) }"
|
||||
>
|
||||
{{ STATUS_CATEGORY_LABEL[cat] }} ({{ tasksByCategory(cat).length }})
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div class="flex flex-col gap-3">
|
||||
<TaskCard
|
||||
v-for="task in tasksByCategory(cat)"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
show-project-color
|
||||
show-status-badge
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
<p
|
||||
v-if="tasksByCategory(cat).length === 0"
|
||||
class="py-4 text-center text-xs text-neutral-400"
|
||||
>
|
||||
{{ $t('myTasks.noTasks') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backlog below kanban (no drag/drop — status change goes through TaskModal) -->
|
||||
<div class="mt-8 rounded-lg bg-neutral-50 p-4">
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('myTasks.backlog') }} ({{ backlogTasks.length }})</h2>
|
||||
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<TaskCard
|
||||
v-for="task in backlogTasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
show-project-color
|
||||
show-status-badge
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="backlogTasks.length === 0"
|
||||
class="py-4 text-center text-xs text-neutral-400"
|
||||
>
|
||||
{{ $t('myTasks.noTasks') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div v-if="viewMode === 'list'" class="mt-6 flex flex-col gap-2.5 rounded-[10px] bg-tertiary-500 p-2.5">
|
||||
<TaskBulkActions
|
||||
:selected-count="selectedTaskIds.size"
|
||||
:total-count="tasks.length"
|
||||
:all-selected="tasks.length > 0 && selectedTaskIds.size === tasks.length"
|
||||
:some-selected="selectedTaskIds.size > 0 && selectedTaskIds.size < tasks.length"
|
||||
:statuses="statuses"
|
||||
:users="users"
|
||||
:priorities="priorities"
|
||||
:efforts="efforts"
|
||||
:groups="groups"
|
||||
:selected-tasks="selectedTasksArray"
|
||||
:projects="projects"
|
||||
@toggle-all="toggleSelectAll(tasks)"
|
||||
@bulk-update="onBulkUpdate"
|
||||
@bulk-archive="onBulkArchive"
|
||||
@bulk-delete="onBulkDelete"
|
||||
/>
|
||||
<TaskListItem
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
show-project-color
|
||||
:selected="selectedTaskIds.has(task.id)"
|
||||
@click="openTaskEdit(task)"
|
||||
@toggle-select="toggleTaskSelect"
|
||||
/>
|
||||
<p
|
||||
v-if="tasks.length === 0 && !isLoading"
|
||||
class="py-8 text-center text-sm text-neutral-400"
|
||||
>
|
||||
{{ $t('myTasks.noTasks') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- StatusPickerPopover (D&D ambiguity resolution) -->
|
||||
<StatusPickerPopover
|
||||
v-if="pendingPicker"
|
||||
:statuses="pendingPicker.statuses"
|
||||
:x="pendingPicker.x"
|
||||
:y="pendingPicker.y"
|
||||
@pick="onPickerChoice"
|
||||
@cancel="pendingPicker = null"
|
||||
/>
|
||||
|
||||
<!-- TaskModal -->
|
||||
<TaskModal
|
||||
v-model="taskModalOpen"
|
||||
:task="selectedTask"
|
||||
:project-id="selectedTask?.project?.id ?? 0"
|
||||
:statuses="statuses"
|
||||
:efforts="efforts"
|
||||
:priorities="priorities"
|
||||
:tags="tags"
|
||||
:groups="selectedTask?.project?.id ? groups.filter(g => g.project?.id === selectedTask?.project?.id) : groups"
|
||||
:users="users"
|
||||
:projects="projects"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,205 +0,0 @@
|
||||
<template>
|
||||
<NuxtLayout name="default">
|
||||
<div class="mx-auto max-w-lg px-4 py-10">
|
||||
<h1 class="mb-8 text-2xl font-bold text-neutral-900">{{ $t('profile.title') }}</h1>
|
||||
|
||||
<div class="flex flex-col items-center gap-6 rounded-xl border border-neutral-200 bg-white p-8 shadow-sm">
|
||||
<!-- Current avatar -->
|
||||
<UserAvatar
|
||||
v-if="auth.user"
|
||||
:user="auth.user"
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
<p class="text-lg font-semibold text-neutral-800">{{ auth.user?.username }}</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<MalioButton
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('profile.changeAvatar')"
|
||||
@click="avatarInput?.click()"
|
||||
/>
|
||||
<input
|
||||
ref="avatarInput"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
class="hidden"
|
||||
@change="onFileSelect"
|
||||
>
|
||||
|
||||
<MalioButton
|
||||
v-if="auth.user?.avatarUrl"
|
||||
variant="danger"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="removing"
|
||||
:label="$t('profile.removeAvatar')"
|
||||
@click="onRemove"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Token MCP (interne uniquement) -->
|
||||
<div
|
||||
class="mt-8 rounded-xl border border-neutral-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<h2 class="mb-1 text-lg font-bold text-neutral-900">{{ $t('profile.apiToken.title') }}</h2>
|
||||
<p class="mb-4 text-sm text-neutral-600">{{ $t('profile.apiToken.help') }}</p>
|
||||
|
||||
<div v-if="auth.user?.apiToken">
|
||||
<MalioInputPassword
|
||||
:model-value="auth.user.apiToken"
|
||||
:label="$t('profile.apiToken.label')"
|
||||
readonly
|
||||
@update:model-value="() => {}"
|
||||
/>
|
||||
<div class="mt-3 flex flex-wrap gap-3">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="w-auto px-4"
|
||||
icon-name="mdi:content-copy"
|
||||
icon-position="left"
|
||||
:label="$t('profile.apiToken.copy')"
|
||||
@click="onCopy"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
button-class="w-auto px-4"
|
||||
icon-name="mdi:refresh"
|
||||
icon-position="left"
|
||||
:disabled="regenerating"
|
||||
:label="$t('profile.apiToken.regenerate')"
|
||||
@click="showConfirm = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<p class="mb-4 text-sm text-neutral-500 italic">{{ $t('profile.apiToken.empty') }}</p>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
button-class="w-auto px-4"
|
||||
icon-name="mdi:key-plus"
|
||||
icon-position="left"
|
||||
:disabled="regenerating"
|
||||
:label="$t('profile.apiToken.generate')"
|
||||
@click="onRegenerate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Crop modal -->
|
||||
<AvatarCropper
|
||||
v-if="selectedFile"
|
||||
:image-file="selectedFile"
|
||||
@crop="onCrop"
|
||||
@cancel="selectedFile = null"
|
||||
/>
|
||||
|
||||
<!-- Confirm regenerate modal -->
|
||||
<Teleport v-if="showConfirm" to="body">
|
||||
<div class="fixed inset-0 z-[70] flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/30" @click.stop="showConfirm = false" />
|
||||
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('profile.apiToken.confirmTitle') }}</h3>
|
||||
<p class="mt-3 text-sm text-neutral-600">
|
||||
{{ $t('profile.apiToken.confirmMessage') }}
|
||||
</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('common.cancel')"
|
||||
@click="showConfirm = false"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="regenerating"
|
||||
:label="$t('profile.apiToken.regenerate')"
|
||||
@click="onRegenerate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAvatarService } from '~/composables/useAvatarService'
|
||||
import { useApiTokenService } from '~/services/api-token'
|
||||
import { copyToClipboard } from '~/utils/clipboard'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
definePageMeta({
|
||||
layout: false,
|
||||
})
|
||||
const { upload, remove } = useAvatarService()
|
||||
const { regenerate } = useApiTokenService()
|
||||
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const avatarInput = ref<HTMLInputElement | null>(null)
|
||||
const removing = ref(false)
|
||||
const regenerating = ref(false)
|
||||
const showConfirm = ref(false)
|
||||
|
||||
function onFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (file) {
|
||||
selectedFile.value = file
|
||||
}
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
async function onCrop(blob: Blob) {
|
||||
selectedFile.value = null
|
||||
if (!auth.user) return
|
||||
|
||||
try {
|
||||
await upload(auth.user.id, blob)
|
||||
await auth.refreshUser()
|
||||
} catch {
|
||||
// Upload error — $fetch will throw on non-2xx
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemove() {
|
||||
if (!auth.user) return
|
||||
removing.value = true
|
||||
|
||||
try {
|
||||
await remove(auth.user.id)
|
||||
await auth.refreshUser()
|
||||
} finally {
|
||||
removing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onCopy() {
|
||||
if (!auth.user?.apiToken) return
|
||||
if (await copyToClipboard(auth.user.apiToken)) {
|
||||
toast.success({ message: t('profile.apiToken.copied') })
|
||||
} else {
|
||||
toast.error({ message: t('profile.apiToken.copyFailed') })
|
||||
}
|
||||
}
|
||||
|
||||
async function onRegenerate() {
|
||||
regenerating.value = true
|
||||
try {
|
||||
const newToken = await regenerate()
|
||||
if (auth.user) {
|
||||
auth.user.apiToken = newToken
|
||||
}
|
||||
showConfirm.value = false
|
||||
toast.success({ message: t('profile.apiToken.regenerated') })
|
||||
} finally {
|
||||
regenerating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,160 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }} — {{ $t('archive.title') }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<MalioSelect
|
||||
v-model="selectedGroupId"
|
||||
:options="groupFilterOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Tous les groupes"
|
||||
group-class="w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p v-if="filteredTasks.length === 0" class="text-sm text-neutral-400">
|
||||
{{ $t('archive.empty') }}
|
||||
</p>
|
||||
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div
|
||||
v-for="task in filteredTasks"
|
||||
:key="task.id"
|
||||
class="flex cursor-pointer items-center justify-between rounded-lg border border-neutral-200 bg-white px-4 py-3 hover:shadow-sm"
|
||||
@click="openTaskEdit(task)"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs font-bold text-neutral-400">{{ project?.code }}-{{ task.number }}</span>
|
||||
<span class="text-sm font-semibold text-neutral-900">{{ task.title }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-if="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.group"
|
||||
class="rounded-full border px-2 py-0.5 text-xs font-semibold"
|
||||
:style="{ borderColor: task.group.color, color: task.group.color }"
|
||||
>
|
||||
{{ task.group.title }}
|
||||
</span>
|
||||
<UserAvatar
|
||||
v-if="task.assignee"
|
||||
:user="task.assignee"
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskModal
|
||||
v-model="taskDrawerOpen"
|
||||
:task="selectedTask"
|
||||
:project-id="projectId"
|
||||
:statuses="statuses"
|
||||
:efforts="efforts"
|
||||
:priorities="priorities"
|
||||
:tags="tags"
|
||||
:groups="groups"
|
||||
:users="users"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Project } from '~/services/dto/project'
|
||||
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 { TaskTag } from '~/services/dto/task-tag'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { useTaskEffortService } from '~/services/task-efforts'
|
||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||
import { useTaskTagService } from '~/services/task-tags'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useUserService } from '~/services/users'
|
||||
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => Number(route.params.id))
|
||||
|
||||
useHead({ title: 'Archives' })
|
||||
|
||||
const projectService = useProjectService()
|
||||
const taskService = useTaskService()
|
||||
const effortService = useTaskEffortService()
|
||||
const priorityService = useTaskPriorityService()
|
||||
const tagService = useTaskTagService()
|
||||
const groupService = useTaskGroupService()
|
||||
const userService = useUserService()
|
||||
|
||||
const project = ref<Project | null>(null)
|
||||
const archivedTasks = ref<Task[]>([])
|
||||
const efforts = ref<TaskEffort[]>([])
|
||||
|
||||
const statuses = computed<TaskStatus[]>(() =>
|
||||
[...(project.value?.workflow?.statuses ?? [])].sort((a, b) => a.position - b.position),
|
||||
)
|
||||
const priorities = ref<TaskPriority[]>([])
|
||||
const tags = ref<TaskTag[]>([])
|
||||
const groups = ref<TaskGroup[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
|
||||
const selectedGroupId = ref<number | null>(null)
|
||||
const taskDrawerOpen = ref(false)
|
||||
const selectedTask = ref<Task | null>(null)
|
||||
|
||||
const groupFilterOptions = computed(() =>
|
||||
groups.value.map(g => ({ label: g.title, value: g.id }))
|
||||
)
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
if (!selectedGroupId.value) return archivedTasks.value
|
||||
return archivedTasks.value.filter(t => t.group?.id === selectedGroupId.value)
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
const [p, t, e, pr, ty, g, u] = await Promise.all([
|
||||
projectService.getById(projectId.value),
|
||||
taskService.getByProject(projectId.value, true),
|
||||
effortService.getAll(),
|
||||
priorityService.getAll(),
|
||||
tagService.getAll(),
|
||||
groupService.getByProject(projectId.value),
|
||||
userService.getAll(),
|
||||
])
|
||||
project.value = p
|
||||
archivedTasks.value = t
|
||||
efforts.value = e
|
||||
priorities.value = pr
|
||||
tags.value = ty
|
||||
groups.value = g
|
||||
users.value = u
|
||||
}
|
||||
|
||||
function openTaskEdit(task: Task) {
|
||||
selectedTask.value = task
|
||||
taskDrawerOpen.value = true
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
@@ -1,34 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }} — Groupes</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ProjectGroupTab :project-id="projectId" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => Number(route.params.id))
|
||||
|
||||
useHead({ title: 'Groupes du projet' })
|
||||
|
||||
const projectService = useProjectService()
|
||||
const project = ref<Project | null>(null)
|
||||
|
||||
async function loadProject() {
|
||||
project.value = await projectService.getById(projectId.value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProject()
|
||||
})
|
||||
</script>
|
||||
@@ -1,500 +0,0 @@
|
||||
<template>
|
||||
<div class="min-w-0">
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-3 shrink-0"
|
||||
@click="openTaskCreate"
|
||||
>
|
||||
<span class="hidden sm:inline">Ajouter un ticket</span>
|
||||
<span class="sm:hidden">Ticket</span>
|
||||
</MalioButton>
|
||||
<button
|
||||
class="flex h-[40px] w-[40px] items-center justify-center rounded-md border transition-colors"
|
||||
:class="viewMode === 'list'
|
||||
? 'border-primary-500 bg-primary-500 text-white'
|
||||
: 'border-primary-500 text-primary-500 hover:bg-primary-50'"
|
||||
title="Vue liste"
|
||||
@click="viewMode = viewMode === 'kanban' ? 'list' : 'kanban'"
|
||||
>
|
||||
<Icon name="mdi:format-list-bulleted" size="20" />
|
||||
</button>
|
||||
<MalioButtonIcon
|
||||
icon="heroicons:cog-6-tooth"
|
||||
aria-label="Paramètres du projet"
|
||||
variant="ghost"
|
||||
@click="projectDrawerOpen = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="selectedGroupId"
|
||||
:options="groupFilterOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Tous les groupes"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedTagId"
|
||||
:options="tagFilterOptions"
|
||||
label="Tags"
|
||||
empty-option-label="Tous les tags"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedAssigneeId"
|
||||
:options="userFilterOptions"
|
||||
label="User"
|
||||
empty-option-label="Tous les users"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="viewMode === 'list'"
|
||||
v-model="selectedStatusId"
|
||||
:options="statusFilterOptions"
|
||||
label="Status"
|
||||
empty-option-label="Tous les status"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedPriorityId"
|
||||
:options="priorityFilterOptions"
|
||||
label="Priorité"
|
||||
empty-option-label="Toutes"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedEffortId"
|
||||
:options="effortFilterOptions"
|
||||
label="Effort"
|
||||
empty-option-label="Tous"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kanban -->
|
||||
<div v-if="viewMode === 'kanban'" class="mt-6 flex h-[calc(100vh-200px)] gap-3 overflow-x-auto pb-4">
|
||||
<div
|
||||
v-for="status in statuses"
|
||||
:key="status.id"
|
||||
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
|
||||
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="onDragEnter(status.id)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDropStatus($event, status)"
|
||||
>
|
||||
<div
|
||||
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold text-white"
|
||||
:style="{ backgroundColor: status.color }"
|
||||
>
|
||||
{{ status.label }} ({{ tasksByStatus(status.id).length }})
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div class="flex flex-col gap-3">
|
||||
<TaskCard
|
||||
v-for="task in tasksByStatus(status.id)"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
<p
|
||||
v-if="tasksByStatus(status.id).length === 0"
|
||||
class="py-4 text-center text-xs text-neutral-400"
|
||||
>
|
||||
Aucun ticket
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backlog -->
|
||||
<div
|
||||
v-if="viewMode === 'kanban'"
|
||||
class="mt-8 rounded-lg p-4 transition-colors"
|
||||
:class="dragOverStatusId === 0 ? 'bg-tertiary-600' : 'bg-tertiary-500'"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="onDragEnter(0)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDropBacklog($event)"
|
||||
>
|
||||
<h2 class="text-lg font-bold text-neutral-900">Backlog ({{ backlogTasks.length }})</h2>
|
||||
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<TaskCard
|
||||
v-for="task in backlogTasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div v-if="viewMode === 'list'" class="mt-6 flex flex-col gap-2.5 rounded-[10px] bg-tertiary-500 p-2.5">
|
||||
<TaskBulkActions
|
||||
:selected-count="selectedTaskIds.size"
|
||||
:total-count="filteredTasks.length"
|
||||
:all-selected="filteredTasks.length > 0 && selectedTaskIds.size === filteredTasks.length"
|
||||
:some-selected="selectedTaskIds.size > 0 && selectedTaskIds.size < filteredTasks.length"
|
||||
:statuses="statuses"
|
||||
:users="users"
|
||||
:priorities="priorities"
|
||||
:efforts="efforts"
|
||||
:groups="groups"
|
||||
:can-archive="canArchiveSelection"
|
||||
@toggle-all="toggleSelectAll(filteredTasks)"
|
||||
@bulk-update="onBulkUpdate"
|
||||
@bulk-archive="onBulkArchive"
|
||||
@bulk-delete="onBulkDelete"
|
||||
/>
|
||||
<TaskListItem
|
||||
v-for="task in filteredTasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
:selected="selectedTaskIds.has(task.id)"
|
||||
@click="openTaskEdit(task)"
|
||||
@toggle-select="toggleTaskSelect"
|
||||
/>
|
||||
<p
|
||||
v-if="filteredTasks.length === 0"
|
||||
class="py-8 text-center text-sm text-neutral-400"
|
||||
>
|
||||
Aucun ticket
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TaskModal
|
||||
v-model="taskDrawerOpen"
|
||||
:task="selectedTask"
|
||||
:project-id="projectId"
|
||||
:statuses="statuses"
|
||||
:efforts="efforts"
|
||||
:priorities="priorities"
|
||||
:tags="tags"
|
||||
:groups="groups"
|
||||
:users="users"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
|
||||
<ProjectDrawer
|
||||
v-model="projectDrawerOpen"
|
||||
:project="project"
|
||||
:clients="clients"
|
||||
@saved="onProjectSaved"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Project } from '~/services/dto/project'
|
||||
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 { TaskTag } from '~/services/dto/task-tag'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useClientService } from '~/services/clients'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { useTaskEffortService } from '~/services/task-efforts'
|
||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||
import { useTaskTagService } from '~/services/task-tags'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useUserService } from '~/services/users'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const projectId = computed(() => Number(route.params.id))
|
||||
|
||||
useHead({ title: 'Projet' })
|
||||
|
||||
const projectService = useProjectService()
|
||||
const clientService = useClientService()
|
||||
const taskService = useTaskService()
|
||||
const effortService = useTaskEffortService()
|
||||
const priorityService = useTaskPriorityService()
|
||||
const tagService = useTaskTagService()
|
||||
const groupService = useTaskGroupService()
|
||||
const userService = useUserService()
|
||||
|
||||
const project = ref<Project | null>(null)
|
||||
const tasks = ref<Task[]>([])
|
||||
const efforts = ref<TaskEffort[]>([])
|
||||
const priorities = ref<TaskPriority[]>([])
|
||||
const tags = ref<TaskTag[]>([])
|
||||
const groups = ref<TaskGroup[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
const clients = ref<Client[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
const statuses = computed<TaskStatus[]>(() =>
|
||||
[...(project.value?.workflow?.statuses ?? [])].sort((a, b) => a.position - b.position),
|
||||
)
|
||||
|
||||
const selectedGroupId = ref<number | null>(null)
|
||||
const selectedTagId = ref<number | null>(null)
|
||||
const selectedAssigneeId = ref<number | null>(null)
|
||||
const selectedStatusId = ref<number | null>(null)
|
||||
const selectedPriorityId = ref<number | null>(null)
|
||||
const selectedEffortId = ref<number | null>(null)
|
||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||
|
||||
watch(viewMode, (mode) => {
|
||||
if (mode === 'kanban') {
|
||||
selectedStatusId.value = null
|
||||
}
|
||||
})
|
||||
const selectedTaskIds = reactive(new Set<number>())
|
||||
const dragOverStatusId = ref<number | null>(null)
|
||||
const dragCounter = ref(0)
|
||||
const taskDrawerOpen = ref(false)
|
||||
const projectDrawerOpen = ref(false)
|
||||
const selectedTask = ref<Task | null>(null)
|
||||
|
||||
const groupFilterOptions = computed(() =>
|
||||
groups.value.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id }))
|
||||
)
|
||||
|
||||
const tagFilterOptions = computed(() =>
|
||||
tags.value.map(t => ({ label: t.label, value: t.id }))
|
||||
)
|
||||
|
||||
const userFilterOptions = computed(() =>
|
||||
users.value.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
const statusFilterOptions = computed(() =>
|
||||
statuses.value.map(s => ({ label: s.label, value: s.id }))
|
||||
)
|
||||
|
||||
const priorityFilterOptions = computed(() =>
|
||||
priorities.value.map(p => ({ label: p.label, value: p.id }))
|
||||
)
|
||||
|
||||
const effortFilterOptions = computed(() =>
|
||||
efforts.value.map(e => ({ label: e.label, value: e.id }))
|
||||
)
|
||||
|
||||
const canArchiveSelection = computed(() => {
|
||||
if (selectedStatusId.value === null) return false
|
||||
const status = statuses.value.find(s => s.id === selectedStatusId.value)
|
||||
return status?.isFinal === true
|
||||
})
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
let result = tasks.value.filter(t => !t.archived)
|
||||
if (selectedGroupId.value) {
|
||||
result = result.filter(t => t.group?.id === selectedGroupId.value)
|
||||
}
|
||||
if (selectedTagId.value) {
|
||||
result = result.filter(t => t.tags?.some(tag => tag.id === selectedTagId.value))
|
||||
}
|
||||
if (selectedAssigneeId.value) {
|
||||
result = result.filter(t =>
|
||||
t.assignee?.id === selectedAssigneeId.value
|
||||
|| t.collaborators?.some(c => c.id === selectedAssigneeId.value)
|
||||
)
|
||||
}
|
||||
if (selectedStatusId.value) {
|
||||
result = result.filter(t => t.status?.id === selectedStatusId.value)
|
||||
}
|
||||
if (selectedPriorityId.value) {
|
||||
result = result.filter(t => t.priority?.id === selectedPriorityId.value)
|
||||
}
|
||||
if (selectedEffortId.value) {
|
||||
result = result.filter(t => t.effort?.id === selectedEffortId.value)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
watch(filteredTasks, (list) => {
|
||||
if (selectedTaskIds.size === 0) return
|
||||
const visibleIds = new Set(list.map(t => t.id))
|
||||
for (const id of selectedTaskIds) {
|
||||
if (!visibleIds.has(id)) selectedTaskIds.delete(id)
|
||||
}
|
||||
})
|
||||
|
||||
function tasksByStatus(statusId: number): Task[] {
|
||||
return filteredTasks.value.filter(t => t.status?.id === statusId)
|
||||
}
|
||||
|
||||
const backlogTasks = computed(() =>
|
||||
filteredTasks.value.filter(t => !t.status)
|
||||
)
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [p, t, e, pr, ty, g, u, c] = await Promise.all([
|
||||
projectService.getById(projectId.value),
|
||||
taskService.getByProject(projectId.value),
|
||||
effortService.getAll(),
|
||||
priorityService.getAll(),
|
||||
tagService.getAll(),
|
||||
groupService.getByProject(projectId.value),
|
||||
userService.getAll(),
|
||||
clientService.getAll(),
|
||||
])
|
||||
project.value = p
|
||||
tasks.value = t
|
||||
efforts.value = e
|
||||
priorities.value = pr
|
||||
tags.value = ty
|
||||
groups.value = g
|
||||
users.value = u
|
||||
clients.value = c
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openTaskCreate() {
|
||||
selectedTask.value = null
|
||||
taskDrawerOpen.value = true
|
||||
router.replace({ query: {} })
|
||||
}
|
||||
|
||||
function openTaskEdit(task: Task) {
|
||||
selectedTask.value = task
|
||||
taskDrawerOpen.value = true
|
||||
if (project.value?.code && task.number) {
|
||||
router.replace({ query: { task: `${project.value.code}-${task.number}` } })
|
||||
}
|
||||
}
|
||||
|
||||
watch(taskDrawerOpen, (open) => {
|
||||
if (!open) {
|
||||
router.replace({ query: {} })
|
||||
}
|
||||
})
|
||||
|
||||
function onDragEnter(id: number) {
|
||||
dragCounter.value++
|
||||
dragOverStatusId.value = id
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
dragCounter.value--
|
||||
if (dragCounter.value === 0) {
|
||||
dragOverStatusId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
dragCounter.value = 0
|
||||
dragOverStatusId.value = null
|
||||
return Number(event.dataTransfer!.getData('text/plain'))
|
||||
}
|
||||
|
||||
|
||||
async function onDropStatus(event: DragEvent, status: TaskStatus) {
|
||||
const taskId = onDrop(event)
|
||||
const task = tasks.value.find(t => t.id === taskId)
|
||||
if (!task || task.status?.id === status.id) return
|
||||
task.status = status
|
||||
await taskService.update(taskId, { status: `/api/task_statuses/${status.id}` })
|
||||
}
|
||||
|
||||
async function onDropBacklog(event: DragEvent) {
|
||||
const taskId = onDrop(event)
|
||||
const task = tasks.value.find(t => t.id === taskId)
|
||||
if (!task || !task.status) return
|
||||
task.status = null
|
||||
await taskService.update(taskId, { status: null })
|
||||
}
|
||||
|
||||
function toggleTaskSelect(taskId: number) {
|
||||
if (selectedTaskIds.has(taskId)) {
|
||||
selectedTaskIds.delete(taskId)
|
||||
} else {
|
||||
selectedTaskIds.add(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAll(taskList: Task[]) {
|
||||
if (selectedTaskIds.size === taskList.length) {
|
||||
selectedTaskIds.clear()
|
||||
} else {
|
||||
taskList.forEach(t => selectedTaskIds.add(t.id))
|
||||
}
|
||||
}
|
||||
|
||||
async function onBulkUpdate(field: string, value: number) {
|
||||
const ids = [...selectedTaskIds]
|
||||
if (ids.length === 0) return
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (field === 'status') payload.status = `/api/task_statuses/${value}`
|
||||
else if (field === 'assignee') payload.assignee = `/api/users/${value}`
|
||||
else if (field === 'priority') payload.priority = `/api/task_priorities/${value}`
|
||||
else if (field === 'effort') payload.effort = `/api/task_efforts/${value}`
|
||||
else if (field === 'group') payload.group = `/api/task_groups/${value}`
|
||||
await Promise.all(ids.map(id => taskService.update(id, payload)))
|
||||
selectedTaskIds.clear()
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function onBulkArchive() {
|
||||
const ids = [...selectedTaskIds]
|
||||
if (ids.length === 0) return
|
||||
await Promise.all(ids.map(id => taskService.update(id, { archived: true })))
|
||||
selectedTaskIds.clear()
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function onBulkDelete() {
|
||||
const ids = [...selectedTaskIds]
|
||||
if (ids.length === 0) return
|
||||
await Promise.all(ids.map(id => taskService.remove(id)))
|
||||
selectedTaskIds.clear()
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function onProjectSaved() {
|
||||
await loadData()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
const taskParam = route.query.task as string | undefined
|
||||
if (taskParam && project.value) {
|
||||
const prefix = `${project.value.code}-`
|
||||
if (taskParam.startsWith(prefix)) {
|
||||
const num = Number(taskParam.slice(prefix.length))
|
||||
if (num) {
|
||||
const task = tasks.value.find(t => t.number === num)
|
||||
if (task) {
|
||||
openTaskEdit(task)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,143 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('projects.title') }}</h1>
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:icon-name="showArchived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-3"
|
||||
@click="toggleArchived"
|
||||
>
|
||||
<span class="hidden sm:inline">{{ showArchived ? $t('projects.hideArchived') : $t('projects.showArchived') }}</span>
|
||||
</MalioButton>
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-3 shrink-0"
|
||||
@click="openCreate"
|
||||
>
|
||||
<span class="hidden sm:inline">{{ $t('projects.addProject') }}</span>
|
||||
<span class="sm:hidden">{{ $t('projects.addProjectShort') }}</span>
|
||||
</MalioButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<div
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
class="cursor-pointer p-4 shadow-sm transition hover:shadow-md"
|
||||
:class="{ 'opacity-60': project.archived }"
|
||||
:style="projectCardStyle(project.color)"
|
||||
@click="navigateTo(`/projects/${project.id}`)"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-md font-bold" :style="{ color: project.color }">{{ project.name }}</h3>
|
||||
<span
|
||||
v-if="project.archived"
|
||||
class="rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700"
|
||||
>
|
||||
{{ $t('common.archived') }}
|
||||
</span>
|
||||
</div>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:pencil-outline"
|
||||
aria-label="Modifier le projet"
|
||||
variant="ghost"
|
||||
icon-size="16"
|
||||
@click.stop="openEdit(project)"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-neutral-600 line-clamp-4">
|
||||
{{ project.description ?? '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="projects.length === 0 && !isLoading"
|
||||
class="col-span-full py-12 text-center text-neutral-400"
|
||||
>
|
||||
{{ showArchived ? $t('projects.noArchivedProjects') : $t('projects.noProjects') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProjectDrawer
|
||||
v-model="drawerOpen"
|
||||
:project="selectedProject"
|
||||
:clients="clients"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useClientService } from '~/services/clients'
|
||||
|
||||
useHead({ title: 'Projets' })
|
||||
|
||||
function projectCardStyle(color: string | null) {
|
||||
const hex = (color || '#222783').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)
|
||||
return {
|
||||
borderRadius: '16px',
|
||||
backgroundColor: `rgba(${r}, ${g}, ${b}, 0.08)`,
|
||||
}
|
||||
}
|
||||
|
||||
const projectService = useProjectService()
|
||||
const clientService = useClientService()
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const clients = ref<Client[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedProject = ref<Project | null>(null)
|
||||
const showArchived = ref(false)
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [p, c] = await Promise.all([
|
||||
projectService.getAll({ archived: showArchived.value }),
|
||||
clientService.getAll(),
|
||||
])
|
||||
projects.value = p
|
||||
clients.value = c
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleArchived() {
|
||||
showArchived.value = !showArchived.value
|
||||
loadData()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedProject.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(project: Project) {
|
||||
selectedProject.value = project
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
@@ -1,479 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<h1 class="text-2xl font-bold text-neutral-900">
|
||||
{{ $t("absences.teamTitle") }}
|
||||
</h1>
|
||||
|
||||
<!-- KPIs -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-4">
|
||||
<p class="text-sm text-neutral-500">
|
||||
{{ $t("absences.admin.kpis.pending") }}
|
||||
</p>
|
||||
<p class="mt-1 text-3xl font-bold text-amber-600">
|
||||
{{ kpis.pending }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-4">
|
||||
<p class="text-sm text-neutral-500">
|
||||
{{ $t("absences.admin.kpis.todayAbsent") }}
|
||||
</p>
|
||||
<p class="mt-1 text-3xl font-bold text-primary-500">
|
||||
{{ kpis.today }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-4">
|
||||
<p class="text-sm text-neutral-500">
|
||||
{{ $t("absences.admin.kpis.weekAbsent") }}
|
||||
</p>
|
||||
<p class="mt-1 text-3xl font-bold text-primary-500">
|
||||
{{ kpis.week }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs">
|
||||
<!-- Requests -->
|
||||
<template #requests>
|
||||
<div class="flex min-h-[30rem] flex-col gap-4 pt-10">
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="filters.status"
|
||||
:label="$t('absences.table.status')"
|
||||
:options="statusOptions"
|
||||
:empty-option-label="
|
||||
$t('absences.filters.allStatuses')
|
||||
"
|
||||
group-class="w-48"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="filters.type"
|
||||
:label="$t('absences.table.type')"
|
||||
:options="typeOptions"
|
||||
:empty-option-label="
|
||||
$t('absences.filters.allTypes')
|
||||
"
|
||||
group-class="w-48"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="filters.user"
|
||||
:label="$t('absences.table.employee')"
|
||||
:options="employeeOptions"
|
||||
:empty-option-label="
|
||||
$t('absences.filters.allEmployees')
|
||||
"
|
||||
group-class="w-48"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioDataTable
|
||||
:columns="requestColumns"
|
||||
:items="requestRows"
|
||||
:total-items="requestRows.length"
|
||||
:empty-message="$t('absences.noRequests')"
|
||||
@row-click="openDetail"
|
||||
>
|
||||
<template #cell-status="{ item }">
|
||||
<StatusBadge
|
||||
:label="
|
||||
statusLabel((item as RequestRow).status)
|
||||
"
|
||||
:variant="
|
||||
statusVariant((item as RequestRow).status)
|
||||
"
|
||||
:icon="statusIcon((item as RequestRow).status)"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-actions="{ item }">
|
||||
<div
|
||||
v-if="(item as RequestRow).status === 'pending'"
|
||||
class="flex gap-1"
|
||||
@click.stop
|
||||
>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:check"
|
||||
:aria-label="$t('absences.review.approve')"
|
||||
button-class="!bg-green-100 !text-green-700"
|
||||
:icon-size="18"
|
||||
@click="approve(item as RequestRow)"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
:aria-label="$t('absences.review.reject')"
|
||||
button-class="!bg-red-100 !text-red-700"
|
||||
:icon-size="18"
|
||||
@click="openReject(item as RequestRow)"
|
||||
/>
|
||||
</div>
|
||||
<span v-else class="text-neutral-300">—</span>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Calendar -->
|
||||
<template #calendar>
|
||||
<div class="min-h-[30rem] pt-10">
|
||||
<AbsenceCalendar
|
||||
:absences="calendarAbsences"
|
||||
@range-change="loadCalendar"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Balances -->
|
||||
<template #balances>
|
||||
<div class="min-h-[30rem] pt-10">
|
||||
<MalioDataTable
|
||||
:columns="balanceColumns"
|
||||
:items="balanceRows"
|
||||
:total-items="balanceRows.length"
|
||||
:row-clickable="false"
|
||||
:empty-message="$t('absences.noBalance')"
|
||||
>
|
||||
<template #cell-actions="{ item }">
|
||||
<div class="flex justify-end">
|
||||
<MalioButton
|
||||
:label="
|
||||
$t(
|
||||
'absences.admin.balancesTable.adjust',
|
||||
)
|
||||
"
|
||||
variant="secondary"
|
||||
icon-name="mdi:pencil"
|
||||
icon-position="left"
|
||||
button-class="w-auto"
|
||||
@click="openAdjust(item as BalanceRow)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Employees -->
|
||||
<template #employees>
|
||||
<div class="min-h-[30rem] pt-10">
|
||||
<MalioDataTable
|
||||
:columns="employeeColumns"
|
||||
:items="employeeRows"
|
||||
:total-items="employeeRows.length"
|
||||
:empty-message="$t('absences.admin.employees.empty')"
|
||||
@row-click="openEmployee"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</MalioTabList>
|
||||
|
||||
<AbsenceDetailDrawer
|
||||
v-model="detailOpen"
|
||||
:request="selectedRequest"
|
||||
:can-cancel="
|
||||
selectedRequest?.status === 'pending' ||
|
||||
selectedRequest?.status === 'approved'
|
||||
"
|
||||
@cancelled="reloadRequests"
|
||||
/>
|
||||
<AbsenceRejectDrawer
|
||||
v-model="rejectOpen"
|
||||
:request="selectedRequest"
|
||||
@rejected="reloadRequests"
|
||||
/>
|
||||
<AbsenceBalanceAdjustDrawer
|
||||
v-model="adjustOpen"
|
||||
:balance="selectedBalance"
|
||||
@adjusted="loadBalances"
|
||||
/>
|
||||
<EmployeeDrawer
|
||||
v-model="employeeDrawerOpen"
|
||||
:user="selectedEmployee"
|
||||
@saved="loadEmployees"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
AbsenceBalance,
|
||||
AbsenceRequest,
|
||||
AbsenceStatus,
|
||||
AbsenceType,
|
||||
} from "~/services/dto/absence";
|
||||
import {
|
||||
useAbsenceService,
|
||||
type AbsenceRequestFilters,
|
||||
} from "~/services/absences";
|
||||
import { useAbsenceHelpers } from "~/composables/useAbsenceHelpers";
|
||||
import { useUserService } from "~/services/users";
|
||||
import type { UserData } from "~/services/dto/user-data";
|
||||
|
||||
definePageMeta({ middleware: ["admin"] });
|
||||
|
||||
type RequestRow = AbsenceRequest & {
|
||||
employeeText: string;
|
||||
typeLabelText: string;
|
||||
periodText: string;
|
||||
daysText: string;
|
||||
createdAtText: string;
|
||||
};
|
||||
type BalanceRow = AbsenceBalance & {
|
||||
employeeText: string;
|
||||
availableText: string;
|
||||
};
|
||||
type EmployeeRow = UserData & {
|
||||
contractText: string;
|
||||
cpTakenText: string;
|
||||
cpRemainingText: string;
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
const service = useAbsenceService();
|
||||
const {
|
||||
statusLabel,
|
||||
statusVariant,
|
||||
statusIcon,
|
||||
formatRange,
|
||||
formatDays,
|
||||
formatDate,
|
||||
} = useAbsenceHelpers();
|
||||
|
||||
useHead({ title: t("absences.teamTitle") });
|
||||
|
||||
const activeTab = ref("requests");
|
||||
const tabs = [
|
||||
{
|
||||
key: "requests",
|
||||
label: t("absences.admin.tabs.requests"),
|
||||
icon: "mdi:format-list-bulleted",
|
||||
},
|
||||
{
|
||||
key: "calendar",
|
||||
label: t("absences.admin.tabs.calendar"),
|
||||
icon: "mdi:calendar-month",
|
||||
},
|
||||
{
|
||||
key: "balances",
|
||||
label: t("absences.admin.tabs.balances"),
|
||||
icon: "mdi:scale-balance",
|
||||
},
|
||||
{
|
||||
key: "employees",
|
||||
label: t("absences.admin.tabs.employees"),
|
||||
icon: "mdi:account-group",
|
||||
},
|
||||
];
|
||||
|
||||
const requests = ref<AbsenceRequest[]>([]);
|
||||
const balances = ref<AbsenceBalance[]>([]);
|
||||
const calendarAbsences = ref<AbsenceRequest[]>([]);
|
||||
|
||||
const employees = ref<UserData[]>([]);
|
||||
const employeeDrawerOpen = ref(false);
|
||||
const selectedEmployee = ref<UserData | null>(null);
|
||||
|
||||
const detailOpen = ref(false);
|
||||
const rejectOpen = ref(false);
|
||||
const adjustOpen = ref(false);
|
||||
const selectedRequest = ref<AbsenceRequest | null>(null);
|
||||
const selectedBalance = ref<AbsenceBalance | null>(null);
|
||||
|
||||
// Empty option of MalioSelect has value null, so filters default to null.
|
||||
const filters = reactive<{
|
||||
status: AbsenceStatus | null;
|
||||
type: AbsenceType | null;
|
||||
user: number | null;
|
||||
}>({
|
||||
status: null,
|
||||
type: null,
|
||||
user: null,
|
||||
});
|
||||
|
||||
const statusOptions = [
|
||||
{ label: t("absences.status.pending"), value: "pending" },
|
||||
{ label: t("absences.status.approved"), value: "approved" },
|
||||
{ label: t("absences.status.rejected"), value: "rejected" },
|
||||
{ label: t("absences.status.cancelled"), value: "cancelled" },
|
||||
];
|
||||
|
||||
const typeOptions = [
|
||||
{ label: t("absences.types.cp"), value: "cp" },
|
||||
{ label: t("absences.types.mariage_pacs"), value: "mariage_pacs" },
|
||||
{ label: t("absences.types.conge_parental"), value: "conge_parental" },
|
||||
{ label: t("absences.types.deces"), value: "deces" },
|
||||
{ label: t("absences.types.maladie"), value: "maladie" },
|
||||
];
|
||||
|
||||
const employeeOptions = computed(() => {
|
||||
const map = new Map<number, string>();
|
||||
for (const r of requests.value) map.set(r.user.id, r.user.username);
|
||||
for (const b of balances.value) map.set(b.user.id, b.user.username);
|
||||
return [...map.entries()].map(([value, label]) => ({ value, label }));
|
||||
});
|
||||
|
||||
const requestColumns = [
|
||||
{ key: "employeeText", label: t("absences.table.employee") },
|
||||
{ key: "typeLabelText", label: t("absences.table.type") },
|
||||
{ key: "periodText", label: t("absences.table.period") },
|
||||
{ key: "daysText", label: t("absences.table.days") },
|
||||
{ key: "status", label: t("absences.table.status") },
|
||||
{ key: "createdAtText", label: t("absences.table.requestedAt") },
|
||||
{ key: "actions", label: t("absences.table.actions") },
|
||||
];
|
||||
|
||||
const requestRows = computed<RequestRow[]>(() =>
|
||||
requests.value.map((r) => ({
|
||||
...r,
|
||||
employeeText: r.user.username,
|
||||
typeLabelText: r.label,
|
||||
periodText: formatRange(r),
|
||||
daysText: formatDays(r.countedDays),
|
||||
createdAtText: formatDate(r.createdAt),
|
||||
})),
|
||||
);
|
||||
|
||||
const balanceColumns = [
|
||||
{ key: "employeeText", label: t("absences.admin.balancesTable.employee") },
|
||||
{ key: "label", label: t("absences.admin.balancesTable.type") },
|
||||
{ key: "period", label: t("absences.admin.balancesTable.period") },
|
||||
{ key: "acquired", label: t("absences.admin.balancesTable.acquired") },
|
||||
{ key: "acquiring", label: t("absences.admin.balancesTable.acquiring") },
|
||||
{ key: "taken", label: t("absences.admin.balancesTable.taken") },
|
||||
{ key: "pending", label: t("absences.admin.balancesTable.pending") },
|
||||
{
|
||||
key: "availableText",
|
||||
label: t("absences.admin.balancesTable.available"),
|
||||
},
|
||||
{ key: "actions", label: "" },
|
||||
];
|
||||
|
||||
const balanceRows = computed<BalanceRow[]>(() =>
|
||||
balances.value.map((b) => ({
|
||||
...b,
|
||||
employeeText: b.user.username,
|
||||
availableText: formatDays(b.available),
|
||||
})),
|
||||
);
|
||||
|
||||
const employeeColumns = [
|
||||
{ key: "username", label: t("absences.admin.employees.columns.name") },
|
||||
{ key: "contractText", label: t("absences.admin.employees.columns.contract") },
|
||||
{ key: "cpTakenText", label: t("absences.admin.employees.columns.cpTaken") },
|
||||
{ key: "cpRemainingText", label: t("absences.admin.employees.columns.cpRemaining") },
|
||||
];
|
||||
|
||||
const employeeRows = computed<EmployeeRow[]>(() => {
|
||||
// Map user.id -> solde CP de la période courante.
|
||||
const cpByUser = new Map<number, AbsenceBalance>();
|
||||
for (const b of balances.value) {
|
||||
if (b.type === "cp") cpByUser.set(b.user.id, b);
|
||||
}
|
||||
const dash = t("absences.admin.employees.noContract");
|
||||
return employees.value.map((u) => {
|
||||
const cp = cpByUser.get(u.id);
|
||||
return {
|
||||
...u,
|
||||
contractText: u.contractType ?? dash,
|
||||
cpTakenText: cp ? formatDays(cp.taken) : dash,
|
||||
cpRemainingText: cp ? formatDays(cp.available) : dash,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const kpis = computed(() => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const now = new Date();
|
||||
const day = (now.getDay() + 6) % 7;
|
||||
const monday = new Date(now);
|
||||
monday.setDate(now.getDate() - day);
|
||||
const sunday = new Date(monday);
|
||||
sunday.setDate(monday.getDate() + 6);
|
||||
const mondayStr = monday.toISOString().slice(0, 10);
|
||||
const sundayStr = sunday.toISOString().slice(0, 10);
|
||||
|
||||
const approved = requests.value.filter((r) => r.status === "approved");
|
||||
const todayUsers = new Set(
|
||||
approved
|
||||
.filter(
|
||||
(r) =>
|
||||
r.startDate.slice(0, 10) <= today &&
|
||||
r.endDate.slice(0, 10) >= today,
|
||||
)
|
||||
.map((r) => r.user.id),
|
||||
);
|
||||
const weekUsers = new Set(
|
||||
approved
|
||||
.filter(
|
||||
(r) =>
|
||||
r.startDate.slice(0, 10) <= sundayStr &&
|
||||
r.endDate.slice(0, 10) >= mondayStr,
|
||||
)
|
||||
.map((r) => r.user.id),
|
||||
);
|
||||
|
||||
return {
|
||||
pending: requests.value.filter((r) => r.status === "pending").length,
|
||||
today: todayUsers.size,
|
||||
week: weekUsers.size,
|
||||
};
|
||||
});
|
||||
|
||||
function openDetail(item: Record<string, unknown>) {
|
||||
selectedRequest.value = item as RequestRow;
|
||||
detailOpen.value = true;
|
||||
}
|
||||
|
||||
function openReject(row: RequestRow) {
|
||||
selectedRequest.value = row;
|
||||
rejectOpen.value = true;
|
||||
}
|
||||
|
||||
function openAdjust(row: BalanceRow) {
|
||||
selectedBalance.value = row;
|
||||
adjustOpen.value = true;
|
||||
}
|
||||
|
||||
async function approve(row: RequestRow) {
|
||||
await service.approve(row.id);
|
||||
await reloadRequests();
|
||||
}
|
||||
|
||||
async function reloadRequests() {
|
||||
const f: AbsenceRequestFilters = {};
|
||||
if (filters.status) f.status = filters.status;
|
||||
if (filters.type) f.type = filters.type;
|
||||
if (filters.user) f.user = filters.user;
|
||||
requests.value = await service.getRequests(f);
|
||||
}
|
||||
|
||||
async function loadBalances() {
|
||||
balances.value = await service.getBalances();
|
||||
}
|
||||
|
||||
async function loadEmployees() {
|
||||
const all = await useUserService().getAll();
|
||||
employees.value = all.filter((u) => u.isEmployee);
|
||||
}
|
||||
|
||||
function openEmployee(item: Record<string, unknown>) {
|
||||
selectedEmployee.value = item as EmployeeRow;
|
||||
employeeDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
async function loadCalendar(from: string, to: string) {
|
||||
calendarAbsences.value = await service.getCalendar(from, to);
|
||||
}
|
||||
|
||||
watch(() => [filters.status, filters.type, filters.user], reloadRequests);
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([reloadRequests(), loadBalances(), loadEmployees()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* MalioTabList (lib) : aère les onglets verticalement (espace haut/bas du texte) */
|
||||
:deep([role="tab"]) {
|
||||
padding-top: 0.9rem;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,450 +0,0 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<div ref="pageHeaderEl" class="sticky top-8 z-20 flex-shrink-0 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">Suivi des temps</h1>
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="shrink-0"
|
||||
@click="openCreateDrawer()"
|
||||
>
|
||||
<span class="hidden sm:inline">Ajouter une Activité</span>
|
||||
<span class="sm:hidden">Activité</span>
|
||||
</MalioButton>
|
||||
</div>
|
||||
|
||||
<div class="relative z-30 mt-4 flex flex-wrap items-center gap-3 sm:gap-4">
|
||||
<div class="flex shrink-0 items-center gap-1 h-8">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:chevron-left"
|
||||
aria-label="Précédent"
|
||||
variant="ghost"
|
||||
@click="navigatePrev"
|
||||
/>
|
||||
<DateFilter v-model="selectedDateFilter" :picker-mode="viewMode === 'day' ? 'day' : 'week'" />
|
||||
<h2 class="shrink-0 whitespace-nowrap text-lg font-bold leading-8 text-orange-500">
|
||||
{{ currentMonthLabel }}
|
||||
</h2>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:chevron-right"
|
||||
aria-label="Suivant"
|
||||
variant="ghost"
|
||||
@click="navigateNext"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center rounded-full bg-neutral-100 p-1">
|
||||
<button
|
||||
v-for="mode in (['week', 'day', 'list'] as const)"
|
||||
:key="mode"
|
||||
class="rounded-full px-4 py-1.5 text-sm font-semibold transition-all"
|
||||
:class="viewMode === mode
|
||||
? 'bg-primary-500 text-white shadow-sm'
|
||||
: 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="viewMode = mode"
|
||||
>
|
||||
{{ mode === 'week' ? 'Semaine' : mode === 'day' ? 'Jour' : 'Liste' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="[&>div]:!mt-0">
|
||||
<MalioSelect
|
||||
v-model="selectedUserId"
|
||||
:options="userOptions"
|
||||
group-class="!w-36 sm:!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
label="User"
|
||||
empty-option-label="Tous"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="[&>div]:!mt-0">
|
||||
<MalioSelect
|
||||
v-model="selectedProjectId"
|
||||
:options="projectOptions"
|
||||
empty-option-label="Tous"
|
||||
label="Projet"
|
||||
group-class="!w-36 sm:!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="[&>div]:!mt-0">
|
||||
<MalioSelect
|
||||
v-model="selectedTagId"
|
||||
:options="tagOptions"
|
||||
empty-option-label="Tous"
|
||||
label="Tag"
|
||||
group-class="!w-36 sm:!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioButton
|
||||
:label="$t('timeEntries.export')"
|
||||
variant="secondary"
|
||||
icon-name="mdi:download"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
@click="exportDrawerOpen = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-0 mt-4 -mb-24 min-h-0 flex-1">
|
||||
<TimeEntryList
|
||||
v-if="viewMode === 'list'"
|
||||
:entries="filteredEntries"
|
||||
@edit-entry="openEditDrawer"
|
||||
@delete-entry="onDelete"
|
||||
/>
|
||||
<TimeTrackingCalendar
|
||||
v-else
|
||||
:entries="filteredEntries"
|
||||
:start-date="startDate"
|
||||
:view-mode="viewMode"
|
||||
:sticky-offset="pageHeaderHeight"
|
||||
@edit-entry="openEditDrawer"
|
||||
@create-entry="openCreateDrawer"
|
||||
@move-entry="onMoveEntry"
|
||||
@resize-entry="onResizeEntry"
|
||||
@contextmenu="onContextMenu"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TimeEntryDrawer
|
||||
v-model="drawerOpen"
|
||||
:entry="editingEntry"
|
||||
:prefill-started-at="prefillStartedAt"
|
||||
:users="users"
|
||||
:projects="projects"
|
||||
:tags="tags"
|
||||
@saved="loadEntries"
|
||||
/>
|
||||
|
||||
<TimeEntryContextMenu
|
||||
:visible="contextMenu.visible"
|
||||
:x="contextMenu.x"
|
||||
:y="contextMenu.y"
|
||||
:entry="contextMenu.entry"
|
||||
:can-paste="!!clipboard"
|
||||
@close="contextMenu.visible = false"
|
||||
@copy="onCopy"
|
||||
@paste="onPaste"
|
||||
@delete="onDelete"
|
||||
/>
|
||||
|
||||
<TimeTrackingExportDrawer
|
||||
v-model="exportDrawerOpen"
|
||||
:users="users"
|
||||
:projects="projects"
|
||||
:tags="tags"
|
||||
:clients="clients"
|
||||
@export="onExport"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } 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 type { Client } from '~/services/dto/client'
|
||||
import { useTimeEntryService } from '~/services/time-entries'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
useHead({ title: 'Suivi des temps' })
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const timeEntryService = useTimeEntryService()
|
||||
|
||||
const viewMode = ref<'week' | 'day' | 'list'>('week')
|
||||
const startDate = ref(getMonday(new Date()))
|
||||
const selectedUserId = ref<number | null>(authStore.user?.id ?? null)
|
||||
const selectedTagId = ref<number | null>(null)
|
||||
const selectedProjectId = ref<number | null>(null)
|
||||
const selectedDateFilter = ref<Date | [Date, Date] | null>(null)
|
||||
|
||||
const entries = ref<TimeEntry[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
const projects = ref<Project[]>([])
|
||||
const tags = ref<TaskTag[]>([])
|
||||
const clients = ref<Client[]>([])
|
||||
const exportDrawerOpen = ref(false)
|
||||
|
||||
const drawerOpen = ref(false)
|
||||
const editingEntry = ref<TimeEntry | null>(null)
|
||||
const prefillStartedAt = ref<string | null>(null)
|
||||
const clipboard = ref<TimeEntry | null>(null)
|
||||
const pageHeaderEl = ref<HTMLElement | null>(null)
|
||||
const pageHeaderHeight = ref(0)
|
||||
|
||||
const contextMenu = reactive({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
entry: null as TimeEntry | null,
|
||||
targetDate: null as string | null,
|
||||
})
|
||||
|
||||
const currentMonthLabel = computed(() => {
|
||||
const d = startDate.value
|
||||
const months = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
|
||||
return `${months[d.getMonth()]} ${d.getFullYear()}`
|
||||
})
|
||||
|
||||
const userOptions = computed(() =>
|
||||
users.value.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
const projectOptions = computed(() =>
|
||||
projects.value.map(p => ({ label: p.name, value: p.id }))
|
||||
)
|
||||
|
||||
const tagOptions = computed(() =>
|
||||
tags.value.map(t => ({ label: t.label, value: t.id }))
|
||||
)
|
||||
|
||||
let pageHeaderResizeObserver: ResizeObserver | null = null
|
||||
|
||||
function updatePageHeaderHeight() {
|
||||
pageHeaderHeight.value = pageHeaderEl.value?.offsetHeight ?? 0
|
||||
}
|
||||
|
||||
const filteredEntries = computed(() => entries.value)
|
||||
|
||||
function getMonday(d: Date): Date {
|
||||
const date = new Date(d)
|
||||
const day = date.getDay()
|
||||
const diff = date.getDate() - day + (day === 0 ? -6 : 1)
|
||||
date.setDate(diff)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
return date
|
||||
}
|
||||
|
||||
function navigatePrev() {
|
||||
const d = new Date(startDate.value)
|
||||
if (viewMode.value === 'day') {
|
||||
d.setDate(d.getDate() - 1)
|
||||
startDate.value = d
|
||||
} else if (viewMode.value === 'list') {
|
||||
d.setMonth(d.getMonth() - 1)
|
||||
d.setDate(1)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
startDate.value = d
|
||||
} else {
|
||||
d.setDate(d.getDate() - 7)
|
||||
startDate.value = getMonday(d)
|
||||
}
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
function navigateNext() {
|
||||
const d = new Date(startDate.value)
|
||||
if (viewMode.value === 'day') {
|
||||
d.setDate(d.getDate() + 1)
|
||||
startDate.value = d
|
||||
} else if (viewMode.value === 'list') {
|
||||
d.setMonth(d.getMonth() + 1)
|
||||
d.setDate(1)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
startDate.value = d
|
||||
} else {
|
||||
d.setDate(d.getDate() + 7)
|
||||
startDate.value = getMonday(d)
|
||||
}
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
function openCreateDrawer(startedAt?: string) {
|
||||
editingEntry.value = null
|
||||
prefillStartedAt.value = startedAt ?? null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEditDrawer(entry: TimeEntry) {
|
||||
editingEntry.value = entry
|
||||
prefillStartedAt.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function onMoveEntry(entry: TimeEntry, newStartedAt: string, newStoppedAt: string) {
|
||||
// Optimistic update — instant visual feedback
|
||||
const idx = entries.value.findIndex((e) => e.id === entry.id)
|
||||
if (idx === -1) return
|
||||
const original = entries.value[idx]!
|
||||
entries.value[idx] = { ...original, startedAt: newStartedAt, stoppedAt: newStoppedAt }
|
||||
|
||||
try {
|
||||
await timeEntryService.update(entry.id, { startedAt: newStartedAt, stoppedAt: newStoppedAt })
|
||||
} catch {
|
||||
entries.value[idx] = original
|
||||
}
|
||||
}
|
||||
|
||||
async function onResizeEntry(entry: TimeEntry, newStartedAt: string, newStoppedAt: string) {
|
||||
// Optimistic update — instant visual feedback
|
||||
const idx = entries.value.findIndex((e) => e.id === entry.id)
|
||||
if (idx === -1) return
|
||||
const original = entries.value[idx]!
|
||||
entries.value[idx] = { ...original, startedAt: newStartedAt, stoppedAt: newStoppedAt }
|
||||
|
||||
try {
|
||||
await timeEntryService.update(entry.id, { startedAt: newStartedAt, stoppedAt: newStoppedAt })
|
||||
} catch {
|
||||
entries.value[idx] = original
|
||||
}
|
||||
}
|
||||
|
||||
function onContextMenu(event: MouseEvent, entry: TimeEntry | null) {
|
||||
contextMenu.visible = true
|
||||
contextMenu.x = event.clientX
|
||||
contextMenu.y = event.clientY
|
||||
contextMenu.entry = entry
|
||||
}
|
||||
|
||||
function onCopy(entry: TimeEntry) {
|
||||
clipboard.value = entry
|
||||
}
|
||||
|
||||
async function onPaste() {
|
||||
if (!clipboard.value) return
|
||||
const { create } = useTimeEntryService()
|
||||
await create({
|
||||
title: clipboard.value.title ?? undefined,
|
||||
description: clipboard.value.description ?? undefined,
|
||||
startedAt: clipboard.value.startedAt,
|
||||
stoppedAt: clipboard.value.stoppedAt ?? undefined,
|
||||
user: `/api/users/${selectedUserId.value}`,
|
||||
project: clipboard.value.project ? `/api/projects/${clipboard.value.project.id}` : null,
|
||||
tags: clipboard.value.tags.map((t) => `/api/task_tags/${t.id}`),
|
||||
})
|
||||
await loadEntries()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
pageHeaderResizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
async function onDelete(entry: TimeEntry) {
|
||||
await timeEntryService.remove(entry.id)
|
||||
await loadEntries()
|
||||
}
|
||||
|
||||
async function onExport(params: {
|
||||
after: string
|
||||
before: string
|
||||
users?: number[]
|
||||
projects?: number[]
|
||||
client?: number
|
||||
tags?: number[]
|
||||
}) {
|
||||
const toast = useToast()
|
||||
const { t } = useNuxtApp().$i18n as { t: (key: string) => string }
|
||||
|
||||
toast.info({ message: t('timeEntries.exportLoading') })
|
||||
|
||||
try {
|
||||
const result = await timeEntryService.downloadExport(params)
|
||||
|
||||
const url = URL.createObjectURL(result.blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = result.filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
toast.success({ message: t('timeEntries.exportSuccess') })
|
||||
} catch {
|
||||
toast.error({ message: t('timeEntries.exportError') })
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEntries() {
|
||||
const end = new Date(startDate.value)
|
||||
if (viewMode.value === 'day') {
|
||||
end.setDate(end.getDate() + 1)
|
||||
} else if (viewMode.value === 'list') {
|
||||
end.setMonth(end.getMonth() + 1)
|
||||
} else {
|
||||
end.setDate(end.getDate() + 7)
|
||||
}
|
||||
|
||||
entries.value = await timeEntryService.getByDateRange({
|
||||
after: startDate.value.toISOString(),
|
||||
before: end.toISOString(),
|
||||
user: selectedUserId.value ?? undefined,
|
||||
project: selectedProjectId.value ?? undefined,
|
||||
tag: selectedTagId.value ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
async function loadReferenceData() {
|
||||
const api = useApi()
|
||||
|
||||
const [usersData, projectsData, typesData, clientsData] = await Promise.all([
|
||||
api.get<HydraCollection<UserData>>('/users'),
|
||||
api.get<HydraCollection<Project>>('/projects'),
|
||||
api.get<HydraCollection<TaskTag>>('/task_tags'),
|
||||
api.get<HydraCollection<Client>>('/clients'),
|
||||
])
|
||||
|
||||
users.value = extractHydraMembers(usersData)
|
||||
projects.value = extractHydraMembers(projectsData)
|
||||
tags.value = extractHydraMembers(typesData)
|
||||
clients.value = extractHydraMembers(clientsData)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
updatePageHeaderHeight()
|
||||
|
||||
if (pageHeaderEl.value && typeof ResizeObserver !== 'undefined') {
|
||||
pageHeaderResizeObserver = new ResizeObserver(() => {
|
||||
updatePageHeaderHeight()
|
||||
})
|
||||
pageHeaderResizeObserver.observe(pageHeaderEl.value)
|
||||
}
|
||||
|
||||
await loadReferenceData()
|
||||
await loadEntries()
|
||||
})
|
||||
|
||||
watch(viewMode, () => {
|
||||
selectedDateFilter.value = null
|
||||
if (viewMode.value === 'day') {
|
||||
// keep current date
|
||||
} else if (viewMode.value === 'list') {
|
||||
const d = new Date(startDate.value)
|
||||
d.setDate(1)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
startDate.value = d
|
||||
} else {
|
||||
startDate.value = getMonday(startDate.value)
|
||||
}
|
||||
loadEntries()
|
||||
})
|
||||
|
||||
watch([selectedUserId, selectedProjectId, selectedTagId], () => {
|
||||
loadEntries()
|
||||
})
|
||||
|
||||
watch(selectedDateFilter, (val) => {
|
||||
if (!val) return
|
||||
if (Array.isArray(val)) {
|
||||
startDate.value = getMonday(val[0])
|
||||
viewMode.value = 'week'
|
||||
} else {
|
||||
startDate.value = val
|
||||
viewMode.value = 'day'
|
||||
}
|
||||
loadEntries()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user