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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user