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
76 changed files with 500 additions and 2668 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`)
- 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
- User admin : `admin` / `admin` (ROLE_ADMIN)
+3 -3
View File
@@ -23,9 +23,9 @@ return [
'icon' => 'mdi:view-dashboard-outline',
'items' => [
['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.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline', 'module' => 'project-management', 'permission' => 'project-management.projects.view'],
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking', 'permission' => 'time-tracking.entries.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'],
['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.
['label' => 'sidebar.general.mail', 'to' => '/mail', 'icon' => 'mdi:email-outline', 'module' => 'mail'],
],
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.4.37'
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..."
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..."
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
@@ -300,31 +294,7 @@ cd /var/www/lesstime
./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
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
```
C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations et vide le cache.
---
@@ -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>
+3 -54
View File
@@ -24,9 +24,7 @@
"updated": "Client mis à jour avec succès.",
"deleted": "Client supprimé avec succès.",
"addClient": "Ajouter 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."
"editClient": "Modifier un client"
},
"projects": {
"title": "Projets",
@@ -910,14 +908,11 @@
"editProspect": "Modifier un prospect",
"convert": "Convertir 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": {
"name": "Nom",
"company": "Société",
"email": "Email",
"phone": "Téléphone",
"website": "Site web",
"street": "Rue",
"city": "Ville",
"postalCode": "Code postal",
@@ -933,51 +928,18 @@
"lost": "Perdu"
},
"validation": {
"nameRequired": "Le nom est requis",
"companyRequired": "La société est requise"
}
},
"prestataires": {
"created": "Prestataire créé avec succès.",
"updated": "Prestataire mis à jour avec succès.",
"deleted": "Prestataire supprimé avec succès.",
"addPrestataire": "Ajouter un prestataire",
"editPrestataire": "Modifier un prestataire",
"deleteConfirmTitle": "Supprimer le prestataire",
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer le prestataire « {name} » ? Cette action est irréversible.",
"fields": {
"name": "Nom / Société",
"email": "Email",
"phone": "Téléphone",
"website": "Site web"
"nameRequired": "Le nom est requis"
}
},
"directory": {
"title": "Répertoire",
"tabs": {
"info": "Informations",
"clients": "Clients",
"prospects": "Prospects",
"prestataires": "Prestataires",
"contact": "Contact",
"address": "Adresse",
"report": "Rapport"
},
"info": {
"fields": {
"name": "Nom",
"email": "Email",
"phone": "Téléphone",
"website": "Site web"
}
},
"validation": {
"nameRequired": "Le nom est requis.",
"subjectRequired": "L'objet est requis.",
"emailInvalid": "Adresse email invalide.",
"phoneInvalid": "Numéro de téléphone invalide (ex. 0549200910).",
"urlInvalid": "URL invalide (ex. https://exemple.fr)."
},
"clients": {
"add": "Ajouter un client",
"empty": "Aucun client trouvé."
@@ -987,10 +949,6 @@
"empty": "Aucun prospect trouvé.",
"allStatuses": "Tous les statuts"
},
"prestataires": {
"add": "Ajouter un prestataire",
"empty": "Aucun prestataire trouvé."
},
"contacts": {
"add": "Ajouter un contact",
"item": "Contact {n}",
@@ -1010,8 +968,6 @@
"item": "Adresse {n}",
"saved": "Adresse enregistrée.",
"deleted": "Adresse supprimée.",
"streetNotFound": "Aucune adresse trouvée — saisie libre possible.",
"autocompleteUnavailable": "Recherche d'adresse indisponible : saisissez l'adresse manuellement.",
"fields": {
"label": "Libellé",
"street": "Rue",
@@ -1022,16 +978,9 @@
},
"reports": {
"add": "Ajouter un compte-rendu",
"addTitle": "Nouveau 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",
"empty": "Aucun compte-rendu.",
"saved": "Compte-rendu enregistré.",
"deleted": "Compte-rendu supprimé.",
"confirmDeleteTitle": "Supprimer ce compte-rendu ?",
"confirmDeleteMessage": "Cette action est irréversible. Les documents joints seront également supprimés.",
"fields": {
"subject": "Objet",
"type": "Type d'échange",
@@ -6,11 +6,21 @@
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.name"
label="Nom société"
label="Nom"
input-class="w-full"
:error="touched.name && !form.name.trim() ? 'Le nom est requis' : ''"
@blur="touched.name = true"
/>
<MalioInputText
v-model="form.email"
label="Email"
input-class="w-full"
/>
<MalioInputText
v-model="form.phone"
label="Téléphone"
input-class="w-full"
/>
<div class="mt-6 flex justify-end">
<MalioButton
@@ -48,16 +58,28 @@ const isSubmitting = ref(false)
const form = reactive({
name: '',
email: '',
phone: '',
})
const touched = reactive({
name: false,
email: false,
})
watch(() => props.modelValue, (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.email = false
}
})
@@ -71,6 +93,8 @@ async function handleSubmit() {
try {
const payload: ClientWrite = {
name: form.name.trim(),
email: form.email.trim() || null,
phone: form.phone.trim() || null,
}
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, prestataire?: 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>
<div class="flex flex-col gap-5 pt-6">
<!-- Barre d'action -->
<div class="flex items-center justify-between gap-3">
<p class="text-sm text-neutral-500">
<span v-if="reports.length">{{ $t('directory.reports.count', { n: reports.length }, reports.length) }}</span>
</p>
<MalioButton
v-if="canManage"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.reports.add')"
@click="openCreate"
<div class="flex flex-col gap-6 pt-6">
<!-- Formulaire d'ajout / édition -->
<div v-if="isAdmin" class="grid grid-cols-2 gap-x-8 gap-y-3 rounded bg-white p-4 shadow">
<MalioInputText
class="col-span-2"
:label="$t('directory.reports.fields.subject')"
v-model="draft.subject"
/>
<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>
<!-- État vide -->
<div
v-if="!loading && !reports.length"
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"
>
<Icon name="mdi:message-text-outline" class="text-4xl text-neutral-300" />
<p class="font-medium text-neutral-600">{{ $t('directory.reports.empty') }}</p>
<p class="max-w-sm text-sm text-neutral-400">{{ $t('directory.reports.emptyHint') }}</p>
<MalioButton
v-if="canManage"
variant="tertiary"
icon-name="mdi:plus"
icon-position="left"
button-class="mt-2 w-auto px-4"
:label="$t('directory.reports.add')"
@click="openCreate"
/>
<!-- Liste des comptes-rendus -->
<div v-for="report in reports" :key="report.id" class="rounded border border-neutral-200 p-4">
<div class="flex items-start justify-between">
<div>
<p class="font-semibold text-neutral-800">{{ report.subject }}</p>
<p class="text-xs text-neutral-500">
{{ formatDate(report.occurredAt) }} · {{ $t(`directory.reports.types.${report.type}`) }}
<span v-if="report.author"> · {{ report.author.username }}</span>
</p>
</div>
<div v-if="isAdmin" class="flex gap-2">
<MalioButtonIcon icon="mdi:pencil-outline" :aria-label="$t('common.edit')" @click="edit(report)" />
<MalioButtonIcon icon="mdi:trash-can-outline" button-class="!text-red-600" :aria-label="$t('common.delete')" @click="remove(report.id)" />
</div>
</div>
<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>
<!-- Timeline antéchronologique -->
<ol v-else class="flex flex-col">
<li
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"
/>
<p v-if="!reports.length" class="text-sm text-neutral-400">
{{ $t('directory.reports.empty') }}
</p>
</div>
</template>
<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 { useReportDocumentService } from '~/modules/directory/services/report-documents'
const props = defineProps<{
owner: { client?: string, prospect?: string, prestataire?: string }
canManage: boolean
owner: { client?: string, prospect?: string }
isAdmin: boolean
}>()
const { t } = useI18n()
const reportService = useCommercialReportService()
const documentService = useReportDocumentService()
const reports = ref<CommercialReport[]>([])
const loading = ref(true)
const editingId = ref<number | null>(null)
const drawerOpen = ref(false)
const editing = ref<CommercialReport | null>(null)
const confirmOpen = ref(false)
const pendingDelete = ref<CommercialReport | null>(null)
const deleting = ref(false)
// 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
function emptyDraft(): CommercialReportWrite {
return {
subject: '',
body: null,
occurredAt: new Date().toISOString().slice(0, 10),
type: 'note',
...props.owner,
}
}
const draft = ref<CommercialReportWrite>(emptyDraft())
async function removeDocument(id: number): Promise<void> {
await documentService.remove(id)
await reload()
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 formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('fr-FR')
}
async function reload(): Promise<void> {
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)
@@ -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>
<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">
{{ title }}
</h3>
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:delete-outline"
variant="ghost"
class="absolute right-3 top-3"
icon="mdi:trash-can-outline"
class="absolute right-2 top-2"
button-class="!text-red-600"
:aria-label="$t('common.delete')"
@click="$emit('remove')"
/>
@@ -19,33 +19,13 @@
:readonly="readonly"
@update:model-value="update('label', $event)"
/>
<!-- Rue : saisie assistée (BAN) en édition, champ texte en lecture seule.
allow-create conserve le texte saisi si la BAN ne propose rien
(erreur/timeout). Choisir une suggestion remplit rue + CP + ville. -->
<div class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly"
:model-value="modelValue.street ?? ''"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:allow-create="true"
:label="$t('directory.addresses.fields.street')"
:no-results-text="$t('directory.addresses.streetNotFound')"
@update:model-value="(v) => update('street', v === null ? '' : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:label="$t('directory.addresses.fields.street')"
:model-value="modelValue.street ?? ''"
:readonly="readonly"
@update:model-value="update('street', $event)"
/>
</div>
<MalioInputText
class="col-span-2"
:label="$t('directory.addresses.fields.street')"
:model-value="modelValue.street ?? ''"
:readonly="readonly"
@update:model-value="update('street', $event)"
/>
<MalioInputText
class="col-span-2"
:label="$t('directory.addresses.fields.streetComplement')"
@@ -53,27 +33,13 @@
:readonly="readonly"
@update:model-value="update('streetComplement', $event)"
/>
<MalioInputText
:label="$t('directory.addresses.fields.postalCode')"
:model-value="modelValue.postalCode ?? ''"
:readonly="readonly"
@update:model-value="onPostalCodeInput"
/>
<!-- Ville : select alimenté par le code postal (BAN). En mode dégradé
(BAN indispo) ou lecture seule, on bascule en saisie libre. -->
<MalioSelect
v-if="!readonly && !degraded"
:model-value="modelValue.city ?? ''"
:options="cityOptions"
:label="$t('directory.addresses.fields.city')"
empty-option-label=""
group-class="w-full"
@update:model-value="(v) => update('city', v === null ? '' : String(v))"
@update:model-value="update('postalCode', $event)"
/>
<MalioInputText
v-else
:label="$t('directory.addresses.fields.city')"
:model-value="modelValue.city ?? ''"
:readonly="readonly"
@@ -84,10 +50,6 @@
<script setup lang="ts">
import type { Address } from '~/modules/directory/services/dto/address'
import {
useAddressAutocomplete,
type AddressSuggestion,
} from '~/modules/directory/composables/useAddressAutocomplete'
const props = defineProps<{
modelValue: Address
@@ -101,98 +63,7 @@ const emit = defineEmits<{
'remove': []
}>()
const { t } = useI18n()
const toast = useToast()
const autocomplete = useAddressAutocomplete()
type Option = { label: string, value: string | number }
const addressOptions = ref<Option[]>([])
// Villes renvoyées par la BAN pour le code postal courant.
const fetchedCityOptions = ref<Option[]>([])
const addressLoading = ref(false)
// Le select Ville n'affiche que les valeurs présentes dans ses options : on
// garantit donc que la ville déjà enregistrée (chargement d'une fiche) ou
// pré-remplie par l'autocomplétion d'adresse figure toujours dans la liste,
// même avant toute recherche par code postal — sinon elle s'afficherait vide.
const cityOptions = computed<Option[]>(() => {
const current = (props.modelValue.city ?? '').trim()
const options = [...fetchedCityOptions.value]
if (current && !options.some(o => o.value === current)) {
options.unshift({ value: current, label: current })
}
return options
})
// Mode dégradé : BAN indisponible → la ville passe en saisie libre.
const degraded = ref(false)
let lastAddressSuggestions: AddressSuggestion[] = []
let notified = false
function update(field: keyof Address, value: string): void {
emit('update:modelValue', { ...props.modelValue, [field]: value === '' ? null : value })
}
// Avertit une seule fois que l'autocomplétion est indisponible (saisie libre).
function notifyUnavailable(): void {
if (notified) return
notified = true
toast.info({ title: '', message: t('directory.addresses.autocompleteUnavailable') })
}
/** Recherche d'adresse assistée (event de MalioInputAutocomplete). */
async function onAddressSearch(query: string): Promise<void> {
if (query.trim().length < 3) {
addressOptions.value = []
return
}
addressLoading.value = true
try {
const postalCode = (props.modelValue.postalCode ?? '').replace(/\D/g, '') || undefined
const suggestions = await autocomplete.searchAddress(query, postalCode)
lastAddressSuggestions = suggestions
addressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
}
catch {
addressOptions.value = []
notifyUnavailable()
}
finally {
addressLoading.value = false
}
}
/** Sélection d'une suggestion → remplit rue + ville + code postal. */
function onAddressSelect(option: Option | null): void {
if (option === null) return
// Matching par `label` (adresse complète, unique côté BAN) plutôt que par
// rue : deux communes peuvent partager le même libellé de voie.
const suggestion = lastAddressSuggestions.find(s => s.label === option.label)
if (!suggestion) {
update('street', String(option.value))
return
}
emit('update:modelValue', {
...props.modelValue,
street: suggestion.street,
city: suggestion.city || props.modelValue.city,
postalCode: suggestion.postalCode || props.modelValue.postalCode,
})
}
/** Saisie du code postal → met à jour le champ + interroge la BAN pour la ville. */
async function onPostalCodeInput(value: string): Promise<void> {
update('postalCode', value)
const digits = (value ?? '').replace(/\D/g, '')
if (digits.length < 5) return
try {
const suggestions = await autocomplete.searchCity(digits)
fetchedCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
degraded.value = false
}
catch {
degraded.value = true
notifyUnavailable()
}
}
</script>
@@ -1,13 +1,13 @@
<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">
{{ title }}
</h3>
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:delete-outline"
variant="ghost"
class="absolute right-3 top-3"
icon="mdi:trash-can-outline"
class="absolute right-2 top-2"
button-class="!text-red-600"
:aria-label="$t('common.delete')"
@click="$emit('remove')"
/>
@@ -35,21 +35,18 @@
:label="$t('directory.contacts.fields.email')"
:model-value="modelValue.email ?? ''"
:readonly="readonly"
:error="emailError"
@update:model-value="update('email', $event)"
/>
<MalioInputText
:label="$t('directory.contacts.fields.phonePrimary')"
:model-value="modelValue.phonePrimary ?? ''"
:readonly="readonly"
:error="phonePrimaryError"
@update:model-value="update('phonePrimary', $event)"
/>
<MalioInputText
:label="$t('directory.contacts.fields.phoneSecondary')"
:model-value="modelValue.phoneSecondary ?? ''"
:readonly="readonly"
:error="phoneSecondaryError"
@update:model-value="update('phoneSecondary', $event)"
/>
</div>
@@ -57,7 +54,6 @@
<script setup lang="ts">
import type { Contact } from '~/modules/directory/services/dto/contact'
import { isValidEmail, isValidFrPhone } from '~/modules/directory/utils/validation'
const props = defineProps<{
modelValue: Contact
@@ -71,18 +67,6 @@ const emit = defineEmits<{
'remove': []
}>()
const { t } = useI18n()
const emailError = computed(() =>
isValidEmail(props.modelValue.email) ? '' : t('directory.validation.emailInvalid'),
)
const phonePrimaryError = computed(() =>
isValidFrPhone(props.modelValue.phonePrimary) ? '' : t('directory.validation.phoneInvalid'),
)
const phoneSecondaryError = computed(() =>
isValidFrPhone(props.modelValue.phoneSecondary) ? '' : t('directory.validation.phoneInvalid'),
)
function update(field: keyof Contact, value: string): void {
emit('update:modelValue', { ...props.modelValue, [field]: value === '' ? null : value })
}
@@ -1,88 +0,0 @@
<template>
<MalioDrawer v-model="isOpen">
<template #header>
<h2 class="text-xl font-bold">{{ isEditing ? $t('prestataires.editPrestataire') : $t('prestataires.addPrestataire') }}</h2>
</template>
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.name"
:label="$t('prestataires.fields.name')"
input-class="w-full"
:error="touched.name && !form.name.trim() ? $t('directory.validation.nameRequired') : ''"
@blur="touched.name = true"
/>
<div class="mt-6 flex justify-end">
<MalioButton
:label="$t('common.save')"
button-class="w-auto px-6"
:disabled="isSubmitting"
@click="handleSubmit"
/>
</div>
</form>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { Prestataire, PrestataireWrite } from '~/modules/directory/services/dto/prestataire'
import { usePrestataireService } from '~/modules/directory/services/prestataires'
const props = defineProps<{
modelValue: boolean
prestataire: Prestataire | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved'): void
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const isEditing = computed(() => !!props.prestataire)
const isSubmitting = ref(false)
const form = reactive({
name: '',
})
const touched = reactive({
name: false,
})
watch(() => props.modelValue, (open) => {
if (open) {
form.name = props.prestataire?.name ?? ''
touched.name = false
}
})
const { create, update } = usePrestataireService()
async function handleSubmit() {
touched.name = true
if (!form.name.trim()) return
isSubmitting.value = true
try {
const payload: PrestataireWrite = {
name: form.name.trim(),
}
if (isEditing.value && props.prestataire) {
await update(props.prestataire.id, payload)
} else {
await create(payload)
}
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
</script>
@@ -4,12 +4,42 @@
<h2 class="text-xl font-bold">{{ isEditing ? $t('prospects.editProspect') : $t('prospects.addProspect') }}</h2>
</template>
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.name"
:label="$t('prospects.fields.name')"
input-class="w-full"
:error="touched.name && !form.name.trim() ? $t('prospects.validation.nameRequired') : ''"
@blur="touched.name = true"
/>
<MalioInputText
v-model="form.company"
:label="$t('prospects.fields.company')"
input-class="w-full"
:error="touched.company && !form.company.trim() ? $t('prospects.validation.companyRequired') : ''"
@blur="touched.company = true"
/>
<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">
@@ -39,7 +69,7 @@
</template>
<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'
const props = defineProps<{
@@ -52,6 +82,8 @@ const emit = defineEmits<{
(e: 'saved'): void
}>()
const { t } = useI18n()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
@@ -61,31 +93,75 @@ const isEditing = computed(() => !!props.prospect)
const isConverted = computed(() => !!props.prospect?.convertedClient)
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: '',
company: '',
email: '',
phone: '',
status: 'new',
source: '',
notes: '',
})
const touched = reactive({
company: false,
name: false,
})
watch(() => props.modelValue, (open) => {
if (open) {
form.company = props.prospect?.company ?? ''
touched.company = false
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
}
})
const { create, update, convert } = useProspectService()
async function handleSubmit() {
touched.company = true
if (!form.company.trim()) return
touched.name = true
if (!form.name.trim()) return
isSubmitting.value = true
try {
const payload: ProspectWrite = {
company: form.company.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) {
@@ -15,7 +15,7 @@
{{ doc.originalName }}
</a>
<MalioButtonIcon
v-if="canManage"
v-if="isAdmin"
icon="mdi:trash-can-outline"
button-class="!text-red-600"
:aria-label="$t('common.delete')"
@@ -32,7 +32,7 @@
import type { ReportDocument } from '~/modules/directory/services/dto/report-document'
import { useReportDocumentService } from '~/modules/directory/services/report-documents'
defineProps<{ documents: ReportDocument[], canManage: boolean }>()
defineProps<{ documents: ReportDocument[], isAdmin: boolean }>()
defineEmits<{ delete: [id: number] }>()
const { getDownloadUrl } = useReportDocumentService()
@@ -1,113 +0,0 @@
import { httpExternal } from '~/utils/httpExternal'
// Autocomplétion d'adresse branchée sur la Base Adresse Nationale (BAN),
// `api-adresse.data.gouv.fr` — service public français, gratuit, CORS ouvert.
//
// Appel HTTP DIRECT depuis le front (pas de proxy back) : la BAN est un domaine
// externe, sans cookie de session ni enveloppe Hydra → on passe par
// `httpExternal` et NON `useApi()`.
//
// Contrat :
// searchCity(postalCode) -> liste { city, postalCode }
// searchAddress(query, cp?) -> liste { label, street, postalCode, city }
// En cas d'erreur/timeout, la méthode THROW une
// AddressAutocompleteUnavailableError. Le composant consommateur catch,
// avertit l'utilisateur et bascule en saisie libre.
/** URL de l'endpoint de recherche BAN. */
const BAN_SEARCH_URL = 'https://api-adresse.data.gouv.fr/search/'
/** Une suggestion de ville renvoyée à partir d'un code postal. */
export interface CitySuggestion {
city: string
postalCode: string
}
/** Une suggestion d'adresse complète (saisie assistée du champ « Rue »). */
export interface AddressSuggestion {
label: string
street: string
postalCode: string
city: string
}
export interface AddressAutocomplete {
searchCity(postalCode: string): Promise<CitySuggestion[]>
searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]>
}
/** Erreur signalant que le service d'autocomplétion BAN n'est pas disponible. */
export class AddressAutocompleteUnavailableError extends Error {
constructor() {
super('Address autocomplete (BAN) is not available.')
this.name = 'AddressAutocompleteUnavailableError'
}
}
/** Propriétés d'une « feature » GeoJSON renvoyée par la BAN (champs utilisés). */
interface BanFeatureProperties {
label?: string
name?: string
street?: string
postcode?: string
city?: string
}
/** Réponse GeoJSON FeatureCollection de la BAN. */
interface BanResponse {
features?: { properties?: BanFeatureProperties }[]
}
export function useAddressAutocomplete(): AddressAutocomplete {
return {
async searchCity(postalCode: string): Promise<CitySuggestion[]> {
let res: BanResponse
try {
res = await httpExternal<BanResponse>(BAN_SEARCH_URL, {
query: { q: postalCode, type: 'municipality' },
})
}
catch {
throw new AddressAutocompleteUnavailableError()
}
return (res.features ?? []).map((feature) => {
const props = feature.properties ?? {}
return {
city: props.city ?? props.name ?? '',
postalCode: props.postcode ?? '',
}
})
},
async searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]> {
// Pas de `type=housenumber` ici : sans filtre, la BAN classe rues +
// numéros par pertinence (comportement d'autocomplétion attendu).
// On n'ajoute `postcode` que s'il est fourni (sinon recherche large).
const banQuery: Record<string, string> = { q: query }
if (postalCode) {
banQuery.postcode = postalCode
}
let res: BanResponse
try {
res = await httpExternal<BanResponse>(BAN_SEARCH_URL, { query: banQuery })
}
catch {
throw new AddressAutocompleteUnavailableError()
}
return (res.features ?? []).map((feature) => {
const props = feature.properties ?? {}
return {
label: props.label ?? '',
// `name` porte la ligne d'adresse complète (numéro + voie) ;
// `street` ne contient que la voie. On privilégie `name`.
street: props.name ?? props.street ?? '',
postalCode: props.postcode ?? '',
city: props.city ?? '',
}
})
},
}
}
@@ -3,15 +3,13 @@ import type { Address } from '~/modules/directory/services/dto/address'
import { useContactService } from '~/modules/directory/services/contacts'
import { useAddressService } from '~/modules/directory/services/addresses'
type Owner = { client?: string, prospect?: string, prestataire?: string }
type Owner = { client?: string, prospect?: string }
/**
* Logique partagée des fiches détail Client/Prospect : blocs répétables Contact
* et Adresse (chargement, ajout, suppression). L'édition est tenue en mémoire
* localement ; la persistance se fait au clic sur « Enregistrer » (saveContacts/
* saveAddresses), comme les formulaires de tâche — pas d'enregistrement au blur.
* Paramétré par l'IRI du propriétaire (`{ client }` ou `{ prospect }`), réutilisé
* tel quel par les deux pages.
* Logique partagée des fiches détail Client/Prospect : gestion des blocs
* répétables Contact et Adresse (chargement, ajout, édition par bloc avec
* persistance immédiate, suppression). Paramétré par l'IRI du propriétaire
* (`{ client }` ou `{ prospect }`), réutilisé tel quel par les deux pages.
*/
export function useDirectoryDetail(owner: Owner) {
const contactService = useContactService()
@@ -19,8 +17,6 @@ export function useDirectoryDetail(owner: Owner) {
const contacts = ref<Contact[]>([])
const addresses = ref<Address[]>([])
const savingContacts = ref(false)
const savingAddresses = ref(false)
function emptyContact(): Contact {
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 }
}
// Édition locale uniquement : on remplace le bloc en mémoire, rien n'est
// persisté tant que l'utilisateur n'a pas cliqué sur « Enregistrer ».
function onContactInput(index: number, value: Contact): void {
async function onContactInput(index: number, value: Contact): Promise<void> {
contacts.value[index] = value
await persistContact(index)
}
function onAddressInput(index: number, value: Address): void {
addresses.value[index] = value
async function persistContact(index: number): Promise<void> {
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 {
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> {
const c = contacts.value[index]
if (c?.id && c.id > 0) await contactService.remove(c.id)
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> {
const a = addresses.value[index]
if (a?.id && a.id > 0) await addressService.remove(a.id)
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> {
contacts.value = await contactService.getByOwner(owner)
addresses.value = await addressService.getByOwner(owner)
@@ -106,16 +81,12 @@ export function useDirectoryDetail(owner: Owner) {
return {
contacts,
addresses,
savingContacts,
savingAddresses,
onContactInput,
addContact,
removeContact,
saveContacts,
onAddressInput,
addAddress,
removeAddress,
saveAddresses,
load,
}
}
@@ -8,44 +8,6 @@
<p v-if="loading">{{ $t('common.loading') }}</p>
<template v-else-if="client">
<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')"
:error="emailError"
/>
<MalioInputText
v-model="info.phone"
:label="$t('directory.info.fields.phone')"
:error="phoneError"
/>
<MalioInputText
v-model="info.website"
class="col-span-2"
:label="$t('directory.info.fields.website')"
:error="websiteError"
/>
</div>
<div class="flex justify-center pt-2">
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingInfo || !infoValid"
@click="saveInfo"
/>
</div>
</div>
</template>
<template #contact>
<div class="flex flex-col gap-4 pt-6">
<DirectoryContactBlock
@@ -57,22 +19,13 @@
@update:model-value="(v) => onContactInput(i, v)"
@remove="removeContact(i)"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.contacts.add')"
@click="addContact"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingContacts"
@click="saveContacts"
/>
</div>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.contacts.add')"
@click="addContact"
/>
</div>
</template>
@@ -87,27 +40,18 @@
@update:model-value="(v) => onAddressInput(i, v)"
@remove="removeAddress(i)"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.addresses.add')"
@click="addAddress"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingAddresses"
@click="saveAddresses"
/>
</div>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.addresses.add')"
@click="addAddress"
/>
</div>
</template>
<template #report>
<CommercialReportTab :owner="owner" :can-manage="canManage" />
<CommercialReportTab :owner="owner" :is-admin="true" />
</template>
</MalioTabList>
</template>
@@ -117,7 +61,6 @@
<script setup lang="ts">
import type { Client } from '~/modules/directory/services/dto/client'
import { useClientService } from '~/modules/directory/services/clients'
import { isValidEmail, isValidFrPhone, isValidUrl } from '~/modules/directory/utils/validation'
definePageMeta({ middleware: ['admin'] })
@@ -133,70 +76,31 @@ const clientService = useClientService()
const {
contacts,
addresses,
savingContacts,
savingAddresses,
onContactInput,
addContact,
removeContact,
saveContacts,
onAddressInput,
addAddress,
removeAddress,
saveAddresses,
load,
} = useDirectoryDetail(owner)
const { can } = usePermissions()
const canManage = computed(() => can('directory.clients.manage'))
const client = ref<Client | null>(null)
const loading = ref(true)
const activeTab = ref('info')
const activeTab = ref('contact')
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: 'address', label: t('directory.tabs.address'), icon: 'mdi:map-marker-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: '', website: '' })
const infoTouched = reactive({ name: false })
const savingInfo = ref(false)
const emailError = computed(() => (isValidEmail(info.email) ? '' : t('directory.validation.emailInvalid')))
const phoneError = computed(() => (isValidFrPhone(info.phone) ? '' : t('directory.validation.phoneInvalid')))
const websiteError = computed(() => (isValidUrl(info.website) ? '' : t('directory.validation.urlInvalid')))
const infoValid = computed(() => !emailError.value && !phoneError.value && !websiteError.value)
async function saveInfo(): Promise<void> {
infoTouched.name = true
if (!info.name.trim() || !infoValid.value || 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,
website: info.website.trim() || null,
})
} finally {
savingInfo.value = false
}
}
function goBack(): void {
router.push('/directory')
}
onMounted(async () => {
client.value = await clientService.getById(id)
info.name = client.value.name ?? ''
info.email = client.value.email ?? ''
info.phone = client.value.phone ?? ''
info.website = client.value.website ?? ''
await load()
loading.value = false
})
@@ -31,17 +31,6 @@
<template #cell-phone="{ item }">
{{ (item as Client).phone ?? '—' }}
</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>
</div>
</template>
@@ -86,63 +75,20 @@
{{ (item as ProspectRow).phone ?? '—' }}
</template>
<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
v-if="!(item as ProspectRow).convertedClient"
icon="mdi:account-convert"
:aria-label="$t('prospects.convert')"
button-class="!bg-green-100 !text-green-700"
:icon-size="18"
@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>
</template>
</MalioDataTable>
</div>
</template>
<!-- Prestataires -->
<template #prestataires>
<div class="flex min-h-[30rem] flex-col gap-4 pt-10">
<div class="flex items-center justify-end">
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.prestataires.add')"
@click="openCreatePrestataire"
/>
</div>
<MalioDataTable
:columns="prestataireColumns"
:items="prestataires"
:total-items="prestataires.length"
:empty-message="$t('directory.prestataires.empty')"
@row-click="openEditPrestataire"
>
<template #cell-email="{ item }">
{{ (item as Prestataire).email ?? '—' }}
</template>
<template #cell-phone="{ item }">
{{ (item as Prestataire).phone ?? '—' }}
</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="askDeletePrestataire(item as Prestataire)"
/>
</div>
<span v-else class="text-neutral-300"></span>
</template>
</MalioDataTable>
</div>
@@ -159,18 +105,6 @@
:prospect="selectedProspect"
@saved="onProspectSaved"
/>
<PrestataireDrawer
v-model="prestataireDrawerOpen"
:prestataire="selectedPrestataire"
@saved="loadPrestataires"
/>
<ConfirmDeleteModal
v-model="deleteModalOpen"
:title="deleteModalTitle"
:message="deleteModalMessage"
@confirm="confirmDelete"
/>
</div>
</template>
@@ -179,8 +113,6 @@ import type { Client } from '~/modules/directory/services/dto/client'
import { useClientService } from '~/modules/directory/services/clients'
import type { Prospect, ProspectStatus } from '~/modules/directory/services/dto/prospect'
import { useProspectService } from '~/modules/directory/services/prospects'
import type { Prestataire } from '~/modules/directory/services/dto/prestataire'
import { usePrestataireService } from '~/modules/directory/services/prestataires'
definePageMeta({ middleware: ['admin'] })
@@ -191,13 +123,11 @@ useHead({ title: t('directory.title') })
const clientService = useClientService()
const prospectService = useProspectService()
const prestataireService = usePrestataireService()
const activeTab = ref('clients')
const tabs = [
{ key: 'clients', label: t('directory.tabs.clients'), icon: 'mdi:account-tie-outline' },
{ key: 'prospects', label: t('directory.tabs.prospects'), icon: 'mdi:account-search-outline' },
{ key: 'prestataires', label: t('directory.tabs.prestataires'), icon: 'mdi:account-hard-hat-outline' },
]
// --- Clients ---
@@ -206,10 +136,9 @@ const clientDrawerOpen = ref(false)
const selectedClient = ref<Client | null>(null)
const clientColumns = [
{ key: 'name', label: t('prospects.fields.company') },
{ key: 'name', label: t('prospects.fields.name') },
{ key: 'email', label: t('prospects.fields.email') },
{ key: 'phone', label: t('prospects.fields.phone') },
{ key: 'actions', label: '' },
]
async function loadClients() {
@@ -240,6 +169,7 @@ const statusOptions = [
]
const prospectColumns = [
{ key: 'name', label: t('prospects.fields.name') },
{ key: 'company', label: t('prospects.fields.company') },
{ key: 'status', label: t('prospects.fields.status') },
{ key: 'email', label: t('prospects.fields.email') },
@@ -295,102 +225,10 @@ async function onProspectSaved() {
await Promise.all([loadProspects(), loadClients()])
}
// --- Prestataires ---
const prestataires = ref<Prestataire[]>([])
const prestataireDrawerOpen = ref(false)
const selectedPrestataire = ref<Prestataire | null>(null)
const prestataireColumns = [
{ key: 'name', label: t('prospects.fields.company') },
{ key: 'email', label: t('prospects.fields.email') },
{ key: 'phone', label: t('prospects.fields.phone') },
{ key: 'actions', label: '' },
]
async function loadPrestataires() {
prestataires.value = await prestataireService.getAll()
}
function openCreatePrestataire() {
selectedPrestataire.value = null
prestataireDrawerOpen.value = true
}
function openEditPrestataire(item: Record<string, unknown>) {
navigateTo(`/directory/prestataires/${(item as Prestataire).id}`)
}
// --- Suppression (clients, prospects & prestataires) ---
type DeleteTarget =
| { type: 'client'; item: Client }
| { type: 'prospect'; item: Prospect }
| { type: 'prestataire'; item: Prestataire }
const deleteModalOpen = ref(false)
const deleteTarget = ref<DeleteTarget | null>(null)
const deleteModalTitle = computed(() => {
switch (deleteTarget.value?.type) {
case 'prospect':
return t('prospects.deleteConfirmTitle')
case 'prestataire':
return t('prestataires.deleteConfirmTitle')
default:
return t('clients.deleteConfirmTitle')
}
})
const deleteModalMessage = computed(() => {
const target = deleteTarget.value
if (!target) return ''
switch (target.type) {
case 'prospect':
return t('prospects.deleteConfirmMessage', { name: target.item.company })
case 'prestataire':
return t('prestataires.deleteConfirmMessage', { name: target.item.name })
default:
return t('clients.deleteConfirmMessage', { name: target.item.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
}
function askDeletePrestataire(item: Prestataire) {
deleteTarget.value = { type: 'prestataire', 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 if (target.type === 'prospect') {
await prospectService.remove(target.item.id)
await loadProspects()
} else {
await prestataireService.remove(target.item.id)
await loadPrestataires()
}
deleteModalOpen.value = false
deleteTarget.value = null
}
watch(statusFilter, loadProspects)
onMounted(async () => {
await Promise.all([loadClients(), loadProspects(), loadPrestataires()])
await Promise.all([loadClients(), loadProspects()])
})
</script>
@@ -1,201 +0,0 @@
<template>
<div class="flex flex-col gap-6">
<div class="flex items-center gap-3 pt-4">
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
<h1 class="text-2xl font-bold text-neutral-900">{{ prestataire?.name ?? '…' }}</h1>
</div>
<p v-if="loading">{{ $t('common.loading') }}</p>
<template v-else-if="prestataire">
<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')"
:error="emailError"
/>
<MalioInputText
v-model="info.phone"
:label="$t('directory.info.fields.phone')"
:error="phoneError"
/>
<MalioInputText
v-model="info.website"
class="col-span-2"
:label="$t('directory.info.fields.website')"
:error="websiteError"
/>
</div>
<div class="flex justify-center pt-2">
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingInfo || !infoValid"
@click="saveInfo"
/>
</div>
</div>
</template>
<template #contact>
<div class="flex flex-col gap-4 pt-6">
<DirectoryContactBlock
v-for="(contact, i) in contacts"
:key="contact.id || `new-${i}`"
:model-value="contact"
:title="$t('directory.contacts.item', { n: i + 1 })"
:removable="contacts.length > 0"
@update:model-value="(v) => onContactInput(i, v)"
@remove="removeContact(i)"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.contacts.add')"
@click="addContact"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingContacts"
@click="saveContacts"
/>
</div>
</div>
</template>
<template #address>
<div class="flex flex-col gap-4 pt-6">
<DirectoryAddressBlock
v-for="(address, i) in addresses"
:key="address.id || `new-${i}`"
:model-value="address"
:title="$t('directory.addresses.item', { n: i + 1 })"
:removable="addresses.length > 0"
@update:model-value="(v) => onAddressInput(i, v)"
@remove="removeAddress(i)"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.addresses.add')"
@click="addAddress"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingAddresses"
@click="saveAddresses"
/>
</div>
</div>
</template>
<template #report>
<CommercialReportTab :owner="owner" :can-manage="canManage" />
</template>
</MalioTabList>
</template>
</div>
</template>
<script setup lang="ts">
import type { Prestataire } from '~/modules/directory/services/dto/prestataire'
import { usePrestataireService } from '~/modules/directory/services/prestataires'
import { isValidEmail, isValidFrPhone, isValidUrl } from '~/modules/directory/utils/validation'
definePageMeta({ middleware: ['admin'] })
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const id = Number(route.params.id)
const ownerIri = `/api/prestataires/${id}`
const owner = { prestataire: ownerIri }
const prestataireService = usePrestataireService()
const {
contacts,
addresses,
savingContacts,
savingAddresses,
onContactInput,
addContact,
removeContact,
saveContacts,
onAddressInput,
addAddress,
removeAddress,
saveAddresses,
load,
} = useDirectoryDetail(owner)
const { can } = usePermissions()
const canManage = computed(() => can('directory.providers.manage'))
const prestataire = ref<Prestataire | null>(null)
const loading = ref(true)
const activeTab = ref('info')
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: 'address', label: t('directory.tabs.address'), icon: 'mdi:map-marker-outline' },
{ key: 'report', label: t('directory.tabs.report'), icon: 'mdi:file-document-outline' },
]
const info = reactive({ name: '', email: '', phone: '', website: '' })
const infoTouched = reactive({ name: false })
const savingInfo = ref(false)
const emailError = computed(() => (isValidEmail(info.email) ? '' : t('directory.validation.emailInvalid')))
const phoneError = computed(() => (isValidFrPhone(info.phone) ? '' : t('directory.validation.phoneInvalid')))
const websiteError = computed(() => (isValidUrl(info.website) ? '' : t('directory.validation.urlInvalid')))
const infoValid = computed(() => !emailError.value && !phoneError.value && !websiteError.value)
async function saveInfo(): Promise<void> {
infoTouched.name = true
if (!info.name.trim() || !infoValid.value || savingInfo.value) return
savingInfo.value = true
try {
prestataire.value = await prestataireService.update(id, {
name: info.name.trim(),
email: info.email.trim() || null,
phone: info.phone.trim() || null,
website: info.website.trim() || null,
})
} finally {
savingInfo.value = false
}
}
function goBack(): void {
router.push('/directory')
}
onMounted(async () => {
prestataire.value = await prestataireService.getById(id)
info.name = prestataire.value.name ?? ''
info.email = prestataire.value.email ?? ''
info.phone = prestataire.value.phone ?? ''
info.website = prestataire.value.website ?? ''
await load()
loading.value = false
})
</script>
@@ -2,65 +2,12 @@
<div class="flex flex-col gap-6">
<div class="flex items-center gap-3 pt-4">
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
<h1 class="text-2xl font-bold text-neutral-900">{{ prospect?.company ?? '…' }}</h1>
<h1 class="text-2xl font-bold text-neutral-900">{{ prospect?.name ?? '…' }}</h1>
</div>
<p v-if="loading">{{ $t('common.loading') }}</p>
<template v-else-if="prospect">
<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.company"
class="col-span-2"
:label="$t('prospects.fields.company')"
:error="infoTouched.company && !info.company.trim() ? $t('prospects.validation.companyRequired') : ''"
@blur="infoTouched.company = true"
/>
<MalioSelect
v-model="info.status"
:label="$t('prospects.fields.status')"
:options="statusOptions"
group-class="w-full"
/>
<MalioInputText
v-model="info.website"
:label="$t('prospects.fields.website')"
:error="websiteError"
/>
<MalioInputText
v-model="info.email"
:label="$t('prospects.fields.email')"
:error="emailError"
/>
<MalioInputText
v-model="info.phone"
:label="$t('prospects.fields.phone')"
:error="phoneError"
/>
<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 || !infoValid"
@click="saveInfo"
/>
</div>
</div>
</template>
<template #contact>
<div class="flex flex-col gap-4 pt-6">
<DirectoryContactBlock
@@ -72,22 +19,13 @@
@update:model-value="(v) => onContactInput(i, v)"
@remove="removeContact(i)"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.contacts.add')"
@click="addContact"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingContacts"
@click="saveContacts"
/>
</div>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.contacts.add')"
@click="addContact"
/>
</div>
</template>
@@ -102,27 +40,18 @@
@update:model-value="(v) => onAddressInput(i, v)"
@remove="removeAddress(i)"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.addresses.add')"
@click="addAddress"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingAddresses"
@click="saveAddresses"
/>
</div>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.addresses.add')"
@click="addAddress"
/>
</div>
</template>
<template #report>
<CommercialReportTab :owner="owner" :can-manage="canManage" />
<CommercialReportTab :owner="owner" :is-admin="true" />
</template>
</MalioTabList>
</template>
@@ -130,9 +59,8 @@
</template>
<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 { isValidEmail, isValidFrPhone, isValidUrl } from '~/modules/directory/utils/validation'
definePageMeta({ middleware: ['admin'] })
@@ -148,92 +76,31 @@ const prospectService = useProspectService()
const {
contacts,
addresses,
savingContacts,
savingAddresses,
onContactInput,
addContact,
removeContact,
saveContacts,
onAddressInput,
addAddress,
removeAddress,
saveAddresses,
load,
} = useDirectoryDetail(owner)
const { can } = usePermissions()
const canManage = computed(() => can('directory.prospects.manage'))
const prospect = ref<Prospect | null>(null)
const loading = ref(true)
const activeTab = ref('info')
const activeTab = ref('contact')
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: 'address', label: t('directory.tabs.address'), icon: 'mdi:map-marker-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<{
company: string
email: string
phone: string
website: string
status: ProspectStatus
source: string
notes: string
}>({ company: '', email: '', phone: '', website: '', status: 'new', source: '', notes: '' })
const infoTouched = reactive({ company: false })
const savingInfo = ref(false)
const emailError = computed(() => (isValidEmail(info.email) ? '' : t('directory.validation.emailInvalid')))
const phoneError = computed(() => (isValidFrPhone(info.phone) ? '' : t('directory.validation.phoneInvalid')))
const websiteError = computed(() => (isValidUrl(info.website) ? '' : t('directory.validation.urlInvalid')))
const infoValid = computed(() => !emailError.value && !phoneError.value && !websiteError.value)
async function saveInfo(): Promise<void> {
infoTouched.company = true
if (!info.company.trim() || !infoValid.value || savingInfo.value) return
savingInfo.value = true
try {
prospect.value = await prospectService.update(id, {
company: info.company.trim(),
email: info.email.trim() || null,
phone: info.phone.trim() || null,
website: info.website.trim() || null,
status: info.status,
source: info.source.trim() || null,
notes: info.notes.trim() || null,
})
} finally {
savingInfo.value = false
}
}
function goBack(): void {
router.push('/directory')
}
onMounted(async () => {
prospect.value = await prospectService.getById(id)
info.company = prospect.value.company ?? ''
info.email = prospect.value.email ?? ''
info.phone = prospect.value.phone ?? ''
info.website = prospect.value.website ?? ''
info.status = prospect.value.status ?? 'new'
info.source = prospect.value.source ?? ''
info.notes = prospect.value.notes ?? ''
await load()
loading.value = false
})
@@ -2,7 +2,7 @@ import type { Address, AddressWrite } from './dto/address'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
type Owner = { client?: string, prospect?: string, prestataire?: string }
type Owner = { client?: string, prospect?: string }
export function useAddressService() {
const api = useApi()
@@ -2,7 +2,7 @@ import type { CommercialReport, CommercialReportWrite } from './dto/commercial-r
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
type Owner = { client?: string, prospect?: string, prestataire?: string }
type Owner = { client?: string, prospect?: string }
export function useCommercialReportService() {
const api = useApi()
@@ -2,7 +2,7 @@ import type { Contact, ContactWrite } from './dto/contact'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
type Owner = { client?: string, prospect?: string, prestataire?: string }
type Owner = { client?: string, prospect?: string }
export function useContactService() {
const api = useApi()
@@ -9,7 +9,6 @@ export type Address = {
country: string
client?: string | null
prospect?: string | null
prestataire?: string | null
}
export type AddressWrite = {
@@ -21,5 +20,4 @@ export type AddressWrite = {
country: string
client?: string | null
prospect?: string | null
prestataire?: string | null
}
@@ -4,12 +4,10 @@ export type Client = {
name: string
email: string | null
phone: string | null
website: string | null
}
export type ClientWrite = {
name: string
email?: string | null
phone?: string | null
website?: string | null
email: string | null
phone: string | null
}
@@ -12,7 +12,6 @@ export type CommercialReport = {
author?: { id: number, username: string } | null
client?: string | null
prospect?: string | null
prestataire?: string | null
documents?: ReportDocument[]
createdAt?: string
updatedAt?: string
@@ -25,5 +24,4 @@ export type CommercialReportWrite = {
type: ReportType
client?: string | null
prospect?: string | null
prestataire?: string | null
}
@@ -9,7 +9,6 @@ export type Contact = {
phoneSecondary: string | null
client?: string | null
prospect?: string | null
prestataire?: string | null
}
export type ContactWrite = {
@@ -21,5 +20,4 @@ export type ContactWrite = {
phoneSecondary: string | null
client?: string | null
prospect?: string | null
prestataire?: string | null
}
@@ -1,15 +0,0 @@
export type Prestataire = {
id: number
'@id'?: string
name: string
email: string | null
phone: string | null
website: string | null
}
export type PrestataireWrite = {
name: string
email?: string | null
phone?: string | null
website?: string | null
}
@@ -5,10 +5,10 @@ export type ProspectStatus = 'new' | 'contacted' | 'qualified' | 'won' | 'lost'
export type Prospect = {
id: number
'@id'?: string
company: string
name: string
company: string | null
email: string | null
phone: string | null
website: string | null
status: ProspectStatus
source: string | null
notes: string | null
@@ -18,11 +18,11 @@ export type Prospect = {
}
export type ProspectWrite = {
company: string
email?: string | null
phone?: string | null
website?: string | null
status?: ProspectStatus
source?: string | null
notes?: string | null
name: string
company: string | null
email: string | null
phone: string | null
status: ProspectStatus
source: string | null
notes: string | null
}
@@ -1,36 +0,0 @@
import type { Prestataire, PrestataireWrite } from './dto/prestataire'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export function usePrestataireService() {
const api = useApi()
async function getAll(): Promise<Prestataire[]> {
const data = await api.get<HydraCollection<Prestataire>>('/prestataires')
return extractHydraMembers(data)
}
async function getById(id: number): Promise<Prestataire> {
return api.get<Prestataire>(`/prestataires/${id}`)
}
async function create(payload: PrestataireWrite): Promise<Prestataire> {
return api.post<Prestataire>('/prestataires', payload as Record<string, unknown>, {
toastSuccessKey: 'prestataires.created',
})
}
async function update(id: number, payload: Partial<PrestataireWrite>): Promise<Prestataire> {
return api.patch<Prestataire>(`/prestataires/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'prestataires.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/prestataires/${id}`, {}, {
toastSuccessKey: 'prestataires.deleted',
})
}
return { getAll, getById, create, update, remove }
}
@@ -1,40 +0,0 @@
// Validateurs partagés du répertoire (annuaire). Chaque validateur considère
// une valeur VIDE comme valide : les champs email/téléphone/site web sont
// facultatifs — la validation ne porte que sur le format quand c'est renseigné.
/** Email basique (présence d'un @ entouré de caractères, un point dans le domaine). */
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
/**
* Téléphone français : 10 chiffres commençant par 0 (ex. `0549200910`) — format
* saisi par l'utilisateur, sans séparateurs — ou notation internationale
* `+33XXXXXXXXX` (9 chiffres après l'indicatif). Les espaces, points et tirets
* sont tolérés à la frappe (retirés avant contrôle).
*/
const FR_PHONE_NATIONAL_RE = /^0\d{9}$/
const FR_PHONE_INTL_RE = /^\+33\d{9}$/
const URL_RE = /^https?:\/\/[^\s.]+\.[^\s]+$/
/** Retire les séparateurs usuels d'un numéro (espaces, points, tirets, parenthèses). */
export function stripPhoneSeparators(value: string): string {
return value.replace(/[\s.\-()]/g, '')
}
export function isValidEmail(value: string | null | undefined): boolean {
const v = (value ?? '').trim()
if (v === '') return true
return EMAIL_RE.test(v)
}
export function isValidFrPhone(value: string | null | undefined): boolean {
const v = stripPhoneSeparators((value ?? '').trim())
if (v === '') return true
return FR_PHONE_NATIONAL_RE.test(v) || FR_PHONE_INTL_RE.test(v)
}
export function isValidUrl(value: string | null | undefined): boolean {
const v = (value ?? '').trim()
if (v === '') return true
return URL_RE.test(v)
}
+3 -1
View File
@@ -21,6 +21,7 @@
</div>
<div>
<AdminClientTab v-if="activeTab === 'clients'" />
<AdminWorkflowTab v-if="activeTab === 'workflows'" />
<AdminEffortTab v-if="activeTab === 'efforts'" />
<AdminPriorityTab v-if="activeTab === 'priorities'" />
@@ -49,6 +50,7 @@ const canViewRoles = computed(() => can('core.roles.view'))
const canViewAudit = computed(() => can('core.audit_log.view'))
const tabs = [
{ key: 'clients', label: 'Clients' },
{ key: 'workflows', label: 'Workflows' },
{ key: 'efforts', label: 'Efforts' },
{ key: 'priorities', label: 'Priorités' },
@@ -70,5 +72,5 @@ const visibleTabs = computed(() =>
tabs.filter((tab) => !('permission' in tab) || can(tab.permission)),
)
const activeTab = ref<TabKey>('workflows')
const activeTab = ref<TabKey>('clients')
</script>
-26
View File
@@ -1,26 +0,0 @@
import { $fetch } from 'ofetch'
/**
* Appel HTTP vers un service EXTERNE (hors API Lesstime) : pas de cookie de
* session, pas d'enveloppe Hydra, timeout court. Utilisé par l'autocomplétion
* d'adresse branchée sur la Base Adresse Nationale (api-adresse.data.gouv.fr).
* Ne jamais passer par `useApi()` pour ces domaines tiers.
*/
export interface HttpExternalOptions {
/** Paramètres de query string (encodés par ofetch). */
query?: Record<string, string | number | undefined>
/** Timeout en millisecondes avant abandon (défaut 5000). */
timeoutMs?: number
}
export async function httpExternal<T>(
url: string,
opts: HttpExternalOptions = {},
): Promise<T> {
return $fetch<T>(url, {
query: opts.query,
credentials: 'omit',
retry: 0,
timeout: opts.timeoutMs ?? 5000,
})
}
-3
View File
@@ -27,9 +27,6 @@ sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintena
echo "==> Running migrations..."
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
-89
View File
@@ -1,89 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260624153709 extends AbstractMigration
{
public function getDescription(): string
{
return 'Directory: prestataire entity + website on client/prospect/prestataire + prestataire ownership on contacts/addresses/reports + prospect company-only';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE prestataire (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, name VARCHAR(255) NOT NULL, email VARCHAR(255) DEFAULT NULL, phone VARCHAR(50) DEFAULT NULL, website VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, created_by INT DEFAULT NULL, updated_by INT DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_60A26480DE12AB56 ON prestataire (created_by)');
$this->addSql('CREATE INDEX IDX_60A2648016FE72E1 ON prestataire (updated_by)');
$this->addSql('ALTER TABLE prestataire ADD CONSTRAINT FK_60A26480DE12AB56 FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('ALTER TABLE prestataire ADD CONSTRAINT FK_60A2648016FE72E1 FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('ALTER TABLE client ADD website VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE commercial_report ADD prestataire_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE commercial_report ADD CONSTRAINT FK_886919D8BE3DB2B7 FOREIGN KEY (prestataire_id) REFERENCES prestataire (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_886919D8BE3DB2B7 ON commercial_report (prestataire_id)');
$this->addSql('ALTER TABLE directory_address ADD prestataire_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE directory_address ADD CONSTRAINT FK_6E5D9707BE3DB2B7 FOREIGN KEY (prestataire_id) REFERENCES prestataire (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_6E5D9707BE3DB2B7 ON directory_address (prestataire_id)');
$this->addSql('ALTER TABLE directory_contact ADD prestataire_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE directory_contact ADD CONSTRAINT FK_2F711EBEBE3DB2B7 FOREIGN KEY (prestataire_id) REFERENCES prestataire (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_2F711EBEBE3DB2B7 ON directory_contact (prestataire_id)');
// Prospect désormais société-only : on conserve la donnée existante en
// recopiant le nom dans la société quand celle-ci est vide, avant de
// rendre la colonne obligatoire et de supprimer la colonne name.
$this->addSql('ALTER TABLE prospect ADD website VARCHAR(255) DEFAULT NULL');
$this->addSql("UPDATE prospect SET company = name WHERE company IS NULL OR company = ''");
$this->addSql('ALTER TABLE prospect ALTER company SET NOT NULL');
$this->addSql('ALTER TABLE prospect DROP name');
// Ownership CHECK constraints: chaque ligne appartient à un client,
// un prospect OU un prestataire.
$this->addSql('ALTER TABLE directory_contact DROP CONSTRAINT chk_contact_owner');
$this->addSql('ALTER TABLE directory_contact ADD CONSTRAINT chk_contact_owner CHECK (client_id IS NOT NULL OR prospect_id IS NOT NULL OR prestataire_id IS NOT NULL)');
$this->addSql('ALTER TABLE directory_address DROP CONSTRAINT chk_address_owner');
$this->addSql('ALTER TABLE directory_address ADD CONSTRAINT chk_address_owner CHECK (client_id IS NOT NULL OR prospect_id IS NOT NULL OR prestataire_id IS NOT NULL)');
$this->addSql('ALTER TABLE commercial_report DROP CONSTRAINT chk_report_owner');
$this->addSql('ALTER TABLE commercial_report ADD CONSTRAINT chk_report_owner CHECK (client_id IS NOT NULL OR prospect_id IS NOT NULL OR prestataire_id IS NOT NULL)');
}
public function down(Schema $schema): void
{
// Rétablit les contraintes d'ownership client/prospect (sans prestataire).
$this->addSql('ALTER TABLE directory_contact DROP CONSTRAINT chk_contact_owner');
$this->addSql('ALTER TABLE directory_address DROP CONSTRAINT chk_address_owner');
$this->addSql('ALTER TABLE commercial_report DROP CONSTRAINT chk_report_owner');
$this->addSql('ALTER TABLE commercial_report DROP CONSTRAINT FK_886919D8BE3DB2B7');
$this->addSql('DROP INDEX IDX_886919D8BE3DB2B7');
$this->addSql('ALTER TABLE commercial_report DROP prestataire_id');
$this->addSql('ALTER TABLE directory_address DROP CONSTRAINT FK_6E5D9707BE3DB2B7');
$this->addSql('DROP INDEX IDX_6E5D9707BE3DB2B7');
$this->addSql('ALTER TABLE directory_address DROP prestataire_id');
$this->addSql('ALTER TABLE directory_contact DROP CONSTRAINT FK_2F711EBEBE3DB2B7');
$this->addSql('DROP INDEX IDX_2F711EBEBE3DB2B7');
$this->addSql('ALTER TABLE directory_contact DROP prestataire_id');
$this->addSql('ALTER TABLE directory_contact ADD CONSTRAINT chk_contact_owner CHECK (client_id IS NOT NULL OR prospect_id IS NOT NULL)');
$this->addSql('ALTER TABLE directory_address ADD CONSTRAINT chk_address_owner CHECK (client_id IS NOT NULL OR prospect_id IS NOT NULL)');
$this->addSql('ALTER TABLE commercial_report ADD CONSTRAINT chk_report_owner CHECK (client_id IS NOT NULL OR prospect_id IS NOT NULL)');
$this->addSql('ALTER TABLE prestataire DROP CONSTRAINT FK_60A26480DE12AB56');
$this->addSql('ALTER TABLE prestataire DROP CONSTRAINT FK_60A2648016FE72E1');
$this->addSql('DROP TABLE prestataire');
$this->addSql('ALTER TABLE client DROP website');
// Restaure la colonne name (recopiée depuis company) puis l'oblige.
$this->addSql('ALTER TABLE prospect ADD name VARCHAR(255) DEFAULT NULL');
$this->addSql('UPDATE prospect SET name = company');
$this->addSql('ALTER TABLE prospect ALTER name SET NOT NULL');
$this->addSql('ALTER TABLE prospect DROP website');
$this->addSql('ALTER TABLE prospect ALTER company DROP NOT NULL');
}
}
+3
View File
@@ -127,6 +127,7 @@ class AppFixtures extends Fixture
// Prospects
$prospectLead = new Prospect();
$prospectLead->setName('Marie Dupont');
$prospectLead->setCompany('Atelier Dupont');
$prospectLead->setEmail('marie@atelier-dupont.fr');
$prospectLead->setPhone('06 11 22 33 44');
@@ -144,6 +145,7 @@ class AppFixtures extends Fixture
$manager->persist($addressLead);
$prospectQualified = new Prospect();
$prospectQualified->setName('Jean Martin');
$prospectQualified->setCompany('Martin & Fils');
$prospectQualified->setEmail('contact@martin-fils.fr');
$prospectQualified->setPhone('07 55 66 77 88');
@@ -161,6 +163,7 @@ class AppFixtures extends Fixture
$manager->persist($addressQualified);
$prospectWon = new Prospect();
$prospectWon->setName('Sophie Bernard');
$prospectWon->setCompany('ACME Corp');
$prospectWon->setEmail('contact@acme.com');
$prospectWon->setPhone('01 23 45 67 89');
@@ -111,18 +111,9 @@ class AccrueLeaveCommand extends Command
$previousBalance = null !== $previousPeriod
? $this->balanceRepository->findOneForPeriod($user, AbsenceType::PaidLeave, $previousPeriod)
: null;
if (null !== $previousBalance) {
// 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());
}
$balance->setAcquired(
null !== $previousBalance ? $previousBalance->getAcquiring() : $profile->getInitialLeaveBalance(),
);
}
if ($monthKey === $balance->getLastAccruedMonth()) {
@@ -14,9 +14,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
*/
final class PermissionVoter extends Voter
{
// Les codes de permission sont au format module.resource.action où chaque
// segment peut contenir des tirets (ex. project-management, time-tracking).
private const string PATTERN = '/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$/';
private const string PATTERN = '/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/';
protected function supports(string $attribute, mixed $subject): bool
{
-2
View File
@@ -38,8 +38,6 @@ final class DirectoryModule implements ModuleInterface
['code' => 'directory.clients.manage', 'label' => 'Gérer les clients'],
['code' => 'directory.prospects.view', 'label' => 'Voir les prospects'],
['code' => 'directory.prospects.manage', 'label' => 'Gérer les prospects'],
['code' => 'directory.providers.view', 'label' => 'Voir les prestataires'],
['code' => 'directory.providers.manage', 'label' => 'Gérer les prestataires'],
];
}
}
+6 -23
View File
@@ -23,17 +23,17 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[Auditable]
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view') or is_granted('directory.providers.view')"),
new Get(security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view') or is_granted('directory.providers.view')"),
new Post(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
new Patch(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
new Delete(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['address:read']],
denormalizationContext: ['groups' => ['address:write']],
order: ['id' => 'ASC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact', 'prestataire' => 'exact'])]
#[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact'])]
#[ORM\Entity(repositoryClass: DoctrineAddressRepository::class)]
#[ORM\Table(name: 'directory_address')]
class Address implements TimestampableInterface, BlamableInterface
@@ -80,11 +80,6 @@ class Address implements TimestampableInterface, BlamableInterface
#[Groups(['address:read', 'address:write'])]
private ?Prospect $prospect = null;
#[ORM\ManyToOne(targetEntity: Prestataire::class)]
#[ORM\JoinColumn(name: 'prestataire_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['address:read', 'address:write'])]
private ?Prestataire $prestataire = null;
public function getId(): ?int
{
return $this->id;
@@ -185,16 +180,4 @@ class Address implements TimestampableInterface, BlamableInterface
return $this;
}
public function getPrestataire(): ?Prestataire
{
return $this->prestataire;
}
public function setPrestataire(?Prestataire $prestataire): static
{
$this->prestataire = $prestataire;
return $this;
}
}
+5 -21
View File
@@ -25,11 +25,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[Auditable]
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view')"),
new Get(security: "is_granted('directory.clients.view')"),
new Post(security: "is_granted('directory.clients.manage')"),
new Patch(security: "is_granted('directory.clients.manage')"),
new Delete(security: "is_granted('directory.clients.manage')"),
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['client:read']],
denormalizationContext: ['groups' => ['client:write']],
@@ -58,10 +58,6 @@ class Client implements ClientInterface, TimestampableInterface, BlamableInterfa
#[Groups(['client:read', 'client:write'])]
private ?string $phone = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['client:read', 'client:write'])]
private ?string $website = null;
/** @var Collection<int, ProjectInterface> */
#[ORM\OneToMany(targetEntity: ProjectInterface::class, mappedBy: 'client')]
private Collection $projects;
@@ -112,18 +108,6 @@ class Client implements ClientInterface, TimestampableInterface, BlamableInterfa
return $this;
}
public function getWebsite(): ?string
{
return $this->website;
}
public function setWebsite(?string $website): static
{
$this->website = $website;
return $this;
}
/** @return Collection<int, ProjectInterface> */
public function getProjects(): Collection
{
@@ -26,17 +26,17 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view') or is_granted('directory.providers.view')"),
new Get(security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view') or is_granted('directory.providers.view')"),
new Post(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
new Patch(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
new Delete(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['commercial_report:read']],
denormalizationContext: ['groups' => ['commercial_report:write']],
order: ['occurredAt' => 'DESC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact', 'prestataire' => 'exact'])]
#[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact'])]
#[ORM\Entity(repositoryClass: DoctrineCommercialReportRepository::class)]
#[ORM\Table(name: 'commercial_report')]
class CommercialReport implements TimestampableInterface
@@ -80,11 +80,6 @@ class CommercialReport implements TimestampableInterface
#[Groups(['commercial_report:read', 'commercial_report:write'])]
private ?Prospect $prospect = null;
#[ORM\ManyToOne(targetEntity: Prestataire::class)]
#[ORM\JoinColumn(name: 'prestataire_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['commercial_report:read', 'commercial_report:write'])]
private ?Prestataire $prestataire = null;
/** @var Collection<int, ReportDocument> */
#[ORM\OneToMany(targetEntity: ReportDocument::class, mappedBy: 'commercialReport', cascade: ['remove'])]
#[Groups(['commercial_report:read'])]
@@ -184,18 +179,6 @@ class CommercialReport implements TimestampableInterface
return $this;
}
public function getPrestataire(): ?Prestataire
{
return $this->prestataire;
}
public function setPrestataire(?Prestataire $prestataire): static
{
$this->prestataire = $prestataire;
return $this;
}
/** @return Collection<int, ReportDocument> */
public function getDocuments(): Collection
{
+6 -23
View File
@@ -23,17 +23,17 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[Auditable]
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view') or is_granted('directory.providers.view')"),
new Get(security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view') or is_granted('directory.providers.view')"),
new Post(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
new Patch(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
new Delete(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['contact:read']],
denormalizationContext: ['groups' => ['contact:write']],
order: ['lastName' => 'ASC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact', 'prestataire' => 'exact'])]
#[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact'])]
#[ORM\Entity(repositoryClass: DoctrineContactRepository::class)]
#[ORM\Table(name: 'directory_contact')]
class Contact implements TimestampableInterface, BlamableInterface
@@ -80,11 +80,6 @@ class Contact implements TimestampableInterface, BlamableInterface
#[Groups(['contact:read', 'contact:write'])]
private ?Prospect $prospect = null;
#[ORM\ManyToOne(targetEntity: Prestataire::class)]
#[ORM\JoinColumn(name: 'prestataire_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['contact:read', 'contact:write'])]
private ?Prestataire $prestataire = null;
public function getId(): ?int
{
return $this->id;
@@ -185,16 +180,4 @@ class Contact implements TimestampableInterface, BlamableInterface
return $this;
}
public function getPrestataire(): ?Prestataire
{
return $this->prestataire;
}
public function setPrestataire(?Prestataire $prestataire): static
{
$this->prestataire = $prestataire;
return $this;
}
}
@@ -1,114 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Directory\Infrastructure\Doctrine\DoctrinePrestataireRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[Auditable]
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('directory.providers.view')"),
new Get(security: "is_granted('directory.providers.view')"),
new Post(security: "is_granted('directory.providers.manage')"),
new Patch(security: "is_granted('directory.providers.manage')"),
new Delete(security: "is_granted('directory.providers.manage')"),
],
normalizationContext: ['groups' => ['prestataire:read']],
denormalizationContext: ['groups' => ['prestataire:write']],
order: ['name' => 'ASC'],
)]
#[ORM\Entity(repositoryClass: DoctrinePrestataireRepository::class)]
#[ORM\Table(name: 'prestataire')]
class Prestataire implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['prestataire:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['prestataire:read', 'prestataire:write'])]
private ?string $name = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['prestataire:read', 'prestataire:write'])]
private ?string $email = null;
#[ORM\Column(length: 50, nullable: true)]
#[Groups(['prestataire:read', 'prestataire:write'])]
private ?string $phone = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['prestataire:read', 'prestataire:write'])]
private ?string $website = null;
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): static
{
$this->email = $email;
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(?string $phone): static
{
$this->phone = $phone;
return $this;
}
public function getWebsite(): ?string
{
return $this->website;
}
public function setWebsite(?string $website): static
{
$this->website = $website;
return $this;
}
}
+24 -24
View File
@@ -27,20 +27,20 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[Auditable]
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('directory.prospects.view')"),
new Get(security: "is_granted('directory.prospects.view')"),
new Post(security: "is_granted('directory.prospects.manage')"),
new Patch(security: "is_granted('directory.prospects.manage')"),
new Delete(security: "is_granted('directory.prospects.manage')"),
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
new Post(
uriTemplate: '/prospects/{id}/convert',
security: "is_granted('directory.prospects.manage')",
security: "is_granted('ROLE_ADMIN')",
processor: ConvertProspectProcessor::class,
),
],
normalizationContext: ['groups' => ['prospect:read']],
denormalizationContext: ['groups' => ['prospect:write']],
order: ['company' => 'ASC'],
order: ['name' => 'ASC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['status' => 'exact'])]
#[ORM\Entity(repositoryClass: DoctrineProspectRepository::class)]
@@ -57,6 +57,10 @@ class Prospect implements TimestampableInterface, BlamableInterface
#[ORM\Column(length: 255)]
#[Groups(['prospect:read', 'prospect:write'])]
private ?string $name = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['prospect:read', 'prospect:write'])]
private ?string $company = null;
#[ORM\Column(length: 255, nullable: true)]
@@ -67,10 +71,6 @@ class Prospect implements TimestampableInterface, BlamableInterface
#[Groups(['prospect:read', 'prospect:write'])]
private ?string $phone = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['prospect:read', 'prospect:write'])]
private ?string $website = null;
#[ORM\Column(type: Types::STRING, length: 32, enumType: ProspectStatus::class)]
#[Groups(['prospect:read', 'prospect:write'])]
private ProspectStatus $status = ProspectStatus::New;
@@ -93,12 +93,24 @@ class Prospect implements TimestampableInterface, BlamableInterface
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getCompany(): ?string
{
return $this->company;
}
public function setCompany(string $company): static
public function setCompany(?string $company): static
{
$this->company = $company;
@@ -129,18 +141,6 @@ class Prospect implements TimestampableInterface, BlamableInterface
return $this;
}
public function getWebsite(): ?string
{
return $this->website;
}
public function setWebsite(?string $website): static
{
$this->website = $website;
return $this;
}
public function getStatus(): ProspectStatus
{
return $this->status;
@@ -20,14 +20,14 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"),
new Get(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('ROLE_USER')"),
new Post(
security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')",
security: "is_granted('ROLE_ADMIN')",
processor: ReportDocumentProcessor::class,
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']],
denormalizationContext: ['groups' => ['report_document:write']],
@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Domain\Repository;
use App\Module\Directory\Domain\Entity\Prestataire;
interface PrestataireRepositoryInterface
{
public function findById(int $id): ?Prestataire;
/**
* @param array<string, mixed> $criteria
* @param null|array<string, string> $orderBy
*
* @return Prestataire[]
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
}
@@ -46,10 +46,9 @@ final readonly class ConvertProspectProcessor implements ProcessorInterface
}
$client = new Client();
$client->setName((string) $prospect->getCompany());
$client->setName($prospect->getCompany() ?: (string) $prospect->getName());
$client->setEmail($prospect->getEmail());
$client->setPhone($prospect->getPhone());
$client->setWebsite($prospect->getWebsite());
$this->entityManager->persist($client);
@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Doctrine;
use App\Module\Directory\Domain\Entity\Prestataire;
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Prestataire>
*/
final class DoctrinePrestataireRepository extends ServiceEntityRepository implements PrestataireRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Prestataire::class);
}
public function findById(int $id): ?Prestataire
{
return $this->find($id);
}
}
@@ -42,10 +42,9 @@ class ConvertProspectTool
if (null === $prospect->getConvertedClient()) {
$client = new Client();
$client->setName((string) $prospect->getCompany());
$client->setName($prospect->getCompany() ?: (string) $prospect->getName());
$client->setEmail($prospect->getEmail());
$client->setPhone($prospect->getPhone());
$client->setWebsite($prospect->getWebsite());
$this->entityManager->persist($client);
@@ -23,7 +23,6 @@ class CreateClientTool
string $name,
?string $email = null,
?string $phone = null,
?string $website = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
@@ -33,7 +32,6 @@ class CreateClientTool
$client->setName($name);
$client->setEmail($email);
$client->setPhone($phone);
$client->setWebsite($website);
$this->entityManager->persist($client);
$this->entityManager->flush();
@@ -15,7 +15,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'create-prospect', description: 'Create a prospect (admin). Only company is required. Status defaults to "new".')]
#[McpTool(name: 'create-prospect', description: 'Create a prospect (admin). Only name is required. Status defaults to "new".')]
class CreateProspectTool
{
public function __construct(
@@ -24,10 +24,10 @@ class CreateProspectTool
) {}
public function __invoke(
string $company,
string $name,
?string $company = null,
?string $email = null,
?string $phone = null,
?string $website = null,
?string $status = null,
?string $source = null,
?string $notes = null,
@@ -37,10 +37,10 @@ class CreateProspectTool
}
$prospect = new Prospect();
$prospect->setName($name);
$prospect->setCompany($company);
$prospect->setEmail($email);
$prospect->setPhone($phone);
$prospect->setWebsite($website);
$prospect->setSource($source);
$prospect->setNotes($notes);
@@ -33,10 +33,10 @@ class DeleteProspectTool
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $id));
}
$company = $prospect->getCompany();
$name = $prospect->getName();
$this->entityManager->remove($prospect);
$this->entityManager->flush();
return json_encode(['success' => true, 'message' => sprintf('Prospect "%s" deleted.', $company)]);
return json_encode(['success' => true, 'message' => sprintf('Prospect "%s" deleted.', $name)]);
}
}
@@ -37,7 +37,7 @@ class ListProspectsTool
$criteria['status'] = $statusEnum;
}
$prospects = $this->prospectRepository->findBy($criteria, ['company' => 'ASC']);
$prospects = $this->prospectRepository->findBy($criteria, ['name' => 'ASC']);
return json_encode(array_map(static fn ($prospect) => Serializer::prospect($prospect), $prospects));
}
@@ -28,7 +28,6 @@ class UpdateClientTool
?string $name = null,
?string $email = null,
?string $phone = null,
?string $website = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
@@ -48,9 +47,6 @@ class UpdateClientTool
if (null !== $phone) {
$client->setPhone($phone);
}
if (null !== $website) {
$client->setWebsite($website);
}
$this->entityManager->flush();
@@ -26,10 +26,10 @@ class UpdateProspectTool
public function __invoke(
int $id,
?string $name = null,
?string $company = null,
?string $email = null,
?string $phone = null,
?string $website = null,
?string $status = null,
?string $source = null,
?string $notes = null,
@@ -43,6 +43,9 @@ class UpdateProspectTool
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $id));
}
if (null !== $name) {
$prospect->setName($name);
}
if (null !== $company) {
$prospect->setCompany($company);
}
@@ -52,9 +55,6 @@ class UpdateProspectTool
if (null !== $phone) {
$prospect->setPhone($phone);
}
if (null !== $website) {
$prospect->setWebsite($website);
}
if (null !== $status) {
$statusEnum = ProspectStatus::tryFrom($status);
if (null === $statusEnum) {
@@ -30,18 +30,18 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view')"),
new Get(security: "is_granted('project-management.projects.view')"),
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(
security: "is_granted('project-management.projects.manage')",
security: "is_granted('ROLE_ADMIN')",
denormalizationContext: ['groups' => ['project:write', 'project:create']],
),
new Patch(security: "is_granted('project-management.projects.manage')"),
new Delete(security: "is_granted('project-management.projects.manage')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
new Post(
uriTemplate: '/projects/{id}/switch-workflow',
uriVariables: ['id' => new Link(fromClass: Project::class)],
security: "is_granted('project-management.projects.manage')",
security: "is_granted('ROLE_ADMIN')",
input: false,
output: SwitchWorkflowOutput::class,
normalizationContext: ['groups' => ['switch_workflow:read']],
@@ -33,11 +33,11 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.tasks.view')"),
new Get(security: "is_granted('project-management.tasks.view')"),
new Post(security: "is_granted('project-management.tasks.manage')", processor: TaskNumberProcessor::class),
new Patch(security: "is_granted('project-management.tasks.manage')", processor: TaskCalendarProcessor::class),
new Delete(security: "is_granted('project-management.tasks.manage')", processor: TaskCalendarProcessor::class),
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')", processor: TaskNumberProcessor::class),
new Patch(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class),
new Delete(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class),
],
normalizationContext: ['groups' => ['task:read']],
denormalizationContext: ['groups' => ['task:write']],
@@ -21,14 +21,14 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.tasks.view')", provider: TaskDocumentProvider::class),
new Get(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('ROLE_USER')", provider: TaskDocumentProvider::class),
new Post(
security: "is_granted('project-management.tasks.manage')",
security: "is_granted('ROLE_ADMIN')",
processor: TaskDocumentProcessor::class,
deserialize: false,
),
new Delete(security: "is_granted('project-management.tasks.manage')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['task_document:read']],
denormalizationContext: ['groups' => ['task_document:write']],
@@ -16,11 +16,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['task_effort:read']],
denormalizationContext: ['groups' => ['task_effort:write']],
@@ -19,11 +19,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['task_group:read']],
denormalizationContext: ['groups' => ['task_group:write']],
@@ -16,11 +16,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['task_priority:read']],
denormalizationContext: ['groups' => ['task_priority:write']],
@@ -20,11 +20,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['task_recurrence:read']],
denormalizationContext: ['groups' => ['task_recurrence:write']],
@@ -18,11 +18,11 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['task_status:read']],
denormalizationContext: ['groups' => ['task_status:write']],
@@ -17,11 +17,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['task_tag:read']],
denormalizationContext: ['groups' => ['task_tag:write']],
@@ -21,11 +21,11 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"),
new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"),
new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')", processor: WorkflowDeleteProcessor::class),
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')", processor: WorkflowDeleteProcessor::class),
],
normalizationContext: ['groups' => ['workflow:read']],
denormalizationContext: ['groups' => ['workflow:write']],
@@ -31,13 +31,13 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('time-tracking.entries.view')"),
new GetCollection(security: "is_granted('ROLE_USER')"),
new GetCollection(
name: 'time_entries_range',
uriTemplate: '/time_entries/range',
description: 'List time entries for a bounded date range without pagination (used by the time-tracking calendar)',
paginationEnabled: false,
security: "is_granted('time-tracking.entries.view')",
security: "is_granted('ROLE_USER')",
),
new GetCollection(
name: 'active_time_entry',
@@ -45,12 +45,12 @@ use Symfony\Component\Serializer\Attribute\Groups;
provider: ActiveTimeEntryProvider::class,
description: 'Get the active timer for the current user',
paginationEnabled: false,
security: "is_granted('time-tracking.entries.view')",
security: "is_granted('ROLE_USER')",
),
new Get(security: "is_granted('time-tracking.entries.view')"),
new Post(security: "is_granted('time-tracking.entries.manage')"),
new Patch(security: "is_granted('ROLE_ADMIN') or (is_granted('time-tracking.entries.manage') and object.getUser() == user)"),
new Delete(security: "is_granted('ROLE_ADMIN') or (is_granted('time-tracking.entries.manage') and object.getUser() == user)"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_USER')"),
new Patch(security: "is_granted('ROLE_ADMIN') or object.getUser() == user"),
new Delete(security: "is_granted('ROLE_ADMIN') or object.getUser() == user"),
],
normalizationContext: ['groups' => ['time_entry:read']],
denormalizationContext: ['groups' => ['time_entry:write']],
@@ -26,13 +26,15 @@ final class TimeTrackingModule implements ModuleInterface
/**
* 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}>
*/
public static function permissions(): array
{
return [
['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'],
];
}
@@ -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);
}
}
+5 -6
View File
@@ -366,11 +366,10 @@ final class Serializer
public static function client(Client $c): array
{
return [
'id' => $c->getId(),
'name' => $c->getName(),
'email' => $c->getEmail(),
'phone' => $c->getPhone(),
'website' => $c->getWebsite(),
'id' => $c->getId(),
'name' => $c->getName(),
'email' => $c->getEmail(),
'phone' => $c->getPhone(),
];
}
@@ -383,10 +382,10 @@ final class Serializer
return [
'id' => $p->getId(),
'name' => $p->getName(),
'company' => $p->getCompany(),
'email' => $p->getEmail(),
'phone' => $p->getPhone(),
'website' => $p->getWebsite(),
'status' => $p->getStatus()->value,
'statusLabel' => $p->getStatus()->label(),
'source' => $p->getSource(),
@@ -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());
}
}
@@ -28,6 +28,7 @@ class ProspectConversionTest extends KernelTestCase
public function testConvertCreatesClientAndFlagsProspectWon(): void
{
$prospect = new Prospect();
$prospect->setName('Lead Test');
$prospect->setCompany('Lead Company '.uniqid());
$prospect->setEmail('lead@example.com');
$prospect->setPhone('06 00 00 00 00');
@@ -46,7 +47,7 @@ class ProspectConversionTest extends KernelTestCase
public function testConvertIsIdempotent(): void
{
$prospect = new Prospect();
$prospect->setCompany('Idempotent Lead');
$prospect->setName('Idempotent Lead');
$prospect->setStatus(ProspectStatus::New);
$this->em->persist($prospect);
$this->em->flush();
@@ -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();
}
}
@@ -28,6 +28,7 @@ final class ConvertProspectProcessorTest extends KernelTestCase
$em = self::getContainer()->get(EntityManagerInterface::class);
$prospect = new Prospect();
$prospect->setName('Atelier Test');
$prospect->setCompany('Atelier Test SARL');
$em->persist($prospect);