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:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -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)
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
105
frontend/src/components/UserAutocomplete.css
Normal file
105
frontend/src/components/UserAutocomplete.css
Normal 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;
|
||||||
|
}
|
||||||
171
frontend/src/components/UserAutocomplete.tsx
Normal file
171
frontend/src/components/UserAutocomplete.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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">×</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">×</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()}>
|
||||||
|
<div className="modal-header">
|
||||||
<h2>Create New Team</h2>
|
<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>
|
||||||
|
<div className="input-with-prefix">
|
||||||
|
<span className="input-prefix">@</span>
|
||||||
<input
|
<input
|
||||||
id="team-slug"
|
id="team-slug"
|
||||||
type="text"
|
type="text"
|
||||||
value={newTeam.slug}
|
value={newTeam.slug}
|
||||||
onChange={e => handleSlugChange(e.target.value)}
|
onChange={e => handleSlugChange(e.target.value)}
|
||||||
placeholder="my-team"
|
placeholder="engineering"
|
||||||
pattern="^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$"
|
pattern="^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$"
|
||||||
title="Lowercase letters, numbers, and hyphens only"
|
title="Lowercase letters, numbers, and hyphens only"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<span className="form-hint">Lowercase letters, numbers, and hyphens only</span>
|
</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>
|
||||||
|
) : 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"/>
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||||
<circle cx="9" cy="7" r="4"/>
|
<circle cx="9" cy="7" r="4"/>
|
||||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||||
</svg>
|
</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>
|
<h2>No teams yet</h2>
|
||||||
<p>Create your first team to start organizing your projects.</p>
|
<p>Create your first team to start organizing your projects.</p>
|
||||||
<button className="btn btn-primary" onClick={() => setShowForm(true)}>
|
<button className="btn btn-primary" onClick={() => setShowForm(true)}>
|
||||||
Create Team
|
Create Team
|
||||||
</button>
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user