189 lines
8.0 KiB
TypeScript
189 lines
8.0 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState } from 'react';
|
|
import { ClubCapability, ClubCapabilityDefinition, ClubRankDefinition } from './types';
|
|
|
|
interface ClubPermissionMatrixProps {
|
|
ranks: ClubRankDefinition[];
|
|
capabilities: ClubCapabilityDefinition[];
|
|
permissions: Record<string, Record<ClubCapability, boolean>>;
|
|
selectedRankId: string | null;
|
|
onTogglePermission: (rankId: string, capability: ClubCapability) => void;
|
|
onResetDefaults: () => void;
|
|
onSavePermissions: () => Promise<void>;
|
|
isSaving: boolean;
|
|
canEdit: boolean;
|
|
}
|
|
|
|
export function ClubPermissionMatrix({
|
|
ranks,
|
|
capabilities,
|
|
permissions,
|
|
selectedRankId,
|
|
onTogglePermission,
|
|
onResetDefaults,
|
|
onSavePermissions,
|
|
isSaving,
|
|
canEdit,
|
|
}: ClubPermissionMatrixProps) {
|
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
|
|
|
async function handleSave() {
|
|
await onSavePermissions();
|
|
setSaveSuccess(true);
|
|
setTimeout(() => setSaveSuccess(false), 2500);
|
|
}
|
|
|
|
return (
|
|
<section className="rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 space-y-6 shadow-lg">
|
|
{/* Header */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 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]">
|
|
tune
|
|
</span>
|
|
<h2 className="font-headline text-lg sm:text-xl font-bold text-white tracking-tight">
|
|
Operational Permission Matrix
|
|
</h2>
|
|
</div>
|
|
<p className="font-body text-xs sm:text-sm text-slate-400 mt-1">
|
|
Configure delegated operational privileges per squad rank. Administrative roles enforce backend limits.
|
|
</p>
|
|
</div>
|
|
|
|
{canEdit && (
|
|
<div className="flex items-center gap-2.5 self-start sm:self-center">
|
|
<button
|
|
type="button"
|
|
onClick={onResetDefaults}
|
|
className="px-3 py-1.5 rounded-xl text-xs font-mono text-slate-400 hover:text-white bg-surface-container hover:bg-surface-container-high border border-brand-border/60 transition-colors"
|
|
>
|
|
Reset Defaults
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={isSaving}
|
|
onClick={handleSave}
|
|
className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-xl text-xs font-headline font-bold bg-brand-cyan text-black hover:bg-brand-cyan/90 disabled:opacity-50 transition-all shadow-[0_0_12px_rgba(0,242,254,0.3)]"
|
|
>
|
|
{isSaving ? (
|
|
<>
|
|
<span className="w-3.5 h-3.5 border-2 border-black border-t-transparent rounded-full animate-spin" />
|
|
Saving...
|
|
</>
|
|
) : saveSuccess ? (
|
|
<>
|
|
<span className="material-symbols-outlined text-[16px]">check_circle</span>
|
|
Saved
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="material-symbols-outlined text-[16px]">save</span>
|
|
Save Matrix
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Desktop / Tablet Matrix Table */}
|
|
<div className="overflow-x-auto rounded-xl border border-brand-border/60 bg-surface-container/60">
|
|
<table className="w-full text-left border-collapse min-w-[720px]">
|
|
<thead>
|
|
<tr className="border-b border-brand-border/80 bg-surface-container-high/80 text-[11px] font-mono text-slate-400 uppercase tracking-wider">
|
|
<th className="py-3 px-4 font-semibold w-48">Squad Rank</th>
|
|
{capabilities.map((cap) => (
|
|
<th key={cap.id} className="py-3 px-3 font-semibold text-center">
|
|
<div className="flex flex-col items-center">
|
|
<span>{cap.label}</span>
|
|
<span className="text-[9px] text-slate-400 font-normal lowercase tracking-normal">
|
|
{cap.category}
|
|
</span>
|
|
</div>
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-brand-border/40 font-body text-xs">
|
|
{ranks.map((rank) => {
|
|
const isSelected = selectedRankId === rank.id;
|
|
const rankPerms = permissions[rank.id] || {};
|
|
|
|
return (
|
|
<tr
|
|
key={rank.id}
|
|
className={`transition-colors ${
|
|
isSelected
|
|
? 'bg-brand-cyan/5 border-l-2 border-l-brand-cyan'
|
|
: 'hover:bg-surface-container-high/40'
|
|
}`}
|
|
>
|
|
<td className="py-3.5 px-4 font-medium">
|
|
<div className="flex items-center gap-2.5">
|
|
<div
|
|
className="w-6 h-6 rounded 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-[14px]">{rank.icon}</span>
|
|
</div>
|
|
<div>
|
|
<div className="font-headline font-bold text-white text-xs">{rank.title}</div>
|
|
<div className="font-mono text-[10px] text-slate-400">Level {rank.level}</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
|
|
{capabilities.map((cap) => {
|
|
const isGranted = rankPerms[cap.id] ?? false;
|
|
const isImmutableOwner = rank.id === 'president';
|
|
|
|
return (
|
|
<td key={cap.id} className="py-3.5 px-3 text-center">
|
|
<button
|
|
type="button"
|
|
disabled={!canEdit || isImmutableOwner}
|
|
onClick={() => onTogglePermission(rank.id, cap.id)}
|
|
aria-label={`Toggle ${cap.label} for ${rank.title}`}
|
|
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none disabled:opacity-75 disabled:cursor-not-allowed ${
|
|
isGranted ? 'bg-brand-cyan' : 'bg-slate-700'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-black shadow ring-0 transition duration-200 ease-in-out ${
|
|
isGranted ? 'translate-x-4' : 'translate-x-0 bg-slate-400'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Legend & Architecture Note */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs font-mono text-slate-400">
|
|
<div className="p-3 rounded-xl bg-surface-container/60 border border-brand-border/40 flex items-center gap-2">
|
|
<span className="w-2.5 h-2.5 rounded-full bg-brand-cyan shrink-0" />
|
|
<span>Active Toggle: Rank delegates authority to lead and coordinate squad members.</span>
|
|
</div>
|
|
<div className="p-3 rounded-xl bg-surface-container/60 border border-brand-border/40 flex items-center gap-2">
|
|
<span className="material-symbols-outlined text-[16px] text-amber-400 shrink-0">
|
|
shield
|
|
</span>
|
|
<span>Software RBAC: Owner and Admin roles enforce immutable security boundaries in Firestore.</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|