feat: add Project Manager role + client/team assignment panel in admin user editor

- Add project_manager to all role dropdowns (UserList filter, create modal, UserDetail edit form)
- Add indigo badge color for project_manager in user list table
- Expose pm_client_ids in UserResponse schema and all admin user endpoints
- Add pm_client_ids to frontend User type
- Add UserAssignmentsPanel to UserDetail sidebar: PM users see client toggle list; other roles see client → team membership picker
- Add flexible hooks (useTeamsForClient, useAssignPMAny, useRemovePMAny, useAddTeamMemberAny, useRemoveTeamMemberAny)
- Fix useClient guard against literal "undefined" string causing 404 requests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-04-27 15:58:55 +01:00
parent 9af1ac3e95
commit bbd324e3c7
6 changed files with 299 additions and 8 deletions

View file

@ -62,7 +62,8 @@ async def list_users(
role=user_doc["role"],
auth_provider=user_doc.get("auth_provider", "local"),
is_active=user_doc["is_active"],
created_at=user_doc.get("created_at", datetime.utcnow()).isoformat()
created_at=user_doc.get("created_at", datetime.utcnow()).isoformat(),
pm_client_ids=user_doc.get("pm_client_ids", []),
))
return UserListResponse(
@ -94,7 +95,8 @@ async def get_user(
role=user_doc["role"],
auth_provider=user_doc.get("auth_provider", "local"),
is_active=user_doc["is_active"],
created_at=user_doc.get("created_at", datetime.utcnow()).isoformat()
created_at=user_doc.get("created_at", datetime.utcnow()).isoformat(),
pm_client_ids=user_doc.get("pm_client_ids", []),
)
@ -141,7 +143,8 @@ async def create_user(
role=user_data.role,
auth_provider="local",
is_active=True,
created_at=user_doc["created_at"].isoformat()
created_at=user_doc["created_at"].isoformat(),
pm_client_ids=[],
)
@ -198,7 +201,8 @@ async def update_user(
role=result["role"],
auth_provider=result.get("auth_provider", "local"),
is_active=result["is_active"],
created_at=result.get("created_at", datetime.utcnow()).isoformat()
created_at=result.get("created_at", datetime.utcnow()).isoformat(),
pm_client_ids=result.get("pm_client_ids", []),
)

View file

@ -53,6 +53,7 @@ class UserResponse(BaseModel):
auth_provider: AuthProvider
is_active: bool
created_at: Optional[str] = None
pm_client_ids: list[str] = []
class UserListResponse(BaseModel):

View file

@ -15,7 +15,7 @@ export function useClient(clientId: string) {
return useQuery({
queryKey: ['clients', clientId],
queryFn: () => apiClient.getClient(clientId),
enabled: !!clientId,
enabled: !!clientId && clientId !== 'undefined',
});
}
@ -39,6 +39,58 @@ export function useUpdateClient(clientId: string) {
});
}
export function useTeamsForClient(clientId: string | null) {
return useQuery({
queryKey: ['clients', clientId, 'teams'],
queryFn: () => apiClient.listTeams(clientId!),
enabled: !!clientId,
});
}
export function useAssignPMAny() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ clientId, userId }: { clientId: string; userId: string }) =>
apiClient.assignPM(clientId, userId),
onSuccess: (_, { clientId, userId }) => {
qc.invalidateQueries({ queryKey: ['clients', clientId, 'pm'] });
qc.invalidateQueries({ queryKey: ['users', userId] });
qc.invalidateQueries({ queryKey: ['users'] });
},
});
}
export function useRemovePMAny() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ clientId, userId }: { clientId: string; userId: string }) =>
apiClient.removePM(clientId, userId),
onSuccess: (_, { clientId, userId }) => {
qc.invalidateQueries({ queryKey: ['clients', clientId, 'pm'] });
qc.invalidateQueries({ queryKey: ['users', userId] });
qc.invalidateQueries({ queryKey: ['users'] });
},
});
}
export function useAddTeamMemberAny() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ clientId, teamId, userId }: { clientId: string; teamId: string; userId: string }) =>
apiClient.addTeamMember(clientId, teamId, userId),
onSuccess: (_, { clientId }) => qc.invalidateQueries({ queryKey: ['clients', clientId, 'teams'] }),
});
}
export function useRemoveTeamMemberAny() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ clientId, teamId, userId }: { clientId: string; teamId: string; userId: string }) =>
apiClient.removeTeamMember(clientId, teamId, userId),
onSuccess: (_, { clientId }) => qc.invalidateQueries({ queryKey: ['clients', clientId, 'teams'] }),
});
}
// ── PMs ──────────────────────────────────────────────────────────────────────
export function usePMs(clientId: string) {

View file

@ -1,8 +1,17 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useUser, useUpdateUser, useResetUserPassword } from '../../hooks/useUsers';
import {
useClients,
useTeamsForClient,
useAssignPMAny,
useRemovePMAny,
useAddTeamMemberAny,
useRemoveTeamMemberAny,
} from '../../hooks/useClients';
import { useToastContext } from '../../contexts/ToastContext';
import type { UserRole, UpdateUserRequest } from '../../types/api';
import { useQueryClient } from '@tanstack/react-query';
import type { UserRole, UpdateUserRequest, User, Client, Team } from '../../types/api';
export function UserDetail() {
const { id } = useParams<{ id: string }>();
@ -20,7 +29,6 @@ export function UserDetail() {
is_active: true,
});
// Initialize form when user data loads
useEffect(() => {
if (user) {
setFormData({
@ -166,6 +174,7 @@ export function UserDetail() {
<option value="reviewer">Reviewer</option>
<option value="linguist">Linguist</option>
<option value="production">Production</option>
<option value="project_manager">Project Manager</option>
<option value="admin">Admin</option>
</select>
</div>
@ -246,6 +255,9 @@ export function UserDetail() {
</dl>
</div>
{/* Assignments Card */}
<UserAssignmentsPanel user={user} />
{/* Actions Card */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Actions</h3>
@ -291,3 +303,221 @@ export function UserDetail() {
</div>
);
}
function UserAssignmentsPanel({ user }: { user: User }) {
const qc = useQueryClient();
const toast = useToastContext();
const { data: clients = [], isLoading: clientsLoading } = useClients();
const [selectedClientId, setSelectedClientId] = useState<string | null>(null);
const { data: teams = [], isLoading: teamsLoading } = useTeamsForClient(selectedClientId);
const assignPM = useAssignPMAny();
const removePM = useRemovePMAny();
const addMember = useAddTeamMemberAny();
const removeMember = useRemoveTeamMemberAny();
const refreshUser = () => qc.invalidateQueries({ queryKey: ['users', user.id] });
if (clientsLoading) {
return (
<div className="bg-white shadow rounded-lg p-6">
<div className="animate-pulse h-4 bg-gray-200 rounded w-1/2"></div>
</div>
);
}
if (clients.length === 0) return null;
if (user.role === 'project_manager') {
return (
<PMAssignmentsCard
user={user}
clients={clients}
assignPM={assignPM}
removePM={removePM}
refreshUser={refreshUser}
toast={toast}
/>
);
}
return (
<TeamMembershipsCard
user={user}
clients={clients}
selectedClientId={selectedClientId}
setSelectedClientId={setSelectedClientId}
teams={teams}
teamsLoading={teamsLoading}
addMember={addMember}
removeMember={removeMember}
toast={toast}
/>
);
}
function PMAssignmentsCard({
user,
clients,
assignPM,
removePM,
refreshUser,
toast,
}: {
user: User;
clients: Client[];
assignPM: ReturnType<typeof useAssignPMAny>;
removePM: ReturnType<typeof useRemovePMAny>;
refreshUser: () => void;
toast: ReturnType<typeof useToastContext>;
}) {
const pmClientIds = user.pm_client_ids ?? [];
const handleToggle = async (clientId: string, isAssigned: boolean) => {
try {
if (isAssigned) {
await removePM.mutateAsync({ clientId, userId: user.id });
toast.toastOnly.success('Removed as PM from client');
} else {
await assignPM.mutateAsync({ clientId, userId: user.id });
toast.toastOnly.success('Assigned as PM to client');
}
refreshUser();
} catch {
toast.toastOnly.error('Failed to update PM assignment');
}
};
const isBusy = assignPM.isPending || removePM.isPending;
return (
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">PM Client Assignments</h3>
{clients.length === 0 ? (
<p className="text-sm text-gray-500">No clients found.</p>
) : (
<ul className="divide-y divide-gray-100">
{clients.map((client) => {
const isAssigned = pmClientIds.includes(client.id);
return (
<li key={client.id} className="flex items-center justify-between py-2">
<div>
<span className="text-sm font-medium text-gray-900">{client.name}</span>
{!client.is_active && (
<span className="ml-2 text-xs text-gray-400">(inactive)</span>
)}
</div>
<button
onClick={() => handleToggle(client.id, isAssigned)}
disabled={isBusy}
className={`px-3 py-1 text-xs font-medium rounded-full border transition-colors disabled:opacity-50 ${
isAssigned
? 'bg-indigo-50 text-indigo-700 border-indigo-200 hover:bg-indigo-100'
: 'bg-gray-50 text-gray-600 border-gray-200 hover:bg-gray-100'
}`}
>
{isAssigned ? 'Assigned ✓' : 'Assign'}
</button>
</li>
);
})}
</ul>
)}
</div>
);
}
function TeamMembershipsCard({
user,
clients,
selectedClientId,
setSelectedClientId,
teams,
teamsLoading,
addMember,
removeMember,
toast,
}: {
user: User;
clients: Client[];
selectedClientId: string | null;
setSelectedClientId: (id: string | null) => void;
teams: Team[];
teamsLoading: boolean;
addMember: ReturnType<typeof useAddTeamMemberAny>;
removeMember: ReturnType<typeof useRemoveTeamMemberAny>;
toast: ReturnType<typeof useToastContext>;
}) {
const handleToggle = async (team: Team, isMember: boolean) => {
if (!selectedClientId) return;
try {
if (isMember) {
await removeMember.mutateAsync({ clientId: selectedClientId, teamId: team.id, userId: user.id });
toast.toastOnly.success(`Removed from team "${team.name}"`);
} else {
await addMember.mutateAsync({ clientId: selectedClientId, teamId: team.id, userId: user.id });
toast.toastOnly.success(`Added to team "${team.name}"`);
}
} catch {
toast.toastOnly.error('Failed to update team membership');
}
};
const isBusy = addMember.isPending || removeMember.isPending;
return (
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Team Memberships</h3>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Select Client</label>
<select
value={selectedClientId ?? ''}
onChange={(e) => setSelectedClientId(e.target.value || null)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value=""> pick a client </option>
{clients.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
{selectedClientId && (
<>
{teamsLoading ? (
<div className="animate-pulse space-y-2">
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
) : teams.length === 0 ? (
<p className="text-sm text-gray-500">No teams for this client.</p>
) : (
<ul className="divide-y divide-gray-100">
{teams.map((team) => {
const isMember = team.member_user_ids.includes(user.id);
return (
<li key={team.id} className="flex items-center justify-between py-2">
<span className="text-sm font-medium text-gray-900">{team.name}</span>
<button
onClick={() => handleToggle(team, isMember)}
disabled={isBusy}
className={`px-3 py-1 text-xs font-medium rounded-full border transition-colors disabled:opacity-50 ${
isMember
? 'bg-teal-50 text-teal-700 border-teal-200 hover:bg-teal-100'
: 'bg-gray-50 text-gray-600 border-gray-200 hover:bg-gray-100'
}`}
>
{isMember ? 'Member ✓' : 'Add'}
</button>
</li>
);
})}
</ul>
)}
</>
)}
</div>
);
}

View file

@ -131,6 +131,7 @@ export function UserList() {
>
<option value="">All Roles</option>
<option value="admin">Admin</option>
<option value="project_manager">Project Manager</option>
<option value="production">Production</option>
<option value="reviewer">Reviewer</option>
<option value="linguist">Linguist</option>
@ -216,12 +217,13 @@ export function UserList() {
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${
user.role === 'admin' ? 'bg-purple-100 text-purple-800' :
user.role === 'project_manager' ? 'bg-indigo-100 text-indigo-800' :
user.role === 'production' ? 'bg-orange-100 text-orange-800' :
user.role === 'reviewer' ? 'bg-blue-100 text-blue-800' :
user.role === 'linguist' ? 'bg-teal-100 text-teal-800' :
'bg-green-100 text-green-800'
}`}>
{user.role}
{user.role === 'project_manager' ? 'Project Manager' : user.role}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
@ -444,6 +446,7 @@ function CreateUserModal({ onClose, onSuccess }: { onClose: () => void; onSucces
<option value="reviewer">Reviewer</option>
<option value="linguist">Linguist</option>
<option value="production">Production</option>
<option value="project_manager">Project Manager</option>
<option value="admin">Admin</option>
</select>
</div>

View file

@ -27,6 +27,7 @@ export interface User {
auth_provider: AuthProvider;
is_active: boolean;
created_at: string;
pm_client_ids?: string[];
}
export interface Source {