Compare commits

..

99 Commits

Author SHA1 Message Date
Matthieu ee9b751a1f fix(project-management) : make my-tasks kanban drag-drop status change instant
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m21s
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m28s
2026-06-23 15:42:57 +02:00
Matthieu bfbab5bbf2 fix(directory) : refresh clients list after converting a prospect
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m13s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m32s
Converting a prospect creates a client and removes the prospect, but only
loadProspects() was called, so the new client did not appear in the Clients
tab until a manual page refresh. Both the table convert button and the
ProspectDrawer saved event now reload prospects and clients.
2026-06-23 12:14:44 +02:00
Matthieu bf55a55fa6 fix(project-management) : reflect saved task immediately in board, list and reopened modal
TaskModal now emits the fresh task returned by the API (same task:read
shape as the collection). The board, my-tasks and archives pages reinject
it into their local state and selectedTask before the background re-fetch,
so the list and a reopened modal no longer show the previous snapshot while
loadData() is still running.
2026-06-23 12:14:22 +02:00
Matthieu f6d37e4667 fix(api) : register #[ApiFilter] services by mapping module entity paths
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m9s
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m20s
Modular monolith moved entities out of src/Entity into src/Module/*/Domain/Entity
without configuring api_platform.mapping.paths. Resources stayed discoverable via
service autoconfiguration, but annotated filter services were registered only for
classes found in resource_class_directories (the now-empty default src/Entity and
src/ApiResource), so every #[ApiFilter] (SearchFilter, BooleanFilter, OrderFilter,
DateFilter) was silently ignored across the whole API — collection filters never
narrowed results (my-tasks showed all users' tasks, time entries leaked across
users, directory would leak per-client data).

Declare the seven module entity directories under mapping.paths so the annotated
filter services are generated again.
2026-06-22 17:28:07 +02:00
Matthieu 1589908e4c docs : add directory commercial reports spec and implementation plan
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 41s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 58s
2026-06-22 14:17:12 +02:00
Matthieu 6aa099cd28 fix(directory) : embed commercial report author (id, username) in API read 2026-06-22 13:52:12 +02:00
Matthieu cd7cb93bac feat(directory) : add i18n keys for contacts, addresses, reports tabs 2026-06-22 13:47:09 +02:00
Matthieu 21eeb36766 feat(directory) : open detail page on row click, drop inline address from drawers 2026-06-22 13:46:05 +02:00
Matthieu fb8cc790d7 feat(directory) : add prospect detail page with contact/address/report tabs 2026-06-22 13:42:57 +02:00
Matthieu e19e392adb feat(directory) : add client detail page with contact/address/report tabs 2026-06-22 13:42:47 +02:00
Matthieu bf7253c52f feat(directory) : add commercial report tab (list, form, documents) 2026-06-22 13:39:34 +02:00
Matthieu f90d30975e feat(directory) : add report document upload/list components 2026-06-22 13:38:08 +02:00
Matthieu 00b9677e8e feat(directory) : add repeatable address block component 2026-06-22 13:37:20 +02:00
Matthieu 1bd9d5bc56 feat(directory) : add repeatable contact block component 2026-06-22 13:37:10 +02:00
Matthieu dd78f6c275 feat(directory) : add frontend services for contacts, addresses, reports, documents 2026-06-22 13:34:51 +02:00
Matthieu bc9c036d1f feat(directory) : add frontend DTOs for contacts, addresses, reports 2026-06-22 13:34:41 +02:00
Matthieu d95f14dadc feat(directory) : carry over contacts/addresses/reports on prospect conversion 2026-06-22 12:13:14 +02:00
Matthieu 354d7c34ba feat(directory) : add contact/address/report tables, migrate inline addresses, drop inline columns 2026-06-22 12:11:11 +02:00
Matthieu 33ba90a00d refactor(directory) : harden report document upload (iri guard, orphan cleanup) 2026-06-22 11:56:21 +02:00
Matthieu b9538454a9 feat(directory) : add ReportDocument storage (entity, upload processor, download, cleanup) 2026-06-22 11:51:53 +02:00
Matthieu 5af529d1b2 feat(directory) : add CommercialReport entity with dual ownership and author 2026-06-22 11:51:19 +02:00
Matthieu 8d63735bd8 feat(directory) : add Address entity with client/prospect dual ownership 2026-06-22 11:45:05 +02:00
Matthieu e5a64a60c4 feat(directory) : add Contact entity with client/prospect dual ownership 2026-06-22 11:42:09 +02:00
Matthieu bf263f4c63 feat(directory) : add ReportType enum for commercial reports 2026-06-22 11:39:12 +02:00
Matthieu a18e1f575f refactor(client-portal) : remove client portal feature entirely
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m11s
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m17s
- drop ClientPortal module, ClientTicket entity, ROLE_CLIENT and all couplings (Task, TaskDocument, User, Notification) back to an internal-only model

- migration drops client_ticket / user_allowed_projects / related FK columns and removes leftover external client accounts (would otherwise be promoted to ROLE_USER)

- remove client-portal frontend module, admin tickets tab, user portal section, portal nav item and portal/clientTicket i18n keys

- fix directory nav icon (invalid mdi:contact-multiple-outline -> mdi:card-account-details-outline)

- add 'make sync-permissions' target, wire it into install/db-reset and the prod deploy script
2026-06-22 09:49:44 +02:00
Matthieu 8a5b115ccd ci : add pull request quality gate workflow targeting develop
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m7s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 1m31s
2026-06-22 09:07:09 +02:00
Matthieu 478b5ec15d refactor(integration) : drop unused bookstack shelf-id cache 2026-06-22 09:06:00 +02:00
Matthieu 987df54175 fix(project-management) : skip caldav sync when unconfigured and require task project 2026-06-22 09:05:16 +02:00
Matthieu a76facbf4c fix(absence) : authorize cancellation before releasing the leave balance 2026-06-22 09:04:34 +02:00
Matthieu 62cdd4614a fix(client-portal) : restore project detail route and unpaginate ticket collection 2026-06-22 09:03:47 +02:00
Matthieu 4a9977e199 fix(security) : restrict bookstack search endpoint to ROLE_USER 2026-06-22 09:02:52 +02:00
Matthieu a547fd38c2 test(client-portal) : regression guard for ROLE_CLIENT endpoint isolation
Data-provided test asserting a pure ROLE_CLIENT gets 403 on the internal
endpoints hardened after the review (/api/users, /api/share/browse,
/api/share/status, bookstack links), so the fixes can't silently regress.
2026-06-21 19:33:13 +02:00
Matthieu 96ef1bf436 fix(security) : harden ROLE_CLIENT isolation + tighten cross-module contracts
Findings from the post-migration code review. The arrival of ROLE_CLIENT
exposed internal endpoints still guarded only by IS_AUTHENTICATED_FULLY (or no
security), reachable by a client. Verified by re-running a multi-role smoke
test (client -> 403, internal roles -> 200).

Security (closed real client-isolation holes):
- TaskDocumentDownloadController: add ownership check (admin all / client only
  own clientTicket docs / user only task-linked docs) — the custom download
  bypassed the cloistered provider.
- Share browse/download/search/status controllers: IS_AUTHENTICATED_FULLY ->
  ROLE_USER (SMB share is internal).
- User Get/GetCollection: add security ROLE_USER (was exposing the internal
  directory to clients).
- BookStackLink GetCollection/Post/Delete: IS_AUTHENTICATED_FULLY -> ROLE_USER.

Contracts / robustness:
- TaskInterface gains getProject(): ?ProjectInterface; TimeTracking export
  controller/service drop concrete cross-module entities for repo interfaces.
- Shared MCP Serializer signatures widened to the contracts (user/projectRef/
  taskRef/tags/users); project()/userFull()/etc. kept concrete (use getters
  outside the contracts).
- RecurrenceHandler: null-guard before findMaxNumberByProjectForUpdate().

180 tests green, cs-fixer clean, routes unchanged.
2026-06-21 19:31:09 +02:00
Matthieu da3d190216 refactor(core) : final legacy cleanup — app is 100% modular
LST-60 (3.3). Closes the modular-monolith migration. src/Entity was already
empty; this removes the last legacy residue.

- Doctrine: drop the legacy "App" mapping (empty src/Entity). resolve_target_
  entities already targets modules only.
- MCP User tools (Reference/) -> Core/Infrastructure/Mcp/Tool; MCP Serializer
  -> Shared/Infrastructure/Mcp (33 usages repointed).
- Controllers (mark-all-read, notification unread-count, regenerate-api-token,
  user-avatar) -> Core/Infrastructure/Controller. TokenEncryptor -> Shared/
  Infrastructure/Service (11 usages). AppVersion resource+provider -> Shared.
  ContractType enum -> Core/Domain/Enum.
- src/{Entity,State,Controller,Service,Enum,ApiResource} now empty; routes,
  MCP tool names and public API unchanged.

180 tests green, mapping valid, no route regression, cs-fixer clean.
Note: final Malio visual harmonisation (subjective) left to the PO.
2026-06-21 01:25:19 +02:00
Matthieu 0cce586a1f feat(client-portal) : phase 3 — ticket notifications
LST-69 (3.2) phase 3. Wires the existing notification system to client-ticket
events (the bell/useNotifications/endpoints already existed).

- Notification.relatedTicket (ManyToOne ClientTicketInterface, SET NULL) +
  additive migration + notification:read group.
- NotifierInterface::notify() gains a backward-compatible optional
  relatedTicket param (existing callers unchanged).
- ClientTicketNumberProcessor (POST): notifies all ROLE_ADMIN users
  (ticket_created), tolerant try/catch after flush. ClientTicketStatusProcessor
  (PATCH): notifies submittedBy on status change (ticket_status_changed).
- Front: notification DTO relatedTicket; NotificationBell navigates to /admin
  (admin) or /portal (client) on ticket notifications.

180 tests green (178 + 2), nuxt build passes, cs-fixer clean.
2026-06-21 01:15:05 +02:00
Matthieu 144a8a4685 feat(client-portal) : portal front + client account admin (phases 1-2 front)
LST-69 (3.2) front. Client portal UI on the phase-1 backend.

- New frontend/modules/client-portal/ layer: /portal (project cards from the
  client's allowedProjects via /me), /portal/projects/[id] (tickets list,
  detail modal, create modal with document upload), client-tickets service +
  DTO, CT-XXX formatting.
- Front tenancy: auth.global.ts redirects a pure ROLE_CLIENT to /portal and
  blocks internal routes; portal pages open to any authenticated user.
- Admin: UserDrawer manages client accounts (ROLE_CLIENT + client +
  allowedProjects); new "Tickets client" admin tab (list, filters, status
  change with required comment on reject, detail modal).
- Kanban/my-tasks: client-ticket icon + tooltip when task.clientTicket is set
  (data via task:read, no extra call). TaskDocument upload generalized with a
  clientTicketId prop. getContent uses native fetch (text response).
- i18n portal/clientTicket keys; sidebar /portal item (module client-portal).

nuxt build passes; /portal routes present, existing routes intact.
2026-06-21 01:03:58 +02:00
Matthieu a2bbc8311d fix(client-portal) : forbid SMB share-link document creation for client users
Security hardening on the document POST that phase 1 widened to ROLE_CLIENT:
a client user could reach the share-link path (arbitrary SMB file reference)
instead of an upload. Now the sharePath branch is admin-only — client users
must upload. attachTarget already scopes documents to the client's own ticket.

178 tests green.
2026-06-21 01:03:24 +02:00
Matthieu 808a290845 feat(client-portal) : phase 1 foundations — ROLE_CLIENT hardening + ClientTicket (back)
LST-69 (3.2) phase 1. New ClientPortal module + security foundations for the
client portal (spec docs/superpowers/specs/2026-03-15-client-portal-design.md).

- Security: User::getRoles() no longer adds ROLE_USER to ROLE_CLIENT users;
  role_hierarchy ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT]. Existing Task/Project/
  Client/TimeEntry/metadata endpoints already required ROLE_USER -> a pure
  ROLE_CLIENT is walled off (verified: 403).
- User (Core): client (ManyToOne ClientInterface, SET NULL) + allowedProjects
  (ManyToMany ProjectInterface). UserInterface extended (getClient/
  getAllowedProjects).
- New ClientTicket entity (module ClientPortal) + enums + repository + API with
  per-client isolation (ClientTicketProvider: own tickets ∩ allowedProjects),
  per-project numbering under advisory lock (rejects if user.client null),
  status transition rules. ClientTicketInterface contract for Task/TaskDocument.
- TaskDocument generalized: task nullable + clientTicket (CASCADE) + CHECK;
  per-role access. Task.clientTicket exposed in task:read.
- Additive migration; demo client fixtures.
- Tenancy tests assert the isolation invariant (a client never sees another
  client's tickets) rather than brittle absolute counts (shared test DB).

178 tests green, mapping valid, cs-fixer clean.
2026-06-21 00:46:26 +02:00
Matthieu f4ffc02028 feat(reporting) : add Reporting dashboard front layer
LST-59 (3.1) front. Completes the Reporting module.

- New frontend/modules/reporting/ layer (auto-detected): /reporting page
  (admin middleware) consuming the 4 read-only report endpoints.
- Filters (period presets + custom dates, project, user). 4 sections (time per
  project, time per user, tasks by status, absences by type) each with a
  DataTable + a Chart.js chart (reused global registration).
- Front-side CSV export per section (useCsvExport: BOM UTF-8, ; separator).
- i18n keys (reporting.*, sidebar.admin.reporting).

nuxt build passes; /reporting routed; no route regression.
2026-06-21 00:16:03 +02:00
Matthieu b3b29fd753 feat(reporting) : add transverse Reporting module (DBAL read-only, back)
LST-59 (3.1) backend. New native reporting module that aggregates across
TimeTracking/ProjectManagement/Absence with ZERO direct inter-module imports —
coupled only to the physical SQL schema via read-only DBAL (AuditLog provider
pattern).

- 4 read-only reports (ApiResource + DBAL provider + readonly DTO,
  paginationEnabled false, security reporting.view): /api/reports/
  {time-per-project, time-per-user, tasks-by-status, absences-by-type}.
  All filters bound-param, dates validated YYYY-MM-DD (default = current month),
  int filters validated by regex (cs-fixer-stable).
- No Doctrine entity, no migration. ReportFilterTrait centralises validation.
  Absence status compared by literal 'approved' to avoid importing the enum.
- ReportingModule registered (id reporting, reporting.view/export perms);
  sidebar /reporting item gated by module + permission (ROLE_ADMIN section).

169 tests green (163 + 6), 4 routes exposed, cs-fixer clean.
2026-06-21 00:08:43 +02:00
Matthieu 0761bbd8c1 feat(integration) : extract Gitea/BookStack/Zimbra/Share front into module layer
LST-68 (2.6) front. Completes the Integration module and Phase 2.

- New frontend/modules/integration/ layer (auto-detected): services
  (gitea, bookstack, zimbra, share, share-settings) + their DTOs, and the
  useShareStatus composable.
- Consumers repointed to ~/modules/integration/...: admin tabs
  (Gitea/BookStack/Zimbra/Share), PM task sections (TaskGitSection,
  TaskBookStackLinks, TaskDocumentShareLinker), ProjectDrawer, TaskModal,
  pages/documents.vue, components/share/SharedFilePreview.vue.
- Admin tabs, SharedFilePreview and documents/admin pages stay at their
  location (only imports updated). i18n stays global.

nuxt build passes; all routes preserved.
2026-06-20 23:53:11 +02:00
Matthieu 90682e809c feat(integration) : migrate Gitea/BookStack/Zimbra/Share into module (back)
LST-68 (2.6) backend. Behaviour-preserving move of the external integrations
into src/Module/Integration/. All 26 routes and securities unchanged.

- 5 entities (4 *Configuration singletons + TaskBookStackLink) + 5 repositories
  (Domain interfaces + Doctrine impls, bound). TaskBookStackLink.task now
  references TaskInterface (contract).
- Domain (FileSource interface, SharePathResolver, share DTOs + exceptions);
  Infrastructure (GiteaApiService, BookStackApiService, SmbFileSource, 15
  ApiResources, 21 State, 4 Share controllers).
- Cross-module couplings via abstractions: CalDavService (PM) injects
  ZimbraConfigurationRepositoryInterface; PM TaskDocument consumers repointed
  to the module's FileSource/SharePathResolver; Gitea/BookStack State load
  tasks via TaskRepositoryInterface (concrete Project read for integration
  fields — documented). ZimbraTestConnection keeps CalDavService (no build
  cycle). TokenEncryptor stays shared.
- IntegrationModule registered; doctrine mapping added.
- #[Auditable] + Timestampable on the 4 Configuration entities (additive
  migration on the 4 *_configuration tables).

163 tests green, container compiles (no cycle), no route regression, cs-fixer clean.
2026-06-20 20:16:20 +02:00
Matthieu bb7d7e7953 feat(mail) : extract Mail front into Nuxt module layer
LST-67 (2.5) front. Completes the Mail module.

- New frontend/modules/mail/ layer (auto-detected): /mail page (3 columns),
  7 components, mail service + DTO, mail store (folders/messages/unread polling).
- sanitizeMailHtml util and useSystemFolderLabel composable stay global;
  AdminMailTab stays in /admin (service import repointed).
- Consumers repointed: AdminMailTab and PM TaskModal -> ~/modules/mail/...;
  the store is auto-imported (Pinia storesDirs) so the layout badge/polling is
  unchanged.
- /mail gated by the mail module: sidebar.php item with module=mail (so
  SidebarFilter disables /mail when the module is off); the layout filters /mail
  from the API sections to avoid a visual duplicate. ROLE_CLIENT exclusion kept.
- i18n key sidebar.general.mail added.

nuxt build passes; /mail and all other routes preserved.
2026-06-20 19:52:13 +02:00
Matthieu 25d3a693f9 feat(mail) : migrate Mail integration into module (back)
LST-67 (2.5) backend. Behaviour-preserving move of the IMAP mail integration
into src/Module/Mail/. All /api/mail/* routes, securities (ROLE_CLIENT still
excluded via MailAccessChecker) and the async sync are unchanged.

- 4 entities + 4 repositories (Domain interfaces + Doctrine impls, bound).
  TaskMailLink.task now references TaskInterface (contract) instead of the
  concrete PM Task. Link/unlink/list-mails controllers load tasks via
  TaskRepositoryInterface; MailCreateTaskController keeps the concrete Task
  (instantiation) — documented Mail->PM coupling.
- Domain (MailProviderInterface, exception), Application (5 DTOs, MailSyncService,
  MailSyncRequested message + handler), Infrastructure (ImapMailProvider +
  MimeHeaderDecoder, MailAccessChecker, 2 console commands, 12 controllers,
  ApiPlatform state + MailSettings resource). TokenEncryptor stays shared.
- doctrine mapping Mail; messenger routing repointed; services.yaml repo +
  provider bindings; MailModule registered (id mail, mail.access/configure).
- #[Auditable] + Timestampable on MailConfiguration only (additive migration);
  IMAP data entities keep their own sync timestamps.

163 tests green, mapping valid, no route regression, cs-fixer clean.
2026-06-20 19:44:19 +02:00
Matthieu 57ccd9a740 feat(directory) : add Clients/Prospects repertoire front layer
LST-58 (2.4) front. Completes the Directory module.

- New frontend/modules/directory/ layer (auto-detected): /directory page with
  Clients and Prospects tabs.
- Client front moved into the layer (clients service + client DTO +
  ClientDrawer). New prospects service, prospect DTO and ProspectDrawer (with
  a "Convert to client" action calling POST /prospects/{id}/convert).
- Consumers repointed to ~/modules/directory/... (admin client tab, PM project
  drawer + project pages + project DTO, time-tracking page + export drawer).
- Sidebar admin item /directory gated by the directory module; /directory
  protected by the admin middleware. i18n keys added (directory.*, prospects.*).

nuxt build passes; routes preserved.

Adds the 2.4 plan doc.
2026-06-20 19:18:09 +02:00
Matthieu d42b288434 feat(directory) : add Prospect entity with conversion to Client (back)
LST-58 (2.4), part 2 — Prospect (new entity). Completes the Directory backend.

- ProspectStatus enum (new/contacted/qualified/won/lost) + Prospect entity
  (name, company, email, phone, address, status, source, notes,
  convertedClient -> ClientInterface) with Timestampable/Blamable + #[Auditable].
- API: GetCollection/Get (ROLE_USER), Post/Patch/Delete (ROLE_ADMIN),
  custom POST /prospects/{id}/convert (ConvertProspectProcessor: creates a
  Client from the prospect, links convertedClient, sets status=Won; idempotent).
  SearchFilter on status.
- Repository interface + Doctrine impl (bound); 6 MCP tools (list/get/create/
  update/delete/convert-prospect); Serializer::prospect(). Module perms
  directory.prospects.view/manage. Demo fixtures (3 prospects, one converted).
- Additive migration: CREATE TABLE prospect + FKs ON DELETE SET NULL + COMMENT.

163 tests green (incl. conversion test), mapping valid, cs-fixer clean.
2026-06-20 19:09:12 +02:00
Matthieu c5738d269b feat(directory) : migrate Client into Directory module (back)
LST-58 (2.4), part 1/2 — Client move. Prospect + repertoire front are pending
the product spec and will be added on this branch afterward.

- Client entity moved to src/Module/Directory/Domain/Entity; repository split
  into Domain/Repository/ClientRepositoryInterface + Doctrine impl (bound in
  services.yaml). 5 client MCP tools moved to Infrastructure/Mcp/Tool, now
  injecting the interface.
- resolve_target_entities ClientInterface repointed to Directory\Client;
  Directory mapping added; DirectoryModule registered (id directory, 2 RBAC
  perms). Client.projects relation now uses ProjectInterface -> Directory no
  longer depends on ProjectManagement.
- ProjectManagement Create/UpdateProjectTool inject Directory's
  ClientRepositoryInterface; Serializer and fixtures repointed.
- Garde-fous: #[Auditable] + Timestampable/Blamable on Client (additive
  migration: created_at/updated_at + created_by/updated_by FK ON DELETE SET
  NULL + COMMENT).

161 tests green, mapping valid, no API route regression, cs-fixer clean.
2026-06-20 18:51:49 +02:00
Matthieu 163bf0891a feat(absence) : extract Absence front into Nuxt module layer
LST-66 (2.3) front. Companion to the backend module migration.

- Move pages (absences, team-absences), 8 components, the absences service +
  DTO and the useAbsenceHelpers composable into frontend/modules/absence/
  (auto-detected layer; composable now auto-imported).
- Rewrite consumers: AdminAbsencePolicyTab and the time-tracking calendar
  (getPublicHolidays) point to ~/modules/absence/...
- Middlewares (employee/admin) and shared services (clients, users,
  user-data DTO) stay at the root. i18n stays global.
- Routes /absences and /team-absences preserved.

nuxt build passes; routes confirmed.
2026-06-20 18:36:48 +02:00
Matthieu 306cfd34cd feat(absence) : migrate Absence domain into module (back)
LST-66 (2.3) backend. Behaviour-preserving move of the absences domain into
src/Module/Absence/. API operations, securities, routes and the 10 MCP tool
names are unchanged.

- 3 entities + 3 enums moved to Domain/{Entity,Enum}; user relations stay on
  UserInterface. 3 repositories split into Domain/Repository interfaces +
  Doctrine impls (bound in services.yaml); find() kept off interfaces
  (findById instead).
- Pure services (AbsenceDayCalculator, PublicHolidayProvider) -> Domain/Service;
  AbsenceBalanceService -> Application/Service; State (5), controllers (5),
  10 MCP tools and AccrueLeaveCommand -> Infrastructure/.
- New LeaveProfileInterface contract (Shared) exposes the HR getters used by
  AbsenceBalanceService/AccrueLeaveCommand; User implements it -> Absence no
  longer imports the concrete Core User. MCP tools/command inject
  UserRepositoryInterface (findById) instead of the concrete repository.
- Timestampable/Blamable added to AbsenceBalance and AbsencePolicy (additive
  migration: created_at/updated_at + created_by/updated_by FK ON DELETE SET
  NULL + COMMENT). AbsenceRequest untouched (already has createdAt/reviewedAt).
- AbsenceModule registered (id absence, 4 RBAC perms, not re-wired); doctrine
  mapping added; team-absences sidebar item gated by the module.

161 tests green, mapping valid, no API route regression, cs-fixer clean.
2026-06-20 18:32:02 +02:00
Matthieu 7446b7dca9 feat(project-management) : extract Projects/Tasks front into Nuxt module layer
Tranche 4 of LST-65. Companion to the backend module migration.

- Move pages (my-tasks, projects, projects/[id]/{index,groups,archives}),
  18 components (project + task), 10 services and 10 DTOs into
  frontend/modules/project-management/ (auto-detected layer).
- Rewrite explicit ~/services/* and ~/services/dto/* imports across 38
  consumers (admin tabs, mail modals, dashboard, mail page, layout) including
  the time-tracking module whose DTOs referenced project/task/task-tag.
- clients.ts and shared DTOs (client, user-data) stay at the root.
- Routes /my-tasks, /projects, /projects/:id(/groups|/archives) preserved;
  i18n stays global.

nuxt build passes; routes confirmed.
2026-06-20 17:06:13 +02:00
Matthieu c90d91d6c4 feat(project-management) : add timestampable/blamable to Task and Project (additive)
Tranche 3 of LST-65. Task and Project adopt TimestampableBlamableTrait.

- Additive migration on task and project: created_at/updated_at (nullable),
  created_by/updated_by (nullable INT, FK to "user" ON DELETE SET NULL) +
  indexes + COMMENT ON COLUMN. down() drops only the added objects.
- Trait fields stay out of the existing API groups (trait carries its own).
- Functional test (TaskTimestampableTest) confirms created_at on persist and
  updated_at refresh on update.

161 tests green, no destructive migration.
2026-06-20 17:05:47 +02:00
Matthieu 23809f165e feat(project-management) : migrate core Projects/Tasks domain into module (back)
Tranche 2 of LST-65. Mechanical, behaviour-preserving move of the core
business domain into src/Module/ProjectManagement/. API operations,
securities, uriTemplates and the 38 MCP tool names are all unchanged.

- 10 entities + 2 enums moved to Domain/{Entity,Enum}; intra-module
  relations stay concrete, cross-module relations go through contracts
  (Project.client -> ClientInterface, Task/TaskDocument users ->
  UserInterface).
- 9 repositories split into Domain/Repository interfaces + Doctrine impls,
  bound in services.yaml; consumers inject the interfaces. find() kept off
  the interfaces (ServiceEntityRepository ?object compat) -> findById().
- State (7), MCP tools (38), controller, CalDavService/RecurrenceCalculator,
  3 Doctrine listeners and SwitchWorkflowOutput moved under Infrastructure/.
- doctrine.yaml: ProjectManagement mapping + resolve_target_entities of the
  3 module contracts repointed to the module (ClientInterface stays legacy).
- ProjectManagementModule registered (id project-management, 4 RBAC perms,
  not re-wired); sidebar my-tasks/projects gated by the module.
- Legacy not-yet-modularised consumers (Mail/Gitea/BookStack, Serializer,
  fixtures, tests) swapped to the module FQCN — transitional coupling to be
  cleaned in 2.4/2.5/2.6.

159 tests green, mapping valid, no API route regression, cs-fixer clean.
2026-06-20 16:54:59 +02:00
Matthieu f119ec30ca refactor(project-management) : introduce Project/Task/TaskTag/Client contracts
Tranche 1 of LST-65 (ProjectManagement module migration). Decouples the
TimeTracking module from the core-business entities before they move, with
no entity relocation yet — keeps the diff minimal and the risk isolated.

- New read contracts in Shared/Domain/Contract (minimal surface, aligned on
  the entities' real nullable signatures): ProjectInterface (id/code/name),
  TaskInterface (id/number/title), TaskTagInterface (id/label/color),
  ClientInterface (id/name).
- Project/Task/TaskTag/Client implement their contract (entities stay in
  src/Entity for now). Project.client typed as ClientInterface.
- TimeEntry (TimeTracking) now references ProjectInterface/TaskInterface/
  TaskTagInterface instead of the concrete entities; repository + DQL
  untouched in behaviour.
- resolve_target_entities maps the 4 contracts to the legacy entities (will
  be repointed to the module in tranche 2).
- Adds the migration plan doc.

159 tests green, mapping valid, cs-fixer clean.
2026-06-20 16:34:15 +02:00
Matthieu 1b652ef680 feat(time-tracking) : extract time-tracking front into Nuxt module layer
Companion to the backend module migration (LST-64). The Nuxt layer is
auto-detected from frontend/modules/* — no nuxt.config change needed.

- Move page, timer store, time-entries service + DTO and the 6 time-tracking
  components into frontend/modules/time-tracking/.
- Rewrite explicit service/DTO imports to ~/modules/time-tracking/* (store and
  components stay auto-imported); update the dashboard (index.vue) consumer.
- Route /time-tracking preserved; i18n keys kept in the global locale file.

nuxt build passes; /time-tracking routed.
2026-06-20 16:16:49 +02:00
Matthieu d1516c3f5d feat(time-tracking) : migrate TimeEntry into TimeTracking module (back)
First business module of Phase 2 (LST-64, rodage). Strangler-style,
additive move — no behavioural change to the public API or MCP tools.

- New module App\Module\TimeTracking (TimeTrackingModule, id "time-tracking",
  declares time-tracking.entries.view/export permissions in the RBAC catalog;
  operation security left on ROLE_USER, not re-wired here).
- Move TimeEntry entity, repository (now interface + Doctrine impl bound in
  services.yaml), ActiveTimeEntryProvider, export service/controller and the
  4 MCP TimeEntry tools into the module. #[ApiResource] (operations, security,
  uriTemplates /time_entries/*), filters and serialization groups preserved.
- Doctrine mapping "TimeTracking" added; table time_entry unchanged.
- Sidebar item gated with module "time-tracking" (SidebarFilter disables the
  route when the module is inactive).
- Timestampable/Blamable adopted (first adopter): additive migration adds
  created_at/updated_at/created_by/updated_by (nullable, FK SET NULL) +
  COMMENT ON COLUMN. Functional test confirms created_at on persist and
  updated_at refresh on update — the suspected preUpdate recompute issue does
  not occur (Doctrine ORM 3.6.2 recomputes change sets after preUpdate).

159 tests green, schema mapping valid, php-cs-fixer clean.
2026-06-20 16:16:13 +02:00
Matthieu a88cb1bc35 fix(core) : harden review findings (me-provider null guard, audit-ignore plainpassword, rbac self-edit guard, module id dedup, audit pagination guard) 2026-06-19 22:39:26 +02:00
Matthieu 7686904c43 docs : log LST-61 audit log session learnings 2026-06-19 21:19:27 +02:00
Matthieu 9b26b43aca fix(core) : align audit entity-types front service with single-resource api shape 2026-06-19 21:18:22 +02:00
Matthieu e7af415a1f feat(core) : add audit log consultation tab in admin gated by permission 2026-06-19 21:15:13 +02:00
Matthieu 90b8ca15cd feat(core) : expose read-only audit-logs api with dbal provider and pagination 2026-06-19 21:09:55 +02:00
Matthieu 8c3699a9b0 feat(core) : add doctrine audit listener and mark core entities auditable 2026-06-19 21:05:34 +02:00
Matthieu d8553f06f5 feat(core) : add audit log writer and request id provider 2026-06-19 21:01:15 +02:00
Matthieu 934cf0835f feat(core) : add audit attributes, audit_log table and dedicated dbal connection 2026-06-19 20:56:32 +02:00
Matthieu fda03bd1f5 docs : add LST-61 audit log implementation plan 2026-06-19 20:53:36 +02:00
Matthieu 4760c386ed docs : log LST-57 rbac fin session learnings 2026-06-19 17:38:26 +02:00
Matthieu 511353c3f5 feat(core) : add usePermissions composable and rbac roles admin front 2026-06-19 17:35:51 +02:00
Matthieu 544d4cf44f feat(core) : gate sidebar by effective permissions 2026-06-19 17:28:42 +02:00
Matthieu 1a9eba93a0 feat(core) : add rbac seeder and seed-rbac command for system roles 2026-06-19 17:22:42 +02:00
Matthieu 48c67a5fb9 feat(core) : expose role and user-rbac api endpoints with processors 2026-06-19 17:16:38 +02:00
Matthieu 5060fb689b feat(core) : add permission voter and expose effective permissions on /api/me 2026-06-19 17:03:34 +02:00
Matthieu ac662e701b feat(core) : aggregate module permissions and add sync-permissions command 2026-06-19 17:00:14 +02:00
Matthieu ffed224979 feat(core) : add rbac role and permission entities with user relations 2026-06-19 16:56:07 +02:00
Matthieu fdc72573ea docs : add implementation plan for rbac fin (LST-57 / 1.2) 2026-06-19 16:47:04 +02:00
Matthieu 52de07ce23 docs : log LST-63 module core session learnings 2026-06-19 16:34:02 +02:00
Matthieu 117c2ff2e3 feat(core) : add core front layer with login and profile pages 2026-06-19 16:31:42 +02:00
Matthieu a98ea3df37 feat(core) : activate core module in modules registry 2026-06-19 16:27:10 +02:00
Matthieu f1a9b42930 feat(core) : move notification into core and expose notifier contract 2026-06-19 16:25:03 +02:00
Matthieu 0b4874e94d refactor(core) : move user repository/providers to core and migrate all consumers off App\Entity\User 2026-06-19 16:16:44 +02:00
Matthieu d70925b812 refactor(core) : point user relations to the shared contract via resolve_target_entities 2026-06-19 16:04:14 +02:00
Matthieu f8fc4d6bd9 feat(core) : move user entity into core module and repoint security/doctrine (temp legacy alias) 2026-06-19 16:03:52 +02:00
Matthieu 6ca91cbd3b feat(core) : add CoreModule, user repository contract, notifier contract and enriched user contract 2026-06-19 15:53:38 +02:00
Matthieu 8865bf51e6 docs : add implementation plan for module core (LST-63 / 1.1) 2026-06-19 15:50:32 +02:00
Matthieu d1a980d1c2 docs : log LST-62 socle front session learnings 2026-06-19 15:37:03 +02:00
Matthieu fdcf8df518 feat(front) : add sidebar i18n labels 2026-06-19 15:33:59 +02:00
Matthieu 977e74f669 feat(front) : render dynamic sidebar from /api/sidebar in default layout 2026-06-19 15:32:23 +02:00
Matthieu a620833550 feat(front) : load sidebar/modules after login and redirect disabled routes 2026-06-19 15:28:16 +02:00
Matthieu fcfb16fc5b docs : correct LST-62 front verification gate (typecheck is not green on this stack) 2026-06-19 15:25:39 +02:00
Matthieu b00e92bdd3 feat(front) : modular nuxt config with app/ shell dirs and modules/* layer auto-detection 2026-06-19 15:24:57 +02:00
Matthieu 1aa43a5356 refactor(front) : move useApi and shared stores (auth, ui) to shared/ 2026-06-19 15:06:50 +02:00
Matthieu 51de96c797 feat(front) : add shared useModules/useSidebar composables and sidebar types 2026-06-19 15:05:35 +02:00
Matthieu 0ee82c8b62 feat(sidebar) : add role gate to sidebar provider and global nav config 2026-06-19 15:03:45 +02:00
Matthieu 111f37a0c9 docs : add implementation plan for socle front (LST-62 / 0.2) 2026-06-19 15:00:23 +02:00
Matthieu 5fbdda1983 docs : log LST-56 socle back session learnings 2026-06-19 15:00:17 +02:00
Matthieu b301c543bb feat(shared) : add column comments catalog helper for migrations 2026-06-19 14:38:40 +02:00
Matthieu 3053c09522 feat(shared) : add timestampable/blamable trait and doctrine subscriber 2026-06-19 14:37:28 +02:00
Matthieu 52399b35d9 feat(sidebar) : expose GET /api/sidebar filtered by active modules 2026-06-19 14:35:17 +02:00
Matthieu 748289b61a feat(modules) : expose GET /api/modules and module registry 2026-06-19 14:33:53 +02:00
Matthieu 2d0e9de155 docs : add implementation plan for socle back (LST-56 / 0.1)
Plan TDD en 4 tâches : endpoints /api/modules et /api/sidebar, garde-fou Timestampable/Blamable, helper ColumnCommentsCatalog.
2026-06-19 10:56:27 +02:00
Matthieu a510b2ca73 docs : add modular monolith migration roadmap and socle design
Plan de migration complet Lesstime vers modular monolith DDD (archi Starseed) : roadmap en 14 tickets ordonnés par dépendances + design technique détaillé du socle (Shared/, contrats, endpoints modules/sidebar, plan strangler).
2026-06-19 10:50:14 +02:00
45 changed files with 425 additions and 1456 deletions
-6
View File
@@ -126,12 +126,6 @@ La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action.
- Config Docker : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`) - Config Docker : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
- Après modif nginx : `docker restart nginx-lesstime` - Après modif nginx : `docker restart nginx-lesstime`
## Déploiement (prod Docker)
- Script : `infra/prod/deploy.sh` (`./deploy.sh [tag]`) — doc complète : `doc/deployment-docker.md`
- Étapes : maintenance → pull image → up → migrations → **`app:seed-rbac`** → **`app:sync-permissions`** → cache clear/warmup
- **RBAC** : les migrations créent les tables `role`/`permission` mais **n'insèrent aucune donnée**. Les rôles système (`admin`, `user`) viennent de `app:seed-rbac` (idempotent) et le catalogue des permissions de `app:sync-permissions` (à relancer à chaque ajout de permission). Symptôme si oubliées : page admin Rôles vide (« Aucun rôle trouvé »).
## Fixtures ## Fixtures
- User admin : `admin` / `admin` (ROLE_ADMIN) - User admin : `admin` / `admin` (ROLE_ADMIN)
+3 -3
View File
@@ -23,9 +23,9 @@ return [
'icon' => 'mdi:view-dashboard-outline', 'icon' => 'mdi:view-dashboard-outline',
'items' => [ 'items' => [
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'], ['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline', 'module' => 'project-management', 'permission' => 'project-management.tasks.view'], ['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline', 'module' => 'project-management'],
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline', 'module' => 'project-management', 'permission' => 'project-management.projects.view'], ['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline', 'module' => 'project-management'],
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking', 'permission' => 'time-tracking.entries.view'], ['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking'],
// Gating module uniquement (cf. en-tête) : rendu visuel + badge gérés côté layout. // Gating module uniquement (cf. en-tête) : rendu visuel + badge gérés côté layout.
['label' => 'sidebar.general.mail', 'to' => '/mail', 'icon' => 'mdi:email-outline', 'module' => 'mail'], ['label' => 'sidebar.general.mail', 'to' => '/mail', 'icon' => 'mdi:email-outline', 'module' => 'mail'],
], ],
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.4.36' app.version: '0.4.30'
+1 -31
View File
@@ -128,12 +128,6 @@ sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintena
echo "==> Running migrations..." echo "==> Running migrations..."
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
echo "==> Seeding RBAC system roles (idempotent)..."
sudo docker compose exec -T -u www-data app php bin/console app:seed-rbac
echo "==> Syncing RBAC permissions catalog..."
sudo docker compose exec -T -u www-data app php bin/console app:sync-permissions
echo "==> Clearing cache..." echo "==> Clearing cache..."
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
@@ -300,31 +294,7 @@ cd /var/www/lesstime
./deploy.sh v0.3.13 # deploie une version specifique ./deploy.sh v0.3.13 # deploie une version specifique
``` ```
C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations, seed les roles C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations et vide le cache.
systeme RBAC, synchronise le catalogue des permissions et vide le cache.
---
## RBAC : roles & permissions (post-deploiement)
Le module RBAC (entites `Role` / `Permission`) repose sur des donnees qui ne sont **pas**
inserees par les migrations (celles-ci creent uniquement les tables). Deux commandes idempotentes
les peuplent, integrees au `deploy.sh` :
| Commande | Effet |
|----------|-------|
| `app:seed-rbac` | Cree les **roles systeme** `admin` (Administrateur) et `user` (Utilisateur). Idempotent : ne recree rien si deja present. |
| `app:sync-permissions` | (Re)synchronise le **catalogue des permissions** a partir des modules actifs. A relancer a chaque ajout de permission dans le code. |
Symptome si elles n'ont pas tourne : la page d'admin **Roles** affiche « Aucun role trouve ».
Correctif manuel sur une prod deja deployee (sans relancer un deploiement complet) :
```bash
cd /var/www/lesstime
sudo docker compose exec -T -u www-data app php bin/console app:seed-rbac
sudo docker compose exec -T -u www-data app php bin/console app:sync-permissions
```
--- ---
@@ -1,61 +0,0 @@
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-[70] flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click.stop="cancel" />
<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('directory.reports.confirmDeleteTitle') }}</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ $t('directory.reports.confirmDeleteMessage') }}
</p>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
:disabled="busy"
@click="cancel"
/>
<MalioButton
variant="danger"
:label="$t('common.delete')"
button-class="w-auto px-4"
:disabled="busy"
@click="$emit('confirm')"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
const props = defineProps<{
modelValue: boolean
// Suppression en cours : on désactive les actions pour éviter un double envoi.
busy?: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
}>()
function cancel() {
if (props.busy) return
emit('update:modelValue', false)
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>
+2 -25
View File
@@ -24,9 +24,7 @@
"updated": "Client mis à jour avec succès.", "updated": "Client mis à jour avec succès.",
"deleted": "Client supprimé avec succès.", "deleted": "Client supprimé avec succès.",
"addClient": "Ajouter un client", "addClient": "Ajouter un client",
"editClient": "Modifier un client", "editClient": "Modifier un client"
"deleteConfirmTitle": "Supprimer le client",
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer le client « {name} » ? Cette action est irréversible."
}, },
"projects": { "projects": {
"title": "Projets", "title": "Projets",
@@ -910,8 +908,6 @@
"editProspect": "Modifier un prospect", "editProspect": "Modifier un prospect",
"convert": "Convertir en client", "convert": "Convertir en client",
"alreadyConverted": "Déjà converti en client", "alreadyConverted": "Déjà converti en client",
"deleteConfirmTitle": "Supprimer le prospect",
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer le prospect « {name} » ? Cette action est irréversible.",
"fields": { "fields": {
"name": "Nom", "name": "Nom",
"company": "Société", "company": "Société",
@@ -938,24 +934,12 @@
"directory": { "directory": {
"title": "Répertoire", "title": "Répertoire",
"tabs": { "tabs": {
"info": "Informations",
"clients": "Clients", "clients": "Clients",
"prospects": "Prospects", "prospects": "Prospects",
"contact": "Contact", "contact": "Contact",
"address": "Adresse", "address": "Adresse",
"report": "Rapport" "report": "Rapport"
}, },
"info": {
"fields": {
"name": "Nom",
"email": "Email",
"phone": "Téléphone"
}
},
"validation": {
"nameRequired": "Le nom est requis.",
"subjectRequired": "L'objet est requis."
},
"clients": { "clients": {
"add": "Ajouter un client", "add": "Ajouter un client",
"empty": "Aucun client trouvé." "empty": "Aucun client trouvé."
@@ -994,16 +978,9 @@
}, },
"reports": { "reports": {
"add": "Ajouter un compte-rendu", "add": "Ajouter un compte-rendu",
"addTitle": "Nouveau compte-rendu", "empty": "Aucun compte-rendu.",
"editTitle": "Modifier le compte-rendu",
"empty": "Aucun compte-rendu",
"emptyHint": "Consignez vos échanges (appels, rendez-vous, emails) pour garder l'historique de la relation.",
"count": "{n} compte-rendu | {n} comptes-rendus",
"documentsLabel": "Documents",
"saved": "Compte-rendu enregistré.", "saved": "Compte-rendu enregistré.",
"deleted": "Compte-rendu supprimé.", "deleted": "Compte-rendu supprimé.",
"confirmDeleteTitle": "Supprimer ce compte-rendu ?",
"confirmDeleteMessage": "Cette action est irréversible. Les documents joints seront également supprimés.",
"fields": { "fields": {
"subject": "Objet", "subject": "Objet",
"type": "Type d'échange", "type": "Type d'échange",
@@ -6,11 +6,21 @@
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.name" v-model="form.name"
label="Nom société" label="Nom"
input-class="w-full" input-class="w-full"
:error="touched.name && !form.name.trim() ? 'Le nom est requis' : ''" :error="touched.name && !form.name.trim() ? 'Le nom est requis' : ''"
@blur="touched.name = true" @blur="touched.name = true"
/> />
<MalioInputText
v-model="form.email"
label="Email"
input-class="w-full"
/>
<MalioInputText
v-model="form.phone"
label="Téléphone"
input-class="w-full"
/>
<div class="mt-6 flex justify-end"> <div class="mt-6 flex justify-end">
<MalioButton <MalioButton
@@ -48,16 +58,28 @@ const isSubmitting = ref(false)
const form = reactive({ const form = reactive({
name: '', name: '',
email: '',
phone: '',
}) })
const touched = reactive({ const touched = reactive({
name: false, name: false,
email: false,
}) })
watch(() => props.modelValue, (open) => { watch(() => props.modelValue, (open) => {
if (open) { if (open) {
form.name = props.client?.name ?? '' if (props.client) {
form.name = props.client.name ?? ''
form.email = props.client.email ?? ''
form.phone = props.client.phone ?? ''
} else {
form.name = ''
form.email = ''
form.phone = ''
}
touched.name = false touched.name = false
touched.email = false
} }
}) })
@@ -71,6 +93,8 @@ async function handleSubmit() {
try { try {
const payload: ClientWrite = { const payload: ClientWrite = {
name: form.name.trim(), name: form.name.trim(),
email: form.email.trim() || null,
phone: form.phone.trim() || null,
} }
if (isEditing.value && props.client) { if (isEditing.value && props.client) {
@@ -1,144 +0,0 @@
<template>
<MalioDrawer v-model="isOpen">
<template #header>
<h2 class="text-xl font-bold">
{{ isEditing ? $t('directory.reports.editTitle') : $t('directory.reports.addTitle') }}
</h2>
</template>
<form @submit.prevent="handleSubmit" class="flex flex-col gap-4">
<MalioInputText
v-model="form.subject"
:label="$t('directory.reports.fields.subject')"
input-class="w-full"
:error="touched.subject && !form.subject.trim() ? $t('directory.validation.subjectRequired') : ''"
@blur="touched.subject = true"
/>
<MalioSelect
v-model="form.type"
:label="$t('directory.reports.fields.type')"
:options="typeOptions"
group-class="w-full"
/>
<MalioDate
v-model="form.occurredAt"
:label="$t('directory.reports.fields.occurredAt')"
/>
<MalioInputRichText
v-model="form.body"
:label="$t('directory.reports.fields.body')"
min-height="180px"
/>
<div class="mt-4 flex justify-end gap-3">
<MalioButton
variant="tertiary"
button-class="w-auto px-4"
:label="$t('common.cancel')"
@click="isOpen = false"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="isSubmitting"
@click="handleSubmit"
/>
</div>
</form>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { CommercialReport, CommercialReportWrite, ReportType } from '~/modules/directory/services/dto/commercial-report'
import { useCommercialReportService } from '~/modules/directory/services/commercial-reports'
const props = defineProps<{
modelValue: boolean
report: CommercialReport | null
owner: { client?: string, prospect?: string }
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved'): void
}>()
const { t } = useI18n()
const { create, update } = useCommercialReportService()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const isEditing = computed(() => !!props.report)
const isSubmitting = ref(false)
const typeOptions: { label: string, value: ReportType }[] = [
{ label: t('directory.reports.types.call'), value: 'call' },
{ label: t('directory.reports.types.meeting'), value: 'meeting' },
{ label: t('directory.reports.types.email'), value: 'email' },
{ label: t('directory.reports.types.note'), value: 'note' },
]
function today(): string {
return new Date().toISOString().slice(0, 10)
}
// L'éditeur riche émet du HTML : un contenu « vide » vaut `<p></p>`. On le
// normalise en null pour ne pas persister une coquille vide.
function normalizeBody(html: string): string | null {
const stripped = html.replace(/<[^>]*>/g, '').replace(/&nbsp;/g, ' ').trim()
return stripped ? html : null
}
const form = reactive<{ subject: string, type: ReportType, occurredAt: string, body: string }>({
subject: '',
type: 'note',
occurredAt: today(),
body: '',
})
const touched = reactive({ subject: false })
watch(() => props.modelValue, (open) => {
if (!open) return
if (props.report) {
form.subject = props.report.subject
form.type = props.report.type
form.occurredAt = props.report.occurredAt.slice(0, 10)
form.body = props.report.body ?? ''
} else {
form.subject = ''
form.type = 'note'
form.occurredAt = today()
form.body = ''
}
touched.subject = false
})
async function handleSubmit(): Promise<void> {
touched.subject = true
if (!form.subject.trim() || isSubmitting.value) return
isSubmitting.value = true
try {
const payload: CommercialReportWrite = {
subject: form.subject.trim(),
type: form.type,
occurredAt: form.occurredAt,
body: normalizeBody(form.body),
...props.owner,
}
if (isEditing.value && props.report) {
await update(props.report.id, payload)
} else {
await create(payload)
}
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
</script>
@@ -1,235 +1,158 @@
<template> <template>
<div class="flex flex-col gap-5 pt-6"> <div class="flex flex-col gap-6 pt-6">
<!-- Barre d'action --> <!-- Formulaire d'ajout / édition -->
<div class="flex items-center justify-between gap-3"> <div v-if="isAdmin" class="grid grid-cols-2 gap-x-8 gap-y-3 rounded bg-white p-4 shadow">
<p class="text-sm text-neutral-500"> <MalioInputText
<span v-if="reports.length">{{ $t('directory.reports.count', { n: reports.length }, reports.length) }}</span> class="col-span-2"
</p> :label="$t('directory.reports.fields.subject')"
<MalioButton v-model="draft.subject"
v-if="canManage"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.reports.add')"
@click="openCreate"
/> />
<MalioSelect
:label="$t('directory.reports.fields.type')"
v-model="draft.type"
:options="typeOptions"
group-class="w-full"
/>
<MalioDate
:label="$t('directory.reports.fields.occurredAt')"
v-model="draft.occurredAt"
/>
<MalioInputTextArea
class="col-span-2"
:label="$t('directory.reports.fields.body')"
v-model="draft.body"
/>
<div class="col-span-2 flex justify-end gap-3">
<MalioButton
v-if="editingId"
variant="secondary"
button-class="w-auto px-4"
:label="$t('common.cancel')"
@click="resetDraft"
/>
<MalioButton
button-class="w-auto px-4"
:label="editingId ? $t('common.save') : $t('directory.reports.add')"
:disabled="!draft.subject"
@click="save"
/>
</div>
</div> </div>
<!-- État vide --> <!-- Liste des comptes-rendus -->
<div <div v-for="report in reports" :key="report.id" class="rounded border border-neutral-200 p-4">
v-if="!loading && !reports.length" <div class="flex items-start justify-between">
class="flex flex-col items-center gap-2 rounded-lg border border-dashed border-neutral-200 bg-neutral-50 px-6 py-12 text-center" <div>
> <p class="font-semibold text-neutral-800">{{ report.subject }}</p>
<Icon name="mdi:message-text-outline" class="text-4xl text-neutral-300" /> <p class="text-xs text-neutral-500">
<p class="font-medium text-neutral-600">{{ $t('directory.reports.empty') }}</p> {{ formatDate(report.occurredAt) }} · {{ $t(`directory.reports.types.${report.type}`) }}
<p class="max-w-sm text-sm text-neutral-400">{{ $t('directory.reports.emptyHint') }}</p> <span v-if="report.author"> · {{ report.author.username }}</span>
<MalioButton </p>
v-if="canManage" </div>
variant="tertiary" <div v-if="isAdmin" class="flex gap-2">
icon-name="mdi:plus" <MalioButtonIcon icon="mdi:pencil-outline" :aria-label="$t('common.edit')" @click="edit(report)" />
icon-position="left" <MalioButtonIcon icon="mdi:trash-can-outline" button-class="!text-red-600" :aria-label="$t('common.delete')" @click="remove(report.id)" />
button-class="mt-2 w-auto px-4" </div>
:label="$t('directory.reports.add')" </div>
@click="openCreate" <p v-if="report.body" class="mt-2 whitespace-pre-wrap text-sm text-neutral-700">{{ report.body }}</p>
/>
<div class="mt-3 flex flex-col gap-2">
<ReportDocumentList
:documents="report.documents ?? []"
:is-admin="isAdmin"
@delete="(id) => removeDocument(report, id)"
/>
<ReportDocumentUpload
v-if="isAdmin"
:report-id="report.id"
@uploaded="reload"
/>
</div>
</div> </div>
<!-- Timeline antéchronologique --> <p v-if="!reports.length" class="text-sm text-neutral-400">
<ol v-else class="flex flex-col"> {{ $t('directory.reports.empty') }}
<li </p>
v-for="report in sortedReports"
:key="report.id"
class="relative flex gap-4 pb-6 last:pb-0"
>
<!-- Rail + pastille de type -->
<div class="flex flex-col items-center">
<span
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full"
:class="typeStyle(report.type).badge"
>
<Icon :name="typeStyle(report.type).icon" class="text-lg" />
</span>
<span class="mt-1 w-px grow bg-neutral-200" aria-hidden="true" />
</div>
<!-- Carte -->
<div class="flex-1 rounded-lg border border-neutral-200 bg-white p-4 shadow-[0_1px_2px_0_rgba(0,0,0,0.05)]">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span
class="rounded-full px-2 py-0.5 text-xs font-medium"
:class="typeStyle(report.type).chip"
>
{{ $t(`directory.reports.types.${report.type}`) }}
</span>
<p class="truncate font-semibold text-neutral-800">{{ report.subject }}</p>
</div>
<p class="mt-1 text-xs text-neutral-500">
<span :title="absoluteDate(report.occurredAt)">{{ relativeDate(report.occurredAt) }}</span>
<span v-if="report.author"> · {{ report.author.username }}</span>
</p>
</div>
<div v-if="canManage" class="flex shrink-0 gap-1">
<MalioButtonIcon
icon="mdi:pencil-outline"
variant="ghost"
:aria-label="$t('common.edit')"
@click="openEdit(report)"
/>
<MalioButtonIcon
icon="mdi:delete-outline"
variant="ghost"
:aria-label="$t('common.delete')"
@click="askDelete(report)"
/>
</div>
</div>
<MalioInputRichText
v-if="report.body"
:model-value="report.body"
:editable="false"
:reserve-message-space="false"
editor-class="!border-0 !rounded-none !bg-transparent !p-0 text-sm text-neutral-700"
class="mt-2"
/>
<!-- Documents joints -->
<div
v-if="(report.documents?.length ?? 0) || canManage"
class="mt-3 border-t border-neutral-100 pt-3"
>
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-neutral-400">
{{ $t('directory.reports.documentsLabel') }}
</p>
<div class="flex flex-col gap-2">
<ReportDocumentList
v-if="report.documents?.length"
:documents="report.documents"
:can-manage="canManage"
@delete="(docId) => removeDocument(docId)"
/>
<ReportDocumentUpload
v-if="canManage"
:report-id="report.id"
@uploaded="reload"
/>
</div>
</div>
</div>
</li>
</ol>
<CommercialReportDrawer
v-model="drawerOpen"
:report="editing"
:owner="owner"
@saved="reload"
/>
<ConfirmDeleteReportModal
v-model="confirmOpen"
:busy="deleting"
@confirm="confirmDelete"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { CommercialReport, ReportType } from '~/modules/directory/services/dto/commercial-report' import type { CommercialReport, CommercialReportWrite, ReportType } from '~/modules/directory/services/dto/commercial-report'
import { useCommercialReportService } from '~/modules/directory/services/commercial-reports' import { useCommercialReportService } from '~/modules/directory/services/commercial-reports'
import { useReportDocumentService } from '~/modules/directory/services/report-documents' import { useReportDocumentService } from '~/modules/directory/services/report-documents'
const props = defineProps<{ const props = defineProps<{
owner: { client?: string, prospect?: string } owner: { client?: string, prospect?: string }
canManage: boolean isAdmin: boolean
}>() }>()
const { t } = useI18n()
const reportService = useCommercialReportService() const reportService = useCommercialReportService()
const documentService = useReportDocumentService() const documentService = useReportDocumentService()
const reports = ref<CommercialReport[]>([]) const reports = ref<CommercialReport[]>([])
const loading = ref(true) const editingId = ref<number | null>(null)
const drawerOpen = ref(false) function emptyDraft(): CommercialReportWrite {
const editing = ref<CommercialReport | null>(null) return {
subject: '',
const confirmOpen = ref(false) body: null,
const pendingDelete = ref<CommercialReport | null>(null) occurredAt: new Date().toISOString().slice(0, 10),
const deleting = ref(false) type: 'note',
...props.owner,
// Le plus récent en haut (l'API ne garantit pas l'ordre).
const sortedReports = computed(() =>
[...reports.value].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)),
)
const typeStyles: Record<ReportType, { icon: string, badge: string, chip: string }> = {
call: { icon: 'mdi:phone-outline', badge: 'bg-emerald-100 text-emerald-700', chip: 'bg-emerald-50 text-emerald-700' },
meeting: { icon: 'mdi:account-group-outline', badge: 'bg-violet-100 text-violet-700', chip: 'bg-violet-50 text-violet-700' },
email: { icon: 'mdi:email-outline', badge: 'bg-sky-100 text-sky-700', chip: 'bg-sky-50 text-sky-700' },
note: { icon: 'mdi:note-text-outline', badge: 'bg-amber-100 text-amber-700', chip: 'bg-amber-50 text-amber-700' },
}
function typeStyle(type: ReportType) {
return typeStyles[type]
}
function startOfDay(d: Date): number {
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
}
function absoluteDate(iso: string): string {
return new Date(iso).toLocaleDateString('fr-FR')
}
// Date relative lisible (« aujourd'hui », « il y a 3 jours »…) avec repli sur la
// date absolue au-delà d'un an. La date exacte reste disponible en infobulle.
function relativeDate(iso: string): string {
const diffDays = Math.round((startOfDay(new Date(iso)) - startOfDay(new Date())) / 86400000)
const rtf = new Intl.RelativeTimeFormat('fr-FR', { numeric: 'auto' })
const abs = Math.abs(diffDays)
if (abs < 1) return rtf.format(0, 'day')
if (abs < 7) return rtf.format(diffDays, 'day')
if (abs < 31) return rtf.format(Math.round(diffDays / 7), 'week')
if (abs < 365) return rtf.format(Math.round(diffDays / 30), 'month')
return absoluteDate(iso)
}
function openCreate(): void {
editing.value = null
drawerOpen.value = true
}
function openEdit(report: CommercialReport): void {
editing.value = report
drawerOpen.value = true
}
function askDelete(report: CommercialReport): void {
pendingDelete.value = report
confirmOpen.value = true
}
async function confirmDelete(): Promise<void> {
if (!pendingDelete.value || deleting.value) return
deleting.value = true
try {
await reportService.remove(pendingDelete.value.id)
confirmOpen.value = false
pendingDelete.value = null
await reload()
} finally {
deleting.value = false
} }
} }
const draft = ref<CommercialReportWrite>(emptyDraft())
async function removeDocument(id: number): Promise<void> { const typeOptions: { label: string, value: ReportType }[] = [
await documentService.remove(id) { label: t('directory.reports.types.call'), value: 'call' },
await reload() { label: t('directory.reports.types.meeting'), value: 'meeting' },
{ label: t('directory.reports.types.email'), value: 'email' },
{ label: t('directory.reports.types.note'), value: 'note' },
]
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('fr-FR')
} }
async function reload(): Promise<void> { async function reload(): Promise<void> {
reports.value = await reportService.getByOwner(props.owner) reports.value = await reportService.getByOwner(props.owner)
loading.value = false }
function resetDraft(): void {
editingId.value = null
draft.value = emptyDraft()
}
function edit(report: CommercialReport): void {
editingId.value = report.id
draft.value = {
subject: report.subject,
body: report.body,
occurredAt: report.occurredAt.slice(0, 10),
type: report.type,
...props.owner,
}
}
async function save(): Promise<void> {
if (editingId.value) {
await reportService.update(editingId.value, draft.value)
} else {
await reportService.create(draft.value)
}
resetDraft()
await reload()
}
async function remove(id: number): Promise<void> {
await reportService.remove(id)
await reload()
}
async function removeDocument(report: CommercialReport, id: number): Promise<void> {
await documentService.remove(id)
await reload()
} }
onMounted(reload) onMounted(reload)
@@ -1,58 +0,0 @@
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancel" />
<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">{{ title }}</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ message }}
</p>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
@click="cancel"
/>
<MalioButton
variant="danger"
:label="$t('common.delete')"
button-class="w-auto px-4"
@click="$emit('confirm')"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
title: string
message: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
}>()
function cancel() {
emit('update:modelValue', false)
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>
@@ -1,13 +1,13 @@
<template> <template>
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]"> <div class="relative grid grid-cols-2 gap-x-8 gap-y-3 rounded bg-white p-4 shadow">
<h3 class="col-span-2 text-sm font-semibold text-neutral-700"> <h3 class="col-span-2 text-sm font-semibold text-neutral-700">
{{ title }} {{ title }}
</h3> </h3>
<MalioButtonIcon <MalioButtonIcon
v-if="removable && !readonly" v-if="removable && !readonly"
icon="mdi:delete-outline" icon="mdi:trash-can-outline"
variant="ghost" class="absolute right-2 top-2"
class="absolute right-3 top-3" button-class="!text-red-600"
:aria-label="$t('common.delete')" :aria-label="$t('common.delete')"
@click="$emit('remove')" @click="$emit('remove')"
/> />
@@ -1,13 +1,13 @@
<template> <template>
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]"> <div class="relative grid grid-cols-2 gap-x-8 gap-y-3 rounded bg-white p-4 shadow">
<h3 class="col-span-2 text-sm font-semibold text-neutral-700"> <h3 class="col-span-2 text-sm font-semibold text-neutral-700">
{{ title }} {{ title }}
</h3> </h3>
<MalioButtonIcon <MalioButtonIcon
v-if="removable && !readonly" v-if="removable && !readonly"
icon="mdi:delete-outline" icon="mdi:trash-can-outline"
variant="ghost" class="absolute right-2 top-2"
class="absolute right-3 top-3" button-class="!text-red-600"
:aria-label="$t('common.delete')" :aria-label="$t('common.delete')"
@click="$emit('remove')" @click="$emit('remove')"
/> />
@@ -6,11 +6,41 @@
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.name" v-model="form.name"
label="Nom société" :label="$t('prospects.fields.name')"
input-class="w-full" input-class="w-full"
:error="touched.name && !form.name.trim() ? $t('prospects.validation.nameRequired') : ''" :error="touched.name && !form.name.trim() ? $t('prospects.validation.nameRequired') : ''"
@blur="touched.name = true" @blur="touched.name = true"
/> />
<MalioInputText
v-model="form.company"
:label="$t('prospects.fields.company')"
input-class="w-full"
/>
<MalioInputText
v-model="form.email"
:label="$t('prospects.fields.email')"
input-class="w-full"
/>
<MalioInputText
v-model="form.phone"
:label="$t('prospects.fields.phone')"
input-class="w-full"
/>
<MalioSelect
v-model="form.status"
:label="$t('prospects.fields.status')"
:options="statusOptions"
group-class="w-full"
/>
<MalioInputText
v-model="form.source"
:label="$t('prospects.fields.source')"
input-class="w-full"
/>
<MalioInputTextArea
v-model="form.notes"
:label="$t('prospects.fields.notes')"
/>
<div class="mt-6 flex items-center justify-between gap-2"> <div class="mt-6 flex items-center justify-between gap-2">
<MalioButton <MalioButton
@@ -39,7 +69,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Prospect, ProspectWrite } from '~/modules/directory/services/dto/prospect' import type { Prospect, ProspectStatus, ProspectWrite } from '~/modules/directory/services/dto/prospect'
import { useProspectService } from '~/modules/directory/services/prospects' import { useProspectService } from '~/modules/directory/services/prospects'
const props = defineProps<{ const props = defineProps<{
@@ -52,6 +82,8 @@ const emit = defineEmits<{
(e: 'saved'): void (e: 'saved'): void
}>() }>()
const { t } = useI18n()
const isOpen = computed({ const isOpen = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (v) => emit('update:modelValue', v), set: (v) => emit('update:modelValue', v),
@@ -61,8 +93,30 @@ const isEditing = computed(() => !!props.prospect)
const isConverted = computed(() => !!props.prospect?.convertedClient) const isConverted = computed(() => !!props.prospect?.convertedClient)
const isSubmitting = ref(false) const isSubmitting = ref(false)
const form = reactive({ const statusOptions = [
{ label: t('prospects.status.new'), value: 'new' },
{ label: t('prospects.status.contacted'), value: 'contacted' },
{ label: t('prospects.status.qualified'), value: 'qualified' },
{ label: t('prospects.status.won'), value: 'won' },
{ label: t('prospects.status.lost'), value: 'lost' },
]
const form = reactive<{
name: string
company: string
email: string
phone: string
status: ProspectStatus
source: string
notes: string
}>({
name: '', name: '',
company: '',
email: '',
phone: '',
status: 'new',
source: '',
notes: '',
}) })
const touched = reactive({ const touched = reactive({
@@ -71,7 +125,23 @@ const touched = reactive({
watch(() => props.modelValue, (open) => { watch(() => props.modelValue, (open) => {
if (open) { if (open) {
form.name = props.prospect?.name ?? '' if (props.prospect) {
form.name = props.prospect.name ?? ''
form.company = props.prospect.company ?? ''
form.email = props.prospect.email ?? ''
form.phone = props.prospect.phone ?? ''
form.status = props.prospect.status ?? 'new'
form.source = props.prospect.source ?? ''
form.notes = props.prospect.notes ?? ''
} else {
form.name = ''
form.company = ''
form.email = ''
form.phone = ''
form.status = 'new'
form.source = ''
form.notes = ''
}
touched.name = false touched.name = false
} }
}) })
@@ -86,6 +156,12 @@ async function handleSubmit() {
try { try {
const payload: ProspectWrite = { const payload: ProspectWrite = {
name: form.name.trim(), name: form.name.trim(),
company: form.company.trim() || null,
email: form.email.trim() || null,
phone: form.phone.trim() || null,
status: form.status,
source: form.source.trim() || null,
notes: form.notes.trim() || null,
} }
if (isEditing.value && props.prospect) { if (isEditing.value && props.prospect) {
@@ -15,7 +15,7 @@
{{ doc.originalName }} {{ doc.originalName }}
</a> </a>
<MalioButtonIcon <MalioButtonIcon
v-if="canManage" v-if="isAdmin"
icon="mdi:trash-can-outline" icon="mdi:trash-can-outline"
button-class="!text-red-600" button-class="!text-red-600"
:aria-label="$t('common.delete')" :aria-label="$t('common.delete')"
@@ -32,7 +32,7 @@
import type { ReportDocument } from '~/modules/directory/services/dto/report-document' import type { ReportDocument } from '~/modules/directory/services/dto/report-document'
import { useReportDocumentService } from '~/modules/directory/services/report-documents' import { useReportDocumentService } from '~/modules/directory/services/report-documents'
defineProps<{ documents: ReportDocument[], canManage: boolean }>() defineProps<{ documents: ReportDocument[], isAdmin: boolean }>()
defineEmits<{ delete: [id: number] }>() defineEmits<{ delete: [id: number] }>()
const { getDownloadUrl } = useReportDocumentService() const { getDownloadUrl } = useReportDocumentService()
@@ -6,12 +6,10 @@ import { useAddressService } from '~/modules/directory/services/addresses'
type Owner = { client?: string, prospect?: string } type Owner = { client?: string, prospect?: string }
/** /**
* Logique partagée des fiches détail Client/Prospect : blocs répétables Contact * Logique partagée des fiches détail Client/Prospect : gestion des blocs
* et Adresse (chargement, ajout, suppression). L'édition est tenue en mémoire * répétables Contact et Adresse (chargement, ajout, édition par bloc avec
* localement ; la persistance se fait au clic sur « Enregistrer » (saveContacts/ * persistance immédiate, suppression). Paramétré par l'IRI du propriétaire
* saveAddresses), comme les formulaires de tâche — pas d'enregistrement au blur. * (`{ client }` ou `{ prospect }`), réutilisé tel quel par les deux pages.
* Paramétré par l'IRI du propriétaire (`{ client }` ou `{ prospect }`), réutilisé
* tel quel par les deux pages.
*/ */
export function useDirectoryDetail(owner: Owner) { export function useDirectoryDetail(owner: Owner) {
const contactService = useContactService() const contactService = useContactService()
@@ -19,8 +17,6 @@ export function useDirectoryDetail(owner: Owner) {
const contacts = ref<Contact[]>([]) const contacts = ref<Contact[]>([])
const addresses = ref<Address[]>([]) const addresses = ref<Address[]>([])
const savingContacts = ref(false)
const savingAddresses = ref(false)
function emptyContact(): Contact { function emptyContact(): Contact {
return { id: 0, firstName: null, lastName: null, jobTitle: null, email: null, phonePrimary: null, phoneSecondary: null, ...owner } return { id: 0, firstName: null, lastName: null, jobTitle: null, email: null, phonePrimary: null, phoneSecondary: null, ...owner }
@@ -29,75 +25,54 @@ export function useDirectoryDetail(owner: Owner) {
return { id: 0, label: null, street: null, streetComplement: null, postalCode: null, city: null, country: 'FR', ...owner } return { id: 0, label: null, street: null, streetComplement: null, postalCode: null, city: null, country: 'FR', ...owner }
} }
// Édition locale uniquement : on remplace le bloc en mémoire, rien n'est async function onContactInput(index: number, value: Contact): Promise<void> {
// persisté tant que l'utilisateur n'a pas cliqué sur « Enregistrer ».
function onContactInput(index: number, value: Contact): void {
contacts.value[index] = value contacts.value[index] = value
await persistContact(index)
} }
function onAddressInput(index: number, value: Address): void { async function persistContact(index: number): Promise<void> {
addresses.value[index] = value const c = contacts.value[index]
if (!c) return
const payload = { firstName: c.firstName, lastName: c.lastName, jobTitle: c.jobTitle, email: c.email, phonePrimary: c.phonePrimary, phoneSecondary: c.phoneSecondary, ...owner }
if (c.id && c.id > 0) {
await contactService.update(c.id, payload)
} else if (c.lastName || c.firstName) {
const created = await contactService.create(payload)
contacts.value[index] = created
}
} }
function addContact(): void { function addContact(): void {
contacts.value.push(emptyContact()) contacts.value.push(emptyContact())
} }
function addAddress(): void {
addresses.value.push(emptyAddress())
}
// Suppression immédiate (comme la corbeille du formulaire de tâche) : un bloc
// déjà enregistré est supprimé côté serveur, une amorce non enregistrée est
// simplement retirée de la liste.
async function removeContact(index: number): Promise<void> { async function removeContact(index: number): Promise<void> {
const c = contacts.value[index] const c = contacts.value[index]
if (c?.id && c.id > 0) await contactService.remove(c.id) if (c?.id && c.id > 0) await contactService.remove(c.id)
contacts.value.splice(index, 1) contacts.value.splice(index, 1)
} }
async function onAddressInput(index: number, value: Address): Promise<void> {
addresses.value[index] = value
await persistAddress(index)
}
async function persistAddress(index: number): Promise<void> {
const a = addresses.value[index]
if (!a) return
const payload = { label: a.label, street: a.street, streetComplement: a.streetComplement, postalCode: a.postalCode, city: a.city, country: a.country, ...owner }
if (a.id && a.id > 0) {
await addressService.update(a.id, payload)
} else if (a.street || a.city || a.postalCode) {
const created = await addressService.create(payload)
addresses.value[index] = created
}
}
function addAddress(): void {
addresses.value.push(emptyAddress())
}
async function removeAddress(index: number): Promise<void> { async function removeAddress(index: number): Promise<void> {
const a = addresses.value[index] const a = addresses.value[index]
if (a?.id && a.id > 0) await addressService.remove(a.id) if (a?.id && a.id > 0) await addressService.remove(a.id)
addresses.value.splice(index, 1) addresses.value.splice(index, 1)
} }
// Persistance au clic : met à jour les blocs existants, crée les nouveaux
// blocs renseignés. Les amorces vides (sans contenu) sont ignorées.
async function saveContacts(): Promise<void> {
if (savingContacts.value) return
savingContacts.value = true
try {
for (let i = 0; i < contacts.value.length; i++) {
const c = contacts.value[i]
if (!c) continue
const payload = { firstName: c.firstName, lastName: c.lastName, jobTitle: c.jobTitle, email: c.email, phonePrimary: c.phonePrimary, phoneSecondary: c.phoneSecondary, ...owner }
if (c.id && c.id > 0) {
contacts.value[i] = await contactService.update(c.id, payload)
} else if (c.lastName || c.firstName) {
contacts.value[i] = await contactService.create(payload)
}
}
} finally {
savingContacts.value = false
}
}
async function saveAddresses(): Promise<void> {
if (savingAddresses.value) return
savingAddresses.value = true
try {
for (let i = 0; i < addresses.value.length; i++) {
const a = addresses.value[i]
if (!a) continue
const payload = { label: a.label, street: a.street, streetComplement: a.streetComplement, postalCode: a.postalCode, city: a.city, country: a.country, ...owner }
if (a.id && a.id > 0) {
addresses.value[i] = await addressService.update(a.id, payload)
} else if (a.street || a.city || a.postalCode) {
addresses.value[i] = await addressService.create(payload)
}
}
} finally {
savingAddresses.value = false
}
}
async function load(): Promise<void> { async function load(): Promise<void> {
contacts.value = await contactService.getByOwner(owner) contacts.value = await contactService.getByOwner(owner)
addresses.value = await addressService.getByOwner(owner) addresses.value = await addressService.getByOwner(owner)
@@ -106,16 +81,12 @@ export function useDirectoryDetail(owner: Owner) {
return { return {
contacts, contacts,
addresses, addresses,
savingContacts,
savingAddresses,
onContactInput, onContactInput,
addContact, addContact,
removeContact, removeContact,
saveContacts,
onAddressInput, onAddressInput,
addAddress, addAddress,
removeAddress, removeAddress,
saveAddresses,
load, load,
} }
} }
@@ -8,36 +8,6 @@
<p v-if="loading">{{ $t('common.loading') }}</p> <p v-if="loading">{{ $t('common.loading') }}</p>
<template v-else-if="client"> <template v-else-if="client">
<MalioTabList v-model="activeTab" :tabs="tabs"> <MalioTabList v-model="activeTab" :tabs="tabs">
<template #info>
<div class="flex flex-col gap-4 pt-6">
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]">
<MalioInputText
v-model="info.name"
class="col-span-2"
:label="$t('directory.info.fields.name')"
:error="infoTouched.name && !info.name.trim() ? $t('directory.validation.nameRequired') : ''"
@blur="infoTouched.name = true"
/>
<MalioInputText
v-model="info.email"
:label="$t('directory.info.fields.email')"
/>
<MalioInputText
v-model="info.phone"
:label="$t('directory.info.fields.phone')"
/>
</div>
<div class="flex justify-center pt-2">
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingInfo"
@click="saveInfo"
/>
</div>
</div>
</template>
<template #contact> <template #contact>
<div class="flex flex-col gap-4 pt-6"> <div class="flex flex-col gap-4 pt-6">
<DirectoryContactBlock <DirectoryContactBlock
@@ -49,22 +19,13 @@
@update:model-value="(v) => onContactInput(i, v)" @update:model-value="(v) => onContactInput(i, v)"
@remove="removeContact(i)" @remove="removeContact(i)"
/> />
<div class="flex justify-center gap-3 pt-2"> <MalioButton
<MalioButton icon-name="mdi:plus"
variant="tertiary" icon-position="left"
icon-name="mdi:plus" button-class="w-auto px-4"
icon-position="left" :label="$t('directory.contacts.add')"
button-class="w-auto px-4" @click="addContact"
:label="$t('directory.contacts.add')" />
@click="addContact"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingContacts"
@click="saveContacts"
/>
</div>
</div> </div>
</template> </template>
@@ -79,27 +40,18 @@
@update:model-value="(v) => onAddressInput(i, v)" @update:model-value="(v) => onAddressInput(i, v)"
@remove="removeAddress(i)" @remove="removeAddress(i)"
/> />
<div class="flex justify-center gap-3 pt-2"> <MalioButton
<MalioButton icon-name="mdi:plus"
variant="tertiary" icon-position="left"
icon-name="mdi:plus" button-class="w-auto px-4"
icon-position="left" :label="$t('directory.addresses.add')"
button-class="w-auto px-4" @click="addAddress"
:label="$t('directory.addresses.add')" />
@click="addAddress"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingAddresses"
@click="saveAddresses"
/>
</div>
</div> </div>
</template> </template>
<template #report> <template #report>
<CommercialReportTab :owner="owner" :can-manage="canManage" /> <CommercialReportTab :owner="owner" :is-admin="true" />
</template> </template>
</MalioTabList> </MalioTabList>
</template> </template>
@@ -124,63 +76,31 @@ const clientService = useClientService()
const { const {
contacts, contacts,
addresses, addresses,
savingContacts,
savingAddresses,
onContactInput, onContactInput,
addContact, addContact,
removeContact, removeContact,
saveContacts,
onAddressInput, onAddressInput,
addAddress, addAddress,
removeAddress, removeAddress,
saveAddresses,
load, load,
} = useDirectoryDetail(owner) } = useDirectoryDetail(owner)
const { can } = usePermissions()
const canManage = computed(() => can('directory.clients.manage'))
const client = ref<Client | null>(null) const client = ref<Client | null>(null)
const loading = ref(true) const loading = ref(true)
const activeTab = ref('info') const activeTab = ref('contact')
const tabs = [ const tabs = [
{ key: 'info', label: t('directory.tabs.info'), icon: 'mdi:information-outline' },
{ key: 'contact', label: t('directory.tabs.contact'), icon: 'mdi:account-outline' }, { key: 'contact', label: t('directory.tabs.contact'), icon: 'mdi:account-outline' },
{ key: 'address', label: t('directory.tabs.address'), icon: 'mdi:map-marker-outline' }, { key: 'address', label: t('directory.tabs.address'), icon: 'mdi:map-marker-outline' },
{ key: 'report', label: t('directory.tabs.report'), icon: 'mdi:file-document-outline' }, { key: 'report', label: t('directory.tabs.report'), icon: 'mdi:file-document-outline' },
] ]
// Champs de base de la fiche, édités en mémoire et persistés au clic sur
// « Enregistrer » (PATCH), comme les onglets Contact/Adresse.
const info = reactive({ name: '', email: '', phone: '' })
const infoTouched = reactive({ name: false })
const savingInfo = ref(false)
async function saveInfo(): Promise<void> {
infoTouched.name = true
if (!info.name.trim() || savingInfo.value) return
savingInfo.value = true
try {
client.value = await clientService.update(id, {
name: info.name.trim(),
email: info.email.trim() || null,
phone: info.phone.trim() || null,
})
} finally {
savingInfo.value = false
}
}
function goBack(): void { function goBack(): void {
router.push('/directory') router.push('/directory')
} }
onMounted(async () => { onMounted(async () => {
client.value = await clientService.getById(id) client.value = await clientService.getById(id)
info.name = client.value.name ?? ''
info.email = client.value.email ?? ''
info.phone = client.value.phone ?? ''
await load() await load()
loading.value = false loading.value = false
}) })
@@ -31,17 +31,6 @@
<template #cell-phone="{ item }"> <template #cell-phone="{ item }">
{{ (item as Client).phone ?? '—' }} {{ (item as Client).phone ?? '—' }}
</template> </template>
<template #cell-actions="{ item }">
<div class="flex justify-end" @click.stop>
<MalioButtonIcon
icon="mdi:trash-can-outline"
:aria-label="$t('common.delete')"
button-class="!bg-red-100 !text-red-700"
:icon-size="18"
@click="askDeleteClient(item as Client)"
/>
</div>
</template>
</MalioDataTable> </MalioDataTable>
</div> </div>
</template> </template>
@@ -86,23 +75,20 @@
{{ (item as ProspectRow).phone ?? '—' }} {{ (item as ProspectRow).phone ?? '—' }}
</template> </template>
<template #cell-actions="{ item }"> <template #cell-actions="{ item }">
<div class="flex justify-end gap-2" @click.stop> <div
v-if="!(item as ProspectRow).convertedClient"
class="flex justify-end"
@click.stop
>
<MalioButtonIcon <MalioButtonIcon
v-if="!(item as ProspectRow).convertedClient"
icon="mdi:account-convert" icon="mdi:account-convert"
:aria-label="$t('prospects.convert')" :aria-label="$t('prospects.convert')"
button-class="!bg-green-100 !text-green-700" button-class="!bg-green-100 !text-green-700"
:icon-size="18" :icon-size="18"
@click="convertProspect(item as ProspectRow)" @click="convertProspect(item as ProspectRow)"
/> />
<MalioButtonIcon
icon="mdi:trash-can-outline"
:aria-label="$t('common.delete')"
button-class="!bg-red-100 !text-red-700"
:icon-size="18"
@click="askDeleteProspect(item as ProspectRow)"
/>
</div> </div>
<span v-else class="text-neutral-300"></span>
</template> </template>
</MalioDataTable> </MalioDataTable>
</div> </div>
@@ -119,13 +105,6 @@
:prospect="selectedProspect" :prospect="selectedProspect"
@saved="onProspectSaved" @saved="onProspectSaved"
/> />
<ConfirmDeleteModal
v-model="deleteModalOpen"
:title="deleteModalTitle"
:message="deleteModalMessage"
@confirm="confirmDelete"
/>
</div> </div>
</template> </template>
@@ -160,7 +139,6 @@ const clientColumns = [
{ key: 'name', label: t('prospects.fields.name') }, { key: 'name', label: t('prospects.fields.name') },
{ key: 'email', label: t('prospects.fields.email') }, { key: 'email', label: t('prospects.fields.email') },
{ key: 'phone', label: t('prospects.fields.phone') }, { key: 'phone', label: t('prospects.fields.phone') },
{ key: 'actions', label: '' },
] ]
async function loadClients() { async function loadClients() {
@@ -247,54 +225,6 @@ async function onProspectSaved() {
await Promise.all([loadProspects(), loadClients()]) await Promise.all([loadProspects(), loadClients()])
} }
// --- Suppression (clients & prospects) ---
type DeleteTarget =
| { type: 'client'; item: Client }
| { type: 'prospect'; item: Prospect }
const deleteModalOpen = ref(false)
const deleteTarget = ref<DeleteTarget | null>(null)
const deleteModalTitle = computed(() =>
deleteTarget.value?.type === 'prospect'
? t('prospects.deleteConfirmTitle')
: t('clients.deleteConfirmTitle'),
)
const deleteModalMessage = computed(() => {
if (!deleteTarget.value) return ''
const name = deleteTarget.value.item.name
return deleteTarget.value.type === 'prospect'
? t('prospects.deleteConfirmMessage', { name })
: t('clients.deleteConfirmMessage', { name })
})
function askDeleteClient(item: Client) {
deleteTarget.value = { type: 'client', item }
deleteModalOpen.value = true
}
function askDeleteProspect(item: Prospect) {
deleteTarget.value = { type: 'prospect', item }
deleteModalOpen.value = true
}
async function confirmDelete() {
const target = deleteTarget.value
if (!target) return
if (target.type === 'client') {
await clientService.remove(target.item.id)
await loadClients()
} else {
await prospectService.remove(target.item.id)
await loadProspects()
}
deleteModalOpen.value = false
deleteTarget.value = null
}
watch(statusFilter, loadProspects) watch(statusFilter, loadProspects)
onMounted(async () => { onMounted(async () => {
@@ -8,56 +8,6 @@
<p v-if="loading">{{ $t('common.loading') }}</p> <p v-if="loading">{{ $t('common.loading') }}</p>
<template v-else-if="prospect"> <template v-else-if="prospect">
<MalioTabList v-model="activeTab" :tabs="tabs"> <MalioTabList v-model="activeTab" :tabs="tabs">
<template #info>
<div class="flex flex-col gap-4 pt-6">
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]">
<MalioInputText
v-model="info.name"
class="col-span-2"
:label="$t('prospects.fields.name')"
:error="infoTouched.name && !info.name.trim() ? $t('prospects.validation.nameRequired') : ''"
@blur="infoTouched.name = true"
/>
<MalioInputText
v-model="info.company"
:label="$t('prospects.fields.company')"
/>
<MalioSelect
v-model="info.status"
:label="$t('prospects.fields.status')"
:options="statusOptions"
group-class="w-full"
/>
<MalioInputText
v-model="info.email"
:label="$t('prospects.fields.email')"
/>
<MalioInputText
v-model="info.phone"
:label="$t('prospects.fields.phone')"
/>
<MalioInputText
v-model="info.source"
class="col-span-2"
:label="$t('prospects.fields.source')"
/>
<MalioInputTextArea
v-model="info.notes"
class="col-span-2"
:label="$t('prospects.fields.notes')"
/>
</div>
<div class="flex justify-center pt-2">
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingInfo"
@click="saveInfo"
/>
</div>
</div>
</template>
<template #contact> <template #contact>
<div class="flex flex-col gap-4 pt-6"> <div class="flex flex-col gap-4 pt-6">
<DirectoryContactBlock <DirectoryContactBlock
@@ -69,22 +19,13 @@
@update:model-value="(v) => onContactInput(i, v)" @update:model-value="(v) => onContactInput(i, v)"
@remove="removeContact(i)" @remove="removeContact(i)"
/> />
<div class="flex justify-center gap-3 pt-2"> <MalioButton
<MalioButton icon-name="mdi:plus"
variant="tertiary" icon-position="left"
icon-name="mdi:plus" button-class="w-auto px-4"
icon-position="left" :label="$t('directory.contacts.add')"
button-class="w-auto px-4" @click="addContact"
:label="$t('directory.contacts.add')" />
@click="addContact"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingContacts"
@click="saveContacts"
/>
</div>
</div> </div>
</template> </template>
@@ -99,27 +40,18 @@
@update:model-value="(v) => onAddressInput(i, v)" @update:model-value="(v) => onAddressInput(i, v)"
@remove="removeAddress(i)" @remove="removeAddress(i)"
/> />
<div class="flex justify-center gap-3 pt-2"> <MalioButton
<MalioButton icon-name="mdi:plus"
variant="tertiary" icon-position="left"
icon-name="mdi:plus" button-class="w-auto px-4"
icon-position="left" :label="$t('directory.addresses.add')"
button-class="w-auto px-4" @click="addAddress"
:label="$t('directory.addresses.add')" />
@click="addAddress"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingAddresses"
@click="saveAddresses"
/>
</div>
</div> </div>
</template> </template>
<template #report> <template #report>
<CommercialReportTab :owner="owner" :can-manage="canManage" /> <CommercialReportTab :owner="owner" :is-admin="true" />
</template> </template>
</MalioTabList> </MalioTabList>
</template> </template>
@@ -127,7 +59,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Prospect, ProspectStatus } from '~/modules/directory/services/dto/prospect' import type { Prospect } from '~/modules/directory/services/dto/prospect'
import { useProspectService } from '~/modules/directory/services/prospects' import { useProspectService } from '~/modules/directory/services/prospects'
definePageMeta({ middleware: ['admin'] }) definePageMeta({ middleware: ['admin'] })
@@ -144,87 +76,31 @@ const prospectService = useProspectService()
const { const {
contacts, contacts,
addresses, addresses,
savingContacts,
savingAddresses,
onContactInput, onContactInput,
addContact, addContact,
removeContact, removeContact,
saveContacts,
onAddressInput, onAddressInput,
addAddress, addAddress,
removeAddress, removeAddress,
saveAddresses,
load, load,
} = useDirectoryDetail(owner) } = useDirectoryDetail(owner)
const { can } = usePermissions()
const canManage = computed(() => can('directory.prospects.manage'))
const prospect = ref<Prospect | null>(null) const prospect = ref<Prospect | null>(null)
const loading = ref(true) const loading = ref(true)
const activeTab = ref('info') const activeTab = ref('contact')
const tabs = [ const tabs = [
{ key: 'info', label: t('directory.tabs.info'), icon: 'mdi:information-outline' },
{ key: 'contact', label: t('directory.tabs.contact'), icon: 'mdi:account-outline' }, { key: 'contact', label: t('directory.tabs.contact'), icon: 'mdi:account-outline' },
{ key: 'address', label: t('directory.tabs.address'), icon: 'mdi:map-marker-outline' }, { key: 'address', label: t('directory.tabs.address'), icon: 'mdi:map-marker-outline' },
{ key: 'report', label: t('directory.tabs.report'), icon: 'mdi:file-document-outline' }, { key: 'report', label: t('directory.tabs.report'), icon: 'mdi:file-document-outline' },
] ]
const statusOptions = [
{ label: t('prospects.status.new'), value: 'new' },
{ label: t('prospects.status.contacted'), value: 'contacted' },
{ label: t('prospects.status.qualified'), value: 'qualified' },
{ label: t('prospects.status.won'), value: 'won' },
{ label: t('prospects.status.lost'), value: 'lost' },
]
// Champs de base de la fiche, édités en mémoire et persistés au clic sur
// « Enregistrer » (PATCH), comme les onglets Contact/Adresse.
const info = reactive<{
name: string
company: string
email: string
phone: string
status: ProspectStatus
source: string
notes: string
}>({ name: '', company: '', email: '', phone: '', status: 'new', source: '', notes: '' })
const infoTouched = reactive({ name: false })
const savingInfo = ref(false)
async function saveInfo(): Promise<void> {
infoTouched.name = true
if (!info.name.trim() || savingInfo.value) return
savingInfo.value = true
try {
prospect.value = await prospectService.update(id, {
name: info.name.trim(),
company: info.company.trim() || null,
email: info.email.trim() || null,
phone: info.phone.trim() || null,
status: info.status,
source: info.source.trim() || null,
notes: info.notes.trim() || null,
})
} finally {
savingInfo.value = false
}
}
function goBack(): void { function goBack(): void {
router.push('/directory') router.push('/directory')
} }
onMounted(async () => { onMounted(async () => {
prospect.value = await prospectService.getById(id) prospect.value = await prospectService.getById(id)
info.name = prospect.value.name ?? ''
info.company = prospect.value.company ?? ''
info.email = prospect.value.email ?? ''
info.phone = prospect.value.phone ?? ''
info.status = prospect.value.status ?? 'new'
info.source = prospect.value.source ?? ''
info.notes = prospect.value.notes ?? ''
await load() await load()
loading.value = false loading.value = false
}) })
@@ -8,6 +8,6 @@ export type Client = {
export type ClientWrite = { export type ClientWrite = {
name: string name: string
email?: string | null email: string | null
phone?: string | null phone: string | null
} }
@@ -19,10 +19,10 @@ export type Prospect = {
export type ProspectWrite = { export type ProspectWrite = {
name: string name: string
company?: string | null company: string | null
email?: string | null email: string | null
phone?: string | null phone: string | null
status?: ProspectStatus status: ProspectStatus
source?: string | null source: string | null
notes?: string | null notes: string | null
} }
-3
View File
@@ -27,9 +27,6 @@ sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintena
echo "==> Running migrations..." echo "==> Running migrations..."
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
echo "==> Seeding RBAC system roles (idempotent)..."
sudo docker compose exec -T -u www-data app php bin/console app:seed-rbac
echo "==> Syncing RBAC permissions catalog..." echo "==> Syncing RBAC permissions catalog..."
sudo docker compose exec -T -u www-data app php bin/console app:sync-permissions sudo docker compose exec -T -u www-data app php bin/console app:sync-permissions
@@ -111,18 +111,9 @@ class AccrueLeaveCommand extends Command
$previousBalance = null !== $previousPeriod $previousBalance = null !== $previousPeriod
? $this->balanceRepository->findOneForPeriod($user, AbsenceType::PaidLeave, $previousPeriod) ? $this->balanceRepository->findOneForPeriod($user, AbsenceType::PaidLeave, $previousPeriod)
: null; : null;
$balance->setAcquired(
if (null !== $previousBalance) { null !== $previousBalance ? $previousBalance->getAcquiring() : $profile->getInitialLeaveBalance(),
// Only the days *not yet taken* carry over. Leave is charged );
// oldest-first: it first consumes the previous "acquired"
// (N-2) bucket — which expires at roll-over anyway — so only
// days taken beyond that bucket eat into the carry-over.
$carryOver = $previousBalance->getAcquiring()
- max(0.0, $previousBalance->getTaken() - $previousBalance->getAcquired());
$balance->setAcquired(max(0.0, $carryOver));
} else {
$balance->setAcquired($profile->getInitialLeaveBalance());
}
} }
if ($monthKey === $balance->getLastAccruedMonth()) { if ($monthKey === $balance->getLastAccruedMonth()) {
@@ -14,9 +14,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
*/ */
final class PermissionVoter extends Voter final class PermissionVoter extends Voter
{ {
// Les codes de permission sont au format module.resource.action où chaque private const string PATTERN = '/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/';
// segment peut contenir des tirets (ex. project-management, time-tracking).
private const string PATTERN = '/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$/';
protected function supports(string $attribute, mixed $subject): bool protected function supports(string $attribute, mixed $subject): bool
{ {
@@ -23,11 +23,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[Auditable] #[Auditable]
#[ApiResource( #[ApiResource(
operations: [ operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"), new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"), new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), new Delete(security: "is_granted('ROLE_ADMIN')"),
], ],
normalizationContext: ['groups' => ['address:read']], normalizationContext: ['groups' => ['address:read']],
denormalizationContext: ['groups' => ['address:write']], denormalizationContext: ['groups' => ['address:write']],
@@ -25,11 +25,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[Auditable] #[Auditable]
#[ApiResource( #[ApiResource(
operations: [ operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view')"), new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('directory.clients.view')"), new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('directory.clients.manage')"), new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('directory.clients.manage')"), new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('directory.clients.manage')"), new Delete(security: "is_granted('ROLE_ADMIN')"),
], ],
normalizationContext: ['groups' => ['client:read']], normalizationContext: ['groups' => ['client:read']],
denormalizationContext: ['groups' => ['client:write']], denormalizationContext: ['groups' => ['client:write']],
@@ -26,11 +26,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource( #[ApiResource(
operations: [ operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"), new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"), new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), new Delete(security: "is_granted('ROLE_ADMIN')"),
], ],
normalizationContext: ['groups' => ['commercial_report:read']], normalizationContext: ['groups' => ['commercial_report:read']],
denormalizationContext: ['groups' => ['commercial_report:write']], denormalizationContext: ['groups' => ['commercial_report:write']],
@@ -23,11 +23,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[Auditable] #[Auditable]
#[ApiResource( #[ApiResource(
operations: [ operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"), new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"), new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), new Delete(security: "is_granted('ROLE_ADMIN')"),
], ],
normalizationContext: ['groups' => ['contact:read']], normalizationContext: ['groups' => ['contact:read']],
denormalizationContext: ['groups' => ['contact:write']], denormalizationContext: ['groups' => ['contact:write']],
@@ -27,14 +27,14 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[Auditable] #[Auditable]
#[ApiResource( #[ApiResource(
operations: [ operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('directory.prospects.view')"), new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('directory.prospects.view')"), new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('directory.prospects.manage')"), new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('directory.prospects.manage')"), new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('directory.prospects.manage')"), new Delete(security: "is_granted('ROLE_ADMIN')"),
new Post( new Post(
uriTemplate: '/prospects/{id}/convert', uriTemplate: '/prospects/{id}/convert',
security: "is_granted('directory.prospects.manage')", security: "is_granted('ROLE_ADMIN')",
processor: ConvertProspectProcessor::class, processor: ConvertProspectProcessor::class,
), ),
], ],
@@ -20,14 +20,14 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource( #[ApiResource(
operations: [ operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"), new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"), new Get(security: "is_granted('ROLE_USER')"),
new Post( new Post(
security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')", security: "is_granted('ROLE_ADMIN')",
processor: ReportDocumentProcessor::class, processor: ReportDocumentProcessor::class,
deserialize: false, deserialize: false,
), ),
new Delete(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), new Delete(security: "is_granted('ROLE_ADMIN')"),
], ],
normalizationContext: ['groups' => ['report_document:read']], normalizationContext: ['groups' => ['report_document:read']],
denormalizationContext: ['groups' => ['report_document:write']], denormalizationContext: ['groups' => ['report_document:write']],
@@ -30,18 +30,18 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource( #[ApiResource(
operations: [ operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view')"), new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('project-management.projects.view')"), new Get(security: "is_granted('ROLE_USER')"),
new Post( new Post(
security: "is_granted('project-management.projects.manage')", security: "is_granted('ROLE_ADMIN')",
denormalizationContext: ['groups' => ['project:write', 'project:create']], denormalizationContext: ['groups' => ['project:write', 'project:create']],
), ),
new Patch(security: "is_granted('project-management.projects.manage')"), new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('project-management.projects.manage')"), new Delete(security: "is_granted('ROLE_ADMIN')"),
new Post( new Post(
uriTemplate: '/projects/{id}/switch-workflow', uriTemplate: '/projects/{id}/switch-workflow',
uriVariables: ['id' => new Link(fromClass: Project::class)], uriVariables: ['id' => new Link(fromClass: Project::class)],
security: "is_granted('project-management.projects.manage')", security: "is_granted('ROLE_ADMIN')",
input: false, input: false,
output: SwitchWorkflowOutput::class, output: SwitchWorkflowOutput::class,
normalizationContext: ['groups' => ['switch_workflow:read']], normalizationContext: ['groups' => ['switch_workflow:read']],
@@ -33,11 +33,11 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ApiResource( #[ApiResource(
operations: [ operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.tasks.view')"), new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('project-management.tasks.view')"), new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('project-management.tasks.manage')", processor: TaskNumberProcessor::class), new Post(security: "is_granted('ROLE_ADMIN')", processor: TaskNumberProcessor::class),
new Patch(security: "is_granted('project-management.tasks.manage')", processor: TaskCalendarProcessor::class), new Patch(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class),
new Delete(security: "is_granted('project-management.tasks.manage')", processor: TaskCalendarProcessor::class), new Delete(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class),
], ],
normalizationContext: ['groups' => ['task:read']], normalizationContext: ['groups' => ['task:read']],
denormalizationContext: ['groups' => ['task:write']], denormalizationContext: ['groups' => ['task:write']],
@@ -21,14 +21,14 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource( #[ApiResource(
operations: [ operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.tasks.view')", provider: TaskDocumentProvider::class), new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')", provider: TaskDocumentProvider::class),
new Get(security: "is_granted('project-management.tasks.view')", provider: TaskDocumentProvider::class), new Get(security: "is_granted('ROLE_USER')", provider: TaskDocumentProvider::class),
new Post( new Post(
security: "is_granted('project-management.tasks.manage')", security: "is_granted('ROLE_ADMIN')",
processor: TaskDocumentProcessor::class, processor: TaskDocumentProcessor::class,
deserialize: false, deserialize: false,
), ),
new Delete(security: "is_granted('project-management.tasks.manage')"), new Delete(security: "is_granted('ROLE_ADMIN')"),
], ],
normalizationContext: ['groups' => ['task_document:read']], normalizationContext: ['groups' => ['task_document:read']],
denormalizationContext: ['groups' => ['task_document:write']], denormalizationContext: ['groups' => ['task_document:write']],
@@ -16,11 +16,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource( #[ApiResource(
operations: [ operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), new Delete(security: "is_granted('ROLE_ADMIN')"),
], ],
normalizationContext: ['groups' => ['task_effort:read']], normalizationContext: ['groups' => ['task_effort:read']],
denormalizationContext: ['groups' => ['task_effort:write']], denormalizationContext: ['groups' => ['task_effort:write']],
@@ -19,11 +19,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource( #[ApiResource(
operations: [ operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), new Delete(security: "is_granted('ROLE_ADMIN')"),
], ],
normalizationContext: ['groups' => ['task_group:read']], normalizationContext: ['groups' => ['task_group:read']],
denormalizationContext: ['groups' => ['task_group:write']], denormalizationContext: ['groups' => ['task_group:write']],
@@ -16,11 +16,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource( #[ApiResource(
operations: [ operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), new Delete(security: "is_granted('ROLE_ADMIN')"),
], ],
normalizationContext: ['groups' => ['task_priority:read']], normalizationContext: ['groups' => ['task_priority:read']],
denormalizationContext: ['groups' => ['task_priority:write']], denormalizationContext: ['groups' => ['task_priority:write']],
@@ -20,11 +20,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource( #[ApiResource(
operations: [ operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), new Delete(security: "is_granted('ROLE_ADMIN')"),
], ],
normalizationContext: ['groups' => ['task_recurrence:read']], normalizationContext: ['groups' => ['task_recurrence:read']],
denormalizationContext: ['groups' => ['task_recurrence:write']], denormalizationContext: ['groups' => ['task_recurrence:write']],
@@ -18,11 +18,11 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource( #[ApiResource(
operations: [ operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), new Delete(security: "is_granted('ROLE_ADMIN')"),
], ],
normalizationContext: ['groups' => ['task_status:read']], normalizationContext: ['groups' => ['task_status:read']],
denormalizationContext: ['groups' => ['task_status:write']], denormalizationContext: ['groups' => ['task_status:write']],
@@ -17,11 +17,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource( #[ApiResource(
operations: [ operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), new Delete(security: "is_granted('ROLE_ADMIN')"),
], ],
normalizationContext: ['groups' => ['task_tag:read']], normalizationContext: ['groups' => ['task_tag:read']],
denormalizationContext: ['groups' => ['task_tag:write']], denormalizationContext: ['groups' => ['task_tag:write']],
@@ -21,11 +21,11 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource( #[ApiResource(
operations: [ operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')", processor: WorkflowDeleteProcessor::class), new Delete(security: "is_granted('ROLE_ADMIN')", processor: WorkflowDeleteProcessor::class),
], ],
normalizationContext: ['groups' => ['workflow:read']], normalizationContext: ['groups' => ['workflow:read']],
denormalizationContext: ['groups' => ['workflow:write']], denormalizationContext: ['groups' => ['workflow:write']],
@@ -31,13 +31,13 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource( #[ApiResource(
operations: [ operations: [
new GetCollection(security: "is_granted('time-tracking.entries.view')"), new GetCollection(security: "is_granted('ROLE_USER')"),
new GetCollection( new GetCollection(
name: 'time_entries_range', name: 'time_entries_range',
uriTemplate: '/time_entries/range', uriTemplate: '/time_entries/range',
description: 'List time entries for a bounded date range without pagination (used by the time-tracking calendar)', description: 'List time entries for a bounded date range without pagination (used by the time-tracking calendar)',
paginationEnabled: false, paginationEnabled: false,
security: "is_granted('time-tracking.entries.view')", security: "is_granted('ROLE_USER')",
), ),
new GetCollection( new GetCollection(
name: 'active_time_entry', name: 'active_time_entry',
@@ -45,12 +45,12 @@ use Symfony\Component\Serializer\Attribute\Groups;
provider: ActiveTimeEntryProvider::class, provider: ActiveTimeEntryProvider::class,
description: 'Get the active timer for the current user', description: 'Get the active timer for the current user',
paginationEnabled: false, paginationEnabled: false,
security: "is_granted('time-tracking.entries.view')", security: "is_granted('ROLE_USER')",
), ),
new Get(security: "is_granted('time-tracking.entries.view')"), new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('time-tracking.entries.manage')"), new Post(security: "is_granted('ROLE_USER')"),
new Patch(security: "is_granted('ROLE_ADMIN') or (is_granted('time-tracking.entries.manage') and object.getUser() == user)"), new Patch(security: "is_granted('ROLE_ADMIN') or object.getUser() == user"),
new Delete(security: "is_granted('ROLE_ADMIN') or (is_granted('time-tracking.entries.manage') and object.getUser() == user)"), new Delete(security: "is_granted('ROLE_ADMIN') or object.getUser() == user"),
], ],
normalizationContext: ['groups' => ['time_entry:read']], normalizationContext: ['groups' => ['time_entry:read']],
denormalizationContext: ['groups' => ['time_entry:write']], denormalizationContext: ['groups' => ['time_entry:write']],
@@ -26,13 +26,15 @@ final class TimeTrackingModule implements ModuleInterface
/** /**
* Permissions RBAC fin du Module TimeTracking (2.1). * Permissions RBAC fin du Module TimeTracking (2.1).
* *
* Additif : alimente le catalogue RBAC. La sécurité des opérations API
* reste en ROLE_USER (non recâblée ici).
*
* @return list<array{code: string, label: string}> * @return list<array{code: string, label: string}>
*/ */
public static function permissions(): array public static function permissions(): array
{ {
return [ return [
['code' => 'time-tracking.entries.view', 'label' => 'Voir les saisies de temps'], ['code' => 'time-tracking.entries.view', 'label' => 'Voir les saisies de temps'],
['code' => 'time-tracking.entries.manage', 'label' => 'Gérer les saisies de temps'],
['code' => 'time-tracking.entries.export', 'label' => 'Exporter les saisies de temps'], ['code' => 'time-tracking.entries.export', 'label' => 'Exporter les saisies de temps'],
]; ];
} }
@@ -1,96 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\Serializer;
use ApiPlatform\Metadata\IriConverterInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Throwable;
use function array_key_exists;
use function is_string;
/**
* Modular monolith: cross-module relations are typed with a Shared\Domain\Contract
* interface (e.g. UserInterface, TaskTagInterface) instead of the concrete entity,
* to keep modules decoupled. Doctrine maps those back to the concrete entity through
* resolve_target_entities.
*
* API Platform denormalizes *single* interface relations fine (the concrete class is
* derived from the IRI), but blows up on *collections*: the collection value type stays
* the interface, which is not a registered API resource, so no normalizer supports it
* and the request fails with NotNormalizableValueException.
*
* This denormalizer bridges that gap for every contract interface, reusing Doctrine's
* resolve_target_entities mapping (no per-entity config):
* - a string value is an IRI -> resolved through the IriConverter
* - an array value is an embedded object -> denormalized into the concrete entity
*/
final class ContractRelationDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface
{
use DenormalizerAwareTrait;
private const CONTRACT_NAMESPACE = 'App\Shared\Domain\Contract\\';
/** @var array<string, ?class-string> */
private array $resolved = [];
public function __construct(
private readonly IriConverterInterface $iriConverter,
private readonly EntityManagerInterface $entityManager,
) {}
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
{
return null !== $this->concreteClassFor($type);
}
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): ?object
{
$concrete = $this->concreteClassFor($type);
if (null === $concrete) {
return null;
}
if (is_string($data)) {
return $this->iriConverter->getResourceFromIri($data, $context);
}
// Embedded object payload: denormalize into the resolved concrete entity.
return $this->denormalizer->denormalize($data, $concrete, $format, $context);
}
public function getSupportedTypes(?string $format): array
{
// Support depends on the runtime-resolved Doctrine mapping, so it cannot be
// statically cached by the serializer.
return ['object' => false];
}
/**
* @return ?class-string the concrete entity a contract interface resolves to, or null
*/
private function concreteClassFor(string $type): ?string
{
if (array_key_exists($type, $this->resolved)) {
return $this->resolved[$type];
}
if (!str_starts_with($type, self::CONTRACT_NAMESPACE) || !interface_exists($type)) {
return $this->resolved[$type] = null;
}
try {
$name = $this->entityManager->getClassMetadata($type)->getName();
} catch (Throwable) {
// Not a Doctrine-mapped (resolve_target_entities) interface.
return $this->resolved[$type] = null;
}
return $this->resolved[$type] = ($name !== $type ? $name : null);
}
}
@@ -1,120 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Command;
use App\Module\Absence\Domain\Entity\AbsenceBalance;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Infrastructure\Command\AccrueLeaveCommand;
use App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsenceBalanceRepository;
use App\Module\Core\Domain\Entity\User;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
/**
* Covers the period roll-over: when a new reference period opens, the previous
* period's "en cours d'acquisition" (N) becomes the new "acquired" (N-1), but
* only for the days that were not already taken.
*
* @internal
*/
class AccrueLeaveCommandTest extends KernelTestCase
{
private EntityManagerInterface $em;
private DoctrineAbsenceBalanceRepository $balances;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->balances = self::getContainer()->get(DoctrineAbsenceBalanceRepository::class);
}
/**
* Tristan's real case: 9.75 accruing, 1 day taken, nothing previously
* acquired → the day taken eats into the carry-over, so 8.75 rolls over
* (not 9.75).
*/
public function testCarryOverDeductsTakenDays(): void
{
$user = $this->createEmployee();
$this->seedPreviousBalance($user, acquired: 0.0, acquiring: 9.75, taken: 1.0);
$this->runForJune2026();
$rolled = $this->balances->findOneForPeriod($user, AbsenceType::PaidLeave, '2026-2027');
self::assertNotNull($rolled);
self::assertEqualsWithDelta(8.75, $rolled->getAcquired(), 0.0001);
}
/**
* Leave is charged oldest-first: the 3 days taken come out of the expiring
* N-2 "acquired" bucket (5), so the full 10 accruing days carry over intact.
*/
public function testCarryOverChargesOldestBucketFirst(): void
{
$user = $this->createEmployee();
$this->seedPreviousBalance($user, acquired: 5.0, acquiring: 10.0, taken: 3.0);
$this->runForJune2026();
$rolled = $this->balances->findOneForPeriod($user, AbsenceType::PaidLeave, '2026-2027');
self::assertNotNull($rolled);
self::assertEqualsWithDelta(10.0, $rolled->getAcquired(), 0.0001);
}
/** No day taken → the whole accruing bucket carries over. */
public function testFullCarryOverWhenNothingTaken(): void
{
$user = $this->createEmployee();
$this->seedPreviousBalance($user, acquired: 0.0, acquiring: 10.0, taken: 0.0);
$this->runForJune2026();
$rolled = $this->balances->findOneForPeriod($user, AbsenceType::PaidLeave, '2026-2027');
self::assertNotNull($rolled);
self::assertEqualsWithDelta(10.0, $rolled->getAcquired(), 0.0001);
}
private function createEmployee(): User
{
$user = new User();
$user->setUsername('accrue-test-'.uniqid());
$user->setPassword('x');
$user->setRoles(['ROLE_USER']);
$user->setIsEmployee(true);
$user->setHireDate(new DateTimeImmutable('2024-01-01'));
$user->setReferencePeriodStart('06-01');
$user->setAnnualLeaveDays(25.0);
$user->setWorkTimeRatio(1.0);
$user->setInitialLeaveBalance(0.0);
$this->em->persist($user);
$this->em->flush();
return $user;
}
private function seedPreviousBalance(User $user, float $acquired, float $acquiring, float $taken): void
{
$balance = new AbsenceBalance();
$balance->setUser($user);
$balance->setType(AbsenceType::PaidLeave);
$balance->setPeriod('2025-2026');
$balance->setAcquired($acquired);
$balance->setAcquiring($acquiring);
$balance->setTaken($taken);
$this->em->persist($balance);
$this->em->flush();
}
private function runForJune2026(): void
{
$command = self::getContainer()->get(AccrueLeaveCommand::class);
$tester = new CommandTester($command);
$tester->execute(['--month' => '2026-06']);
self::assertSame(0, $tester->getStatusCode());
}
}
@@ -1,82 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\ProjectManagement;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* Vérifie que les ressources métier sont bien gardées par les permissions RBAC
* granulaires et non plus par le simple ROLE_USER.
*
* @internal
*/
final class ProjectAccessControlTest extends WebTestCase
{
public function testAuthenticatedUserWithoutPermissionIsForbidden(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$user = $this->createPlainUser($em, 'proj-noperm-'.uniqid());
$em->flush();
$client->loginUser($user);
$client->request('GET', '/api/projects');
self::assertResponseStatusCodeSame(403);
}
public function testUserWithViewPermissionCanListProjects(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'project-management.projects.view']);
self::assertInstanceOf(Permission::class, $permission, 'Le catalogue de permissions doit contenir project-management.projects.view (lancer app:sync-permissions).');
$user = $this->createPlainUser($em, 'proj-view-'.uniqid());
$user->addDirectPermission($permission);
$em->flush();
$client->loginUser($user);
$client->request('GET', '/api/projects');
self::assertResponseIsSuccessful();
}
public function testViewPermissionDoesNotGrantWrite(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'project-management.projects.view']);
self::assertInstanceOf(Permission::class, $permission);
$user = $this->createPlainUser($em, 'proj-noWrite-'.uniqid());
$user->addDirectPermission($permission);
$em->flush();
$client->loginUser($user);
$client->request('POST', '/api/projects', server: [
'CONTENT_TYPE' => 'application/ld+json',
], content: json_encode(['name' => 'Should be denied']));
self::assertResponseStatusCodeSame(403);
}
private function createPlainUser(EntityManagerInterface $em, string $username): User
{
$user = new User();
$user->setUsername($username);
$user->setPassword('x');
$user->setRoles(['ROLE_USER']);
$em->persist($user);
return $user;
}
}
@@ -1,119 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\Shared;
use App\Module\Core\Domain\Entity\User;
use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Module\ProjectManagement\Domain\Entity\TaskTag;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* Regression: cross-module to-many relations are typed with a Shared contract
* interface (TaskTagInterface[], UserInterface[]). API Platform cannot
* denormalize a collection whose value type is an interface (no resource
* normalizer supports it), so every POST/PATCH carrying such a collection
* blew up with NotNormalizableValueException.
*
* @internal
*/
final class InterfaceCollectionDenormalizationTest extends WebTestCase
{
protected function tearDown(): void
{
$conn = self::getContainer()->get(EntityManagerInterface::class)->getConnection();
$conn->executeStatement("DELETE FROM time_entry_task_type WHERE time_entry_id IN (SELECT id FROM time_entry WHERE title = 'iface-denorm-te')");
$conn->executeStatement("DELETE FROM time_entry WHERE title = 'iface-denorm-te'");
$conn->executeStatement("DELETE FROM task_collaborator WHERE task_id IN (SELECT id FROM task WHERE title = 'iface-denorm-task')");
$conn->executeStatement("DELETE FROM task WHERE title = 'iface-denorm-task'");
parent::tearDown();
}
public function testPostTimeEntryWithInterfaceTypedTags(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$this->loginAdmin($client);
$userId = $this->adminId($em);
$tagId = $this->aTaskTagId($em);
$client->request('POST', '/api/time_entries', server: [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
], content: json_encode([
'title' => 'iface-denorm-te',
'startedAt' => '2026-06-22T10:00:00+02:00',
'stoppedAt' => '2026-06-22T11:00:00+02:00',
'user' => '/api/users/'.$userId,
'tags' => ['/api/task_tags/'.$tagId],
]));
self::assertResponseStatusCodeSame(201, $client->getResponse()->getContent() ?: '');
$data = json_decode($client->getResponse()->getContent(), true);
self::assertCount(1, $data['tags'] ?? []);
}
public function testPostTaskWithInterfaceTypedCollaborators(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$this->loginAdmin($client);
$userId = $this->adminId($em);
$projectId = $this->aProjectId($em);
$client->request('POST', '/api/tasks', server: [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
], content: json_encode([
'title' => 'iface-denorm-task',
'project' => '/api/projects/'.$projectId,
'collaborators' => ['/api/users/'.$userId],
]));
self::assertResponseStatusCodeSame(201, $client->getResponse()->getContent() ?: '');
$data = json_decode($client->getResponse()->getContent(), true);
self::assertCount(1, $data['collaborators'] ?? []);
}
private function loginAdmin(KernelBrowser $client): void
{
$em = self::getContainer()->get(EntityManagerInterface::class);
$user = $em->getRepository(User::class)->findOneBy(['username' => 'admin']);
self::assertInstanceOf(User::class, $user);
$client->loginUser($user);
}
private function adminId(EntityManagerInterface $em): int
{
$user = $em->getRepository(User::class)->findOneBy(['username' => 'admin']);
self::assertInstanceOf(User::class, $user);
return $user->getId();
}
private function aTaskTagId(EntityManagerInterface $em): int
{
$tag = $em->getRepository(TaskTag::class)->findOneBy([]);
if (null === $tag) {
$tag = new TaskTag();
$tag->setLabel('iface-denorm-tag');
$em->persist($tag);
$em->flush();
}
return $tag->getId();
}
private function aProjectId(EntityManagerInterface $em): int
{
$project = $em->getRepository(Project::class)->findOneBy([]);
self::assertInstanceOf(Project::class, $project);
return $project->getId();
}
}