wip
This commit is contained in:
169
apps/website/components/teams/CreateTeamForm.tsx
Normal file
169
apps/website/components/teams/CreateTeamForm.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { createTeam, getCurrentDriverId } from '@gridpilot/racing/application';
|
||||
|
||||
interface CreateTeamFormProps {
|
||||
onCancel?: () => void;
|
||||
onSuccess?: (teamId: string) => void;
|
||||
}
|
||||
|
||||
export default function CreateTeamForm({ onCancel, onSuccess }: CreateTeamFormProps) {
|
||||
const router = useRouter();
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
tag: '',
|
||||
description: '',
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'Team name is required';
|
||||
} else if (formData.name.length < 3) {
|
||||
newErrors.name = 'Team name must be at least 3 characters';
|
||||
}
|
||||
|
||||
if (!formData.tag.trim()) {
|
||||
newErrors.tag = 'Team tag is required';
|
||||
} else if (formData.tag.length > 4) {
|
||||
newErrors.tag = 'Team tag must be 4 characters or less';
|
||||
}
|
||||
|
||||
if (!formData.description.trim()) {
|
||||
newErrors.description = 'Description is required';
|
||||
} else if (formData.description.length < 10) {
|
||||
newErrors.description = 'Description must be at least 10 characters';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const currentDriverId = getCurrentDriverId();
|
||||
const team = createTeam(
|
||||
formData.name,
|
||||
formData.tag.toUpperCase(),
|
||||
formData.description,
|
||||
currentDriverId,
|
||||
[] // Empty leagues array for now
|
||||
);
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess(team.id);
|
||||
} else {
|
||||
router.push(`/teams/${team.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'Failed to create team');
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-2">
|
||||
Team Name *
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Enter team name..."
|
||||
disabled={submitting}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-danger-red text-xs mt-1">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-2">
|
||||
Team Tag *
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.tag}
|
||||
onChange={(e) => setFormData({ ...formData, tag: e.target.value.toUpperCase() })}
|
||||
placeholder="e.g., APEX"
|
||||
maxLength={4}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Max 4 characters</p>
|
||||
{errors.tag && (
|
||||
<p className="text-danger-red text-xs mt-1">{errors.tag}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-2">
|
||||
Description *
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full px-3 py-3 bg-iron-gray border-0 rounded-md text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm resize-none"
|
||||
rows={4}
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Describe your team's goals and racing style..."
|
||||
disabled={submitting}
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className="text-danger-red text-xs mt-1">{errors.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-deep-graphite border border-charcoal-outline rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-2xl">ℹ️</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-white font-medium mb-1">About Team Creation</h4>
|
||||
<ul className="text-sm text-gray-400 space-y-1">
|
||||
<li>• You will be assigned as the team owner</li>
|
||||
<li>• You can invite other drivers to join your team</li>
|
||||
<li>• Team standings are calculated across leagues</li>
|
||||
<li>• This is alpha data - it resets on page reload</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={submitting}
|
||||
className="flex-1"
|
||||
>
|
||||
{submitting ? 'Creating Team...' : 'Create Team'}
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
109
apps/website/components/teams/JoinTeamButton.tsx
Normal file
109
apps/website/components/teams/JoinTeamButton.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import {
|
||||
getCurrentDriverId,
|
||||
getTeamMembership,
|
||||
getDriverTeam,
|
||||
joinTeam,
|
||||
requestToJoinTeam,
|
||||
leaveTeam,
|
||||
} from '@gridpilot/racing/application';
|
||||
|
||||
interface JoinTeamButtonProps {
|
||||
teamId: string;
|
||||
requiresApproval?: boolean;
|
||||
onUpdate?: () => void;
|
||||
}
|
||||
|
||||
export default function JoinTeamButton({
|
||||
teamId,
|
||||
requiresApproval = false,
|
||||
onUpdate,
|
||||
}: JoinTeamButtonProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const currentDriverId = getCurrentDriverId();
|
||||
const membership = getTeamMembership(teamId, currentDriverId);
|
||||
const currentTeam = getDriverTeam(currentDriverId);
|
||||
|
||||
const handleJoin = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (requiresApproval) {
|
||||
requestToJoinTeam(teamId, currentDriverId);
|
||||
alert('Join request sent! Wait for team approval.');
|
||||
} else {
|
||||
joinTeam(teamId, currentDriverId);
|
||||
alert('Successfully joined team!');
|
||||
}
|
||||
onUpdate?.();
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'Failed to join team');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLeave = async () => {
|
||||
if (!confirm('Are you sure you want to leave this team?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
leaveTeam(teamId, currentDriverId);
|
||||
alert('Successfully left team');
|
||||
onUpdate?.();
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'Failed to leave team');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Already a member
|
||||
if (membership && membership.status === 'active') {
|
||||
if (membership.role === 'owner') {
|
||||
return (
|
||||
<Button variant="secondary" disabled>
|
||||
Team Owner
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleLeave}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Leaving...' : 'Leave Team'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Already on another team
|
||||
if (currentTeam && currentTeam.team.id !== teamId) {
|
||||
return (
|
||||
<Button variant="secondary" disabled>
|
||||
Already on {currentTeam.team.name}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Can join
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleJoin}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading
|
||||
? 'Processing...'
|
||||
: requiresApproval
|
||||
? 'Request to Join'
|
||||
: 'Join Team'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
249
apps/website/components/teams/TeamAdmin.tsx
Normal file
249
apps/website/components/teams/TeamAdmin.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { getDriverRepository } from '@/lib/di-container';
|
||||
import { EntityMappers, DriverDTO } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import {
|
||||
Team,
|
||||
TeamJoinRequest,
|
||||
getTeamJoinRequests,
|
||||
approveTeamJoinRequest,
|
||||
rejectTeamJoinRequest,
|
||||
updateTeam,
|
||||
} from '@gridpilot/racing/application';
|
||||
|
||||
interface TeamAdminProps {
|
||||
team: Team;
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
||||
const [joinRequests, setJoinRequests] = useState<TeamJoinRequest[]>([]);
|
||||
const [requestDrivers, setRequestDrivers] = useState<Record<string, DriverDTO>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [editedTeam, setEditedTeam] = useState({
|
||||
name: team.name,
|
||||
tag: team.tag,
|
||||
description: team.description,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadJoinRequests();
|
||||
}, [team.id]);
|
||||
|
||||
const loadJoinRequests = async () => {
|
||||
const requests = getTeamJoinRequests(team.id);
|
||||
setJoinRequests(requests);
|
||||
|
||||
const driverRepo = getDriverRepository();
|
||||
const allDrivers = await driverRepo.findAll();
|
||||
const driverMap: Record<string, DriverDTO> = {};
|
||||
|
||||
for (const request of requests) {
|
||||
const driver = allDrivers.find(d => d.id === request.driverId);
|
||||
if (driver) {
|
||||
const dto = EntityMappers.toDriverDTO(driver);
|
||||
if (dto) {
|
||||
driverMap[request.driverId] = dto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setRequestDrivers(driverMap);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleApprove = async (requestId: string) => {
|
||||
try {
|
||||
approveTeamJoinRequest(requestId);
|
||||
await loadJoinRequests();
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'Failed to approve request');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (requestId: string) => {
|
||||
try {
|
||||
rejectTeamJoinRequest(requestId);
|
||||
await loadJoinRequests();
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'Failed to reject request');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveChanges = () => {
|
||||
try {
|
||||
updateTeam(team.id, editedTeam, team.ownerId);
|
||||
setEditMode(false);
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'Failed to update team');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-xl font-semibold text-white">Team Settings</h3>
|
||||
{!editMode && (
|
||||
<Button variant="secondary" onClick={() => setEditMode(true)}>
|
||||
Edit Details
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editMode ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-2">
|
||||
Team Name
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={editedTeam.name}
|
||||
onChange={(e) => setEditedTeam({ ...editedTeam, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-2">
|
||||
Team Tag
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={editedTeam.tag}
|
||||
onChange={(e) => setEditedTeam({ ...editedTeam, tag: e.target.value })}
|
||||
maxLength={4}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Max 4 characters</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full px-3 py-3 bg-iron-gray border-0 rounded-md text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm resize-none"
|
||||
rows={4}
|
||||
value={editedTeam.description}
|
||||
onChange={(e) => setEditedTeam({ ...editedTeam, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="primary" onClick={handleSaveChanges}>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setEditMode(false);
|
||||
setEditedTeam({
|
||||
name: team.name,
|
||||
tag: team.tag,
|
||||
description: team.description,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-sm text-gray-400">Team Name</div>
|
||||
<div className="text-white font-medium">{team.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-400">Team Tag</div>
|
||||
<div className="text-white font-medium">{team.tag}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-400">Description</div>
|
||||
<div className="text-white">{team.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-xl font-semibold text-white mb-6">Join Requests</h3>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-gray-400">Loading requests...</div>
|
||||
) : joinRequests.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{joinRequests.map((request) => {
|
||||
const driver = requestDrivers[request.driverId];
|
||||
if (!driver) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={request.id}
|
||||
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="w-12 h-12 rounded-full bg-primary-blue/20 flex items-center justify-center text-lg font-bold text-white">
|
||||
{driver.name.charAt(0)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-white font-medium">{driver.name}</h4>
|
||||
<p className="text-sm text-gray-400">
|
||||
{driver.country} • Requested {new Date(request.requestedAt).toLocaleDateString()}
|
||||
</p>
|
||||
{request.message && (
|
||||
<p className="text-sm text-gray-300 mt-1 italic">
|
||||
"{request.message}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => handleApprove(request.id)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => handleReject(request.id)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
No pending join requests
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-xl font-semibold text-white mb-4">Danger Zone</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-lg bg-danger-red/10 border border-danger-red/30">
|
||||
<h4 className="text-white font-medium mb-2">Disband Team</h4>
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
Permanently delete this team. This action cannot be undone.
|
||||
</p>
|
||||
<Button variant="danger" disabled>
|
||||
Disband Team (Coming Soon)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
apps/website/components/teams/TeamCard.tsx
Normal file
92
apps/website/components/teams/TeamCard.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import Card from '../ui/Card';
|
||||
|
||||
interface TeamCardProps {
|
||||
id: string;
|
||||
name: string;
|
||||
logo?: string;
|
||||
memberCount: number;
|
||||
leagues: string[];
|
||||
performanceLevel?: 'beginner' | 'intermediate' | 'advanced' | 'pro';
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function TeamCard({
|
||||
id,
|
||||
name,
|
||||
logo,
|
||||
memberCount,
|
||||
leagues,
|
||||
performanceLevel,
|
||||
onClick,
|
||||
}: TeamCardProps) {
|
||||
const performanceBadgeColors = {
|
||||
beginner: 'bg-green-500/20 text-green-400',
|
||||
intermediate: 'bg-blue-500/20 text-blue-400',
|
||||
advanced: 'bg-purple-500/20 text-purple-400',
|
||||
pro: 'bg-red-500/20 text-red-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="cursor-pointer hover:scale-[1.03] transition-transform duration-150"
|
||||
onClick={onClick}
|
||||
>
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-16 h-16 bg-charcoal-outline rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
{logo ? (
|
||||
<img src={logo} alt={name} className="w-full h-full object-cover rounded-lg" />
|
||||
) : (
|
||||
<span className="text-2xl font-bold text-gray-500">
|
||||
{name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-white truncate">
|
||||
{name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
{memberCount} {memberCount === 1 ? 'member' : 'members'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{performanceLevel && (
|
||||
<div>
|
||||
<span
|
||||
className={`inline-block px-3 py-1 rounded-full text-xs font-medium ${
|
||||
performanceBadgeColors[performanceLevel]
|
||||
}`}
|
||||
>
|
||||
{performanceLevel.charAt(0).toUpperCase() + performanceLevel.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-gray-400">Active in:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{leagues.slice(0, 3).map((league, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-block px-2 py-1 bg-charcoal-outline text-gray-300 rounded text-xs"
|
||||
>
|
||||
{league}
|
||||
</span>
|
||||
))}
|
||||
{leagues.length > 3 && (
|
||||
<span className="inline-block px-2 py-1 bg-charcoal-outline text-gray-400 rounded text-xs">
|
||||
+{leagues.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
apps/website/components/teams/TeamRoster.tsx
Normal file
205
apps/website/components/teams/TeamRoster.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { getDriverRepository, getDriverStats } from '@/lib/di-container';
|
||||
import { EntityMappers, DriverDTO } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import { TeamMembership, TeamRole } from '@gridpilot/racing/application';
|
||||
|
||||
interface TeamRosterProps {
|
||||
teamId: string;
|
||||
memberships: TeamMembership[];
|
||||
isAdmin: boolean;
|
||||
onRemoveMember?: (driverId: string) => void;
|
||||
onChangeRole?: (driverId: string, newRole: TeamRole) => void;
|
||||
}
|
||||
|
||||
export default function TeamRoster({
|
||||
teamId,
|
||||
memberships,
|
||||
isAdmin,
|
||||
onRemoveMember,
|
||||
onChangeRole,
|
||||
}: TeamRosterProps) {
|
||||
const [drivers, setDrivers] = useState<Record<string, DriverDTO>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sortBy, setSortBy] = useState<'role' | 'rating' | 'name'>('rating');
|
||||
|
||||
useEffect(() => {
|
||||
const loadDrivers = async () => {
|
||||
const driverRepo = getDriverRepository();
|
||||
const allDrivers = await driverRepo.findAll();
|
||||
const driverMap: Record<string, DriverDTO> = {};
|
||||
|
||||
for (const membership of memberships) {
|
||||
const driver = allDrivers.find(d => d.id === membership.driverId);
|
||||
if (driver) {
|
||||
const dto = EntityMappers.toDriverDTO(driver);
|
||||
if (dto) {
|
||||
driverMap[membership.driverId] = dto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setDrivers(driverMap);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
loadDrivers();
|
||||
}, [memberships]);
|
||||
|
||||
const getRoleBadgeColor = (role: TeamRole) => {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return 'bg-warning-amber/20 text-warning-amber';
|
||||
case 'manager':
|
||||
return 'bg-primary-blue/20 text-primary-blue';
|
||||
default:
|
||||
return 'bg-charcoal-outline text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleLabel = (role: TeamRole) => {
|
||||
return role.charAt(0).toUpperCase() + role.slice(1);
|
||||
};
|
||||
|
||||
const sortedMemberships = [...memberships].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'rating': {
|
||||
const statsA = getDriverStats(a.driverId);
|
||||
const statsB = getDriverStats(b.driverId);
|
||||
return (statsB?.rating || 0) - (statsA?.rating || 0);
|
||||
}
|
||||
case 'role': {
|
||||
const roleOrder = { owner: 0, manager: 1, driver: 2 };
|
||||
return roleOrder[a.role] - roleOrder[b.role];
|
||||
}
|
||||
case 'name': {
|
||||
const driverA = drivers[a.driverId];
|
||||
const driverB = drivers[b.driverId];
|
||||
return (driverA?.name || '').localeCompare(driverB?.name || '');
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const teamAverageRating = memberships.length > 0
|
||||
? Math.round(
|
||||
memberships.reduce((sum, m) => {
|
||||
const stats = getDriverStats(m.driverId);
|
||||
return sum + (stats?.rating || 0);
|
||||
}, 0) / memberships.length
|
||||
)
|
||||
: 0;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-8 text-gray-400">Loading roster...</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-white">Team Roster</h3>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{memberships.length} {memberships.length === 1 ? 'member' : 'members'} • Avg Rating: <span className="text-primary-blue font-medium">{teamAverageRating}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-gray-400">Sort by:</label>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
className="px-3 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||
>
|
||||
<option value="rating">Rating</option>
|
||||
<option value="role">Role</option>
|
||||
<option value="name">Name</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{sortedMemberships.map((membership) => {
|
||||
const driver = drivers[membership.driverId];
|
||||
const driverStats = getDriverStats(membership.driverId);
|
||||
if (!driver) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={membership.driverId}
|
||||
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-charcoal-outline/60 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="w-12 h-12 rounded-full bg-primary-blue/20 flex items-center justify-center text-lg font-bold text-white">
|
||||
{driver.name.charAt(0)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-white font-medium">{driver.name}</h4>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${getRoleBadgeColor(membership.role)}`}>
|
||||
{getRoleLabel(membership.role)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">
|
||||
{driver.country} • Joined {new Date(membership.joinedAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{driverStats && (
|
||||
<div className="flex items-center gap-6 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-bold text-primary-blue">{driverStats.rating}</div>
|
||||
<div className="text-xs text-gray-400">Rating</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-300">#{driverStats.overallRank}</div>
|
||||
<div className="text-xs text-gray-500">Rank</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAdmin && membership.role !== 'owner' && (
|
||||
<div className="flex items-center gap-2">
|
||||
{onChangeRole && (
|
||||
<select
|
||||
className="px-3 py-2 bg-iron-gray border-0 rounded text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm"
|
||||
value={membership.role}
|
||||
onChange={(e) => onChangeRole(membership.driverId, e.target.value as TeamRole)}
|
||||
>
|
||||
<option value="driver">Driver</option>
|
||||
<option value="manager">Manager</option>
|
||||
</select>
|
||||
)}
|
||||
|
||||
{onRemoveMember && (
|
||||
<button
|
||||
onClick={() => onRemoveMember(membership.driverId)}
|
||||
className="px-3 py-2 bg-danger-red/20 hover:bg-danger-red/30 text-danger-red rounded text-sm font-medium transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{memberships.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
No team members yet.
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
135
apps/website/components/teams/TeamStandings.tsx
Normal file
135
apps/website/components/teams/TeamStandings.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { getStandingRepository, getLeagueRepository } from '@/lib/di-container';
|
||||
import { EntityMappers, LeagueDTO } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import { getTeamMembers } from '@gridpilot/racing/application';
|
||||
|
||||
interface TeamStandingsProps {
|
||||
teamId: string;
|
||||
leagues: string[];
|
||||
}
|
||||
|
||||
interface TeamLeagueStanding {
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
position: number;
|
||||
points: number;
|
||||
wins: number;
|
||||
racesCompleted: number;
|
||||
}
|
||||
|
||||
export default function TeamStandings({ teamId, leagues }: TeamStandingsProps) {
|
||||
const [standings, setStandings] = useState<TeamLeagueStanding[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadStandings = async () => {
|
||||
const standingRepo = getStandingRepository();
|
||||
const leagueRepo = getLeagueRepository();
|
||||
const members = getTeamMembers(teamId);
|
||||
const memberIds = members.map(m => m.driverId);
|
||||
|
||||
const teamStandings: TeamLeagueStanding[] = [];
|
||||
|
||||
for (const leagueId of leagues) {
|
||||
const league = await leagueRepo.findById(leagueId);
|
||||
if (!league) continue;
|
||||
|
||||
const leagueStandings = await standingRepo.findByLeagueId(leagueId);
|
||||
|
||||
// Calculate team points (sum of all team members)
|
||||
let totalPoints = 0;
|
||||
let totalWins = 0;
|
||||
let totalRaces = 0;
|
||||
|
||||
for (const standing of leagueStandings) {
|
||||
if (memberIds.includes(standing.driverId)) {
|
||||
totalPoints += standing.points;
|
||||
totalWins += standing.wins;
|
||||
totalRaces = Math.max(totalRaces, standing.racesCompleted);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate team position (simplified - based on total points)
|
||||
const allTeamPoints = leagueStandings
|
||||
.filter(s => memberIds.includes(s.driverId))
|
||||
.reduce((sum, s) => sum + s.points, 0);
|
||||
|
||||
const position = leagueStandings
|
||||
.filter((_, idx, arr) => {
|
||||
const teamPoints = arr
|
||||
.filter(s => memberIds.includes(s.driverId))
|
||||
.reduce((sum, s) => sum + s.points, 0);
|
||||
return teamPoints > allTeamPoints;
|
||||
}).length + 1;
|
||||
|
||||
teamStandings.push({
|
||||
leagueId,
|
||||
leagueName: league.name,
|
||||
position,
|
||||
points: totalPoints,
|
||||
wins: totalWins,
|
||||
racesCompleted: totalRaces,
|
||||
});
|
||||
}
|
||||
|
||||
setStandings(teamStandings);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
loadStandings();
|
||||
}, [teamId, leagues]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-8 text-gray-400">Loading standings...</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h3 className="text-xl font-semibold text-white mb-6">League Standings</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{standings.map((standing) => (
|
||||
<div
|
||||
key={standing.leagueId}
|
||||
className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-white font-medium">{standing.leagueName}</h4>
|
||||
<span className="px-3 py-1 bg-primary-blue/20 text-primary-blue rounded-full text-sm font-semibold">
|
||||
P{standing.position}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">{standing.points}</div>
|
||||
<div className="text-xs text-gray-400">Points</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">{standing.wins}</div>
|
||||
<div className="text-xs text-gray-400">Wins</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">{standing.racesCompleted}</div>
|
||||
<div className="text-xs text-gray-400">Races</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{standings.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
No standings available yet.
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user