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,93 +0,0 @@
|
||||
export type AbsenceType = 'cp' | 'mariage_pacs' | 'naissance' | 'conge_parental' | 'deces' | 'maladie'
|
||||
export type AbsenceStatus = 'pending' | 'approved' | 'rejected' | 'cancelled'
|
||||
export type HalfDay = 'matin' | 'apres_midi'
|
||||
|
||||
export type AbsenceUserRef = {
|
||||
'@id'?: string
|
||||
id: number
|
||||
username: string
|
||||
avatarUrl: string | null
|
||||
}
|
||||
|
||||
export type AbsenceRequest = {
|
||||
'@id'?: string
|
||||
id: number
|
||||
user: AbsenceUserRef
|
||||
type: AbsenceType
|
||||
label: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
startHalfDay: HalfDay | null
|
||||
endHalfDay: HalfDay | null
|
||||
countedDays: number
|
||||
reason: string | null
|
||||
justificationFileName: string | null
|
||||
justificationUrl: string | null
|
||||
status: AbsenceStatus
|
||||
rejectionReason: string | null
|
||||
createdAt: string
|
||||
reviewedAt: string | null
|
||||
reviewedBy: AbsenceUserRef | null
|
||||
}
|
||||
|
||||
export type AbsenceRequestWrite = {
|
||||
type: AbsenceType
|
||||
startDate: string
|
||||
endDate: string
|
||||
startHalfDay?: HalfDay | null
|
||||
endHalfDay?: HalfDay | null
|
||||
reason?: string | null
|
||||
}
|
||||
|
||||
export type AbsenceBalance = {
|
||||
'@id'?: string
|
||||
id: number
|
||||
user: AbsenceUserRef
|
||||
type: AbsenceType
|
||||
label: string
|
||||
period: string
|
||||
acquired: number
|
||||
acquiring: number
|
||||
acquiredTotal: number
|
||||
taken: number
|
||||
pending: number
|
||||
available: number
|
||||
}
|
||||
|
||||
export type AbsencePolicy = {
|
||||
'@id'?: string
|
||||
id: number
|
||||
type: AbsenceType
|
||||
label: string
|
||||
daysPerYear: number | null
|
||||
daysPerEvent: number | null
|
||||
justificationRequired: boolean
|
||||
noticeDays: number
|
||||
countWorkingDaysOnly: boolean
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export type AbsencePolicyWrite = {
|
||||
daysPerYear?: number | null
|
||||
daysPerEvent?: number | null
|
||||
justificationRequired?: boolean
|
||||
noticeDays?: number
|
||||
countWorkingDaysOnly?: boolean
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
export type AbsencePreviewPayload = {
|
||||
type: AbsenceType
|
||||
startDate: string
|
||||
endDate: string
|
||||
startHalfDay?: HalfDay | null
|
||||
endHalfDay?: HalfDay | null
|
||||
}
|
||||
|
||||
export type AbsencePreviewResult = {
|
||||
countedDays: number
|
||||
period: string | null
|
||||
available: number | null
|
||||
projectedAvailable: number | null
|
||||
justificationRequired: boolean
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
export type BookStackSettings = {
|
||||
url: string | null
|
||||
hasToken: boolean
|
||||
}
|
||||
|
||||
export type BookStackSettingsWrite = {
|
||||
url: string | null
|
||||
tokenId: string | null
|
||||
tokenSecret: string | null
|
||||
}
|
||||
|
||||
export type BookStackTestResult = {
|
||||
success: boolean
|
||||
}
|
||||
|
||||
export type BookStackShelf = {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export type BookStackLink = {
|
||||
id: number
|
||||
bookstackId: number
|
||||
bookstackType: 'page' | 'book'
|
||||
title: string
|
||||
url: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type BookStackLinkCreate = {
|
||||
bookstackId: number
|
||||
bookstackType: 'page' | 'book'
|
||||
title: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type BookStackSearchResult = {
|
||||
id: number
|
||||
type: 'page' | 'book'
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
export type Client = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
name: string
|
||||
email: string | null
|
||||
phone: string | null
|
||||
street: string | null
|
||||
city: string | null
|
||||
postalCode: string | null
|
||||
}
|
||||
|
||||
export type ClientWrite = {
|
||||
name: string
|
||||
email: string | null
|
||||
phone: string | null
|
||||
street: string | null
|
||||
city: string | null
|
||||
postalCode: string | null
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
export type GiteaSettings = {
|
||||
url: string | null
|
||||
hasToken: boolean
|
||||
}
|
||||
|
||||
export type GiteaSettingsWrite = {
|
||||
url: string | null
|
||||
token: string | null
|
||||
}
|
||||
|
||||
export type GiteaRepository = {
|
||||
fullName: string
|
||||
name: string
|
||||
owner: string
|
||||
}
|
||||
|
||||
export type GiteaBranch = {
|
||||
name: string
|
||||
commits: GiteaCommit[]
|
||||
}
|
||||
|
||||
export type GiteaCommit = {
|
||||
sha: string
|
||||
message: string
|
||||
author: string
|
||||
date: string
|
||||
}
|
||||
|
||||
export type GiteaBranchCreate = {
|
||||
type: string
|
||||
baseBranch: string
|
||||
}
|
||||
|
||||
export type GiteaPullRequest = {
|
||||
number: number
|
||||
title: string
|
||||
state: string
|
||||
merged: boolean
|
||||
headBranch: string
|
||||
author: string
|
||||
url: string
|
||||
ciStatuses: GiteaCiStatus[]
|
||||
}
|
||||
|
||||
export type GiteaCiStatus = {
|
||||
context: string
|
||||
status: string
|
||||
target_url: string
|
||||
}
|
||||
|
||||
export type GiteaBranchName = {
|
||||
name: string
|
||||
}
|
||||
|
||||
export type GiteaTestResult = {
|
||||
success: boolean
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
// Lecture de la configuration mail (singleton admin)
|
||||
export type MailConfigurationDto = {
|
||||
protocol: string | null
|
||||
imapHost: string | null
|
||||
imapPort: number | null
|
||||
imapEncryption: string | null
|
||||
smtpHost: string | null
|
||||
smtpPort: number | null
|
||||
smtpEncryption: string | null
|
||||
username: string | null
|
||||
sentFolderPath: string | null
|
||||
enabled: boolean
|
||||
hasPassword: boolean
|
||||
// password JAMAIS présent dans les réponses GET
|
||||
}
|
||||
|
||||
// Input PATCH configuration (password optionnel, write-only)
|
||||
export type MailConfigurationUpdateDto = {
|
||||
protocol?: string | null
|
||||
imapHost?: string | null
|
||||
imapPort?: number | null
|
||||
imapEncryption?: string | null
|
||||
smtpHost?: string | null
|
||||
smtpPort?: number | null
|
||||
smtpEncryption?: string | null
|
||||
username?: string | null
|
||||
sentFolderPath?: string | null
|
||||
enabled?: boolean
|
||||
password?: string // write-only, jamais retourné
|
||||
}
|
||||
|
||||
// Résultat du test de connexion
|
||||
export type MailTestConnectionResultDto = {
|
||||
ok: boolean
|
||||
foldersCount?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Dossier mail (peut être imbriqué)
|
||||
export type MailFolderDto = {
|
||||
path: string
|
||||
displayName: string
|
||||
parentPath: string | null
|
||||
unreadCount: number
|
||||
totalCount: number
|
||||
children?: MailFolderDto[]
|
||||
}
|
||||
|
||||
// Adresse mail (nom + email)
|
||||
export type MailAddressDto = {
|
||||
name: string | null
|
||||
email: string
|
||||
}
|
||||
|
||||
// En-tête d'un message (liste)
|
||||
export type MailMessageHeaderDto = {
|
||||
id: number
|
||||
messageId: string // identifiant IMAP unique
|
||||
folderPath: string
|
||||
subject: string | null
|
||||
fromName: string | null
|
||||
fromEmail: string | null
|
||||
toRecipients: MailAddressDto[]
|
||||
ccRecipients: MailAddressDto[]
|
||||
sentAt: string | null // ISO 8601
|
||||
receivedAt: string // ISO 8601
|
||||
isRead: boolean
|
||||
isFlagged: boolean
|
||||
hasAttachments: boolean
|
||||
linkedTaskIds: number[]
|
||||
}
|
||||
|
||||
// Pièce jointe (métadonnées uniquement, téléchargement via downloadId)
|
||||
export type MailAttachmentDto = {
|
||||
downloadId: string
|
||||
filename: string
|
||||
mimeType: string
|
||||
size: number // octets
|
||||
}
|
||||
|
||||
// Détail complet d'un message (enrichi avec body + PJ)
|
||||
export type MailMessageDetailDto = {
|
||||
header: MailMessageHeaderDto
|
||||
bodyHtml: string | null // HTML brut — TOUJOURS passer par sanitizeMailHtml() avant affichage
|
||||
bodyText: string | null // Fallback texte plain
|
||||
attachments: MailAttachmentDto[]
|
||||
}
|
||||
|
||||
// Page de messages paginée (cursor-based)
|
||||
export type MailMessagesPageDto = {
|
||||
items: MailMessageHeaderDto[]
|
||||
nextCursor: string | null // null = plus de page suivante
|
||||
total: number
|
||||
}
|
||||
|
||||
// Input : marquer lu/non-lu
|
||||
export type MailMessageReadInput = {
|
||||
read: boolean
|
||||
}
|
||||
|
||||
// Input : marquer étoilé/non-étoilé
|
||||
export type MailMessageFlagInput = {
|
||||
flagged: boolean
|
||||
}
|
||||
|
||||
// Input : créer une tâche depuis un mail
|
||||
export type MailCreateTaskInput = {
|
||||
projectId: number
|
||||
taskGroupId?: number | null
|
||||
assigneeId?: number
|
||||
statusId?: number
|
||||
}
|
||||
|
||||
// Input : lier une tâche existante à un mail
|
||||
export type MailLinkTaskInput = {
|
||||
taskId: number
|
||||
}
|
||||
|
||||
// Résultat de la sync manuelle
|
||||
export type MailSyncResultDto = {
|
||||
dispatched: boolean
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export type NotificationType = 'ticket_created' | 'ticket_status_changed'
|
||||
export type NotificationType = 'task_assigned' | 'task_collaborator_added'
|
||||
|
||||
export type Notification = {
|
||||
'@id'?: string
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { Client } from './client'
|
||||
import type { Workflow } from './workflow'
|
||||
|
||||
export type Project = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
code: string
|
||||
name: string
|
||||
description: string | null
|
||||
color: string
|
||||
client: Client | null
|
||||
workflow: Workflow
|
||||
giteaOwner: string | null
|
||||
giteaRepo: string | null
|
||||
bookstackShelfId: number | null
|
||||
bookstackShelfName: string | null
|
||||
archived: boolean
|
||||
taskCount: number
|
||||
}
|
||||
|
||||
export type ProjectWrite = {
|
||||
code?: string
|
||||
name: string
|
||||
description: string | null
|
||||
color: string
|
||||
client: string | null // IRI : "/api/clients/1" ou null
|
||||
workflow?: string // IRI : "/api/workflows/1"
|
||||
giteaOwner?: string | null
|
||||
giteaRepo?: string | null
|
||||
bookstackShelfId?: number | null
|
||||
bookstackShelfName?: string | null
|
||||
archived?: boolean
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
export type FileEntry = {
|
||||
name: string
|
||||
path: string
|
||||
isDir: boolean
|
||||
size: number
|
||||
modifiedAt: number | null
|
||||
mimeType: string
|
||||
}
|
||||
|
||||
export type Breadcrumb = {
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export type ShareBrowseResult = {
|
||||
path: string
|
||||
breadcrumb: Breadcrumb[]
|
||||
entries: FileEntry[]
|
||||
}
|
||||
|
||||
export type ShareSearchResult = {
|
||||
query: string
|
||||
entries: FileEntry[]
|
||||
}
|
||||
|
||||
export type ShareStatus = {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type ShareSettings = {
|
||||
host: string | null
|
||||
shareName: string | null
|
||||
basePath: string | null
|
||||
domain: string | null
|
||||
username: string | null
|
||||
enabled: boolean
|
||||
hasPassword: boolean
|
||||
}
|
||||
|
||||
export type ShareSettingsWrite = {
|
||||
host: string | null
|
||||
shareName: string | null
|
||||
basePath: string | null
|
||||
domain: string | null
|
||||
username: string | null
|
||||
password?: string | null
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type ShareTestResult = {
|
||||
success: boolean
|
||||
message: string | null
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { UserData } from './user-data'
|
||||
|
||||
export type TaskDocument = {
|
||||
'@id'?: string
|
||||
id: number
|
||||
task: string
|
||||
originalName: string
|
||||
fileName?: string | null
|
||||
sharePath?: string | null
|
||||
mimeType: string
|
||||
size: number
|
||||
createdAt: string
|
||||
uploadedBy: UserData | null
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export type TaskEffort = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type TaskEffortWrite = {
|
||||
label: string
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { Project } from './project'
|
||||
|
||||
export type TaskGroup = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
title: string
|
||||
description: string | null
|
||||
color: string
|
||||
project: Project | null
|
||||
archived: boolean
|
||||
}
|
||||
|
||||
export type TaskGroupWrite = {
|
||||
title: string
|
||||
description: string | null
|
||||
color: string
|
||||
project: string
|
||||
archived?: boolean
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export type TaskPriority = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export type TaskPriorityWrite = {
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
export type TaskRecurrence = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||
interval: number
|
||||
daysOfWeek: string[] | null
|
||||
dayOfMonth: number | null
|
||||
weekOfMonth: number | null
|
||||
endDate: string | null
|
||||
maxOccurrences: number | null
|
||||
occurrenceCount: number
|
||||
}
|
||||
|
||||
export type TaskRecurrenceWrite = {
|
||||
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||
interval: number
|
||||
daysOfWeek?: string[] | null
|
||||
dayOfMonth?: number | null
|
||||
weekOfMonth?: number | null
|
||||
endDate?: string | null
|
||||
maxOccurrences?: number | null
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { StatusCategory } from './workflow'
|
||||
|
||||
export type TaskStatus = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
label: string
|
||||
color: string
|
||||
position: number
|
||||
isFinal: boolean
|
||||
category: StatusCategory
|
||||
workflow?: { '@id': string, id: number } | string
|
||||
}
|
||||
|
||||
export type TaskStatusWrite = {
|
||||
label: string
|
||||
color: string
|
||||
position: number
|
||||
isFinal: boolean
|
||||
category: StatusCategory
|
||||
workflow?: string
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export type TaskTag = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export type TaskTagWrite = {
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { TaskStatus } from './task-status'
|
||||
import type { TaskEffort } from './task-effort'
|
||||
import type { TaskPriority } from './task-priority'
|
||||
import type { TaskTag } from './task-tag'
|
||||
import type { TaskGroup } from './task-group'
|
||||
import type { UserData } from './user-data'
|
||||
import type { Project } from './project'
|
||||
import type { TaskDocument } from './task-document'
|
||||
|
||||
export type Task = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
number: number
|
||||
title: string
|
||||
description: string | null
|
||||
status: TaskStatus | null
|
||||
effort: TaskEffort | null
|
||||
priority: TaskPriority | null
|
||||
assignee: UserData | null
|
||||
collaborators: UserData[]
|
||||
group: TaskGroup | null
|
||||
project: Project | null
|
||||
tags: TaskTag[]
|
||||
documents: TaskDocument[]
|
||||
archived: boolean
|
||||
scheduledStart: string | null
|
||||
scheduledEnd: string | null
|
||||
deadline: string | null
|
||||
syncToCalendar: boolean
|
||||
calendarSyncError: string | null
|
||||
recurrence: {
|
||||
id: number
|
||||
'@id'?: string
|
||||
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||
interval: number
|
||||
daysOfWeek: string[] | null
|
||||
dayOfMonth: number | null
|
||||
weekOfMonth: number | null
|
||||
endDate: string | null
|
||||
maxOccurrences: number | null
|
||||
occurrenceCount: number
|
||||
} | null
|
||||
}
|
||||
|
||||
export type TaskWrite = {
|
||||
title: string
|
||||
description: string | null
|
||||
status: string | null
|
||||
effort: string | null
|
||||
priority: string | null
|
||||
assignee: string | null
|
||||
collaborators?: string[]
|
||||
group: string | null
|
||||
project: string
|
||||
tags: string[]
|
||||
archived?: boolean
|
||||
scheduledStart?: string | null
|
||||
scheduledEnd?: string | null
|
||||
deadline?: string | null
|
||||
syncToCalendar?: boolean
|
||||
recurrence?: string | null
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { UserData } from './user-data'
|
||||
import type { Project } from './project'
|
||||
import type { Task } from './task'
|
||||
import type { TaskTag } from './task-tag'
|
||||
|
||||
export type TimeEntry = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
title: string | null
|
||||
description: string | null
|
||||
startedAt: string
|
||||
stoppedAt: string | null
|
||||
user: UserData
|
||||
project: Project | null
|
||||
task: Task | null
|
||||
tags: TaskTag[]
|
||||
}
|
||||
|
||||
export type TimeEntryWrite = {
|
||||
title?: string | null
|
||||
description?: string | null
|
||||
startedAt: string
|
||||
stoppedAt?: string | null
|
||||
user: string
|
||||
project?: string | null
|
||||
task?: string | null
|
||||
tags?: string[]
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export type UserData = {
|
||||
firstName?: string | null
|
||||
lastName?: string | null
|
||||
roles: string[]
|
||||
effectivePermissions?: string[]
|
||||
avatarUrl?: string | null
|
||||
apiToken?: string | null
|
||||
// HR / absence management
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { TaskStatus, TaskStatusWrite } from './task-status'
|
||||
|
||||
export type StatusCategory = 'todo' | 'in_progress' | 'blocked' | 'review' | 'done'
|
||||
|
||||
export const STATUS_CATEGORY_LABEL: Record<StatusCategory, string> = {
|
||||
todo: 'À faire',
|
||||
in_progress: 'En cours',
|
||||
blocked: 'Bloqué',
|
||||
review: 'En validation',
|
||||
done: 'Terminé',
|
||||
}
|
||||
|
||||
/** Palette canonique des catégories (couleurs « classiques »), indépendante des workflows. */
|
||||
export const STATUS_CATEGORY_COLOR: Record<StatusCategory, string> = {
|
||||
todo: '#222783',
|
||||
in_progress: '#4A90D9',
|
||||
blocked: '#C62828',
|
||||
review: '#FF8F00',
|
||||
done: '#26A69A',
|
||||
}
|
||||
|
||||
/** Renvoie '#1f2937' (foncé) ou '#ffffff' (blanc) selon la luminance du fond, pour rester lisible. */
|
||||
export function contrastText(hex: string): string {
|
||||
const c = hex.replace('#', '')
|
||||
const r = parseInt(c.slice(0, 2), 16)
|
||||
const g = parseInt(c.slice(2, 4), 16)
|
||||
const b = parseInt(c.slice(4, 6), 16)
|
||||
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
return lum > 0.6 ? '#1f2937' : '#ffffff'
|
||||
}
|
||||
|
||||
export type Workflow = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
name: string
|
||||
isDefault: boolean
|
||||
position: number
|
||||
statuses: TaskStatus[]
|
||||
}
|
||||
|
||||
export type WorkflowWrite = {
|
||||
name: string
|
||||
isDefault: boolean
|
||||
position: number
|
||||
statuses?: TaskStatusWrite[]
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
export type ZimbraSettings = {
|
||||
serverUrl: string | null
|
||||
username: string | null
|
||||
calendarPath: string | null
|
||||
enabled: boolean
|
||||
hasPassword: boolean
|
||||
}
|
||||
|
||||
export type ZimbraSettingsWrite = {
|
||||
serverUrl: string | null
|
||||
username: string | null
|
||||
calendarPath: string | null
|
||||
password?: string | null
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type ZimbraTestResult = {
|
||||
success: boolean
|
||||
}
|
||||
Reference in New Issue
Block a user