bricksoffice-projects — agent rules¶
Always-on guidance for the Projects back-office app. Loads for Claude Code (via the projects/bricksoffice-projects/CLAUDE.md symlink) and Cursor (glob-scoped).
Canonical source is this file at
.cursor/rules/bo/.projects/bricksoffice-projects/CLAUDE.mdand everything under.claude/are symlinks — edit the original, not the symlink.Root rules apply. See
AGENTS.mdat the repo root. This file only restates the BO-specific delta.
Topical rules¶
@bricks-common-bo.mdc(Shared BO lib) —@bricks-common/boarchitecture, public surface,#-imports, router/auth wiring, build/deployform.mdc(Forms) — idtlt pattern, primitives, discriminated unions, runtime rules, branded submittable.mdc(Tables) — DataTable providers, pinning, toolbar, URL state, CSV/XLSXmodal.mdc(Modals) —modules/<domain>/modals/, Dialog/AlertDialog, drawer+actions, disabled-with-tooltipstyling.mdc(Styling) — tokens, cva, topical DS foldersdata-fetching.mdc(Data fetching) —appQueryClient,processHttpResult, admin API error pipelinei18n.mdc(i18n) — paraglide, dotted-key bracket access, BO instance compile
Scaffolding & workflows:
- Scaffold a list page → .cursor/skills/bo-projects-create-page-table/SKILL.md
- Publish a changelog entry → /bo-projects-changelog-entry
- Audit rules for drift → /rules-hygiene (see .cursor/skills/rules-hygiene/)
BO-specific non-negotiables¶
These refine the root rules — don't restate them.
- Default-export exception: TanStack Router files in
src/routes/(file-based routing requires it). - Logging: no logger wired yet — surface errors via React Query / form state.
console.warn|error|infoallowed by Biome. - No
React.memoreflex: small SPA, React Compiler isn't wired here. - Import aliases (per-app):
@/→src/*,@core/→src/core/*,@shared/→src/shared/*. Workspace imports via@bricks-common/bo/.... - Root cause over symptoms: no suppressions, defensive guards, or swallowed errors. If the real fix is bigger than expected, surface options.
- No
Intl.NumberFormatfor currency. UseformatCentsFull/formatCentsShortfrom@bricks-common/bo/core; euro inputs parse viaparseEurosInputToCents. Missing helper → lift into BO core, don't roll local. - No direct
window.localStorage. UsereadLocalStorage/writeLocalStorage/removeLocalStorage/useLocalStorageStatefrom@bricks-common/bo/core(SSR guard, JSON, runtime validation). Keys namespacedbo:<feature>:<detail>. - English identifiers, French UI. All code (files, components, hooks, types,
OpenModalkindliterals, i18n key slugs) in English. UI strings in French. API fields that mirror the contract (soldeDeToutCompteDoneAt) stay French; surrounding artifacts use the English equivalent (ProjectFinalSettlementModal,useMarkProjectFinalSettlementDone). - Module-internal names drop the domain prefix. Cells and utils inside
modules/<domain>/read in context (NameCell,LeiExpiryCell,toListQuery); only the module-level pieces carry the domain word (RoyaltiesTable,useRoyaltyColumns,RoyaltiesPage,useRoyalties,useRoyaltiesListSearch). - Scaffolded folders (
.gitkeepstubs undersrc/modules/projects/) are intentional — don't fill without specs. - Every project-financing-request (espace financement) screen or block is gated. Ops must not see unfinished work by default; Bubble stays the source of truth. New page →
<BetaPageLayout flag="projectFinancingRequest" href={AppConfig.network.appEspaceFiURL}>(DS beta kit:PageLayout+ banner + gate); block on an existing page →<BetaGate flag="projectFinancingRequest">. A block locked for a business reason (a card the row's status forbids) → the generic<Gate isLocked reason>from the DS, same look, never a hidden card. No beta-only column on an existing table (dropped on review: a column can't be gated visually). Menu entries carry abetamarker inAppSidebar(resolveBetaNavItems). Screens not built yet renderPlaceholderPage(@shared/placeholder/, generic, router-bound so not DS) inside that layout, from their final module and name (projectFinancingRequests/ProjectFinancingRequestDetailPage) — noEf/EF/Beta/Stubin identifiers, and no flag-specific layout wrapper: the flag is a prop. An entry point into a gated screen (a sidebar item, a row action on a non-gated page) is hidden when the flag is off, likeAppSidebardoes — the destination'sBetaPageLayoutcatches direct URLs, but ops must not see a button leading to a locked page. The flag is read where the entry point is assembled (useProjectColumns), never in the leaf cell, which stays dumb and storybook-safe. - No Technique in the apps switcher. Shared
adminAuth— Projects users must not discoverbricksoffice-technique. Do not add it togetHubCatalog().
BO rule-file mapping¶
Où vit une convention BO (pour la lire, et pour savoir où l'écrire si elle change) :
- Form pattern, primitive, validator →
./form.mdc - Table convention (sizing, toolbar, URL state, CSV) →
./table.mdc - Modal placement, drawer+actions, disabled-reason helpers →
./modal.mdc - Tokens, theme vars, DS topical folders, cva →
./styling.mdc appQueryClient, error handling,processHttpResult→./data-fetching.mdc- Paraglide setup, key naming, compile →
./i18n.mdc - BO public surface,
#-imports, router/auth, build pruning →./@bricks-common-bo.mdc - BO-specific non-negotiable or stack item → this file. Monorepo-wide →
AGENTS.md.
What this app is¶
Back-office for the Projects domain, sibling to bricksoffice-invest and bricksoffice-app-config. Operational SPA used by ops/admin to manage obligation & royalty projects: payment schedules (échéancier), construction budgets, management fees, incoming wires, lifecycle (publish, funds transfer, final settlement). App Invest banner / home-news lives in bricksoffice-app-config.
UI strings: French only. Static SPA built by Vite, served by nginx on Railway. Build/deploy gotchas → ./@bricks-common-bo.mdc.
Stack (one-liners)¶
- Vite 7.2.2 + React 19 (StrictMode, no SSR)
- TanStack Router file-based (
autoCodeSplitting: true) —src/routeTree.gen.tsis generated, never hand-edit - TanStack Query — single
appQueryClientfrom@bricks-common/bo/core, exposed via router context - Tailwind v4 via
@tailwindcss/vite— config in CSS@import, notailwind.config.* - Design system:
@bricks-common/bo— shadcnnew-york/zinc,radix-ui+cva - Forms:
react-hook-form+<Form>wrapper — see./form.mdc - i18n:
@inlang/paraglide-js,fronly — see./i18n.mdc - Dates:
dayjs(localefr) — useformatDate/formatDateTimefrom@bricks-common/bo/core - Tests: Vitest 4 + RTL + jsdom (none wired yet)
- Lint/format: Biome 2.4 — root config authoritative
Commands¶
| Command | What |
|---|---|
pnpm dev |
Vite dev server (port 3000) |
pnpm build |
vite build to dist/ (tsc --noEmit stays in type-check) |
pnpm preview |
Preview built bundle |
pnpm type-check |
tsc --noEmit |
pnpm lint |
Biome check (scoped) |
pnpm format |
Biome format --write (scoped) |
No test script yet; run pnpm exec vitest once tests exist.
Project layout¶
src/
├── main.tsx # mounts <App />
├── App.tsx # QueryClientProvider → RouterProvider
├── styles.css # @import '@bricks-common/bo/styles.css'
├── routeTree.gen.ts # GENERATED — never hand-edit
├── core/{config,i18n,router,providers,layout}
├── modules/<domain>/ # see topical rules
│ ├── components/ # tables, cells, drawers, panels
│ ├── modals/ # Dialog/AlertDialog only
│ ├── pages/
│ ├── services/ # queryKeys + hooks
│ ├── store/ # URL search state
│ ├── types/
│ └── utils/
└── routes/ # file-based, __root.tsx + devtools
Module map¶
17 active modules. Routes auto-generated under src/routes/_authenticated/ unless noted.
| Module | Route(s) | Purpose |
|---|---|---|
auth |
/login |
Admin login (wraps shared AdminLoginPage) |
projects |
/projects/obligations, /projects/obligations/$projectId (sidebar label « Projets », slug unchanged), /projects/financing-simulation |
Obligation list, ProjectDrawer, simulation, lifecycle modals, project documents (add/delete PDF), and the project fiche. Facturation modal reads Axonaut live (quota-bound) — one request per open, never per table row. ProjectDetailPage = the one fiche for a dossier and its funded project, on the DS detail kit (components/detail/), keyed on the stable project id (pfr.projectId, reserved at creation = the future properties.id, BRI-1549): the URL id is parsed with uuid_zod and resolves two nullable halves (ProjectDetailData) — project = the row of the cached obligation list (useProjects + a find in the page, as the drawer does, no per-project endpoint) and financingRequest = useProjectFinancingRequestByProject (GET /administration/project-financing-request/project/:projectId, a 404 resolves to null: every legacy project has no dossier). Not found only when both are missing. A block takes its half non-null and never tests presence: ProjectGate / DossierGate (DetailGate.tsx, children as a function) render the block with its data or a same-shaped card shell blurred by the DS Gate with one reason per half (project.detail.gate.*); the compiler refuses a project block under a DossierGate. Tabs declare their half once in TAB_SOURCE (constants.ts): a single-half tab is disabled with DisabledTooltip (getTabLockedReason) when that half is missing, and useProjectDetailTab lands a deep link onto a locked tab on « Général ». Only Header reads both halves, through the pure toHeaderModel (unit-tested: title = project name, else presentation name, else « Sans nom »; identifiers = business id, project id, dossier id; trailing = collected amount or creation date) plus the dossier status / category pills and the project visibility / status / funding badges, each group only when its half exists. HeaderActions follows the mock with inline buttons disabled + tooltip rather than a blurred card: internal note, « Voir sur l'app » and ProjectActions need the project; « Voir sur l'EF » → ${appEspaceFiURL}/financing-request/${projectId} needs the dossier; « Voir sur l'outil d'analyse » stays disabled (no per-project route) and « Messagerie » until BRI-2204. Aside: AccountManagerCard, CompanyCard « Société emprunteuse » and UsersCard « Personnes du projet » (imported from projectFinancingRequests, dossier half) + SpvCard (SPV identity + the shared ProjectOwnerCompanyLinkButton from the row's projectOwnerCompanyId) and ProjectOwnerCard (project half; dumb card, Detail owns the navigation). Five tabs whose value lives in the URL (?tab=general|kyb|contracts|notary|documents, validateSearch on the route, tabs and their labels living in Detail.tsx itself): « Général » = GeneralInformationCard (dossier; editable in draft only) then the project blocks, « KYC / KYB » and « Notaire » = project tabs, « Documents » = dossier tab (the shared documents kit, DocumentsCard scope={{ type: 'project', projectFinancingRequestId }}, entityName = fiche title), « Contrats et signatures » = the only tab fed by both (signature on the stable id, mandates on the SPV — blurred row without project). « Général » holds FundingCard (collected amount = funding.amountToFundCents — a listed project always reached its target, no progress bar; rate; nominal duration with the total prorogated duration underneath when longer; investor count from funding.investorCount; next unpaid échéance from the schedule) then the same EcheancierTab as the drawer, full width and bringing its own cards. « Contrats et signatures » (components/detail/contracts/, BRI-2287) reads GET /administration/project/:projectId/signature — keyed on the stable project id like the fiche, the API resolving the financing request through projectId_view and gating on signature | completed. One row per document, not per signature group: the row carries its group's signers, progress and status, and unfolds (Collapsible) on the PDF actions and the signer table (fonction = the BO-entered title of the matching representative). Lemonway SDD mandates (useSpvSddMandates, SPV-keyed) are listed below at the same gabarit and render even when the project has no financing request; the empty state branches on the error code (getAdminApiErrorCode), not the status, because analyse-not-found is a 404 too — inline, no toast, no retry. The mock's document reference, generation date and per-signer signed copy are not in the bundle and nothing about the signature step is stored on our side (computeProjectFinancingRequestSteps derives its status), so those three slots are absent — ask Projet Analyse before adding them. « KYC / KYB » (components/detail/kyb/, BRI-2285) reads the SPV through the existing GET /administration/spv/:id (useAdminSpv, 404 = « aucune SPV rattachée », inline, no toast, no retry) and shows only what Bricks holds: KybFileCard (onboarding version, account / profile / wallet statuses through LemonwayStatusBadge, French labels per Lemonway vocabulary, wallet blocked) with « Générer le lien d'onboarding » → POST /administration/spv/:id/onboarding-url (admin twin of the espace-financement route, AdminActionName.SPV_ONBOARDING_URL_GENERATE), disabled with the exact reason on onboarding v1 or without onboardingId — 247 of the 378 prod SPVs —, hidden once the KYB is validated; IdentifiersCard (SPV, account, profile, onboarding, wallet, all CopyButton); PaymentMethodsCard (virtual IBAN / BIC); CompanyCard (SIREN, R.C.S., capital, registration date, address). The stored Lemonway statuses are a webhook/cron snapshot, so v2 also calls the admin twin GET /administration/spv/:id/onboarding-status (useSpvOnboardingStatus) and the live answer wins — silently (showErrorNotification: false), the snapshot keeping the card readable on a Lemonway outage. The aside already carries the SPV name, country, KYC pill and Lemonway dashboard link, so the tab never repeats them; the « Société » fiche covers project_owner_company, a different legal entity from the SPV, so the SPV identity is not a duplicate. Absent on purpose, nothing being stored our side: KYB submission (BRI-1592), KYB payload and presentation, constitutive documents (project_financing_request_document finalization is empty in prod), validation date and author, identity documents. Legal representative and beneficial owners stay out too — sensitive personal data, and they belong to the « Société » fiche once project_owner_company is migrated. « Notaire » (components/detail/notary/) reads GET /administration/project/:projectId/notary — read-only, the study stays master of its dates. Two cards, always rendered because an unset date is what the ops comes to check: rendez-vous (notary_project.json->'appointment'->>'scheduledAt', « Tenu » once the date has passed — the study never confirms the meeting happened —, « À venir » before, « Non planifié » without) and « Fonds reçus par le PDP » (receivedByProjectOwnerAt = properties.funding.receivedByProjectOwnerAt, with the studyName of the most recent completed financing request). Étude coordinates, treatment flag and notarial documents stay out of the tab. Gated (BetaPageLayout). Entry points, all on the stable project id: first button of the list's actions column (ProjectDetailLinkCell), ActionCell of the financing-requests list (the row's projectId, every status) and « Projets attachés » of the Société fiche |
royalties |
/projects/royalties |
Royalty properties, yearly financial update, resale, marketplace close toggle, project documents (add/delete PDF), monthly updates |
constructionBudget |
/projects/budget-chantier |
PDP wire approve/decline, transfer to échéance |
paymentsByPeriod |
/suivi-echeances |
Cross-project payment monitoring per month |
managementFees |
/suivi-frais-gestion |
Overdue fee tracking, transfer / mark-executed |
wiresToBeAssigned |
/paiements-a-assigner |
Match incoming wires to project/échéance |
wiresAssignedArchived |
/paiements-assignes-archives |
Read-only history of assigned/archived wires |
pendingPayouts |
/paiements-en-attente |
Pending versement queue |
capitalRepayments |
/remboursement-capital-impaye |
Unpaid capital repayment tracking |
spvsWallets |
/spvs-wallets |
SPV wallet ledger |
testBankDebits |
/prelevements-test |
Test bank debit (1 €) list, status, schedule |
changelog |
/changelog |
"Quoi de neuf ?" release timeline (unread badge in sidebar) |
projectFinancingRequests |
/projects/project-financing-requests |
Dossiers project_financing_request — server mode table (tableModeOptions({ mode: 'server' })): sort / q / status / category / createdOn (toolbar day picker) / page flow from the URL hooks into GET /administration/project-financing-request query params, placeholderData: keepPreviousData, export = current page. Statuses = PFR model as-is. KYC and Visibilité columns are placeholders (placeholderColumn, no source in the model yet); Péremption LEI = leiObtainedAt + 1 year, badge when past. « Créé par » = creator's email (not necessarily the representative role). Gated (BetaPageLayout, sidebar Nouveau). No fiche of its own: ActionCell (« Compléter le projet » in draft, « Voir la fiche » otherwise) opens the Projet fiche on the row's projectId, whatever the status. The module keeps the dossier cards the Projet fiche composes (components/detail/): status / category / role pills and their labels come from @bricks-common/bo/modules/project-financing-request (no local StatusBadge / translate* in this module or in projectFinancingRequestUsers), GeneralInformationCard = one DetailEditableCard (name + category Select, PATCH …/:id/presentation sends only dirty fields, null clears the name; « Modifier » disabled with tooltip outside draft, the API answering 409 project-not-editable past it), AccountManagerCard (DetailEditableCard whose read mode shows « Prénom N. » alone — the combobox is EDIT_ONLY through the card's data-editing attribute like BankDetailsCard; options from GET /administration/project-financing-request-account-manager, « Aucun » retires the AM via PATCH …/:id/account-manager), CompanyCard « Société emprunteuse » (one presentational DetailCardRow — name or « Sans nom », « SIREN · forme juridique » — from the detail's company, « Aucune société rattachée à ce dossier » while the portal has not created it, and the shared ProjectOwnerCompanyLinkButton to the « Société » fiche) and UsersCard « Personnes du projet » (DetailCardRow per active role → /third-party-accounts/users?selectedUser=<email>, pending invitations badged, « Inviter » disabled with tooltip — no invitation from the BO); the detail view (ProjectFinancingRequestDetail, now carrying projectId) is fetched by the fiche through useProjectFinancingRequestByProject (services/, key byProject). Beta toggle = <BetaSwitchCard flag="projectFinancingRequest"> in AppSidebar topSlot; flag bo:beta:projectFinancingRequest:enabled, off by default |
projectConversations |
/messaging/project-conversations |
Account-manager messaging = CRM iframe /embed/conversations-all. The URL comes ready-made from GET /administration/project-conversation/all-conversations/embed (CRM URL + api key live API-side, never in the front). Iframe is flex-1 min-h-0 under BetaPageLayout (full height, no double scroll), src = 'about:blank' on unmount. Gated (BetaPageLayout, sidebar Nouveau) |
projectFinancingRequestUsers |
/third-party-accounts/users |
Utilisateurs espace financement — two tabs (?status=active active users, the default / invitation-pending invitees) over a server mode table on GET /administration/project-financing-request/users (status required, q / role enum filter / page; no sort whitelist, API order = newest first). Rows are lean (identity, roles, projectCount, dates), keyed by email (an invitee has no user row); an active user has held a role (active or revoked) or signed up with registeredFrom = 'financing', so a project owner without a project shows roles: [] / 0 projets (the drawer's projects editor then asks for a role per ticked project); phone and last-login columns only exist on the active users tab. Row click (DataTable onRowClick, no action column) sets ?selectedUser=<email>, which opens ProjectFinancingRequestUserDrawer (vaul Drawer, components/, same shape as ProjectDrawer) fed by GET …/users/by-email/:email (useProjectFinancingRequestUserDetail; 404 = « aucun utilisateur » in the body, no toast, no retry). Drawer = identity form for active users (PATCH user; null clears, undefined leaves) and a read-only identity with a tooltip for invitees (no account row to write to), account stats + « Se connecter en tant que » (window.open) + « Copier le lien d'invitation » (POST link = token rotation, clipboard), granted projects read/edit inline (PUT { projects: [{ projectId, role }] }, picker reuses projectFinancingRequests' list query + translateProjectFinancingRequestStatus). Role is per granted project, as in DB: the read list shows a role badge per project, each ticked row in the editor carries its own role select (required, no default), an apporteur_affaires row keeps its select locked with a tooltip (the API would 409); the identity form carries no role. The identity <Form> spans body + DrawerFooter (« Enregistrer » at the bottom, as mocked); the projects picker saves with its own type="button" and its search input swallows Enter so it never submits the identity form. Inputs get the card fill through [--input-bg:var(--card)] on the drawer content — the DS default is transparent, cream on the drawer background. Gated (BetaPageLayout, sidebar Nouveau) |
projectOwnerCompanies |
/projects/companies, /projects/companies/$companyId |
Sociétés emprunteuses project_owner_company — server mode table on GET /administration/project-owner-company (q = name / SIREN with or without spaces / id, legalForm enum filter, sort whitelist companyName / siren / leiExpiresAt / createdAt, page), placeholderData: keepPreviousData, export = current page. One row per project_owner_company (id = id_view): a draft without a name shows « Sans nom » / « — »; projectCount is 1 until the planned 1-N model. « Péremption LEI » = LeiCell: a warning « LEI non renseigné » badge without leiNumber, success « LEI valide » / « Valide jusqu'au alert « Expiré le LeiExpiryCell) past the expiry from projectFinancingRequests (the API derives leiExpiresAt with computeLeiExpiresAt) — no LEI status enum, no LEI filter. Row click (DataTable onRowClick, no action column) → /projects/companies/$companyId. ProjectOwnerCompanyDetailPage = the fiche on the DS detail kit (components/detail/): Header (back to the list; no actions menu and no identifiers line — the mock has neither; Building2Icon, name or « Sans nom », subtitle « SIREN 895 734 834 · SAS · France » with the SIREN as an inline CopyButton copying the digits and the country label from Intl.DisplayNames when the portal stored an ISO code, a local LeiBadge (warning without number, alert expired, success otherwise — a number without obtention date counts as valid) in the trailing slot), DetailTabs = four TabsTrigger whose active value lives in the URL (?tab=identity|banking|beneficial-owners|documents, validateSearch on the route, store/useProjectOwnerCompanyDetailTab), the « Identité & LEI » tab holds two DetailEditableCards side by side (IdentityTab): « Identité de la société » (name disabled with a « Non modifiable » hint, CountrySelect storing the alpha-2 code, SIREN, read-only creation date (row createdAt), legal-form Select whose label is the code, share capital in euros with a formatCentsFull hint, address / postal code / city) → PATCH …/:id/identity with the dirty fields only, null clears, the API answers 409 company-invalid-for-status inline when a completed company would lose a field; « Identifiant LEI » (LeiBadge in badges, number, obtention DatePicker, derived expiry read-only) → PATCH …/:id/lei, wrapped in <Gate isLocked={status === 'draft'}> because a draft has no banking block. The banking tab = components/detail/banking/: BankDetailsCard (DetailEditableCard on the idtlt ibanValidator / bicValidator of the contract package; read mode = « Banque · Titulaire » as card description + IBAN grouped by 4 through formatIban from BO core + BIC, the label / bank / holder fields only show while editing — hidden through the card's data-editing attribute, no DS change; PATCH …/:id/banking with dirty fields only, null clears a label; wrapped in <Gate isLocked={status !== 'finalized'}> — the borrower owns the RIB until finalization, the API answers 409 company-banking-not-editable before) + SddMandatesCard (useSddMandates(linkedSpvId) module-local on the shared spvSddMandatesKeys, 404 = empty state without toast, mandate count ahead of the description, one DetailCardRow per Lemonway mandate — « IBAN · titulaire » — with the shared SddMandateStatusBadge from modules/projects/components and an amber « IBAN différent du RIB » pill when a mandate IBAN differs from the company IBAN; no download, Lemonway has no PDF); the « Bénéficiaires effectifs » tab lists representatives[] — the representatives are the beneficial owners, no separate list — one DetailCardRow per person (nationality · residence, birth date or « Date de naissance à saisir » in trailing, Alert warning + TabsCount = missing birth dates) opening modals/BeneficialOwnerEditModal (name, email, birth date, birth country, nationality, residence country → PUT …/representatives/:id; the form never clears a birth date), the « Documents » tab = the shared documents kit (DocumentsCard scope={{ type: 'company', projectOwnerCompanyId }}): one table row per file with its projects, the 4 finalization kinds (finalizationDocumentKinds from the contract package) shown first as « Manquant » rows with « Importer » (kind prefilled), add (one checkbox per project of the company, all checked by default) / replace / edit kind / delete, TabsCount = listMissingKinds on the kit query (DetailTabs and the card share it), a « Représentants » aside card (DetailCardRow per person — « Fonction · email » description, shared ProjectFinancingRequestUserRoleBadge role="representative" pill → /third-party-accounts/users?selectedUser=<email> like UsersCard, not clickable without an email; no add button: representatives are born in the portal) and a « Projets attachés » aside card (AttachedProjectsCard: one DetailCardRow per projects[] entry — name or « Sans nom », shared status + category pills from @bricks-common/bo/modules/project-financing-request in badges, requested amount (fundingAmountRequested, formatCentsShort) in trailing → the Projet fiche on the entry's projectId; « Aucun projet attaché » placeholder). Both aside cards are presentational: Detail owns the navigation, as on the Projet fiche. PUT …/representatives/:id accepts every status; birthDate: null or residenceCountry: null on a completed company answers 409 company-invalid-for-status. Fed by GET /administration/project-owner-company/:id (useProjectOwnerCompany; 404 = « Société introuvable » in the body via isNotFoundError, no toast, no retry). Gated (BetaPageLayout, sidebar Nouveau) |
documents |
— (no route) | Shared admin-documents kit for the Projet and Société fiches, parameterised by scope (project | company): DocumentsCard (owns the useDocuments query and the modal state as useState<DocumentModal \| null>) → DocumentsTable (low-level Table inside the card, columns per scope, 4 inline icon actions always rendered — Ouvrir / Modifier / Remplacer / Supprimer — disabled + tooltip from getDocumentDisabledReason, shared with the modal submits) → add / edit / replace / delete modals (idtlt validators, useSubmitWithError inline alert). Upload = one multipart POST …/documents (payload JSON + file, filename kept: the API types by extension), client-side extension + 50 MB checks in utils/fileRules.ts; useCreateDocument is not silent (an upload also fails without a code). Labels for every projectDocumentKind live in utils/kinds.ts (the typecheck breaks when the enum grows); « Rattaché à » / « Rattachement », never « Concerne ». |
componentStories |
/component-stories/* |
Non-prod DS gallery (gated on isComponentStoriesEnabled) |
Cross-cutting patterns¶
- ProjectDrawer — URL-driven (
?selectedProject=<uuid>&selectedProjectTab=overview|echeancier) shared acrossprojects,constructionBudget,managementFees,paymentsByPeriod,testBankDebits. Side panel without dedicated route; échéancier Actions include a read-only prorogation preview. - Echeancier —
projects/components/Echeancier/(out of the drawer since BRI-2356): the drawer and the project fiche mount the sameEcheancierTab, and the modals read its sub-components directly. - ProjectModalActionsProvider — global modal provider at
_authenticatedlayout level for project lifecycle actions. - URL search state — each table page persists search/filter/pagination in URL via
store/use*ListSearch.ts+ routevalidateSearch.