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:
parent
9af1ac3e95
commit
bbd324e3c7
6 changed files with 299 additions and 8 deletions
|
|
@ -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", []),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export interface User {
|
|||
auth_provider: AuthProvider;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
pm_client_ids?: string[];
|
||||
}
|
||||
|
||||
export interface Source {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue