311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { Link, useNavigate } from 'react-router-dom';
|
|
import { TeamDetail, TeamCreate, PaginatedResponse } from '../types';
|
|
import { listTeams, createTeam } from '../api';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import { Badge } from '../components/Badge';
|
|
import { DataTable } from '../components/DataTable';
|
|
import './TeamsPage.css';
|
|
|
|
function TeamsPage() {
|
|
const navigate = useNavigate();
|
|
const { user } = useAuth();
|
|
const [teamsData, setTeamsData] = useState<PaginatedResponse<TeamDetail> | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [newTeam, setNewTeam] = useState<TeamCreate>({ name: '', slug: '', description: '' });
|
|
const [creating, setCreating] = useState(false);
|
|
const [slugManuallySet, setSlugManuallySet] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
const loadTeams = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
const data = await listTeams({ limit: 100 });
|
|
setTeamsData(data);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load teams');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadTeams();
|
|
}, [loadTeams]);
|
|
|
|
// Auto-generate slug from name
|
|
const handleNameChange = (name: string) => {
|
|
setNewTeam(prev => ({
|
|
...prev,
|
|
name,
|
|
slug: slugManuallySet ? prev.slug : name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''),
|
|
}));
|
|
};
|
|
|
|
const handleSlugChange = (slug: string) => {
|
|
setSlugManuallySet(true);
|
|
setNewTeam(prev => ({ ...prev, slug }));
|
|
};
|
|
|
|
async function handleCreateTeam(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
try {
|
|
setCreating(true);
|
|
const team = await createTeam(newTeam);
|
|
setNewTeam({ name: '', slug: '', description: '' });
|
|
setSlugManuallySet(false);
|
|
setShowForm(false);
|
|
navigate(`/teams/${team.slug}`);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to create team');
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
}
|
|
|
|
const closeModal = () => {
|
|
setShowForm(false);
|
|
setNewTeam({ name: '', slug: '', description: '' });
|
|
setSlugManuallySet(false);
|
|
};
|
|
|
|
// Filter teams by search
|
|
const filteredTeams = teamsData?.items.filter(team =>
|
|
team.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
team.slug.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
(team.description?.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
) || [];
|
|
|
|
const totalTeams = teamsData?.items.length || 0;
|
|
|
|
const roleConfig: Record<string, { variant: 'success' | 'info' | 'default'; label: string }> = {
|
|
owner: { variant: 'success', label: 'Owner' },
|
|
admin: { variant: 'info', label: 'Admin' },
|
|
member: { variant: 'default', label: 'Member' },
|
|
};
|
|
|
|
if (!user) {
|
|
return (
|
|
<div className="teams-page">
|
|
<div className="teams-empty-state">
|
|
<div className="teams-empty-icon">
|
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
<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>
|
|
</div>
|
|
<h2>Sign in to view your teams</h2>
|
|
<p>Teams help you organize projects and collaborate with others.</p>
|
|
<Link to="/login" className="btn btn-primary">Sign In</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="teams-page">
|
|
{/* Header */}
|
|
<div className="teams-header">
|
|
<h1>Teams</h1>
|
|
<button className="btn btn-primary" onClick={() => setShowForm(true)}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<line x1="12" y1="5" x2="12" y2="19" />
|
|
<line x1="5" y1="12" x2="19" y2="12" />
|
|
</svg>
|
|
Create Team
|
|
</button>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
{!loading && totalTeams > 3 && (
|
|
<div className="teams-search">
|
|
<svg className="teams-search__icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<circle cx="11" cy="11" r="8"/>
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
</svg>
|
|
<input
|
|
type="text"
|
|
placeholder="Search teams..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="teams-search__input"
|
|
/>
|
|
{searchQuery && (
|
|
<button className="teams-search__clear" onClick={() => setSearchQuery('')}>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="teams-error">
|
|
{error}
|
|
<button onClick={() => setError(null)} className="teams-error__dismiss">×</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Create Team Modal */}
|
|
{showForm && (
|
|
<div className="modal-overlay" onClick={closeModal}>
|
|
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
|
<div className="modal-header">
|
|
<h2>Create New Team</h2>
|
|
<button className="modal-close" onClick={closeModal}>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<form onSubmit={handleCreateTeam}>
|
|
<div className="form-group">
|
|
<label htmlFor="team-name">Team Name</label>
|
|
<input
|
|
id="team-name"
|
|
type="text"
|
|
value={newTeam.name}
|
|
onChange={e => handleNameChange(e.target.value)}
|
|
placeholder="Engineering"
|
|
required
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div className="form-group">
|
|
<label htmlFor="team-slug">URL Slug</label>
|
|
<div className="input-with-prefix">
|
|
<span className="input-prefix">@</span>
|
|
<input
|
|
id="team-slug"
|
|
type="text"
|
|
value={newTeam.slug}
|
|
onChange={e => handleSlugChange(e.target.value)}
|
|
placeholder="engineering"
|
|
pattern="^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$"
|
|
title="Lowercase letters, numbers, and hyphens only"
|
|
required
|
|
/>
|
|
</div>
|
|
<span className="form-hint">Used in URLs. Lowercase letters, numbers, and hyphens.</span>
|
|
</div>
|
|
<div className="form-group">
|
|
<label htmlFor="team-description">Description <span className="optional">(optional)</span></label>
|
|
<textarea
|
|
id="team-description"
|
|
value={newTeam.description}
|
|
onChange={e => setNewTeam({ ...newTeam, description: e.target.value })}
|
|
placeholder="What is this team for?"
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
<div className="form-actions">
|
|
<button type="button" className="btn btn-secondary" onClick={closeModal}>
|
|
Cancel
|
|
</button>
|
|
<button type="submit" className="btn btn-primary" disabled={creating}>
|
|
{creating ? 'Creating...' : 'Create Team'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Content */}
|
|
{loading ? (
|
|
<div className="teams-loading">
|
|
<div className="teams-loading__spinner" />
|
|
<span>Loading teams...</span>
|
|
</div>
|
|
) : filteredTeams.length === 0 ? (
|
|
<div className="teams-empty-state">
|
|
<div className="teams-empty-icon">
|
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
<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>
|
|
</div>
|
|
{searchQuery ? (
|
|
<>
|
|
<h2>No teams found</h2>
|
|
<p>No teams match "{searchQuery}"</p>
|
|
<button className="btn btn-secondary" onClick={() => setSearchQuery('')}>
|
|
Clear search
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<h2>No teams yet</h2>
|
|
<p>Create your first team to start organizing your projects.</p>
|
|
<button className="btn btn-primary" onClick={() => setShowForm(true)}>
|
|
Create Team
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<DataTable
|
|
data={filteredTeams}
|
|
keyExtractor={(team) => team.id}
|
|
onRowClick={(team) => navigate(`/teams/${team.slug}`)}
|
|
columns={[
|
|
{
|
|
key: 'name',
|
|
header: 'Name',
|
|
render: (team) => (
|
|
<div className="team-name-cell">
|
|
<Link
|
|
to={`/teams/${team.slug}`}
|
|
className="cell-name"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{team.name}
|
|
</Link>
|
|
<span className="team-slug">@{team.slug}</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'description',
|
|
header: 'Description',
|
|
className: 'cell-description',
|
|
render: (team) => team.description || <span className="text-muted">—</span>,
|
|
},
|
|
{
|
|
key: 'role',
|
|
header: 'Role',
|
|
render: (team) => team.user_role ? (
|
|
<Badge variant={roleConfig[team.user_role]?.variant || 'default'}>
|
|
{roleConfig[team.user_role]?.label || team.user_role}
|
|
</Badge>
|
|
) : null,
|
|
},
|
|
{
|
|
key: 'members',
|
|
header: 'Members',
|
|
render: (team) => <span className="text-muted">{team.member_count}</span>,
|
|
},
|
|
{
|
|
key: 'projects',
|
|
header: 'Projects',
|
|
render: (team) => <span className="text-muted">{team.project_count}</span>,
|
|
},
|
|
]}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default TeamsPage;
|