feat(FRE-596): wire TeamManagement UI to real tRPC API

- Replace mock data with tRPC API calls for team CRUD operations
- Add TeamList view with fetch, create, and delete teams
- Add TeamDetail view with member management (list, invite, update role, remove, leave)
- Use solid-js <For> for proper keyed list rendering
- Add loading/error states and confirmation dialogs
- Use @tanstack/react-query for data fetching and cache invalidation
This commit is contained in:
Senior Engineer
2026-04-28 01:39:15 -04:00
committed by Michael Freno
parent 408d94f731
commit b6d1f4c3b6

View File

@@ -1,23 +1,82 @@
import { Component, createSignal } from 'solid-js';
import { A } from '@solidjs/router';
import { useAuth } from '../../lib/auth';
import { Team } from '../../lib/auth/types';
import { Component, createSignal, For, Show } from 'solid-js';
import { A, useLocation } from '@solidjs/router';
import { createTRPCClient } from '../../trpc/client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { Team as DbTeam, TeamMember as DbTeamMember } from '../../db/schema/teams';
export const TeamManagement: Component<any> = () => {
const auth = useAuth();
const [teams] = createSignal<Team[]>([
{
id: 'team_default',
name: 'My Workspace',
members: [
{ userId: auth().user?.id || '', role: 'owner', joinedAt: new Date().toISOString() },
],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
]);
const API_URL = (import.meta as any).env?.VITE_API_URL || 'http://localhost:8080';
function formatDateTime(iso: Date | string): string {
return new Date(iso).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
const TeamList: Component = () => {
const queryClient = useQueryClient();
const client = createTRPCClient(API_URL);
const [showCreateDialog, setShowCreateDialog] = createSignal(false);
const [newTeamName, setNewTeamName] = createSignal('');
const [error, setError] = createSignal('');
const teamsQuery = useQuery({
queryKey: ['team', 'listTeams'],
queryFn: async () => {
const auth = localStorage.getItem('clerk_token');
const res = await fetch(`${API_URL}/trpc/team.listTeams`, {
headers: auth ? { Authorization: `Bearer ${auth}` } : {},
});
if (!res.ok) throw new Error('Failed to fetch teams');
const data = await res.json();
return data.data as DbTeam[];
},
});
const createTeamMutation = useMutation({
mutationFn: async (name: string) => {
const auth = localStorage.getItem('clerk_token');
const res = await fetch(`${API_URL}/trpc/team.createTeam`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(auth ? { Authorization: `Bearer ${auth}` } : {}),
},
body: JSON.stringify({ input: { name } }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || 'Failed to create team');
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['team', 'listTeams'] });
setShowCreateDialog(false);
setNewTeamName('');
},
onError: (err) => setError(err.message),
});
const deleteTeamMutation = useMutation({
mutationFn: async (id: string) => {
const auth = localStorage.getItem('clerk_token');
const res = await fetch(`${API_URL}/trpc/team.deleteTeam`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(auth ? { Authorization: `Bearer ${auth}` } : {}),
},
body: JSON.stringify({ input: { id } }),
});
if (!res.ok) throw new Error('Failed to delete team');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['team', 'listTeams'] });
},
});
return (
<div class="freno-teams">
@@ -25,29 +84,70 @@ export const TeamManagement: Component<any> = () => {
<h1>Teams</h1>
<button
class="freno-btn freno-btn-primary"
onClick={() => setShowCreateDialog(true)}
onClick={() => { setShowCreateDialog(true); setError(''); }}
>
+ New Team
</button>
</div>
<div class="freno-team-grid">
{teams().map((team) => (
<A href={`/teams/${team.id}`} class="freno-team-card">
<div class="freno-team-icon">👥</div>
<h3>{team.name}</h3>
<p class="freno-team-members">{team.members.length} member{team.members.length !== 1 ? 's' : ''}</p>
<span class="freno-date">
Created {new Date(team.createdAt).toLocaleDateString()}
</span>
</A>
))}
{error() && (
<div class="freno-alert freno-alert-error">
{error()}
<button onClick={() => setError('')} class="freno-alert-dismiss">×</button>
</div>
)}
<button class="freno-team-card freno-team-card-new" onClick={() => setShowCreateDialog(true)}>
<div class="freno-team-icon">+</div>
<h3>Create Team</h3>
</button>
</div>
{teamsQuery.isLoading ? (
<div class="freno-loading">Loading teams...</div>
) : teamsQuery.error ? (
<div class="freno-empty-state">
<div class="freno-empty-icon"></div>
<h3>Failed to load teams</h3>
<p>{teamsQuery.error.message}</p>
<button class="freno-btn freno-btn-secondary" onClick={() => teamsQuery.refetch()}>
Retry
</button>
</div>
) : (
<div class="freno-team-grid">
<For each={teamsQuery.data || []}>
{(team) => (
<div class="freno-team-card">
<A href={`/teams/${team.id}`} class="freno-team-card-link">
<div class="freno-team-icon">👥</div>
<h3>{team.name}</h3>
<span class="freno-date">
Created {formatDateTime(team.createdAt)}
</span>
</A>
<div class="freno-team-card-actions">
<button
class="freno-btn freno-btn-danger-sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (confirm(`Delete team "${team.name}"? This cannot be undone.`)) {
deleteTeamMutation.mutate(team.id);
}
}}
disabled={deleteTeamMutation.isPending}
>
Delete
</button>
</div>
</div>
)}
</For>
<button
class="freno-team-card freno-team-card-new"
onClick={() => { setShowCreateDialog(true); setError(''); }}
>
<div class="freno-team-icon">+</div>
<h3>Create Team</h3>
</button>
</div>
)}
{showCreateDialog() && (
<div class="freno-modal-overlay" onClick={() => setShowCreateDialog(false)}>
@@ -58,9 +158,10 @@ export const TeamManagement: Component<any> = () => {
</div>
<form class="freno-form" onSubmit={(e) => {
e.preventDefault();
if (newTeamName().trim()) {
setShowCreateDialog(false);
setNewTeamName('');
const name = newTeamName().trim();
if (name) {
setError('');
createTeamMutation.mutate(name);
}
}}>
<div class="freno-form-group">
@@ -73,11 +174,22 @@ export const TeamManagement: Component<any> = () => {
value={newTeamName()}
onInput={(e) => setNewTeamName(e.target.value)}
autofocus
required
/>
</div>
<div class="freno-form-actions">
<button type="submit" class="freno-btn freno-btn-primary">Create Team</button>
<button type="button" class="freno-btn freno-btn-secondary" onClick={() => setShowCreateDialog(false)}>
<button
type="submit"
class="freno-btn freno-btn-primary"
disabled={createTeamMutation.isPending || !newTeamName().trim()}
>
{createTeamMutation.isPending ? 'Creating...' : 'Create Team'}
</button>
<button
type="button"
class="freno-btn freno-btn-secondary"
onClick={() => setShowCreateDialog(false)}
>
Cancel
</button>
</div>
@@ -88,3 +200,305 @@ export const TeamManagement: Component<any> = () => {
</div>
);
};
const TeamDetail: Component<{ teamId: string }> = ({ teamId }) => {
const queryClient = useQueryClient();
const [error, setError] = createSignal('');
const [showInviteDialog, setShowInviteDialog] = createSignal(false);
const [inviteUserId, setInviteUserId] = createSignal('');
const [inviteRole, setInviteRole] = createSignal<'editor' | 'viewer' | 'admin'>('editor');
const teamQuery = useQuery({
queryKey: ['team', 'getTeam', teamId],
queryFn: async () => {
const auth = localStorage.getItem('clerk_token');
const res = await fetch(`${API_URL}/trpc/team.getTeam?input=${encodeURIComponent(JSON.stringify({ id: teamId }))}`, {
headers: auth ? { Authorization: `Bearer ${auth}` } : {},
});
if (!res.ok) throw new Error('Failed to fetch team');
const data = await res.json();
return data.data as DbTeam;
},
});
const membersQuery = useQuery({
queryKey: ['team', 'listMembers', teamId],
queryFn: async () => {
const auth = localStorage.getItem('clerk_token');
const res = await fetch(`${API_URL}/trpc/team.listMembers?input=${encodeURIComponent(JSON.stringify({ teamId }))}`, {
headers: auth ? { Authorization: `Bearer ${auth}` } : {},
});
if (!res.ok) throw new Error('Failed to fetch members');
const data = await res.json();
return data.data as DbTeamMember[];
},
});
const updateRoleMutation = useMutation({
mutationFn: async ({ userId, role }: { userId: number; role: string }) => {
const auth = localStorage.getItem('clerk_token');
const res = await fetch(`${API_URL}/trpc/team.updateMemberRole`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(auth ? { Authorization: `Bearer ${auth}` } : {}),
},
body: JSON.stringify({ input: { teamId, userId, role } }),
});
if (!res.ok) throw new Error('Failed to update role');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['team', 'listMembers', teamId] });
},
onError: (err) => setError(err.message),
});
const removeMemberMutation = useMutation({
mutationFn: async (userId: number) => {
const auth = localStorage.getItem('clerk_token');
const res = await fetch(`${API_URL}/trpc/team.removeMember`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(auth ? { Authorization: `Bearer ${auth}` } : {}),
},
body: JSON.stringify({ input: { teamId, userId } }),
});
if (!res.ok) throw new Error('Failed to remove member');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['team', 'listMembers', teamId] });
},
onError: (err) => setError(err.message),
});
const leaveTeamMutation = useMutation({
mutationFn: async () => {
const auth = localStorage.getItem('clerk_token');
const res = await fetch(`${API_URL}/trpc/team.leaveTeam`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(auth ? { Authorization: `Bearer ${auth}` } : {}),
},
body: JSON.stringify({ input: { teamId } }),
});
if (!res.ok) throw new Error('Failed to leave team');
return res.json();
},
onSuccess: () => {
window.location.href = '/teams';
},
onError: (err) => setError(err.message),
});
const addMemberMutation = useMutation({
mutationFn: async ({ userId, role }: { userId: number; role: string }) => {
const auth = localStorage.getItem('clerk_token');
const res = await fetch(`${API_URL}/trpc/team.addMember`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(auth ? { Authorization: `Bearer ${auth}` } : {}),
},
body: JSON.stringify({ input: { teamId, userId, role } }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || 'Failed to add member');
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['team', 'listMembers', teamId] });
setShowInviteDialog(false);
setInviteUserId('');
},
onError: (err) => setError(err.message),
});
return (
<div class="freno-team-detail">
<div class="freno-page-header">
<div>
<A href="/teams" class="freno-back-link"> Back to Teams</A>
<h1>
{teamQuery.isLoading ? 'Loading...' : teamQuery.data?.name || 'Team'}
</h1>
<p class="freno-team-meta">
Created {teamQuery.data ? formatDateTime(teamQuery.data.createdAt) : ''}
</p>
</div>
<div class="freno-header-actions">
<button
class="freno-btn freno-btn-primary"
onClick={() => setShowInviteDialog(true)}
>
+ Invite Member
</button>
<button
class="freno-btn freno-btn-danger"
onClick={() => {
if (confirm('Leave this team? You will lose access to shared projects.')) {
leaveTeamMutation.mutate();
}
}}
disabled={leaveTeamMutation.isPending}
>
Leave Team
</button>
</div>
</div>
{error() && (
<div class="freno-alert freno-alert-error">
{error()}
<button onClick={() => setError('')} class="freno-alert-dismiss">×</button>
</div>
)}
<div class="freno-team-members-section">
<h2>Members ({membersQuery.data?.length || 0})</h2>
{membersQuery.isLoading ? (
<div class="freno-loading">Loading members...</div>
) : membersQuery.error ? (
<div class="freno-empty-state">
<p>Failed to load members: {membersQuery.error.message}</p>
<button class="freno-btn freno-btn-secondary" onClick={() => membersQuery.refetch()}>
Retry
</button>
</div>
) : (membersQuery.data || []).length === 0 ? (
<div class="freno-empty-state">
<div class="freno-empty-icon">👤</div>
<h3>No members yet</h3>
<p>Invite people to collaborate with your team.</p>
</div>
) : (
<div class="freno-members-list">
<For each={membersQuery.data || []}>
{(member) => (
<div class="freno-member-row">
<div class="freno-member-info">
<span class="freno-member-id">User #{member.userId}</span>
<span class="freno-member-joined">Joined {formatDateTime(member.joinedAt)}</span>
</div>
<div class="freno-member-actions">
<select
class="freno-select"
value={member.role}
onChange={(e) => {
setError('');
updateRoleMutation.mutate({ userId: member.userId, role: e.target.value });
}}
>
<option value="owner">Owner</option>
<option value="admin">Admin</option>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</select>
<button
class="freno-btn freno-btn-danger-sm"
onClick={() => {
if (confirm(`Remove this member from the team?`)) {
removeMemberMutation.mutate(member.userId);
}
}}
disabled={member.role === 'owner'}
>
Remove
</button>
</div>
</div>
)}
</For>
</div>
)}
</div>
{showInviteDialog() && (
<div class="freno-modal-overlay" onClick={() => setShowInviteDialog(false)}>
<div class="freno-modal" onClick={(e) => e.stopPropagation()}>
<div class="freno-modal-header">
<h2>Invite Member</h2>
<button class="freno-btn-icon" onClick={() => setShowInviteDialog(false)}></button>
</div>
<form class="freno-form" onSubmit={(e) => {
e.preventDefault();
setError('');
const userId = parseInt(inviteUserId(), 10);
if (isNaN(userId) || userId <= 0) {
setError('Please enter a valid user ID.');
return;
}
addMemberMutation.mutate({ userId, role: inviteRole() });
}}>
<div class="freno-form-group">
<label class="freno-label" for="invite-user-id">User ID</label>
<input
class="freno-input"
id="invite-user-id"
type="number"
placeholder="Enter user ID"
value={inviteUserId()}
onInput={(e) => setInviteUserId(e.target.value)}
required
/>
</div>
<div class="freno-form-group">
<label class="freno-label" for="invite-role">Role</label>
<select
class="freno-select"
id="invite-role"
value={inviteRole()}
onChange={(e) => setInviteRole(e.target.value as 'editor' | 'viewer' | 'admin')}
>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="freno-form-actions">
<button
type="submit"
class="freno-btn freno-btn-primary"
disabled={addMemberMutation.isPending || !inviteUserId()}
>
{addMemberMutation.isPending ? 'Inviting...' : 'Invite'}
</button>
<button
type="button"
class="freno-btn freno-btn-secondary"
onClick={() => setShowInviteDialog(false)}
>
Cancel
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export const TeamManagement: Component<any> = (props) => {
const location = useLocation();
const teamId = () => {
const match = location.pathname.match(/^\/teams\/([^/]+)$/);
return match ? match[1] : null;
};
return (
<>
<Show when={!teamId()} fallback={<TeamDetail teamId={teamId()!} />}>
<TeamList />
</Show>
</>
);
};