Tableau de bord super-admin
Vue rendue sous /dashboard (views/SuperadminDashboard.vue, via Dashboard.vue quand la casquette super-admin est active), portée pour le design depuis .lovable-ref/.../SuperadminDashboard.tsx (voir le skill inspiration-to-vue). Accessible aux utilisateurs connectés ; c'est l'accueil des super-admins (voir Routage & gardes).
Composition
views/SuperadminDashboard.vue— coquille :SidebarProvider+ barre latérale- barre supérieure (fil d'Ariane, bouton de déconnexion) + section active. La déconnexion passe par le store de session puis redirige vers
/login. La section courante est dérivée de l'URL (/dashboard/:section, composableuseDashboardSection).
- barre supérieure (fil d'Ariane, bouton de déconnexion) + section active. La déconnexion passe par le store de session puis redirige vers
components/superadmin/SuperadminSidebar.vue— barre latérale repliable (primitive shadcn-vuesidebar), navigation groupée et repliable (collapsible) en quatre groupes : Pilotage (cockpit, tours), Validations (prestataires, revendeurs — chacun avec son badge de compteur), Base de données (codes — avec son badge de demandes en attente (extension/remboursement) —, prestataires, revendeurs, régions, voyageurs, utilisateurs) et Administration (tags). Propsactive/pendingPartnerApps/pendingResellerApps/pendingCodeRequests, émetselect. Son en-tête rend leRoleSwitcher.components/auth/RoleSwitcher.vue— sélecteur de casquette réutilisable : affiche la casquette active (icône + libellé + nom de l'utilisateur) ; si l'utilisateur en a plusieurs, un menu (dropdown-menu) permet d'en changer —setActiveHatpuis navigation vers/dashboard(resolveHomeRoute), qui rend alors le portail de la nouvelle casquette. Avec une seule casquette, affichage statique. Autonome (lit le store de session), donc réutilisable par tout shell de portail.components/superadmin/CockpitView.vue— vue d'ensemble : hero + indicateurs clés. ReçoitpendingResellerAppset affiche une alerte « candidatures revendeur à valider » cliquable (navigue vers la sectionresellerApplications) lorsqu'il y en a.components/superadmin/ResellerApplicationsManager.vue— section « Revendeurs » du groupe Validations : charge les candidatures revendeur (api/reseller-applications.ts), applique filtre par statut + recherche (filterResellerApplications), orchestre le dialog de détail et émetpending-count-changed(badge de la barre latérale) après chargement et chaque action. Composants associés :ResellerApplicationsDirectory.vue— présentationnel : titre, 4 KPI, pilules de filtre par statut, recherche (entreprise / contact / email), liste de cartes cliquables, états vide et chargement.ResellerApplicationDetailDialog.vue— détail (contact, type de point de vente, message), notes internes, boutons « Rejeter » / « Approuver » (l'approbation passe l'organisation àapproved, ce qui déclenche l'email d'invitation de l'organisateur ; le rejet la suspend). N'appelle pas l'API : émetupdateStatus/saveNotes.reseller-applications.ts— types métier (ResellerApplication, statutspending / approved / rejected) et helpers purs (countResellerApplications,filterResellerApplications).
components/ComingSoon.vue— espace réservé réutilisable (partagé avec le portail voyageur) pour les sections pas encore construites ; reçoit le libellé et le message d'attente déjà traduits.components/superadmin/sections.ts— modèle de navigation (groupes, sections, icônes) partagé par la barre latérale et le fil d'Ariane.groupOfSection()donne le groupe de la section active : le fil d'Ariane duDashboardShelll'affiche en segment intermédiaire (prop optionnellegroupLabel), ce qui désambiguïse les libellés volontairement dupliqués entre Validations et Base de données (« Prestataires », « Revendeurs »).
Section codes — liste, filtres, génération et export
La section codes (components/superadmin/CodesManager.vue) liste les codes d'activation, permet d'en générer via GenerateCodesDialog.vue, de les exporter en CSV, d'agir sur leur cycle de vie (annuler / rembourser / prolonger) et de résoudre les demandes voyageur en attente (prolongation / remboursement — voir la file des demandes plus bas).
Un sélecteur de vue (segmenté « Codes | Lots ») en tête bascule entre l'annuaire des codes (CodesDirectory.vue) et l'annuaire des lots (BatchesDirectory.vue, voir Vue Lots). Chaque vue a sa propre URL — /dashboard/codes et /dashboard/codes/batches (route codes-batches, voir Routage) : la vue active est dérivée de la route et le sélecteur navigue par router.push, donc les deux vues sont partageables et le bouton Précédent circule entre elles. Le manager pilote les deux : CodesDirectory reste toujours monté (v-show) pour préserver ses filtres ; BatchesDirectory est monté à la demande (v-if). Le chargement est paresseux et symétrique : la vue sur laquelle l'URL arrive est chargée immédiatement, l'autre à sa première activation.
Filtrage et pagination côté serveur
Le filtrage est résolu par le backend : chaque changement de filtre, de recherche ou de page relance GET /activation-codes avec les paramètres du contrat (voir Couche API, buildActivationCodesQuery). Le total paginé (meta.total) reflète donc l'ensemble filtré, plus seulement la page visible. CodesDirectory.vue reste purement présentationnel : les lignes reçues sont finales.
- Filtres — statut en multi-sélection (menu à cases à cocher,
DropdownMenuCheckboxItem; aucune case cochée = tous les statuts ; les sept statuts UI reprennent 1:1 ceux du backend : en stock, acheté, actif, utilisé, expiré, annulé, remboursé), organisation émettrice (sélecteur alimenté parlistOrganizations(), filtre parorganisation_id), préréglages d'expiration (expiré / ≤ 30 j / > 30 j, bornes calculées côté client), recherche partielle sur le code complet (maxlength100 aligné sur la validation backend), et numéro de lot exact (champ numérique, icôneLayers, débouncé 300 ms comme la recherche ; envoyé enbatch_number— seule exception accordée à la règle «src/apien lecture seule » :batchNumberajouté àActivationCodeFilterParams/buildFilterQuerydesrc/api/codes.ts, donc repris aussi par l'export). - Filtres canoniques dans l'URL — tous les filtres de la vue Codes sont sérialisés (
codes-url.ts:?status— jointure par virgules en ordre canonique, jetons inconnus ignorés —,?org,?expiry,?q,?batch,?requests,?page; seules les valeurs non par défaut apparaissent,pageSizereste une préférence d'appareil hors URL). Tout lien est profond et partageable. Le manager latch l'état depuis la route (applyCodesChange: une action utilisateur pose les latches de façon synchrone — un seul flush, une seule requête — puis écrit l'URL enrouter.replace, jamais d'entrée d'historique par frappe ; le watcher de route ne réagit qu'aux changements externes — lien profond, précédent/suivant, re-clic latéral). Les champs deCodesDirectorysont tous contrôlés (valeurs en props, événementsupdate:*). Re-cliquer « Codes » dans la barre latérale (URL nue) remet tous les filtres à zéro ; les valeurs malformées retombent sur le défaut. - Export CSV — le bouton « Exporter CSV » télécharge l'ensemble filtré courant (mêmes filtres que le listing, recherche débouncée comprise ; la pagination ne s'applique pas) sous
activation-codes-AAAA-MM-JJ.csv, viaexportActivationCodesCsv()(GET /activation-codes/export) et le helperdownloadBlob(src/lib/download.ts). Le bouton affiche un spinner et se désactive pendant l'export ; en cas d'échec, un message traduit apparaît sous les boutons d'en-tête. - Réactivité — un watcher remet
pageà 1 à chaque changement de filtre avant que le watcher de requête ne lise les paramètres (l'ordre de création des watchers fusionne les deux mutations en une seule requête par action). La recherche est débouncée à 300 ms ; une garde anti-course (dernière requête gagnante) ignore les réponses périmées. En cas d'échec, les lignes sont vidées et un message d'erreur traduit s'affiche au-dessus du tableau. - KPI globaux — les cartes KPI viennent de l'endpoint de statistiques (non filtrable) : elles ne suivent volontairement pas les filtres du tableau.
- Colonne statut — badge par code selon
STATUS_VARIANT/STATUS_CLASS(codes.ts) : « Utilisé » en émeraude (écho du KPI « Utilisés ») et « Remboursé » en ambre, distincts du rouge destructif d'« Annulé » / « Expiré ». À côté du badge, un sablier ambre (components/CodePendingExtensionIcon.vue, partagé avec les portails revendeur et voyageur) marque un code dont la demande de prolongation est ouverte (has_open_extension_request, orthogonal au statut : un code encore valide reste « Acheté » et utilisable pendant l'instruction) ; le libellé « Extension en attente » n'apparaît qu'au survol (tooltip shadcn-vue, patron deTourStepsPanel.vue). Son jumeaucomponents/CodePendingReimbursementIcon.vue(icôneHandCoins, même ambre) marque une demande de remboursement en attente — là, pas de booléen dédié au contrat : l'attente est le statut backendpending-reimbursement(canDenyReimbursement,codes.ts), que le badge dérivé des booléens ne sait pas exprimer (il affiche « Acheté »). - Note interne dans la colonne code — la
descriptiondu code (note de lot, visible des superadmins seulement) s'affiche sous le code en tout petit (10 px, italique, tronquée partruncate+max-w-48), reprise du visuel de la référence Lovable. - Détail au clic de ligne — chaque ligne est cliquable (
cursor-pointer, émissioninspect, même patron que l'annuaire des revendeurs) et ouvreCodeDetailDialog.vue: vue en lecture seule du code — code, badge de statut (sablier de prolongation en attente compris), revendeur, acheteur, montant, dates du cycle de vie (achat, activation, expiration, annulation, remboursement, demande de prolongation) et note interne complète. Les cellules interactives (case à cocher, menu d'actions) stoppent la propagation du clic (@click.stop) pour ne pas ouvrir le détail.
Actions de cycle de vie — annuler / rembourser / prolonger
Chaque ligne du tableau porte un menu d'actions (dropdown-menu) : Prolonger la validité, Rembourser et Annuler (destructif). Les entrées se désactivent selon les gardes canCancelCode / canReimburseCode / canRenewCode (codes.ts) : un code annulé ou remboursé ne se réannule pas ; un code remboursé ne se rembourse pas deux fois ; tout code non consommé — ni utilisé, ni annulé, ni remboursé — se prolonge, qu'il soit expiré, acheté ou encore en stock (décision produit : pas besoin d'attendre l'expiration ni la vente pour repousser l'échéance). Divergence temporaire avec le backend : sa garde de renouvellement rejette encore les codes jamais achetés (« This activation code has not been purchased yet. ») — tant qu'elle n'est pas assouplie, ces codes atterrissent dans skipped et la raison s'affiche via la bannière de succès partiel. CodeActionDialog.vue confirme l'action : rappel du code, état d'attente, erreur affichée dans le dialogue en cas d'échec (le dialogue reste ouvert). L'appel API vit dans le manager ; après succès, la ligne est remplacée par le code renvoyé (pas de rechargement de la liste) et les statistiques sont rechargées.
La prolongation est le renouvellement du contrat (status=purchased + new_expiration sur l'endpoint de transition, bulkRenewActivationCodes — voir Couche API) : le dialogue recueille une durée en mois (3 par défaut) ajoutée à l'expiration courante — ou à aujourd'hui si le code est déjà expiré, pour que la nouvelle échéance reste future — et prévisualise la date résultante avant confirmation. L'entrée « Réémettre le code » de la maquette a été retirée : la réémission n'existe pas au contrat.
Refuser une demande (prolongation ou remboursement) : deux entrées du menu de ligne, chacune visible seulement quand le code porte la demande ouverte correspondante (canDenyExtension / canDenyReimbursement, codes.ts), ouvrent DenyRequestDialog.vue — un seul dialogue, libellés et icône commutés par la prop kind (extension / reimbursement) ; motif obligatoire (textarea, 5000 caractères max comme la validation backend), confirmation destructive. Les appels (denyCardExtension / denyCardReimbursement, components/superadmin/requests-api.ts — module local à la fonctionnalité pour la résolution des demandes voyageur, précédent tags-api.ts, contexte super-admin) frappent POST /activation-codes/{id}/extension-request/denial et POST /activation-codes/{id}/reimbursement-request/denial ; après succès la ligne est refetchée en place (fetchActivationCode) et un toast confirme. Le motif est visible du demandeur sur sa carte. L'octroi, lui, n'a pas d'entrée dédiée : prolonger le code (ou la date du lot) est l'approbation d'une prolongation — le backend tamponne extension_granted_at — et rembourser le code est celle d'un remboursement.
File des demandes — filtre « Demandes (N) » et accepter / refuser
Au-dessus de la grille de filtres, un bouton « Demandes (N) » (icône sablier, aria-pressed) filtre la liste sur les seuls codes dont une demande attend une décision. Il n'apparaît que si N > 0 — ou si le filtre est actif, pour pouvoir le désactiver une fois la dernière demande résolue. Le filtre est canonique dans l'URL (?requests=1) comme les autres.
- N compte les demandes ouvertes (
fetchPendingRequestsCount,requests-api.ts) : aucun endpoint de statistiques ne les porte, donc deux sondesper_page=1(pending_extension=true/pending_reimbursement=true) dont on somme lesmeta.total— un code portant les deux demandes compte deux fois. Le même compte alimente le badge « Codes » de la barre latérale : semé au montage du tableau de bord (SuperadminDashboard.vue), puis maintenu par l'événementpending-count-changeddu manager (mêmes mécaniques que les badges de validation) — émis au montage, à l'actualisation et après chaque demande résolue. - La liste filtrée est l'union de deux requêtes (
fetchCodesWithPendingRequests) : les deux drapeaux du contrat ne s'expriment pas en OU dans un seul appel (pending_extensionest orthogonal au statut,pending_reimbursementest un statut). Chaque volet est agrégé par pages généreuses (per_page=100+last_page, patrontags-api.ts), l'union est dédupliquée par id et triée demande la plus ancienne d'abord (ordre de traitement) ; la pagination est alors côté client (l'union n'a pas de pagination serveur), les autres filtres continuent de s'appliquer côté serveur sur chaque volet. Le module refait localement le mappingActivationCodeResource → ActivationCode(copie assumée du mapper privé desrc/api/codes.ts, en lecture seule). - Chaque ligne devient une décision : la paire Accepter / Refuser remplace le menu d'actions générique. Accepter route vers le dialogue de confirmation existant selon la nature de la demande (
requestKind,codes.ts— le remboursement gagne si les deux demandes sont ouvertes) : renouvellement pour une prolongation, remboursement pour un remboursement. Refuser ouvreDenyRequestDialogavec lekindcorrespondant. Après résolution, le code quitte la liste filtrée (rechargement de l'union, pas de remplacement en place) et le compteur suit.
Actions groupées — sélection multi-lignes
Le tableau porte une colonne de cases à cocher (Checkbox shadcn-vue) avec une case d'en-tête limitée à la page affichée : tout coché → tout décoché, sinon sélection de la page entière, état indeterminate (icône moins) sur sélection partielle. La sélection (selectedIds) appartient au manager et est épurée dans loadCodes() à chaque rechargement (changement de page, de filtre, actualisation) — volontairement pas dans un watch(codes), que le remplacement in-place après une action déclencherait aussi. Elle ne référence donc que des codes visibles.
Dès qu'une ligne est cochée, une barre de sélection apparaît entre les filtres et le tableau : compteur, menu « Actions groupées » (mêmes trois entrées que le menu de ligne, mêmes icônes et ordre) et bouton « Tout désélectionner ». Une entrée groupée n'est désactivée que si aucun code sélectionné ne passe sa garde ; l'appel envoie tous les ids sélectionnés en une seule requête — le backend fait autorité et renvoie les inéligibles dans skipped. Le dialogue (CodeActionDialog.vue, prop codes) pluralise ses textes, affiche le compteur à la place du code, et annonce combien de codes sélectionnés sont réellement éligibles.
La prolongation groupée applique une date commune à tous les codes : aujourd'hui + N mois (l'endpoint ne prend qu'un new_expiration). Le dialogue l'explicite et avertit quand des codes sélectionnés expirent déjà après cette date (leur validité serait raccourcie) — l'admin peut alors les désélectionner.
Après succès : les lignes renvoyées sont remplacées en place, les codes traités sortent de la sélection, les écartés y restent (l'admin voit ce qui n'est pas passé), et une bannière au-dessus du tableau résume « X mis à jour, Y ignorés » avec les raisons backend dédoublonnées (anglais, non localisées — public superadmin). Elle se ferme au clic, à la prochaine action ou au prochain rechargement. Une réponse entièrement écartée (HTTP 200 sans updated) garde le dialogue ouvert avec l'erreur générique, comme un échec unitaire.
Génération
À l'ouverture, le dialogue présélectionne l'organisation par défaut (« maison » Travelise, lue via getDefaultOrganization(), voir Couche API) : elle apparaît en tête du sélecteur (dédoublonnée de la liste des organisations) et le bouton Générer est actif immédiatement. Si sa récupération échoue, aucune présélection : le bouton reste désactivé tant qu'aucune organisation n'est choisie. La soumission envoie toujours un organisation_id explicite.
Le dialogue collecte aussi une note interne optionnelle (description, copiée sur chaque code du lot, 1000 caractères max, visible des superadmins seulement ; vide → null) et une date d'expiration (expires_at), préremplie à aujourd'hui + 1 an — le défaut backend rendu explicite — et bornée entre demain et aujourd'hui + 1 an. Toujours envoyée, elle est sérialisée en fin de journée locale (23:59:59.999 → ISO UTC, helpers toLocalDateString / endOfLocalDayIso de src/lib/dates.ts) pour que les codes restent valables tout le jour choisi.
Vue Lots — répartition par statut et édition d'un lot
Un lot regroupe les codes générés ensemble en un seul appel generate. La vue Lots (BatchesDirectory.vue) liste les lots du plus récent au plus ancien via fetchActivationCodeBatches() (GET /activation-codes/batches, voir Couche API) : numéro de lot, revendeur émetteur, répartition par statut (code_counts — un badge par statut non nul, mêmes STATUS_VARIANT / STATUS_CLASS que les codes), date d'expiration et date de création. Deux filtres serveur : recherche par numéro de lot exact et organisation émettrice (mêmes câblage et gardes que les codes — remise à la page 1 sur changement de filtre, recherche débouncée 300 ms, garde anti-course batchRequestSeq, pagination via meta.total). BatchesDirectory est présentationnel et contrôlé : filtres passés en props, changements émis vers le manager (source de vérité unique), ce qui garde l'état correct au remontage v-if. Ses filtres sont eux aussi canoniques dans l'URL (/dashboard/codes/batches?org=…&q=…&page=…, mêmes mécanique de latch et politique replace que la vue Codes — chaque vue possède son chemin, donc son espace de noms de paramètres) ; le sélecteur de vue ré-applique la requête préservée de la vue cible à chaque bascule.
La colonne Codes porte une barre d'utilisation (primitive Progress, src/components/ui/progress/, reka-ui) entre le total et les badges : pourcentage arrondi (active + used) / total (batchUsagePercent), soit les codes réellement consommés (used_at posé) — les codes morts (expirés/annulés/remboursés) ne comptent pas comme de l'utilisation.
La colonne Expiration est teintée selon l'échéance (batchExpiryStatus dans src/components/superadmin/codes.ts, calcul de jours via daysUntil de src/lib/dates.ts) : rouge « Expiré ({n} j) » une fois la date passée (0 j = expiré aujourd'hui, là où la maquette de référence affichait encore « 0 j restants »), ambre « {n} j restants » à 30 jours ou moins (EXPIRY_SOON_DAYS, dupliquée côté feature car la constante homonyme de src/api/codes.ts n'est pas exportée — même fenêtre que le filtre serveur « soon » des codes), vert au-delà ; la date formatée passe en seconde ligne. Cet enrichissement profite tel quel au portail revendeur (composant partagé, prop readonly).
Édition d'un lot (BatchEditDialog.vue, super-admins uniquement) : le bouton « Modifier » d'une ligne ouvre un dialogue qui ajuste la note interne (internal_notes) et la date d'expiration (expires_at) du lot via updateActivationCodeBatch() (PATCH /activation-codes/batches/{id}). L'expiration s'applique à tous les codes du lot sans renouvellement individuel plus tardif (le backend garde le plus grand des deux). La date est saisie en jour local et sérialisée en fin de journée locale (23:59:59.999 → ISO), même logique que la génération. Dès que le jour choisi diffère de l'actuel, un aperçu en direct (panneau bg-muted/50) montre l'expiration actuelle, la nouvelle expiration (mise en évidence text-primary, formatée depuis la valeur réellement soumise — fin de journée locale) et le nombre de codes impactés (counts.total) — rappel visuel qu'une date de lot déplace tout le stock d'un coup. Après succès : la ligne est remplacée en place (pas de rechargement), un toast confirme ; en cas d'échec, le dialogue reste ouvert avec un message d'erreur.
Écarts assumés avec la maquette de référence (.lovable-ref, onglet « Gestion des lots ») : pas de réattribution du revendeur d'un lot (contraire au modèle backend — un lot meurt avec son organisation émettrice). Restent en attente côté backend avant portage : tuiles KPI agrégées des lots, filtre d'expiration sur le listing des lots, export CSV des lots, colonnes valeur unitaire / préfixe (absentes d'ActivationCodeBatchResource).
Drill-down lot → codes : chaque ligne de lot porte une action « Voir les codes » (icône CreditCard, masquée en readonly — donc absente du portail revendeur) qui réinitialise les filtres locaux de la vue Codes (statuts, revendeur, expiration, recherche — aucun filtre périmé ne doit cacher des codes du lot), pré-remplit le filtre lot et navigue en push vers /dashboard/codes?batch=N (Précédent revient sur la vue Lots). Le tout part en une seule requête.
Revendeurs
La section « Revendeurs » (ResellersManager) liste les organisations de type reseller (annuaire ResellersDirectory, détail ResellerDetail) et orchestre la création / édition via ResellerFormDialog. Les lectures tiennent en une requête par page : listOrganizationsPage({ type: "reseller", page, perPage }) renvoie le listing enrichi (OrganizationListItem — email de contact, paire de commission et comptes de codes ; le stock vendu se dérive activationCodesCount − activationCodesInStockCount), donc fetchResellers n'a plus de fan-out par revendeur (fini les getOrganization et comptages de codes ligne à ligne). Le contenu de la page (statuts compris) est la décision du backend : l'annuaire n'exclut plus les pending côté client. La pagination est côté serveur (barre Pagination sous le tableau, même câblage que les codes : remise à la page 1 sur changement de taille, garde anti-course requestSeq) ; la recherche ne filtre que la page chargée — le contrat n'a pas encore de paramètre de recherche — et il en va de même du contrôle d'unicité du nom dans le formulaire.
Le détail est piloté par l'URL : cliquer une ligne (ou son chevron) navigue vers /dashboard/resellers/{uuid} (route reseller-detail, voir Routage) — l'URL est partageable et survit au rechargement. Un uuid absent de la page chargée est récupéré individuellement via getReseller(id) (GET /organization + les deux comptages de codes pour le stock, seul survivant de l'endpoint de comptage — la ressource détail n'expose pas les compteurs) ; seul un échec de cette récupération (uuid inconnu, lien révoqué) est remplacé (router.replace) par la liste, pour que « Précédent » ne retombe pas sur l'URL morte. La recherche survit à l'aller-retour liste ↔ détail : l'instance du manager est réutilisée (même écran Dashboard.vue pour les deux routes). Le crayon d'édition a été retiré de l'annuaire — l'édition passe par le bouton « Modifier » de la vue détail (même ResellerFormDialog).
Côté écritures (src/api/resellers.ts) :
- la création est réelle :
POST /organizationsavec le manager (prénom, nom, email de contact + langue préférée) — le backend rattache le compte et envoie immédiatement la notification ou l'invitation. L'organisation créée revientpending:createResellerla réconcilie aussitôt avec l'interrupteur « Actif » du formulaire (approvedoususpended). Après création, le manager vide la recherche et recharge la page 1 (sous tri serveur, aucune insertion locale ne peut garantir la visibilité de la nouvelle ligne) ; - activer / désactiver passe par
PATCH /organizations/{id}/status(approved/suspended). Les deux sens passent par une modale de confirmation (titre, description et bouton adaptés au sens viaactivateDialog/deactivateDialog), et l'issue est signalée par un toast de succès ou d'échec. Le drapeau actif dérive toujours du statut de l'organisation ; - le nom, l'email de contact et la commission persistent : les deux premiers via
PATCH /organizations/{id}, la commission via la pairecommission+commissionType(CHF / carteou% / carte, bascule à deux boutons dans le formulaire et l'éditeur du détail — voir plus bas). Champ vide = paire omise (défaut plateforme à la création, inchangée à l'édition) ; le détail borne un pourcentage à 100 côté client (même plafond que le backend) ; - prénom / nom du manager, langue préférée et notes restent des surcouches locales de session (perdues au rechargement) tant que le backend ne stocke pas ces champs ; l'action Supprimer a été retirée (aucun endpoint).
Les écritures pouvant désormais échouer, le manager affiche une erreur en ligne (superadmin.resellers.actionError) et le formulaire reste ouvert avec un message (dialog.submitError) en cas d'échec d'enregistrement. Une création ou une édition réussie affiche un toast de succès (superadmin.resellers.created / updated).
Le formulaire (ResellerFormDialog) applique :
- Nom unique : vérification côté client (insensible à la casse) contre la liste des revendeurs déjà chargée, le revendeur en cours d'édition étant exclu de la comparaison — le backend n'a ni contrainte d'unicité sur
organizations.nameni endpoint de vérification. Doublon ⇒ erreur sous le champ et enregistrement bloqué. - Prénom / nom du contact requis à la création : la validation backend exige l'identité du manager (
manager.first_name/last_name) alors que le contrat les type optionnels. Champs requis à la création, facultatifs à l'édition (où ils ne sont qu'une surcouche locale). - Email de contact requis : pendant la saisie (debounce 300 ms),
checkEmailExists(src/api/users.ts) cherche un compte utilisateur associé. Aucun compte ⇒ note « un lien d'invitation sera envoyé à cette adresse » ; compte existant ⇒ note « recevra les droits de gestion » (informatif, non bloquant). - Langue préférée (fr / de / en, groupe de boutons comme dans le dialogue d'invitation utilisateur) : transmise dans le payload (
preferredLanguage), défaut = locale de l'UI, ou langue du revendeur édité.
L'ancienne section « rôles attribués » du formulaire a été supprimée (plus applicable).
Commission
Le backend stocke sur l'organisation une commission (commission décimal + commission_type : percentage | fixed_amount) — un modèle pour les futurs lots, figé (snapshot) sur chaque code à la génération. L'UI expose la paire complète : le listing renvoie toujours la valeur effective (plus de notion d'« override » ni de constante de défaut côté frontend), et le formulaire comme l'éditeur du détail proposent la bascule CHF / carte ↔ % / carte. L'éditeur du détail est validé par Regle (montant requis, positif, ≤ 100 en pourcentage — message detail.commissionInvalid). Les deux colonnes sont co-dépendantes côté backend (required_with mutuel) et l'écriture est réservée au super-admin.
Régions
La section « Régions » (RegionsManager, écran maître-détail monolithique) liste les organisations de type region via fetchRegions({ page, perPage }) → listOrganizationsPage({ type: "region", … }) : une requête par page, statuts rendus tels quels — plus d'exclusion des pending côté client. La couche API conserve l'ordre du serveur, mais l'affichage trie la page chargée : régions actives d'abord, puis alphabétique (localeCompare sensible à la locale) dans chaque groupe (sortRegions, appliqué à l'assignation pour que l'auto-sélection suive ce qui est affiché). Tri client sur une page à la fois : avec plusieurs pages, le regroupement vaut dans chaque page, pas d'une page à l'autre. Le volet liste (colonne de 320 px) pagine avec la variante compacte de Pagination (compact : ni sélecteur de taille ni Premier/Dernier — taille fixée à 25) ; la recherche ne filtre que la page chargée. Les lignes en pause s'effacent visuellement : nom en text-muted-foreground et pastille de statut ambre (bg-amber-500/15 text-amber-700 dark:text-amber-400, l'idiome d'avertissement du repo — l'état actif garde sa pastille primary). Après chaque chargement, la sélection est réconciliée : conservée si la région est encore dans la page (édition, bascule de statut), sinon la première région affichée prend le relais — généralisation de l'auto-sélection du montage. Le détail (getRegionDetail) reste une lecture par id, indépendante de la page affichée. Création, édition et activation / pause passent par les endpoints organisation (mêmes réconciliations de statut que les revendeurs), le formulaire étant validé par Regle (nom requis, emails bien formés). Les KPI, la capacité, les itinéraires liés et les alertes de saturation viennent du roll-up de capacité (fetchRegionCapacity, voir Couche API), chargé indépendamment de la fiche (un échec de l'agrégat laisse la fiche utilisable, message dédié sous les KPI) : saturation régionale en pourcentage, itinéraires = tours actifs du roll-up (pastille couleur, capacité par tour, étapes non datées signalées), alertes = tours dont la saturation ≥ 1. La structure de capacité est rendue selon son exactness (nombre exact, « ≥ n » si partielle, « Illimitée », tiret si inconnue). Seul le KPI « voyageurs actifs » reste sans backend (tiret).
La fiche porte aussi l'image vitrine de la région (RegionImageCard.vue, monté entre l'en-tête d'identité et les KPI) : un aperçu 16/10 en variante medium (cadre pointillé délibéré quand la région n'en a pas), avec envoi et retrait à persistance immédiate — le fichier choisi (gardes type/taille de src/api/images.ts) part sur POST /images puis est rattaché dans la foulée par updateRegionImage(id, imageId) (PATCH organisation, primary_image_id, réservé au super-admin — un manager envoyant la clé reçoit un 422) ; Retirer rattache null. Toast en succès, alerte en échec, la fiche est rechargée après chaque changement. Côté backend le lien est nullOnDelete : la suppression d'une image rompt le lien en silence, l'image réapparaît simplement absente. C'est cette image que le portail voyageur affiche sur les cartes du choix de région.
Tours : itinéraires et étapes
Section Pilotage > Tours, portée pour le design depuis .lovable-ref/.../superadmin/ToursView.tsx et dashboard/ItinerairesView.tsx (recherche de prestataire : dashboard/RouteStepEditor.tsx ; accordéon : commits 93d9ea5/20bb366/a8d93b2 de la référence). Les itinéraires sont des lignes de tableau (pastille couleur + nom + compteur voyageurs, badges statut / région / partenaire, actions dupliquer / supprimer révélées au survol, chevron) sous une ligne d'en-têtes de colonnes ; cliquer une ligne déplie un panneau accordéon (un seul ouvert à la fois, re-clic replie) qui contient les contrôles du tour, l'éditeur d'étapes et l'emplacement carte.
ToursManager.vue: conteneur. Liste, filtre par partenaire, création et suppression passent par le backend (src/api/tours.ts), filtrage et pagination résolus côté serveur. Le filtre de statut s'ouvre suractive(les itinéraires en cours) ; « Tous les statuts » reste à un clic. Chargement, échec de chargement avec réessai, erreur d'action et rejet de création sont gérés. La sélection est une bascule (selectedId), effacée quand le tour quitte la page chargée (filtre, pagination, suppression) pour éviter une ré-expansion fantôme. Le formulaire se conforme àStoreTourRequest: nom et partenaire obligatoires, aucun choix de statut (le tour naîtpending), voir Couche API. Il exige aussi la paire départ (ville + heure, règle du formulaire, optionnelle surTourPayloadpour que leduplicateTourgelé compile) :createTourétant gelé et antérieur aux champs, la ville est patchée en suite d'appel (updateTourDeparture) et l'heure crée le premier créneau vide « Départ » du tour (departureSlotPayload,createStepsans activités) — l'heure de départ EST l'heure de ce créneau. Un échec de ces suites garde le tour créé et se signale en toast, le bloc restant éditable depuis l'éditeur d'étapes.ToursDirectory.vue: présentationnel : en-tête de section, filtres, formulaire de création dépliable, la carte-tableau (en-têtes de colonnes + uneTourAccordionRowpar tour), état vide, pagination. Le slot scopé#steps(rempli parToursManageravec unTourStepsManager) descend dans la ligne dépliée — une seule instance de l'éditeur d'étapes existe donc à tout moment (un seul fetch tags/prestataires, un seul garde de formulaire).TourAccordionRow.vue: une ligne + son panneau. La ligne est undiv[role="button"]accessible clavier (Entrée / Espace,aria-expanded/aria-controls). Le dépliage est une<Transition>à hooks JS (hauteur seule, 250 ms) :overflow: hiddenuniquement pendant la transition, retiré à l'arrivée — indispensable, sinon la carte sticky et le menu de recherche prestataire (z-20) resteraient rognés par le panneau ; c'est la raison du non-recours à la primitive accordion shadcn (au contenu rogné en permanence). Les hooks « cancelled » partagent le même nettoyage : un aller-retour rapide ne peut pas laisser unoverflowgelé. Dans le panneau : la strip de contrôles, puis une grillelg2/3 éditeur d'étapes + 1/3 carte.TourPanelControls.vue: la strip du panneau — nom (brouillon commité au blur / Entrée, Échap annule, bornes du formulaire de création) et couleur (pastilles, persistance au clic), tous deux viaPATCH /tour/{id}(updateTour, capacité post-création nouvelle), plus les sélecteurs statut et région qui vivaient sur la ligne.TourMapPlaceholder.vue: l'emplacement de la future carte d'itinéraire, en pointillés. Sticky en défilement (dèslg) sous la bannière d'environnement + la barre h-14 du shell, plafonné au viewport, dans les limites du panneau. Le sticky exigeoverflow: visiblesur toute la chaîne d'ancêtres : c'est pour lui que le<main>deDashboardShell.vueest passé d'overflow-x-hidden(qui tue silencieusementposition: sticky) àmin-w-0— changement partagé par les quatre portails.tours.ts: modèle d'affichage, palette et classes de badge par statut.
Le modèle suit la table tours du backend (voir sa doc base-de-donnees.md) : name, color (hex #RRGGBB imposé par tours_color_hex_check ; la palette proposée est donc en hexadécimal, là où la maquette Lovable utilise hsl()), status (pending | active | suspended, énum TourStatus), partner_organization_id et region_organization_id.
La région est reliée au backend : le sélecteur du formulaire de création et celui de la strip du panneau envoient region_organization_id (respectivement à la création et via un PATCH /tour/{id}, avec retour toast) ; null retire le lien. Le champ embarqué region fournit le nom du badge de la ligne. Les régions proposées sont les organisations de type region.
Champ affiché mais relié à rien, faute d'équivalent backend : le compteur de voyageurs. Le nombre d'étapes ne s'affiche plus sur la ligne (les colonnes fixes l'ont évincé — il affichait de toute façon toujours 0, GET /tour ne chargeant pas la relation steps).
Éditeur d'étapes
Quintette TourStepsManager.vue (état + API) → TourStepsPanel.vue (liste, drag & drop, modale d'édition) → SlotPlanStack.vue (pile des plans d'un créneau) → TourStepForm.vue (sélecteur de plans, validation Regle) → StepPlanFields.vue (champs d'un plan). Le quintette est monté par la ligne dépliée (colonne 2/3 du panneau accordéon, props tourId / tourName inchangées) et démonté au repli — replier ou changer de ligne avec un formulaire ouvert perd la saisie sans garde, comme le changement de tour d'avant l'accordéon (useUnsavedGuard n'arme que la navigation). L'étape est un créneau : sa position, son jour et ses horaires forment un emplacement fixe que les activités occupent. Persistés sur l'étape : day_number, l'heure de début (start_time, heure HH:MM du créneau, vide = horaire libre) et la révélation (reveal_time, heure du jour HH:MM, vide = pas de révélation prévue, plus reveal_days_before, jours d'avance, 0 = le jour même). Le formulaire d'étape n'édite pas le planning : l'heure et la révélation se règlent dans le rail (édition en place, voir plus bas), le jour reste affiché dans le rail et échangé par le réordonnancement — le formulaire ne porte que le contenu (type, plans). Créer un créneau se fait en deux temps : « Ajouter un créneau » demande d'abord l'heure du créneau (champ heure, Entrée ou « Continuer » valide, vide = horaire libre, Échap annule), puis le formulaire prend sa place, semé de cette heure — rappelée dans l'en-tête avec un lien « modifier l'horaire » qui rouvre le champ heure prérempli et, à la re-confirmation, ne déplace que l'heure (le contenu déjà saisi survit) ; à l'enregistrement, le créneau créé glisse à sa position chronologique via le même resortSlots que les éditions d'heure. Restent par plan : le lien prestataire, l'image, les étiquettes et la capacité de l'activité (capacity, voyageurs par jour de départ, vide = inconnue, et un interrupteur « Illimitée » qui désactive et vide le champ numérique — les deux étant mutuellement exclusifs côté backend) ; le reste des champs (portés de la maquette) est affiché mais jamais transmis. Chaque plan enregistré porte aussi son bloc Fermetures (ActivityClosuresField.vue) : liste, ajout (dates natives, fin par défaut = début pour un jour unique, motif facultatif) et suppression des fermetures de l'activité — CRUD immédiat sur /tour/activity/{id}/closures, hors du cycle d'enregistrement du formulaire ; un plan pas encore enregistré n'a pas d'activité et n'affiche qu'un rappel.
Rail de créneaux : chaque ligne de la liste se scinde en une colonne créneau fixe (numéro, heure — --:-- + « horaire libre » sans heure —, jour, moment de révélation) et, à côté, la pile des plans du créneau (SlotPlanStack.vue) : une carte compacte par activité, dans l'ordre des plans. Chaque carte porte sa poignée de drag, son titre cliquable (la carte A affiche le name de l'étape — l'étape EST son plan A —, les alternatives le nom de leur activité) avec le prestataire enrichi en dessous, des badges compacts (nombre d'affaires à prendre, de restrictions), les actions Éditer / Supprimer au survol et une pastille lettre (A en primary, B en secondary, C+ en secondary atténué) avec l'infobulle principal / alternative. Glisser une carte réordonne les plans dans son créneau (la première devient plan A) ; supprimer se confirme en deux clics sur la carte — la corbeille arme la carte (elle devient un bouton « Confirmer », quitter la carte désarme), le second clic retire le plan (supprimer A promeut le suivant) et la dernière carte supprime le créneau, sans autre dialogue ; le + entre deux filets ajoute un plan vide et ouvre le formulaire dessus ; éditer (titre ou crayon) ouvre le formulaire directement sur le plan cliqué (prop initialPlan). Le formulaire vit dans une modale (DialogScrollContent, large et défilante) qui héberge les deux phases de la création comme l'édition — le clic hors de la modale ne ferme jamais (un formulaire à moitié rempli ne se perd pas d'un clic) : Échap, le X et Annuler restent les sorties ; les lignes de la liste, elles, restent toujours des lignes de créneau. Ces mutations passent par l'événement updatePlans, appliqué de façon optimiste : l'ordre déposé — ou les plans survivants d'une suppression — est reporté immédiatement sur l'étape canonique (activités permutées ou filtrées, name re-dérivé du nouveau plan A), puis l'écriture (le PUT d'étape habituel, liens inchangés = ids réutilisés, planning intact) part en arrière-plan sans indicateur saving ni rechargement — le panneau ne se démonte jamais, ni flash ni saut de défilement ; la réponse est fusionnée sur l'étape (tags connus conservés via keepKnownTags, enrichissement prestataire via enrichSteps), des écritures rapprochées étant sérialisées par un compteur par étape (planWriteSeq : seule la plus récente fusionne sa réponse ou signale son échec) ; en cas d'échec, toast + rechargement ramènent la vérité serveur. Une suppression confirme par un toast « Plan X supprimé » une fois l'écriture posée. La suppression d'un créneau (dernière carte, événement delete) est optimiste elle aussi : la ligne disparaît immédiatement et le DELETE part en arrière-plan — côté backend seules les positions suivantes se décalent (jour et heures restent attachés à leurs étapes) et le rail numérote par index, donc les lignes restantes sont déjà justes ; un refus remonte en toast et recharge la ligne. Il n'y a plus ni titre de ligne, ni badge « Plan B / Plans B–D », ni actions au niveau étape : la poignée de la ligne déplace l'ensemble des activités du créneau. La colonne lit l'horaire de la position depuis l'ordre canonique (props.steps), pas depuis la copie locale réordonnée par le drag : pendant qu'un dépôt attend le serveur, les heures restent visuellement en place et seules les cartes activité s'échangent — exactement la sémantique du backend, dont le réordonnancement (PUT /tour/step/reorder) fait hériter à l'étape posée en position p le day_number, le start_time et la révélation que p portait avant l'appel — le drag est le seul geste de réordonnancement (plus de flèches ▲▼). Le drag utilise forceFallback : le clone qui suit le curseur masque la colonne créneau en CSS — seule la carte activité voyage. Comme Sortable fait glisser des lignes entières pour prévisualiser le dépôt, un rail figé prend le relais pendant le drag : au onStart chaque colonne créneau est mesurée, une superposition statique (mêmes cellules, affichage seul) se pose à leurs coordonnées et les colonnes réelles passent en visibility: hidden jusqu'au dépôt — à l'écran, seules les cartes activité glissent, la position d'origine gardant sa cellule créneau pleine et une zone activité estompée en pointillés. Chaque moitié de ligne dessine sa propre bordure (elles se rejoignent en une seule pilule) pour qu'une colonne masquée n'emporte aucun cadre vide avec elle. L'heure et la révélation d'un créneau s'éditent en place : cliquer l'une des deux lignes la remplace par un champ heure (Entrée ou blur valide, Échap annule, vide efface — l'heure libère le créneau, la révélation retombe à « pas de révélation » avec ses jours à 0), persisté par un patch d'étape partiel (updateStepSchedule, seules les clés présentes sont envoyées) dont la réponse est fusionnée sur l'étape sans recharger (les activités enrichies restent intactes). Le vocabulaire de la liste suit : compteur « X créneaux configurés », bouton « Ajouter un créneau ».
Bloc Départ : le rail est coiffé d'un bloc épinglé en première position — même anatomie de pilule qu'une ligne de créneau, mais hors de la liste triable : le drag ne peut ni le déplacer ni le mesurer (le rail figé ne le duplique jamais), et il ne se supprime pas. Sa cellule de gauche (departure-col, jamais .slot-col) porte le libellé « Départ » et deux puces éditées en place avec les gestes du rail : l'heure de départ — aucune colonne backend : c'est un miroir de l'heure du PREMIER créneau (steps[0].startTime), l'éditer passe par le chemin d'édition de ce créneau (et éditer la puce du créneau déplace le départ, par construction) ; sur un tour sans créneau, valider une heure sème le créneau vide « Départ » (departureSlotPayload, même forme que la création de tour) — et la révélation de la ville de départ (departure_city_reveal_time, heure HH:MM de la veille du départ où la ville est dévoilée au voyageur) ; le corps est un titre cliquable portant la ville (departure_city, texte libre 255 max, trim, vidé = effacé, placeholder en italique sans ville). Ville et révélation vivent sur le tour, pas sur une étape : elles passent par le wrapper local tour-departure-api.ts (précédent tags-api.ts — src/api/ reste gelé), qui lit GET /tour/{id} et patche PUT /tour/{id} en n'envoyant que les clés présentes, avec la même normalisation HH:MM aller-retour que les heures d'étape. Le chargement est couplé à load() (Promise.all avec les étapes : un seul état d'échec, un seul « Réessayer », même garde de séquence) et la réponse d'une édition n'est fusionnée que si le tour édité est toujours le tour affiché — état de niveau tour, un écho périmé après un changement de tour corromprait le bloc du voisin. L'heure de départ, elle, n'est pas une colonne du tour : elle vit dans le premier créneau (voir plus haut) — conséquence assumée du miroir : si un tri chronologique change le premier créneau, l'heure de départ affichée devient celle du nouveau premier.
Trois règles accompagnent toute édition d'horaire dans le rail :
- Jours d'avance dérivés (
deriveRevealDays,tour-steps.ts) : une révélation après l'heure du créneau n'a de sens que la veille au soir → J-1 ; à l'heure ou avant → le jour même (J-0). La dérivation se rejoue dès que l'une des deux heures change ; sans heure de créneau le compte stocké reste tel quel. - Tri automatique (
resortSlots,TourStepsManager) : si la nouvelle heure casse la chronologie des créneaux horodatés de son jour, le créneau est déplacé à sa position chronologique — le créneau entier, activité ET planning. Le réordonnancement backend échangeant les plannings par position, chaque position déplacée est ensuite re-patchée avec le planning que son occupant transportait (updateStepSchedulecomplet), puis la liste est rechargée ; les créneaux des autres jours et les créneaux sans heure ne bougent jamais. Le même tri place un créneau fraîchement créé. - Plancher du départ (
clampStartToDeparture,tour-steps.ts) : un créneau horodaté ne peut pas commencer avant le départ — l'heure du premier créneau —, quel que soit son jour. Le premier créneau, étant le départ, bouge librement ; pour les autres, l'édition en rail et la création clampent silencieusement l'heure au plancher (avant la dérivation des jours d'avance, qui lit donc l'heure réellement persistée) ; relever le départ au-dessus d'heures que le tri du jour ne rattrape pas s'enregistre toujours et marque les créneaux en conflit d'un drapeau ambre dérivé des props — les données antérieures à la règle se signalent donc d'elles-mêmes au chargement, et le rail figé reflète le drapeau pendant un drag. L'enregistrement du formulaire, lui, ne clampe jamais : un conflit périmé doit se voir, pas se réécrire en douce. Premier créneau sans heure = pas de plancher ; la révélation de la ville, elle, ne contraint plus rien.
Plans par étape : le formulaire est découpé en plans (Plan A, Plan B, …), chacun matérialisé par une activité — l'ordre de activities est l'ordre des plans, le premier étant le plan A. StepPayload porte une liste ordonnée plans: PlanPayload[] (jamais vide) ; chaque PlanPayload garde l'activityId qui le matérialise (null pour un plan ajouté depuis le dernier enregistrement). L'étape n'a pas de champ nom propre : son name dérive du nom du plan A à l'enregistrement — l'étape EST son plan A. Le formulaire n'a plus de sélecteur de plans : il s'ouvre sur le plan de la carte cliquée (initialPlan) et n'affiche que lui — tous les plans restent montés (v-show) parce qu'une soumission invalide amène le plan fautif à l'écran. Toute la gestion des plans (ajout via +, suppression avec promotion, réordonnancement) vit dans la pile du créneau (voir le rail ci-dessus) ; les libellés restent purement positionnels. Les champs distance et durée (jamais persistés) ont été retirés du formulaire.
Validation multi-plans : une seule instance Regle dans TourStepForm, avec une collection $each (nom requis + longueur, email, URL par plan) ; StepPlanFields reste sans Regle (messages en prop errors, touch en événement). Une soumission invalide bascule sur le premier plan fautif et le nomme (« Plan B : vérifiez les champs en erreur. »). Deux contournements regle 1.27 sont commentés dans le code : pas de $key dans les règles $each (casse $validate() dès deux plans) et report de l'$id de suivi sur les clones immuables dans patchPlan (sinon l'état « touché » se réinitialise à chaque frappe).
Étiquettes du registre (TagListField.vue, trois blocs en bas de chaque plan) : affaires à prendre (thing_to_bring), accessibilités (suitability) et restrictions (restriction) sont des autocomplétions multi-sélection sur le registre GET /tags — plus de texte libre : seuls les tags en base sont utilisables. Le manager charge le registre entier une fois au montage (fetchAllTags, voir API) et le redescend en props ; chaque champ filtre son vocabulaire côté client, résout les libellés par locale (repli français) et exclut les tags déjà choisis. Un échec de chargement ne bloque pas l'enregistrement : les puces restent retirables et le menu du champ propose une relance. Chaque PlanPayload porte les ids dans trois listes (thingsToBring, restrictions, suitabilities), réhydratées à l'édition depuis les tags de son activité groupés par vocabulaire.
Image de l'activité (StepImageField.vue, sous la description de chaque plan) : choisir un fichier l'envoie immédiatement sur POST /images (garde-fous type et taille de api/images.ts avant tout appel) et affiche la variante thumb signée de la réponse en aperçu — pas d'aperçu blob local. L'id obtenu vit dans PlanPayload.image (donc dans le suivi de formulaire sale) jusqu'à l'enregistrement ; le bouton « Retirer l'image » remet null. Pendant un envoi sur n'importe quel plan, le formulaire bloque soumission, ajout et suppression de plan (l'id n'est pas encore connu). À l'édition, l'image existante de chaque activité est réhydratée depuis la réponse d'étape.
Recherche d'image Pexels (« Trouver une image », à côté du bouton d'envoi ; visible seulement si isPexelsEnabled, cf. architecture/environnements.md) : ouvre PexelsSearchDialog.vue, pré-rempli avec le nom du plan et lancé à l'ouverture — grille de 4 résultats, tuile [+] pour ajouter la page suivante, crédits photographes et attribution Pexels. L'appel passe par pexels-api.ts (module colocalisé, même raison d'être que tags-api.ts : src/api/ reste réservé au contrat backend), qui adapte la locale de l'app en locale régionale Pexels. La photo choisie est téléchargée depuis le CDN Pexels (rendu large2x, CORS ouvert) puis ré-envoyée sur POST /images avec le même verrouillage du formulaire qu'un envoi manuel — accompagnée de son crédit (pexels_url + pexels_author), que le backend persiste avec l'image et resert dans chaque payload imbriqué (ImageSummary côté client : {variants, pexelsUrl, pexelsAuthor}, mappé par toImageSummary de src/api/images.ts). Conformément aux règles Pexels, le crédit s'affiche partout où l'image apparaît : sous le champ image de l'étape, sous l'image vitrine d'une région (RegionImageCard) et sur la carte région du parcours voyageur — clé i18n commune common.pexelsCredit. Un envoi manuel n'a pas de crédit (paire nulle) et n'affiche rien.
Recherche de prestataire sur le champ nom de chaque plan (StepProviderSearch.vue) : le nom d'un plan est aussi une recherche débouncée (300 ms, 2 caractères minimum, 8 résultats) sur GET /tour/provider/search — chaque plan peut donc lier un prestataire différent. À partir de la deuxième étape, la position du prestataire du plan A de l'étape immédiatement précédente est jointe à la requête : le backend classe alors par proximité et chaque ligne affiche la distance en km (distance_meters arrondi ; jamais de distance estimée côté client). Sélectionner un résultat écrase le nom, ne remplit que les champs encore vides (adresse, position, contacts) et lie le prestataire ; retaper le nom délie. La liaison est silencieuse : aucune UI ne l'expose (l'ancienne pastille « Lié à » + Délier a été retirée), elle ne vit que dans PlanPayload.provider pour piloter la persistance.
Persistance des plans : le contrat n'a pas de provider_id sur l'étape — chaque lien passe par une activité (Étape → activities[] → Activité → Prestataire). À l'enregistrement, TourStepsManager construit un id d'activité par plan, dans l'ordre des plans, puis écrit l'étape avec la liste complète (remplacement intégral côté backend) :
- prestataire choisi →
POST /tour/activity(nom = nom du plan,image_idsi une image a été téléversée,tagsavec les ids sélectionnés,capacityouunlimitedsi renseignés — l'interrupteur illimité forcecapacityànull) ; - nom saisi librement → crée d'abord un prestataire
on_the_fly(brouillon, semé avec nom, adresse, position et contacts du plan) puis son activité — chaque plan enregistré a donc toujours son activité ; - lien inchangé (
activityIdretrouvé et même prestataire) → l'id courant est réutilisé ; une activité existante n'est jamais modifiée (les tours dupliqués partagent les mêmes lignes — un patch se voit donc aussi dans les tours dupliqués), à trois exceptions : si l'image, les étiquettes ou la capacité du plan ont changé, son activité est retouchée en place viaPUT /tour/activity/{id}avant l'écriture de l'étape. Le patch des tags re-fusionne les tagsthemede l'activité (vocabulaire absent du formulaire) puisque l'API remplace l'ensemble en bloc ; le patch de capacité envoie toujours la paire{capacity, unlimited}(basculer en illimité exigecapacity: nulldans le même payload) ; une activité re-créée (autre branche) les abandonne, comme toute propriété d'une activité remplacée. Les horaires et la révélation, eux, sont des facettes du créneau : ils voyagent dans l'écriture de l'étape, jamais dans un patch d'activité ; - plan supprimé → son id n'entre simplement pas dans la liste (aucun endpoint de suppression : la ligne se détache de l'étape) ; plan ajouté → résolu comme un plan neuf ;
- échec de l'écriture de l'étape → les créations déjà réussies sont mémorisées par plan (deux
Map: prestataires par nom, activités par clé prestataire + nom + image + tags + capacité) et réutilisées au réessai ; changer l'image ou les tags d'un plan avant le réessai ne re-crée que l'activité de ce plan. Deux plans identiques saisis librement dans le même enregistrement obtiennent bien deux activités distinctes (gardeusedIds).
Les réponses d'étapes n'embarquent pas le prestataire : après chaque chargement, le manager enrichit toutes les activités (nom + position) via getProvider, avec un cache par prestataire — chaque plan retrouve ainsi sa liaison prestataire à l'édition (nécessaire à la réutilisation d'activité), et la position du plan A alimente la recherche de l'étape suivante. Les tags, eux, arrivent dans les réponses de liste et d'écriture — mais pas dans celle du réordonnancement (l'endpoint ne les charge pas) : le manager reporte alors les tags connus de chaque activité sur la réponse, sans quoi une édition enregistrée après un déplacement les effacerait côté backend.
Section prestataires — annuaire
La section Prestataires du groupe Base de données (components/superadmin/ProvidersManager.vue, section partners) est l'annuaire des prestataires touristiques : une entité Provider autonome et géolocalisée que les activités référencent (Tour → Étape → Activité → Prestataire). Le préfixe /tour/provider n'est qu'un regroupement de routes, pas un scope par tour.
- L'annuaire lui-même est en lecture seule : liste + recherche, via
src/api/providers.ts(fetchProviders,searchProviders,getProvider). Liste et recherche passent par le client brut (même cas que les codes). S'y ajoutecreateProvider(POST /tour/provider), utilisé par l'éditeur d'étapes pour les prestataireson_the_fly— pas de formulaire de création dans l'annuaire. - Recherche : sans requête,
GET /tour/provider(parcours filtré par statut) ; avec requête (débouncée 300 ms),GET /tour/provider/search(plein-texte classé par pertinence, ou par proximité si des coordonnées sont fournies — chaque résultat porte alorsdistanceMeters). - Visibilité : un super-admin voit tous les statuts (
draft/approved/archived) ; les autres casquettes ne voient que lesapproved. - Chaque carte affiche nom, standing (badge
premium), statut, localisation et coordonnées de réservation (email, téléphone, site web).
À distinguer des candidatures prestataires (PartnerApplicationsManager, mock) : concept différent (file de validation), sans rapport avec cet annuaire.
Gestion des tags
La section « Tags » (TagsManager.vue, groupe Administration — dernier groupe de la barre latérale, design repris du TagsManager.tsx de la référence) administre le registre d'étiquettes : les quatre vocabulaires (theme, thing_to_bring, restriction, suitability) en une liste groupée, filtrée côté client (onglets par type avec compteurs vivants, recherche sur slug, libellés et icône) — le registre est assez petit pour être chargé entier.
tags-api.ts(colocalisé avec la section) — accès API : modèleAdminTag(noms et descriptions trilingues),fetchAllAdminTags(),createTag(),updateTag(),deleteTag(). Le dossiersrc/api/étant aligné sur le contrat backend, ce wrapper vit avec la fonctionnalité et importe@/api/generated,toApiErroretSUPER_ADMIN_VIEWING_ASsans y toucher.tags.ts— modèle de présentation pur :TAG_TYPE_ORDER,slugify(+ varianteslugifyWhileTypingqui préserve le tiret final pendant la saisie),matchesSearch,groupByType(groupes dans l'ordre des vocabulaires, lignes triées par nom français).TagEditDialog.vue— création/édition contrôlée (pas d'appel API) : le slug se dérive du nom français jusqu'à être saisi manuellement ; le contrat fige type et slug après création, le dialogue les gèle donc en édition et la mise à jour ne les envoie jamais. Champs vides normalisés ennullà l'émission.TagIconPicker.vue— combobox sur le catalogue Lucide chargé paresseusement (import("@/lib/lucide-catalog")) : aperçu en direct, 40 résultats max, message « icône introuvable » une fois le catalogue chargé. Le catalogue (~1700 glyphes, ~480 ko minifiés) forme le premier chunk asynchrone de l'app — il ne pèse pas sur le bundle principal et les lignes replient sur le glypheTaggénérique tant qu'il n'est pas arrivé.- Mutations en place : création ajoutée à la liste, édition remplace la ligne, suppression retire — pas de rechargement. La suppression est confirmée par dialogue ; un tag encore porté par des activités est refusé par le backend (409) et expliqué par un toast dédié (
superadmin.tags.remove.inUse).
État d'avancement
Les sections cockpit, codes (CodesManager), revendeurs à valider (ResellerApplicationsManager), prestataires à valider (PartnerApplicationsManager), base prestataires (ProvidersManager, lecture seule), revendeurs (ResellersManager), régions (RegionsManager), tours (ToursManager, lignes-accordéon avec éditeur d'étapes, recherche de prestataire et emplacement carte sticky) et tags (TagsManager) sont construites. La section utilisateurs affiche ComingSoon : l'ancien RolesManager (annuaire + invitations) est débranché de la navigation en attendant sa refonte — le composant et ses sous-composants restent dans le code. La section statistiques a été retirée (avec le lien « voir toutes les statistiques » du cockpit). Les chiffres du cockpit sont des valeurs de démonstration (superadmin.cockpit.demoNote) : le backend n'expose pas encore les endpoints correspondants (cartes, itinéraires, prestataires…).
Les candidatures revendeur sont servies par le backend : une candidature est une organisation reseller au statut pending (voir src/api/reseller-applications.ts) ; approbation et rejet passent par PATCH /organizations/{id}/status. Les champs sans source backend (type de point de vente, message, dates) s'affichent en tirets et les notes internes restent locales à la session. Les deux badges du groupe Validations sont alimentés au montage du tableau de bord (fetchResellerApplicationStats / fetchPartnerApplicationStats) puis tenus à jour par l'événement pending-count-changed de chaque manager. Les candidatures prestataires (PartnerApplicationsManager, src/api/partner-applications.ts) restent sur le mock en mémoire (createApplicationReviewMock) : le contrat n'a pas de type d'organisation prestataire.
i18n
Toutes les chaînes passent par t() sous les clés superadmin.* et roles.* (libellés des casquettes utilisés par le RoleSwitcher), présentes dans les trois catalogues (fr / de / en).

