340 lines
14 KiB
TypeScript
340 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState } from 'react';
|
|
import { ClubMember, ClubRankDefinition } from './types';
|
|
|
|
interface ClubHierarchyBuilderProps {
|
|
clubId: string;
|
|
ranks: ClubRankDefinition[];
|
|
members: ClubMember[];
|
|
selectedRankId: string | null;
|
|
onSelectRank: (rankId: string) => void;
|
|
onUpdateMemberRank: (memberUid: string, newRank: string) => Promise<void>;
|
|
canManageRanks: boolean;
|
|
}
|
|
|
|
export function ClubHierarchyBuilder({
|
|
ranks,
|
|
members,
|
|
selectedRankId,
|
|
onSelectRank,
|
|
onUpdateMemberRank,
|
|
canManageRanks,
|
|
}: ClubHierarchyBuilderProps) {
|
|
const [assignModalRank, setAssignModalRank] = useState<ClubRankDefinition | null>(null);
|
|
const [selectedMemberUid, setSelectedMemberUid] = useState<string>('');
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [actionMessage, setActionMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
|
|
|
|
// Group ranks into organizational tiers
|
|
const tier1 = ranks.filter((r) => r.level === 1);
|
|
const tier2 = ranks.filter((r) => r.level === 2);
|
|
const tier3 = ranks.filter((r) => r.level === 3 || r.level === 4);
|
|
const tier4 = ranks.filter((r) => r.level >= 5);
|
|
|
|
const getRidersForRank = (rankTitle: string) => {
|
|
return members.filter((m) => {
|
|
const currentRank = m.displayRank?.toLowerCase().trim();
|
|
const targetRank = rankTitle.toLowerCase().trim();
|
|
if (targetRank === 'president' && (m.role === 'owner' || currentRank === 'president' || currentRank === 'founder')) {
|
|
return true;
|
|
}
|
|
return currentRank === targetRank;
|
|
});
|
|
};
|
|
|
|
async function handleAssignMember() {
|
|
if (!assignModalRank || !selectedMemberUid) return;
|
|
setIsSubmitting(true);
|
|
setActionMessage(null);
|
|
|
|
try {
|
|
await onUpdateMemberRank(selectedMemberUid, assignModalRank.title);
|
|
setActionMessage({ text: `Assigned ${assignModalRank.title} rank successfully.`, type: 'success' });
|
|
setSelectedMemberUid('');
|
|
setTimeout(() => {
|
|
setAssignModalRank(null);
|
|
setActionMessage(null);
|
|
}, 1200);
|
|
} catch {
|
|
setActionMessage({ text: 'Failed to update member rank. Try again.', type: 'error' });
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
}
|
|
|
|
const renderRankNode = (rank: ClubRankDefinition) => {
|
|
const isSelected = selectedRankId === rank.id;
|
|
const assignedRiders = getRidersForRank(rank.title);
|
|
|
|
return (
|
|
<div
|
|
key={rank.id}
|
|
onClick={() => onSelectRank(rank.id)}
|
|
className={`group relative cursor-pointer rounded-xl p-4 transition-all duration-200 border ${
|
|
isSelected
|
|
? 'bg-surface-container-high/90 border-brand-cyan shadow-[0_0_15px_rgba(0,242,254,0.2)] ring-1 ring-brand-cyan/50'
|
|
: 'bg-surface-container/70 border-brand-border/60 hover:border-brand-border hover:bg-surface-container-high/60'
|
|
}`}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex items-center gap-2.5">
|
|
<div
|
|
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 border"
|
|
style={{
|
|
backgroundColor: rank.badgeBg,
|
|
borderColor: `${rank.badgeColor}40`,
|
|
color: rank.badgeColor,
|
|
}}
|
|
>
|
|
<span className="material-symbols-outlined text-[18px]">{rank.icon}</span>
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-headline font-bold text-sm text-white">{rank.title}</span>
|
|
<span
|
|
className="font-mono text-[9px] uppercase px-1.5 py-0.5 rounded font-semibold border"
|
|
style={{
|
|
backgroundColor: rank.badgeBg,
|
|
borderColor: `${rank.badgeColor}30`,
|
|
color: rank.badgeColor,
|
|
}}
|
|
>
|
|
Level {rank.level}
|
|
</span>
|
|
</div>
|
|
<span className="font-mono text-[10px] text-slate-400 capitalize">
|
|
{rank.category} • Default: {rank.defaultRole}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
<span className="px-2 py-0.5 rounded-full bg-slate-800/80 border border-slate-700/80 text-slate-300 font-mono text-[11px] font-medium">
|
|
{assignedRiders.length} {assignedRiders.length === 1 ? 'rider' : 'riders'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<p className="font-body text-xs text-slate-400 mt-2.5 line-clamp-2 leading-relaxed">
|
|
{rank.description}
|
|
</p>
|
|
|
|
{/* Assigned Riders Preview */}
|
|
{assignedRiders.length > 0 ? (
|
|
<div className="mt-3 pt-2.5 border-t border-brand-border/40 flex flex-wrap gap-1.5 items-center">
|
|
{assignedRiders.slice(0, 3).map((rider) => (
|
|
<span
|
|
key={rider.uid}
|
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-surface-container border border-brand-border/60 text-slate-300 text-[11px] font-mono"
|
|
>
|
|
<span className="w-1.5 h-1.5 rounded-full bg-brand-cyan" />
|
|
{rider.displayName || rider.handle || rider.uid.slice(0, 6)}
|
|
</span>
|
|
))}
|
|
{assignedRiders.length > 3 && (
|
|
<span className="text-[10px] font-mono text-slate-400">
|
|
+{assignedRiders.length - 3} more
|
|
</span>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="mt-3 pt-2.5 border-t border-brand-border/40 text-[11px] font-mono text-slate-400 italic">
|
|
No active riders currently assigned
|
|
</div>
|
|
)}
|
|
|
|
{/* Quick Assign Button */}
|
|
{canManageRanks && (
|
|
<div className="mt-3 flex justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setAssignModalRank(rank);
|
|
setActionMessage(null);
|
|
}}
|
|
className="inline-flex items-center gap-1 text-[11px] font-mono font-medium text-brand-cyan hover:text-white px-2 py-1 rounded bg-brand-cyan/10 hover:bg-brand-cyan/20 border border-brand-cyan/30 transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">person_add</span>
|
|
Assign Rider
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<section className="rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 space-y-6 shadow-lg">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border-b border-brand-border/60 pb-4">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="material-symbols-outlined text-brand-cyan text-[20px]">
|
|
account_tree
|
|
</span>
|
|
<h2 className="font-headline text-lg sm:text-xl font-bold text-white tracking-tight">
|
|
Club Hierarchy Builder
|
|
</h2>
|
|
</div>
|
|
<p className="font-body text-xs sm:text-sm text-slate-400 mt-1">
|
|
Visual organizational structure. Select any squad rank to view operational privileges or reassign riders.
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 self-start sm:self-center">
|
|
<span className="px-2.5 py-0.5 rounded-full bg-brand-cyan/10 border border-brand-cyan/30 text-brand-cyan font-mono text-[11px] font-semibold">
|
|
{ranks.length} RANKS
|
|
</span>
|
|
<span className="px-2.5 py-0.5 rounded-full bg-slate-800 border border-slate-700 text-slate-300 font-mono text-[11px]">
|
|
{members.length} MEMBERS
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Visual Hierarchy Tree Canvas */}
|
|
<div className="space-y-6">
|
|
{/* Tier 1: Executive Command */}
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<span className="font-mono text-xs text-amber-400 font-bold uppercase tracking-wider">
|
|
Tier 1 • Executive Command
|
|
</span>
|
|
<div className="flex-1 h-px bg-amber-400/20" />
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{tier1.map(renderRankNode)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tier 2: Field Leadership & Governance */}
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<span className="font-mono text-xs text-brand-cyan font-bold uppercase tracking-wider">
|
|
Tier 2 • Leadership & Administration
|
|
</span>
|
|
<div className="flex-1 h-px bg-brand-cyan/20" />
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{tier2.map(renderRankNode)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tier 3: Tactical Operations */}
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<span className="font-mono text-xs text-purple-400 font-bold uppercase tracking-wider">
|
|
Tier 3 • Tactical Operations & Pack Safety
|
|
</span>
|
|
<div className="flex-1 h-px bg-purple-400/20" />
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
{tier3.map(renderRankNode)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tier 4: Squad Formation */}
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<span className="font-mono text-xs text-slate-400 font-bold uppercase tracking-wider">
|
|
Tier 4 • Squad Formation & Prospects
|
|
</span>
|
|
<div className="flex-1 h-px bg-slate-400/20" />
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{tier4.map(renderRankNode)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Assign Member Modal */}
|
|
{assignModalRank && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-fade-in">
|
|
<div className="bg-surface-container border border-brand-border/80 rounded-2xl p-6 max-w-md w-full shadow-2xl space-y-4">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h3 className="font-headline font-bold text-lg text-white">
|
|
Assign to {assignModalRank.title}
|
|
</h3>
|
|
<p className="font-body text-xs text-slate-400 mt-0.5">
|
|
Select an active member to grant this honorary squad rank.
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setAssignModalRank(null)}
|
|
className="text-slate-400 hover:text-white p-1 rounded-lg hover:bg-surface-container-high transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined text-[20px]">close</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<label className="block font-mono text-xs text-slate-300">
|
|
Select Active Member
|
|
</label>
|
|
<select
|
|
value={selectedMemberUid}
|
|
onChange={(e) => setSelectedMemberUid(e.target.value)}
|
|
className="w-full bg-surface-container-high border border-brand-border/80 rounded-xl px-3 py-2.5 text-sm text-white font-body focus:outline-none focus:border-brand-cyan"
|
|
>
|
|
<option value="">-- Choose member --</option>
|
|
{members.map((m) => (
|
|
<option key={m.uid} value={m.uid}>
|
|
{m.displayName || m.handle || m.uid.slice(0, 8)} ({m.displayRank || m.role})
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
<div className="p-3 rounded-lg bg-surface-container-high/60 border border-brand-border/40 text-xs text-slate-400">
|
|
<span className="font-semibold text-white">Rank Notice:</span> Setting this rank updates the member's title displayed on the mobile app, Club Wall, and group rides.
|
|
</div>
|
|
|
|
{actionMessage && (
|
|
<div
|
|
className={`p-3 rounded-lg text-xs font-mono border ${
|
|
actionMessage.type === 'success'
|
|
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400'
|
|
: 'bg-rose-500/10 border-rose-500/30 text-rose-400'
|
|
}`}
|
|
>
|
|
{actionMessage.text}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-3 pt-3 border-t border-brand-border/60">
|
|
<button
|
|
type="button"
|
|
onClick={() => setAssignModalRank(null)}
|
|
className="px-4 py-2 rounded-xl text-xs font-body text-slate-400 hover:text-white hover:bg-surface-container-high transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={!selectedMemberUid || isSubmitting}
|
|
onClick={handleAssignMember}
|
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-headline font-bold bg-brand-cyan text-black hover:bg-brand-cyan/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
|
>
|
|
{isSubmitting ? (
|
|
<>
|
|
<span className="w-3.5 h-3.5 border-2 border-black border-t-transparent rounded-full animate-spin" />
|
|
Saving...
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="material-symbols-outlined text-[16px]">check</span>
|
|
Assign Rank
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|