Allow commissioners to exist without owning a team
- Add opt-out checkbox on league creation ("I want to play in this league")
so the creator can be commissioner-only without claiming a team
- Add Commissioner Management card to league settings with add/remove
commissioner UI; guards against removing the last commissioner
- Add countCommissionersByLeagueId model helper for the last-commissioner guard
- Show "No team" indicator on the league homepage next to commissioners
who don't own a team in the current season
https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3
This commit is contained in:
parent
7cf6fb6d18
commit
e1114e1d1a
4 changed files with 176 additions and 5 deletions
|
|
@ -50,6 +50,16 @@ export async function isCommissioner(
|
||||||
return !!commissioner;
|
return !!commissioner;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function countCommissionersByLeagueId(
|
||||||
|
leagueId: string
|
||||||
|
): Promise<number> {
|
||||||
|
const db = database();
|
||||||
|
const commissioners = await db.query.commissioners.findMany({
|
||||||
|
where: eq(schema.commissioners.leagueId, leagueId),
|
||||||
|
});
|
||||||
|
return commissioners.length;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteCommissioner(id: string): Promise<void> {
|
export async function deleteCommissioner(id: string): Promise<void> {
|
||||||
const db = database();
|
const db = database();
|
||||||
await db.delete(schema.commissioners).where(eq(schema.commissioners.id, id));
|
await db.delete(schema.commissioners).where(eq(schema.commissioners.id, id));
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,13 @@ import { getAuth } from "@clerk/react-router/server";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { CalendarIcon } from "lucide-react";
|
import { CalendarIcon } from "lucide-react";
|
||||||
import { findLeagueById, updateLeague, deleteLeague } from "~/models/league";
|
import { findLeagueById, updateLeague, deleteLeague } from "~/models/league";
|
||||||
import { isCommissioner } from "~/models/commissioner";
|
import {
|
||||||
|
isCommissioner,
|
||||||
|
findCommissionersByLeagueId,
|
||||||
|
createCommissioner,
|
||||||
|
countCommissionersByLeagueId,
|
||||||
|
removeCommissionerByLeagueAndUser,
|
||||||
|
} from "~/models/commissioner";
|
||||||
import { findCurrentSeasonWithSports, updateSeason } from "~/models/season";
|
import { findCurrentSeasonWithSports, updateSeason } from "~/models/season";
|
||||||
import { findTeamsBySeasonId, createManyTeams, deleteTeam, removeTeamOwner, assignTeamOwner } from "~/models/team";
|
import { findTeamsBySeasonId, createManyTeams, deleteTeam, removeTeamOwner, assignTeamOwner } from "~/models/team";
|
||||||
import { findAllUsers, findUserByClerkId, isUserAdminByClerkId } from "~/models/user";
|
import { findAllUsers, findUserByClerkId, isUserAdminByClerkId } from "~/models/user";
|
||||||
|
|
@ -92,8 +98,20 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
// Check if user is admin
|
// Check if user is admin
|
||||||
const isAdmin = await isUserAdminByClerkId(userId);
|
const isAdmin = await isUserAdminByClerkId(userId);
|
||||||
|
|
||||||
// Get all users if admin (for team assignment dropdown)
|
// Get all users (for commissioner and team assignment dropdowns)
|
||||||
const allUsers = isAdmin ? await findAllUsers() : [];
|
const allUsers = await findAllUsers();
|
||||||
|
|
||||||
|
// Get commissioners for this league with user info
|
||||||
|
const commissioners = await findCommissionersByLeagueId(leagueId);
|
||||||
|
const commissionerUserData = await Promise.all(
|
||||||
|
commissioners.map(async (c) => {
|
||||||
|
const user = await findUserByClerkId(c.userId);
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
userName: user?.username || user?.displayName || "Unknown User",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// Get owner details for teams
|
// Get owner details for teams
|
||||||
const ownerIds = teams
|
const ownerIds = teams
|
||||||
|
|
@ -125,6 +143,7 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
isAdmin,
|
isAdmin,
|
||||||
allUsers,
|
allUsers,
|
||||||
ownerMap: Object.fromEntries(ownerMap),
|
ownerMap: Object.fromEntries(ownerMap),
|
||||||
|
commissioners: commissionerUserData,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -471,11 +490,55 @@ export async function action(args: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (intent === "add-commissioner") {
|
||||||
|
const userClerkId = formData.get("userClerkId") as string;
|
||||||
|
|
||||||
|
if (!userClerkId) {
|
||||||
|
return { error: "User is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is already a commissioner
|
||||||
|
const alreadyCommissioner = await isCommissioner(leagueId, userClerkId);
|
||||||
|
if (alreadyCommissioner) {
|
||||||
|
return { error: "This user is already a commissioner" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createCommissioner({ leagueId, userId: userClerkId });
|
||||||
|
return { success: true, message: "Commissioner added successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error adding commissioner:", error);
|
||||||
|
return { error: "Failed to add commissioner. Please try again." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "remove-commissioner") {
|
||||||
|
const commissionerUserId = formData.get("commissionerUserId") as string;
|
||||||
|
|
||||||
|
if (!commissionerUserId) {
|
||||||
|
return { error: "User is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent removing the last commissioner
|
||||||
|
const count = await countCommissionersByLeagueId(leagueId);
|
||||||
|
if (count <= 1) {
|
||||||
|
return { error: "Cannot remove the last commissioner" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await removeCommissionerByLeagueAndUser(leagueId, commissionerUserId);
|
||||||
|
return { success: true, message: "Commissioner removed successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error removing commissioner:", error);
|
||||||
|
return { error: "Failed to remove commissioner. Please try again." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { error: "Invalid action" };
|
return { error: "Invalid action" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LeagueSettings({ loaderData, actionData }: Route.ComponentProps) {
|
export default function LeagueSettings({ loaderData, actionData }: Route.ComponentProps) {
|
||||||
const { league, season, teams, teamCount, teamsWithOwners, allSportsSeasons, draftSlots, isAdmin, allUsers, ownerMap } = loaderData;
|
const { league, season, teams, teamCount, teamsWithOwners, allSportsSeasons, draftSlots, isAdmin, allUsers, ownerMap, commissioners } = loaderData;
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
const [selectedSports, setSelectedSports] = useState<Set<string>>(
|
const [selectedSports, setSelectedSports] = useState<Set<string>>(
|
||||||
|
|
@ -1047,6 +1110,84 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Commissioner Management */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Commissioner Management</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Commissioners can manage league settings and oversee the draft. They do not need to own a team.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{commissioners.map((commissioner: any) => (
|
||||||
|
<div
|
||||||
|
key={commissioner.id}
|
||||||
|
className="flex items-center justify-between p-3 border rounded-md"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium">{commissioner.userName}</p>
|
||||||
|
{!teams.some((t: any) => t.ownerId === commissioner.userId) && (
|
||||||
|
<p className="text-xs text-muted-foreground">No team in current season</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{commissioners.length > 1 && (
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="remove-commissioner" />
|
||||||
|
<input type="hidden" name="commissionerUserId" value={commissioner.userId} />
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={navigation.state === "submitting"}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<p className="text-sm font-medium mb-3">Add Commissioner</p>
|
||||||
|
<Form method="post" className="flex gap-2">
|
||||||
|
<input type="hidden" name="intent" value="add-commissioner" />
|
||||||
|
<Select name="userClerkId" required>
|
||||||
|
<SelectTrigger className="flex-1">
|
||||||
|
<SelectValue placeholder="Select a user" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{allUsers.map((user: any) => {
|
||||||
|
const alreadyCommissioner = commissioners.some(
|
||||||
|
(c: any) => c.userId === user.clerkId
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<SelectItem
|
||||||
|
key={user.id}
|
||||||
|
value={user.clerkId}
|
||||||
|
disabled={alreadyCommissioner}
|
||||||
|
>
|
||||||
|
{user.username || user.displayName || user.email}
|
||||||
|
{alreadyCommissioner && " (already commissioner)"}
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={navigation.state === "submitting"}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Danger Zone */}
|
{/* Danger Zone */}
|
||||||
<Card className="border-destructive">
|
<Card className="border-destructive">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
|
||||||
|
|
@ -373,6 +373,9 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{commissioners.map((commissioner) => {
|
{commissioners.map((commissioner) => {
|
||||||
const commissionerName = commissionerMap[commissioner.userId];
|
const commissionerName = commissionerMap[commissioner.userId];
|
||||||
|
const hasTeam = teams.some(
|
||||||
|
(t) => t.ownerId === commissioner.userId
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={commissioner.id}
|
key={commissioner.id}
|
||||||
|
|
@ -386,6 +389,11 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
{!hasTeam && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
No team
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -194,11 +194,12 @@ export async function action(args: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create teams with fun random names
|
// Create teams with fun random names
|
||||||
|
const joinAsPlayer = formData.get("joinAsPlayer") === "on";
|
||||||
const teamNames = generateUniqueTeamNames(teamCountNum);
|
const teamNames = generateUniqueTeamNames(teamCountNum);
|
||||||
const teams = teamNames.map((name, i) => ({
|
const teams = teamNames.map((name, i) => ({
|
||||||
seasonId: season.id,
|
seasonId: season.id,
|
||||||
name,
|
name,
|
||||||
ownerId: i === 0 ? userId : null, // Assign first team to creator
|
ownerId: joinAsPlayer && i === 0 ? userId : null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await createManyTeams(teams);
|
await createManyTeams(teams);
|
||||||
|
|
@ -485,6 +486,17 @@ export default function NewLeague({ loaderData, actionData }: Route.ComponentPro
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="joinAsPlayer"
|
||||||
|
name="joinAsPlayer"
|
||||||
|
defaultChecked={true}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="joinAsPlayer" className="font-normal cursor-pointer">
|
||||||
|
I want to play in this league (claim a team)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{actionData?.error && (
|
{actionData?.error && (
|
||||||
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||||||
{actionData.error}
|
{actionData.error}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue