8313c759c6
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
270 lines
10 KiB
Vue
270 lines
10 KiB
Vue
<template>
|
|
<div
|
|
ref="blockEl"
|
|
class="absolute z-10 cursor-pointer rounded-md text-xs shadow-sm select-none"
|
|
:style="blockStyle"
|
|
:class="{ 'opacity-40': isDragSource }"
|
|
@contextmenu.prevent="emit('contextmenu', $event, entry)"
|
|
@mousedown="onMouseDown"
|
|
@click.stop
|
|
>
|
|
<!-- Resize handle top (outside block) -->
|
|
<div
|
|
class="absolute left-0 right-0 h-3 cursor-n-resize group"
|
|
style="bottom: 100%"
|
|
@mousedown.stop.prevent="onResizeTopStart"
|
|
>
|
|
<div class="absolute bottom-0 left-1/2 -translate-x-1/2 h-[3px] w-8 rounded-full bg-black/0 group-hover:bg-black/20 transition" />
|
|
</div>
|
|
|
|
<div class="flex flex-col h-full overflow-hidden px-1.5 py-1">
|
|
<!-- Top: title + project -->
|
|
<div class="min-w-0">
|
|
<div v-if="sizeLevel >= 1" class="font-bold truncate leading-tight" style="color: #0A2168">{{ entry.title || $t('common.untitled') }}</div>
|
|
<div v-if="sizeLevel >= 2 && entry.project" class="truncate text-[10px] font-semibold opacity-80 leading-tight">{{ entry.project.name }}</div>
|
|
</div>
|
|
|
|
<!-- Spacer -->
|
|
<div class="flex-1" />
|
|
|
|
<!-- Bottom: tags left, duration right -->
|
|
<div v-if="sizeLevel >= 3" class="flex items-end justify-between gap-1 min-w-0">
|
|
<div v-if="showTags && entry.tags.length" class="flex flex-wrap items-center gap-0.5 overflow-hidden min-w-0">
|
|
<span
|
|
v-for="tag in visibleTags"
|
|
:key="tag.id"
|
|
class="inline-flex items-center rounded-full px-1.5 py-0.5 text-[9px] font-bold text-white truncate max-w-[5rem]"
|
|
:style="{ backgroundColor: tag.color }"
|
|
>
|
|
{{ tag.label }}
|
|
</span>
|
|
<span
|
|
v-if="hiddenTagCount > 0"
|
|
class="inline-flex items-center rounded-full bg-black/20 px-1 py-0.5 text-[9px] font-bold text-white"
|
|
>
|
|
+{{ hiddenTagCount }}
|
|
</span>
|
|
</div>
|
|
<span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
|
|
</div>
|
|
<div v-else-if="sizeLevel === 2" class="flex items-end justify-end">
|
|
<span class="shrink-0 text-[10px] tabular-nums font-bold" style="color: #0A2168">{{ duration }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Resize handle bottom (outside block) -->
|
|
<div
|
|
class="absolute left-0 right-0 h-3 cursor-s-resize group"
|
|
style="top: 100%"
|
|
@mousedown.stop.prevent="onResizeBottomStart"
|
|
>
|
|
<div class="absolute top-0 left-1/2 -translate-x-1/2 h-[3px] w-8 rounded-full bg-black/0 group-hover:bg-black/20 transition" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
|
|
|
|
const props = defineProps<{
|
|
entry: TimeEntry
|
|
hourHeight: number
|
|
dayStartHour: number
|
|
isDragSource?: boolean
|
|
columnIndex?: number
|
|
totalColumns?: number
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'click', entry: TimeEntry): void
|
|
(e: 'contextmenu', event: MouseEvent, entry: TimeEntry): void
|
|
(e: 'resize', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): void
|
|
(e: 'moveStart', payload: { entry: TimeEntry; offsetY: number }): void
|
|
}>()
|
|
|
|
const blockEl = ref<HTMLElement | null>(null)
|
|
|
|
const startDate = computed(() => new Date(props.entry.startedAt))
|
|
const endDate = computed(() => props.entry.stoppedAt ? new Date(props.entry.stoppedAt) : new Date())
|
|
|
|
const resizeTopDeltaMinutes = ref(0)
|
|
const resizeBottomDeltaMinutes = ref(0)
|
|
|
|
const duration = computed(() => {
|
|
const mins = Math.floor((endDate.value.getTime() + resizeBottomDeltaMinutes.value * 60000
|
|
- startDate.value.getTime() - resizeTopDeltaMinutes.value * 60000) / 60000)
|
|
const h = Math.floor(mins / 60)
|
|
const m = mins % 60
|
|
return m > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${h}h`
|
|
})
|
|
|
|
const heightPx = computed(() => {
|
|
const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value
|
|
const endMinutes = endDate.value.getHours() * 60 + endDate.value.getMinutes() + resizeBottomDeltaMinutes.value
|
|
return Math.max(((endMinutes - startMinutes) / 60) * props.hourHeight, 20)
|
|
})
|
|
|
|
// Responsive content levels based on block height
|
|
// 3 = full (title + project + types + duration)
|
|
// 2 = medium (title + duration)
|
|
// 1 = small (title only)
|
|
// 0 = tiny (colored bar only)
|
|
const sizeLevel = computed(() => {
|
|
const h = heightPx.value
|
|
if (h >= 50) return 3
|
|
if (h >= 35) return 2
|
|
if (h >= 20) return 1
|
|
return 0
|
|
})
|
|
|
|
const showTags = computed(() => (props.totalColumns ?? 1) <= 2)
|
|
|
|
const maxVisibleTags = computed(() => {
|
|
const total = props.totalColumns ?? 1
|
|
if (total >= 2) return 1
|
|
return 2
|
|
})
|
|
|
|
const visibleTags = computed(() => props.entry.tags.slice(0, maxVisibleTags.value))
|
|
const hiddenTagCount = computed(() => Math.max(0, props.entry.tags.length - maxVisibleTags.value))
|
|
|
|
const hasProject = computed(() => !!props.entry.project)
|
|
|
|
const blockStyle = computed(() => {
|
|
const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes() + resizeTopDeltaMinutes.value
|
|
const topPx = ((startMinutes - props.dayStartHour * 60) / 60) * props.hourHeight
|
|
|
|
const col = props.columnIndex ?? 0
|
|
const total = props.totalColumns ?? 1
|
|
const gapPx = 2
|
|
const leftPercent = (col / total) * 100
|
|
const widthPercent = (1 / total) * 100
|
|
|
|
const base: Record<string, string> = {
|
|
top: `${topPx}px`,
|
|
height: `${heightPx.value}px`,
|
|
left: `calc(${leftPercent}% + ${gapPx}px)`,
|
|
width: `calc(${widthPercent}% - ${gapPx * 2}px)`,
|
|
}
|
|
|
|
if (hasProject.value) {
|
|
const hex = props.entry.project!.color.replace('#', '')
|
|
const r = parseInt(hex.substring(0, 2), 16)
|
|
const g = parseInt(hex.substring(2, 4), 16)
|
|
const b = parseInt(hex.substring(4, 6), 16)
|
|
base.backgroundColor = `rgb(${Math.round(r + (255 - r) * 0.6)}, ${Math.round(g + (255 - g) * 0.6)}, ${Math.round(b + (255 - b) * 0.6)})`
|
|
base.color = `rgb(${r}, ${g}, ${b})`
|
|
} else {
|
|
base.backgroundColor = '#e5e7eb'
|
|
base.backgroundImage = 'repeating-conic-gradient(#d1d5db 0% 25%, #f3f4f6 0% 50%)'
|
|
base.backgroundSize = '12px 12px'
|
|
base.color = '#6b7280'
|
|
}
|
|
|
|
return base
|
|
})
|
|
|
|
// --- Click / Drag detection ---
|
|
let mouseDownPos = { x: 0, y: 0 }
|
|
let mouseDownHandled = false
|
|
|
|
function onMouseDown(event: MouseEvent) {
|
|
if (event.button !== 0) return
|
|
if ((event.target as HTMLElement).closest('.cursor-s-resize, .cursor-n-resize')) return
|
|
|
|
mouseDownPos = { x: event.clientX, y: event.clientY }
|
|
mouseDownHandled = false
|
|
|
|
document.addEventListener('mousemove', onMouseMoveDetect)
|
|
document.addEventListener('mouseup', onMouseUpDetect)
|
|
}
|
|
|
|
function onMouseMoveDetect(event: MouseEvent) {
|
|
const dx = event.clientX - mouseDownPos.x
|
|
const dy = event.clientY - mouseDownPos.y
|
|
if (Math.abs(dx) + Math.abs(dy) > 5 && !mouseDownHandled) {
|
|
mouseDownHandled = true
|
|
document.removeEventListener('mousemove', onMouseMoveDetect)
|
|
document.removeEventListener('mouseup', onMouseUpDetect)
|
|
|
|
const rect = blockEl.value!.getBoundingClientRect()
|
|
emit('moveStart', {
|
|
entry: props.entry,
|
|
offsetY: mouseDownPos.y - rect.top,
|
|
})
|
|
}
|
|
}
|
|
|
|
function onMouseUpDetect() {
|
|
document.removeEventListener('mousemove', onMouseMoveDetect)
|
|
document.removeEventListener('mouseup', onMouseUpDetect)
|
|
if (!mouseDownHandled) {
|
|
emit('click', props.entry)
|
|
}
|
|
}
|
|
|
|
// --- Resize bottom (change stoppedAt) ---
|
|
function onResizeBottomStart(event: MouseEvent) {
|
|
const startY = event.clientY
|
|
resizeBottomDeltaMinutes.value = 0
|
|
|
|
document.body.style.userSelect = 'none'
|
|
document.body.style.cursor = 's-resize'
|
|
|
|
function onMouseMove(e: MouseEvent) {
|
|
const delta = e.clientY - startY
|
|
resizeBottomDeltaMinutes.value = Math.round((delta / props.hourHeight) * 60 / 15) * 15
|
|
}
|
|
|
|
function onMouseUp() {
|
|
document.removeEventListener('mousemove', onMouseMove)
|
|
document.removeEventListener('mouseup', onMouseUp)
|
|
document.body.style.userSelect = ''
|
|
document.body.style.cursor = ''
|
|
|
|
const finalDelta = resizeBottomDeltaMinutes.value
|
|
resizeBottomDeltaMinutes.value = 0
|
|
|
|
if (finalDelta !== 0) {
|
|
const newEnd = new Date(endDate.value.getTime() + finalDelta * 60000)
|
|
emit('resize', props.entry, props.entry.startedAt, newEnd.toISOString())
|
|
}
|
|
}
|
|
|
|
document.addEventListener('mousemove', onMouseMove)
|
|
document.addEventListener('mouseup', onMouseUp)
|
|
}
|
|
|
|
// --- Resize top (change startedAt) ---
|
|
function onResizeTopStart(event: MouseEvent) {
|
|
const startY = event.clientY
|
|
resizeTopDeltaMinutes.value = 0
|
|
|
|
document.body.style.userSelect = 'none'
|
|
document.body.style.cursor = 'n-resize'
|
|
|
|
function onMouseMove(e: MouseEvent) {
|
|
const delta = e.clientY - startY
|
|
resizeTopDeltaMinutes.value = Math.round((delta / props.hourHeight) * 60 / 15) * 15
|
|
}
|
|
|
|
function onMouseUp() {
|
|
document.removeEventListener('mousemove', onMouseMove)
|
|
document.removeEventListener('mouseup', onMouseUp)
|
|
document.body.style.userSelect = ''
|
|
document.body.style.cursor = ''
|
|
|
|
const finalDelta = resizeTopDeltaMinutes.value
|
|
resizeTopDeltaMinutes.value = 0
|
|
|
|
if (finalDelta !== 0) {
|
|
const newStart = new Date(startDate.value.getTime() + finalDelta * 60000)
|
|
emit('resize', props.entry, newStart.toISOString(), props.entry.stoppedAt ?? endDate.value.toISOString())
|
|
}
|
|
}
|
|
|
|
document.addEventListener('mousemove', onMouseMove)
|
|
document.addEventListener('mouseup', onMouseUp)
|
|
}
|
|
</script>
|