290 lines
12 KiB
TypeScript
290 lines
12 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { Link, useParams, useNavigate } from 'react-router-dom';
|
|
import { TeamDetail, Project, PaginatedResponse } from '../types';
|
|
import { getTeam, listTeamProjects, createProject } from '../api';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import { Badge } from '../components/Badge';
|
|
import { Breadcrumb } from '../components/Breadcrumb';
|
|
import './TeamDashboardPage.css';
|
|
|
|
function TeamDashboardPage() {
|
|
const { slug } = useParams<{ slug: string }>();
|
|
const navigate = useNavigate();
|
|
const { user } = useAuth();
|
|
const [team, setTeam] = useState<TeamDetail | null>(null);
|
|
const [projects, setProjects] = useState<PaginatedResponse<Project> | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [showProjectForm, setShowProjectForm] = useState(false);
|
|
const [newProject, setNewProject] = useState({ name: '', description: '', is_public: true });
|
|
const [creating, setCreating] = useState(false);
|
|
|
|
const loadTeamData = useCallback(async () => {
|
|
if (!slug) return;
|
|
try {
|
|
setLoading(true);
|
|
const [teamData, projectsData] = await Promise.all([
|
|
getTeam(slug),
|
|
listTeamProjects(slug, { limit: 10 }),
|
|
]);
|
|
setTeam(teamData);
|
|
setProjects(projectsData);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load team');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [slug]);
|
|
|
|
useEffect(() => {
|
|
loadTeamData();
|
|
}, [loadTeamData]);
|
|
|
|
async function handleCreateProject(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!team) return;
|
|
try {
|
|
setCreating(true);
|
|
const project = await createProject({ ...newProject, team_id: team.id });
|
|
setNewProject({ name: '', description: '', is_public: true });
|
|
setShowProjectForm(false);
|
|
navigate(`/project/${project.name}`);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to create project');
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="team-dashboard">
|
|
<div className="loading-state">Loading team...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !team) {
|
|
return (
|
|
<div className="team-dashboard">
|
|
<div className="error-state">
|
|
<h2>Error loading team</h2>
|
|
<p>{error || 'Team not found'}</p>
|
|
<Link to="/teams" className="btn btn-primary">Back to Teams</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isAdminOrOwner = team.user_role === 'owner' || team.user_role === 'admin' || user?.is_admin;
|
|
|
|
const roleVariants: Record<string, 'success' | 'info' | 'default'> = {
|
|
owner: 'success',
|
|
admin: 'info',
|
|
member: 'default',
|
|
};
|
|
|
|
return (
|
|
<div className="team-dashboard">
|
|
<Breadcrumb
|
|
items={[
|
|
{ label: 'Teams', href: '/teams' },
|
|
{ label: team.name },
|
|
]}
|
|
/>
|
|
|
|
<div className="team-header">
|
|
<div className="team-header-info">
|
|
<h1>{team.name}</h1>
|
|
{team.user_role && (
|
|
<Badge variant={roleVariants[team.user_role] || 'default'}>
|
|
{team.user_role}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
{team.description && (
|
|
<p className="team-description">{team.description}</p>
|
|
)}
|
|
<div className="team-meta">
|
|
<span className="team-slug">@{team.slug}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="team-stats">
|
|
<div className="stat-card">
|
|
<div className="stat-value">{team.project_count}</div>
|
|
<div className="stat-label">Projects</div>
|
|
</div>
|
|
<div className="stat-card">
|
|
<div className="stat-value">{team.member_count}</div>
|
|
<div className="stat-label">Members</div>
|
|
</div>
|
|
</div>
|
|
|
|
{isAdminOrOwner && (
|
|
<div className="team-actions">
|
|
<Link to={`/teams/${slug}/settings`} className="btn btn-secondary">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<circle cx="12" cy="12" r="3"/>
|
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
|
</svg>
|
|
Settings
|
|
</Link>
|
|
<Link to={`/teams/${slug}/members`} className="btn btn-secondary">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
|
<circle cx="9" cy="7" r="4"/>
|
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
|
</svg>
|
|
Members
|
|
</Link>
|
|
</div>
|
|
)}
|
|
|
|
{showProjectForm && (
|
|
<div className="modal-overlay" onClick={() => setShowProjectForm(false)}>
|
|
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
|
<h2>Create New Project</h2>
|
|
<form onSubmit={handleCreateProject}>
|
|
<div className="form-group">
|
|
<label htmlFor="project-name">Project Name</label>
|
|
<input
|
|
id="project-name"
|
|
type="text"
|
|
value={newProject.name}
|
|
onChange={e => setNewProject({ ...newProject, name: e.target.value })}
|
|
placeholder="my-project"
|
|
required
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div className="form-group">
|
|
<label htmlFor="project-description">Description (optional)</label>
|
|
<textarea
|
|
id="project-description"
|
|
value={newProject.description}
|
|
onChange={e => setNewProject({ ...newProject, description: e.target.value })}
|
|
placeholder="What is this project for?"
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
<div className="form-group checkbox-group">
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={newProject.is_public}
|
|
onChange={e => setNewProject({ ...newProject, is_public: e.target.checked })}
|
|
/>
|
|
Public project
|
|
</label>
|
|
<span className="form-hint">Public projects are visible to everyone</span>
|
|
</div>
|
|
<div className="form-actions">
|
|
<button type="button" className="btn btn-secondary" onClick={() => setShowProjectForm(false)}>
|
|
Cancel
|
|
</button>
|
|
<button type="submit" className="btn btn-primary" disabled={creating}>
|
|
{creating ? 'Creating...' : 'Create Project'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="team-section">
|
|
<div className="section-header">
|
|
<h2>Projects</h2>
|
|
{isAdminOrOwner && (
|
|
<button className="btn btn-primary btn-sm" onClick={() => setShowProjectForm(true)}>
|
|
+ New Project
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{projects?.items.length === 0 ? (
|
|
<div className="empty-state">
|
|
<p>No projects in this team yet.</p>
|
|
{isAdminOrOwner && (
|
|
<button className="btn btn-primary" onClick={() => setShowProjectForm(true)}>
|
|
Create Project
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="projects-table-container">
|
|
<table className="projects-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Description</th>
|
|
<th>Visibility</th>
|
|
<th>Created By</th>
|
|
{isAdminOrOwner && <th></th>}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{projects?.items.map(project => (
|
|
<tr
|
|
key={project.id}
|
|
className="project-row"
|
|
onClick={() => navigate(`/project/${project.name}`)}
|
|
>
|
|
<td>
|
|
<Link
|
|
to={`/project/${project.name}`}
|
|
className="project-name-link"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{project.name}
|
|
</Link>
|
|
</td>
|
|
<td className="project-description-cell">
|
|
{project.description || <span className="text-muted">—</span>}
|
|
</td>
|
|
<td>
|
|
<Badge variant={project.is_public ? 'public' : 'private'}>
|
|
{project.is_public ? 'Public' : 'Private'}
|
|
</Badge>
|
|
</td>
|
|
<td className="text-muted">{project.created_by}</td>
|
|
{isAdminOrOwner && (
|
|
<td className="actions-cell">
|
|
<button
|
|
className="btn btn-sm btn-ghost"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
navigate(`/project/${project.name}/settings`);
|
|
}}
|
|
title="Settings"
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<circle cx="12" cy="12" r="3"/>
|
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
|
</svg>
|
|
</button>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{projects && projects.pagination.total > 10 && (
|
|
<div className="section-footer">
|
|
<Link to={`/teams/${slug}/projects`} className="view-all-link">
|
|
View all {projects.pagination.total} projects
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default TeamDashboardPage;
|