Redesign teams portal and add user autocomplete for member invitations

- Redesign TeamsPage with modern card-based layout including stats bar,
  search functionality, and empty states
- Add UserAutocomplete component with debounced search and keyboard
  navigation for selecting existing users
- Add /api/v1/users/search endpoint for username prefix search
- Update TeamMembersPage to use UserAutocomplete instead of free text input
This commit is contained in:
Mondo Diaz
2026-01-28 15:42:55 +00:00
parent 69f3737303
commit 86f2f031db
8 changed files with 835 additions and 162 deletions

View File

@@ -48,6 +48,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Team-based access displayed as read-only with "Source" column indicating origin - Team-based access displayed as read-only with "Source" column indicating origin
- Team members with access show team slug and role - Team members with access show team slug and role
- Added integration tests for team CRUD, membership, and project operations - Added integration tests for team CRUD, membership, and project operations
- Redesigned teams portal with modern card-based layout
- Card grid view with team avatar, name, slug, role badge, and stats
- Stats bar showing total teams, owned teams, and total projects
- Search functionality for filtering teams (appears when >3 teams)
- Empty states for no teams and no search results
- Added user autocomplete component for team member invitations
- `GET /api/v1/users/search` endpoint for username prefix search
- Dropdown shows matching users as you type
- Keyboard navigation support (arrow keys, enter, escape)
- Debounced search to reduce API calls
- Added unit tests for TeamAuthorizationService - Added unit tests for TeamAuthorizationService
- Added `ORCHARD_ADMIN_PASSWORD` environment variable to configure initial admin password (#87) - Added `ORCHARD_ADMIN_PASSWORD` environment variable to configure initial admin password (#87)
- When set, admin user is created with the specified password (no password change required) - When set, admin user is created with the specified password (no password change required)

View File

@@ -1093,6 +1093,43 @@ def oidc_callback(
return response return response
# --- User Search Routes (for autocomplete) ---
@router.get("/api/v1/users/search")
def search_users(
q: str = Query(..., min_length=1, description="Search query for username"),
limit: int = Query(default=10, ge=1, le=50, description="Maximum results"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Search for users by username prefix.
Returns basic user info for autocomplete (no email for privacy).
Any authenticated user can search.
"""
search_pattern = f"{q.lower()}%"
users = (
db.query(User)
.filter(
func.lower(User.username).like(search_pattern),
User.is_active == True,
)
.order_by(User.username)
.limit(limit)
.all()
)
return [
{
"id": str(u.id),
"username": u.username,
"is_admin": u.is_admin,
}
for u in users
]
# --- Admin User Management Routes --- # --- Admin User Management Routes ---

View File

@@ -668,3 +668,17 @@ export async function listTeamProjects(
}); });
return handleResponse<PaginatedResponse<Project>>(response); return handleResponse<PaginatedResponse<Project>>(response);
} }
// User search (for autocomplete)
export interface UserSearchResult {
id: string;
username: string;
is_admin: boolean;
}
export async function searchUsers(query: string, limit: number = 10): Promise<UserSearchResult[]> {
const response = await fetch(`${API_BASE}/users/search?q=${encodeURIComponent(query)}&limit=${limit}`, {
credentials: 'include',
});
return handleResponse<UserSearchResult[]>(response);
}

View File

@@ -0,0 +1,105 @@
.user-autocomplete {
position: relative;
width: 100%;
}
.user-autocomplete__input-wrapper {
position: relative;
}
.user-autocomplete__input {
width: 100%;
padding: 0.625rem 2.5rem 0.625rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.875rem;
}
.user-autocomplete__input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-alpha, rgba(59, 130, 246, 0.1));
}
.user-autocomplete__spinner {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: translateY(-50%) rotate(360deg); }
}
.user-autocomplete__dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
padding: 0.25rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 100;
max-height: 240px;
overflow-y: auto;
list-style: none;
}
.user-autocomplete__option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.1s;
}
.user-autocomplete__option:hover,
.user-autocomplete__option.selected {
background: var(--color-bg-secondary);
}
.user-autocomplete__avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.875rem;
flex-shrink: 0;
}
.user-autocomplete__user-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.user-autocomplete__username {
font-weight: 500;
color: var(--color-text);
}
.user-autocomplete__admin-badge {
font-size: 0.6875rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.025em;
}

View File

@@ -0,0 +1,171 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { searchUsers, UserSearchResult } from '../api';
import './UserAutocomplete.css';
interface UserAutocompleteProps {
value: string;
onChange: (username: string) => void;
placeholder?: string;
disabled?: boolean;
autoFocus?: boolean;
}
export function UserAutocomplete({
value,
onChange,
placeholder = 'Search users...',
disabled = false,
autoFocus = false,
}: UserAutocompleteProps) {
const [query, setQuery] = useState(value);
const [results, setResults] = useState<UserSearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
// Search for users with debounce
const doSearch = useCallback(async (searchQuery: string) => {
if (searchQuery.length < 1) {
setResults([]);
setIsOpen(false);
return;
}
setLoading(true);
try {
const users = await searchUsers(searchQuery);
setResults(users);
setIsOpen(users.length > 0);
setSelectedIndex(-1);
} catch {
setResults([]);
setIsOpen(false);
} finally {
setLoading(false);
}
}, []);
// Handle input change with debounce
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setQuery(newValue);
onChange(newValue); // Update parent immediately for form validation
// Debounce the search
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
doSearch(newValue);
}, 200);
};
// Handle selecting a user
const handleSelect = (user: UserSearchResult) => {
setQuery(user.username);
onChange(user.username);
setIsOpen(false);
setResults([]);
inputRef.current?.focus();
};
// Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex(prev => (prev < results.length - 1 ? prev + 1 : prev));
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(prev => (prev > 0 ? prev - 1 : -1));
break;
case 'Enter':
e.preventDefault();
if (selectedIndex >= 0 && results[selectedIndex]) {
handleSelect(results[selectedIndex]);
}
break;
case 'Escape':
setIsOpen(false);
break;
}
};
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Sync external value changes
useEffect(() => {
setQuery(value);
}, [value]);
// Cleanup debounce on unmount
useEffect(() => {
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, []);
return (
<div className="user-autocomplete" ref={containerRef}>
<div className="user-autocomplete__input-wrapper">
<input
ref={inputRef}
type="text"
value={query}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={() => query.length >= 1 && results.length > 0 && setIsOpen(true)}
placeholder={placeholder}
disabled={disabled}
autoFocus={autoFocus}
autoComplete="off"
className="user-autocomplete__input"
/>
{loading && (
<div className="user-autocomplete__spinner" />
)}
</div>
{isOpen && results.length > 0 && (
<ul className="user-autocomplete__dropdown">
{results.map((user, index) => (
<li
key={user.id}
className={`user-autocomplete__option ${index === selectedIndex ? 'selected' : ''}`}
onClick={() => handleSelect(user)}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="user-autocomplete__avatar">
{user.username.charAt(0).toUpperCase()}
</div>
<div className="user-autocomplete__user-info">
<span className="user-autocomplete__username">{user.username}</span>
{user.is_admin && (
<span className="user-autocomplete__admin-badge">Admin</span>
)}
</div>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -11,6 +11,7 @@ import {
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { Badge } from '../components/Badge'; import { Badge } from '../components/Badge';
import { Breadcrumb } from '../components/Breadcrumb'; import { Breadcrumb } from '../components/Breadcrumb';
import { UserAutocomplete } from '../components/UserAutocomplete';
import './TeamMembersPage.css'; import './TeamMembersPage.css';
function TeamMembersPage() { function TeamMembersPage() {
@@ -166,13 +167,10 @@ function TeamMembersPage() {
<form onSubmit={handleAddMember}> <form onSubmit={handleAddMember}>
<div className="form-group"> <div className="form-group">
<label htmlFor="username">Username</label> <label htmlFor="username">Username</label>
<input <UserAutocomplete
id="username"
type="text"
value={newMember.username} value={newMember.username}
onChange={e => setNewMember({ ...newMember, username: e.target.value })} onChange={(username) => setNewMember({ ...newMember, username })}
placeholder="Enter username" placeholder="Search for a user..."
required
autoFocus autoFocus
/> />
</div> </div>

View File

@@ -1,89 +1,117 @@
.teams-page { .teams-page {
padding: 1.5rem 0; padding: 1.5rem 0;
max-width: 1200px;
margin: 0 auto;
} }
.page-header { /* Header */
.teams-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
margin-bottom: 1.5rem; margin-bottom: 2rem;
gap: 1rem; gap: 1rem;
} }
.page-header h1 { .teams-header__content h1 {
margin: 0; margin: 0;
font-size: 1.75rem; font-size: 1.75rem;
font-weight: 600;
} }
.page-subtitle { .teams-header__subtitle {
margin: 0.25rem 0 0; margin: 0.25rem 0 0;
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 0.9375rem; font-size: 0.9375rem;
} }
.team-name-cell { /* Stats */
.teams-stats {
display: flex; display: flex;
flex-direction: column; gap: 2rem;
gap: 0.125rem; padding: 1rem 1.5rem;
}
.team-name-link {
font-weight: 500;
color: var(--color-text);
text-decoration: none;
}
.team-name-link:hover {
color: var(--color-primary);
}
.team-slug {
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.team-description {
color: var(--color-text-secondary);
font-size: 0.875rem;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 4rem 2rem;
background: var(--color-bg-secondary); background: var(--color-bg-secondary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
margin-bottom: 1.5rem;
}
.teams-stat {
display: flex;
flex-direction: column;
align-items: center;
}
.teams-stat__value {
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text);
}
.teams-stat__label {
font-size: 0.75rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Search */
.teams-search {
position: relative;
margin-bottom: 1.5rem;
}
.teams-search__icon {
position: absolute;
left: 0.875rem;
top: 50%;
transform: translateY(-50%);
color: var(--color-text-muted);
pointer-events: none;
}
.teams-search__input {
width: 100%;
padding: 0.625rem 2.5rem 0.625rem 2.75rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.875rem;
} }
.empty-state svg { .teams-search__input:focus {
color: var(--color-text-muted); outline: none;
margin-bottom: 1rem; border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-alpha, rgba(59, 130, 246, 0.1));
} }
.empty-state h2 { .teams-search__input::placeholder {
margin: 0 0 0.5rem;
font-size: 1.25rem;
}
.empty-state p {
margin: 0 0 1.5rem;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
/* Loading state */ .teams-search__clear {
.loading-state { position: absolute;
text-align: center; right: 0.5rem;
padding: 4rem 2rem; top: 50%;
transform: translateY(-50%);
background: none;
border: none;
padding: 0.375rem;
cursor: pointer;
color: var(--color-text-muted); color: var(--color-text-muted);
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
} }
/* Error message */ .teams-search__clear:hover {
.error-message { color: var(--color-text);
background: var(--color-bg-secondary);
}
/* Error */
.teams-error {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -96,7 +124,7 @@
font-size: 0.875rem; font-size: 0.875rem;
} }
.error-dismiss { .teams-error__dismiss {
background: none; background: none;
border: none; border: none;
font-size: 1.25rem; font-size: 1.25rem;
@@ -106,6 +134,145 @@
line-height: 1; line-height: 1;
} }
/* Loading */
.teams-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 4rem 2rem;
color: var(--color-text-muted);
}
.teams-loading__spinner {
width: 32px;
height: 32px;
border: 3px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: teams-spin 0.8s linear infinite;
}
@keyframes teams-spin {
to { transform: rotate(360deg); }
}
/* Empty State */
.teams-empty-state {
text-align: center;
padding: 4rem 2rem;
background: var(--color-bg-secondary);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
}
.teams-empty-icon {
color: var(--color-text-muted);
margin-bottom: 1rem;
}
.teams-empty-state h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
}
.teams-empty-state p {
margin: 0 0 1.5rem;
color: var(--color-text-muted);
}
/* Grid */
.teams-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
}
/* Team Card */
.team-card {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 1.25rem;
cursor: pointer;
transition: all 0.15s ease;
}
.team-card:hover {
border-color: var(--color-primary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.team-card__header {
display: flex;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.team-card__avatar {
width: 40px;
height: 40px;
border-radius: var(--radius-md);
background: linear-gradient(135deg, var(--color-primary), var(--color-primary-hover, #2563eb));
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 1.125rem;
flex-shrink: 0;
}
.team-card__title {
flex: 1;
min-width: 0;
}
.team-card__title h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.team-card__slug {
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.team-card__description {
font-size: 0.875rem;
color: var(--color-text-secondary);
margin: 0 0 1rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.team-card__footer {
display: flex;
gap: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border);
}
.team-card__stat {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.team-card__stat svg {
opacity: 0.7;
}
/* Modal */ /* Modal */
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
@@ -124,15 +291,43 @@
.modal-content { .modal-content {
background: var(--color-bg); background: var(--color-bg);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.5rem;
width: 100%; width: 100%;
max-width: 480px; max-width: 480px;
box-shadow: var(--shadow-xl); box-shadow: var(--shadow-xl);
overflow: hidden;
} }
.modal-content h2 { .modal-header {
margin: 0 0 1.5rem; display: flex;
font-size: 1.25rem; justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--color-border);
}
.modal-header h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
color: var(--color-text-muted);
display: flex;
border-radius: var(--radius-sm);
}
.modal-close:hover {
color: var(--color-text);
background: var(--color-bg-secondary);
}
.modal-content form {
padding: 1.5rem;
} }
/* Form */ /* Form */
@@ -147,13 +342,18 @@
font-size: 0.875rem; font-size: 0.875rem;
} }
.form-group .optional {
font-weight: 400;
color: var(--color-text-muted);
}
.form-group input, .form-group input,
.form-group textarea { .form-group textarea {
width: 100%; width: 100%;
padding: 0.5rem 0.75rem; padding: 0.625rem 0.75rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
font-size: 0.9375rem; font-size: 0.875rem;
background: var(--color-bg); background: var(--color-bg);
color: var(--color-text); color: var(--color-text);
} }
@@ -162,13 +362,34 @@
.form-group textarea:focus { .form-group textarea:focus {
outline: none; outline: none;
border-color: var(--color-primary); border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-bg); box-shadow: 0 0 0 3px var(--color-primary-alpha, rgba(59, 130, 246, 0.1));
}
.input-with-prefix {
display: flex;
align-items: stretch;
}
.input-prefix {
display: flex;
align-items: center;
padding: 0 0.75rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-right: none;
border-radius: var(--radius-md) 0 0 var(--radius-md);
color: var(--color-text-muted);
font-size: 0.875rem;
}
.input-with-prefix input {
border-radius: 0 var(--radius-md) var(--radius-md) 0;
} }
.form-hint { .form-hint {
display: block; display: block;
margin-top: 0.25rem; margin-top: 0.25rem;
font-size: 0.8125rem; font-size: 0.75rem;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
@@ -177,6 +398,8 @@
justify-content: flex-end; justify-content: flex-end;
gap: 0.75rem; gap: 0.75rem;
margin-top: 1.5rem; margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
} }
/* Buttons */ /* Buttons */
@@ -216,3 +439,23 @@
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
background: var(--color-bg-tertiary); background: var(--color-bg-tertiary);
} }
/* Responsive */
@media (max-width: 640px) {
.teams-header {
flex-direction: column;
align-items: stretch;
}
.teams-header .btn {
justify-content: center;
}
.teams-stats {
justify-content: space-around;
}
.teams-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -4,7 +4,6 @@ import { TeamDetail, TeamCreate, PaginatedResponse } from '../types';
import { listTeams, createTeam } from '../api'; import { listTeams, createTeam } from '../api';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { Badge } from '../components/Badge'; import { Badge } from '../components/Badge';
import { DataTable } from '../components/DataTable';
import './TeamsPage.css'; import './TeamsPage.css';
function TeamsPage() { function TeamsPage() {
@@ -17,6 +16,7 @@ function TeamsPage() {
const [newTeam, setNewTeam] = useState<TeamCreate>({ name: '', slug: '', description: '' }); const [newTeam, setNewTeam] = useState<TeamCreate>({ name: '', slug: '', description: '' });
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [slugManuallySet, setSlugManuallySet] = useState(false); const [slugManuallySet, setSlugManuallySet] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const loadTeams = useCallback(async () => { const loadTeams = useCallback(async () => {
try { try {
@@ -65,16 +65,42 @@ function TeamsPage() {
} }
} }
const roleVariants: Record<string, 'success' | 'info' | 'default'> = { const closeModal = () => {
owner: 'success', setShowForm(false);
admin: 'info', setNewTeam({ name: '', slug: '', description: '' });
member: 'default', 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()))
) || [];
// Stats
const totalTeams = teamsData?.items.length || 0;
const totalProjects = teamsData?.items.reduce((sum, t) => sum + t.project_count, 0) || 0;
const ownedTeams = teamsData?.items.filter(t => t.user_role === 'owner').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) { if (!user) {
return ( return (
<div className="teams-page"> <div className="teams-page">
<div className="empty-state"> <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> <h2>Sign in to view your teams</h2>
<p>Teams help you organize projects and collaborate with others.</p> <p>Teams help you organize projects and collaborate with others.</p>
<Link to="/login" className="btn btn-primary">Sign In</Link> <Link to="/login" className="btn btn-primary">Sign In</Link>
@@ -83,76 +109,86 @@ function TeamsPage() {
); );
} }
const columns = [
{
key: 'name',
header: 'Team',
render: (team: TeamDetail) => (
<div className="team-name-cell">
<Link to={`/teams/${team.slug}`} className="team-name-link">
{team.name}
</Link>
<span className="team-slug">@{team.slug}</span>
</div>
),
},
{
key: 'description',
header: 'Description',
render: (team: TeamDetail) => (
<span className="team-description">{team.description || '-'}</span>
),
},
{
key: 'role',
header: 'Your Role',
render: (team: TeamDetail) => (
team.user_role ? (
<Badge variant={roleVariants[team.user_role] || 'default'}>
{team.user_role}
</Badge>
) : null
),
},
{
key: 'members',
header: 'Members',
render: (team: TeamDetail) => team.member_count,
},
{
key: 'projects',
header: 'Projects',
render: (team: TeamDetail) => team.project_count,
},
];
return ( return (
<div className="teams-page"> <div className="teams-page">
<div className="page-header"> {/* Header */}
<div> <div className="teams-header">
<div className="teams-header__content">
<h1>Teams</h1> <h1>Teams</h1>
<p className="page-subtitle">Organize projects and collaborate with others</p> <p className="teams-header__subtitle">Organize projects and collaborate with your team</p>
</div> </div>
<button className="btn btn-primary" onClick={() => setShowForm(true)}> <button className="btn btn-primary" onClick={() => setShowForm(true)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <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="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" /> <line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
New Team Create Team
</button> </button>
</div> </div>
{error && ( {/* Stats */}
<div className="error-message"> {!loading && totalTeams > 0 && (
{error} <div className="teams-stats">
<button onClick={() => setError(null)} className="error-dismiss">&times;</button> <div className="teams-stat">
<span className="teams-stat__value">{totalTeams}</span>
<span className="teams-stat__label">Teams</span>
</div>
<div className="teams-stat">
<span className="teams-stat__value">{ownedTeams}</span>
<span className="teams-stat__label">Owned</span>
</div>
<div className="teams-stat">
<span className="teams-stat__value">{totalProjects}</span>
<span className="teams-stat__label">Projects</span>
</div>
</div> </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">&times;</button>
</div>
)}
{/* Create Team Modal */}
{showForm && ( {showForm && (
<div className="modal-overlay" onClick={() => setShowForm(false)}> <div className="modal-overlay" onClick={closeModal}>
<div className="modal-content" onClick={e => e.stopPropagation()}> <div className="modal-content" onClick={e => e.stopPropagation()}>
<h2>Create New Team</h2> <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}> <form onSubmit={handleCreateTeam}>
<div className="form-group"> <div className="form-group">
<label htmlFor="team-name">Team Name</label> <label htmlFor="team-name">Team Name</label>
@@ -161,27 +197,30 @@ function TeamsPage() {
type="text" type="text"
value={newTeam.name} value={newTeam.name}
onChange={e => handleNameChange(e.target.value)} onChange={e => handleNameChange(e.target.value)}
placeholder="My Team" placeholder="Engineering"
required required
autoFocus autoFocus
/> />
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="team-slug">Slug</label> <label htmlFor="team-slug">URL Slug</label>
<input <div className="input-with-prefix">
id="team-slug" <span className="input-prefix">@</span>
type="text" <input
value={newTeam.slug} id="team-slug"
onChange={e => handleSlugChange(e.target.value)} type="text"
placeholder="my-team" value={newTeam.slug}
pattern="^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$" onChange={e => handleSlugChange(e.target.value)}
title="Lowercase letters, numbers, and hyphens only" placeholder="engineering"
required pattern="^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$"
/> title="Lowercase letters, numbers, and hyphens only"
<span className="form-hint">Lowercase letters, numbers, and hyphens only</span> required
/>
</div>
<span className="form-hint">Used in URLs. Lowercase letters, numbers, and hyphens.</span>
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="team-description">Description (optional)</label> <label htmlFor="team-description">Description <span className="optional">(optional)</span></label>
<textarea <textarea
id="team-description" id="team-description"
value={newTeam.description} value={newTeam.description}
@@ -191,7 +230,7 @@ function TeamsPage() {
/> />
</div> </div>
<div className="form-actions"> <div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={() => setShowForm(false)}> <button type="button" className="btn btn-secondary" onClick={closeModal}>
Cancel Cancel
</button> </button>
<button type="submit" className="btn btn-primary" disabled={creating}> <button type="submit" className="btn btn-primary" disabled={creating}>
@@ -203,29 +242,85 @@ function TeamsPage() {
</div> </div>
)} )}
{/* Content */}
{loading ? ( {loading ? (
<div className="loading-state">Loading teams...</div> <div className="teams-loading">
) : teamsData?.items.length === 0 ? ( <div className="teams-loading__spinner" />
<div className="empty-state"> <span>Loading teams...</span>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"> </div>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/> ) : filteredTeams.length === 0 ? (
<circle cx="9" cy="7" r="4"/> <div className="teams-empty-state">
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/> <div className="teams-empty-icon">
<path d="M16 3.13a4 4 0 0 1 0 7.75"/> <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
</svg> <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<h2>No teams yet</h2> <circle cx="9" cy="7" r="4"/>
<p>Create your first team to start organizing your projects.</p> <path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<button className="btn btn-primary" onClick={() => setShowForm(true)}> <path d="M16 3.13a4 4 0 0 1 0 7.75"/>
Create Team </svg>
</button> </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> </div>
) : ( ) : (
<DataTable <div className="teams-grid">
columns={columns} {filteredTeams.map(team => (
data={teamsData?.items || []} <div
keyExtractor={team => team.id} key={team.id}
onRowClick={team => navigate(`/teams/${team.slug}`)} className="team-card"
/> onClick={() => navigate(`/teams/${team.slug}`)}
>
<div className="team-card__header">
<div className="team-card__avatar">
{team.name.charAt(0).toUpperCase()}
</div>
<div className="team-card__title">
<h3>{team.name}</h3>
<span className="team-card__slug">@{team.slug}</span>
</div>
{team.user_role && (
<Badge variant={roleConfig[team.user_role]?.variant || 'default'}>
{roleConfig[team.user_role]?.label || team.user_role}
</Badge>
)}
</div>
{team.description && (
<p className="team-card__description">{team.description}</p>
)}
<div className="team-card__footer">
<div className="team-card__stat">
<svg width="14" height="14" 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"/>
</svg>
<span>{team.member_count} member{team.member_count !== 1 ? 's' : ''}</span>
</div>
<div className="team-card__stat">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
<span>{team.project_count} project{team.project_count !== 1 ? 's' : ''}</span>
</div>
</div>
</div>
))}
</div>
)} )}
</div> </div>
); );