diff --git a/backend/app/api/v1/routes_admin.py b/backend/app/api/v1/routes_admin.py index f7bc80a..54949ce 100644 --- a/backend/app/api/v1/routes_admin.py +++ b/backend/app/api/v1/routes_admin.py @@ -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", []), ) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 11e6e1a..7d6bd11 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -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): diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts index 6045cec..905239e 100644 --- a/frontend/src/hooks/useClients.ts +++ b/frontend/src/hooks/useClients.ts @@ -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) { diff --git a/frontend/src/routes/admin/UserDetail.tsx b/frontend/src/routes/admin/UserDetail.tsx index 0021703..e2f9713 100644 --- a/frontend/src/routes/admin/UserDetail.tsx +++ b/frontend/src/routes/admin/UserDetail.tsx @@ -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() { + @@ -246,6 +255,9 @@ export function UserDetail() { + {/* Assignments Card */} + + {/* Actions Card */}

Actions

@@ -291,3 +303,221 @@ export function UserDetail() {
); } + +function UserAssignmentsPanel({ user }: { user: User }) { + const qc = useQueryClient(); + const toast = useToastContext(); + const { data: clients = [], isLoading: clientsLoading } = useClients(); + const [selectedClientId, setSelectedClientId] = useState(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 ( +
+
+
+ ); + } + + if (clients.length === 0) return null; + + if (user.role === 'project_manager') { + return ( + + ); + } + + return ( + + ); +} + +function PMAssignmentsCard({ + user, + clients, + assignPM, + removePM, + refreshUser, + toast, +}: { + user: User; + clients: Client[]; + assignPM: ReturnType; + removePM: ReturnType; + refreshUser: () => void; + toast: ReturnType; +}) { + 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 ( +
+

PM Client Assignments

+ {clients.length === 0 ? ( +

No clients found.

+ ) : ( +
    + {clients.map((client) => { + const isAssigned = pmClientIds.includes(client.id); + return ( +
  • +
    + {client.name} + {!client.is_active && ( + (inactive) + )} +
    + +
  • + ); + })} +
+ )} +
+ ); +} + +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; + removeMember: ReturnType; + toast: ReturnType; +}) { + 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 ( +
+

Team Memberships

+
+ + +
+ + {selectedClientId && ( + <> + {teamsLoading ? ( +
+
+
+
+ ) : teams.length === 0 ? ( +

No teams for this client.

+ ) : ( +
    + {teams.map((team) => { + const isMember = team.member_user_ids.includes(user.id); + return ( +
  • + {team.name} + +
  • + ); + })} +
+ )} + + )} +
+ ); +} diff --git a/frontend/src/routes/admin/UserList.tsx b/frontend/src/routes/admin/UserList.tsx index f4312c6..949d1a6 100644 --- a/frontend/src/routes/admin/UserList.tsx +++ b/frontend/src/routes/admin/UserList.tsx @@ -131,6 +131,7 @@ export function UserList() { > + @@ -216,12 +217,13 @@ export function UserList() { - {user.role} + {user.role === 'project_manager' ? 'Project Manager' : user.role} @@ -444,6 +446,7 @@ function CreateUserModal({ onClose, onSuccess }: { onClose: () => void; onSucces + diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index a132058..7e40455 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -27,6 +27,7 @@ export interface User { auth_provider: AuthProvider; is_active: boolean; created_at: string; + pm_client_ids?: string[]; } export interface Source {