feat(team): édition inline du rôle + retrait + sélection de membre existant

Backend
  - ProjectMembersController::update : nouveau PATCH /api/projects/:slug/members/:member_slug
    pour changer le rôle (admin/contributor/observer). 404 si pivot absent,
    no-op idempotent si déjà même rôle, 422 si rôle invalide. Route câblée
    dans public/index.php.

Frontend
  - JoinProjectModal : 2 onglets (Membre existant / Nouveau membre)
      * "existant" : <select> de l'annuaire filtré sur excludeSlugs (membres
        déjà sur le projet), preview avatar+bio du membre sélectionné
      * "nouveau" : flow d'origine (créer le membre puis l'attacher)
      * sélecteur de rôle visible dans les 2 modes
      * onJoined remonte un Member complet (au lieu de {id,slug,name})
        -> ProjectDetailPage peut pousser un ProjectMemberEntry avec ses
           vraies initials/avatar/kind dans la liste optimiste

  - ProjectDetailPage panneau Équipe (mode admin uniquement) :
      * Rôle inline éditable via <select> stylé minimal -> PATCH optimiste
        avec rollback en cas d'échec
      * Bouton retirer-membre par ligne (DELETE) avec confirm
      * Le badge "Vous" / "Admin connecté" ne change pas

Code-review (high effort, --fix appliqué) :
  - loadDirectory affiche maintenant une vraie erreur réseau au lieu de
    silencieusement présenter "tous les membres déjà sur ce projet"
  - loadDirectory est lazy : on ne fetche l'annuaire que si l'onglet
    "existant" est actif (économise 1 requête si admin saute direct à "nouveau")
  - Aperçu du membre sélectionné : computed `selectedMember` au lieu de
    <template v-for><template v-if>
  - Bouton retirer-membre : opacity-60 sur mobile (sm: et plus garde le
    behavior hover-to-reveal) -> reste atteignable sur touch
  - + label `aria-label` pour l'accessibilité

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 13:31:34 +02:00
parent adb2bd3fc1
commit 689dfeee14
4 changed files with 363 additions and 96 deletions
@@ -98,6 +98,51 @@ final class ProjectMembersController
], 201);
}
public function update(Request $req, array $params): void
{
$p = ProjectsController::findOr404($params['slug']);
$member = Database::fetchOne(
'SELECT id, slug, display_name FROM members WHERE slug = :s',
[':s' => $params['member_slug']],
);
if ($member === null) {
throw new \ApiException(404, ['error' => 'not_found']);
}
$body = $req->json();
$v = new Validator($body);
$v->required('role')->in('role', ['admin', 'contributor', 'observer']);
$v->check();
$updated = Database::update(
'project_members',
['role' => (string) $body['role']],
'project_id = :pid AND member_id = :mid',
[':pid' => (int) $p['id'], ':mid' => (int) $member['id']],
);
if ($updated === 0) {
// Soit la ligne pivot n'existe pas, soit le rôle est déjà le même.
// Vérifie pour distinguer 404 vs no-op.
$exists = Database::fetchValue(
'SELECT 1 FROM project_members WHERE project_id = :pid AND member_id = :mid',
[':pid' => (int) $p['id'], ':mid' => (int) $member['id']],
);
if ($exists === null) {
throw new \ApiException(404, ['error' => 'not_found']);
}
}
Response::json([
'project_id' => (int) $p['id'],
'member' => [
'id' => (int) $member['id'],
'slug' => $member['slug'],
'display_name' => $member['display_name'],
],
'role' => (string) $body['role'],
]);
}
public function destroy(Request $req, array $params): void
{
$p = ProjectsController::findOr404($params['slug']);