adds access modifiers for each role
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useMockStore } from '../data/mockStore';
|
||||
import { Trophy, TrendingUp, DollarSign, Target, Calendar, Medal, ArrowUpRight, Crown } from 'lucide-react';
|
||||
import { Trophy, TrendingUp, DollarSign, Target, Calendar, Medal, ArrowUpRight, Crown, ShieldOff } from 'lucide-react';
|
||||
import { SpotlightCard } from '../components/SpotlightCard';
|
||||
import AnimatedNumber from '../components/AnimatedNumber';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
|
||||
const LeaderboardPage = () => {
|
||||
const { users, salesHistory } = useMockStore();
|
||||
const { can } = usePermissions();
|
||||
const [timeframe, setTimeframe] = useState('month'); // 'week', 'month', 'custom'
|
||||
const [metric, setMetric] = useState('revenue'); // 'revenue', 'volume', 'winRate'
|
||||
|
||||
@@ -108,6 +110,20 @@ const LeaderboardPage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
if (!can('leaderboard', 'view')) {
|
||||
return (
|
||||
<div className="min-h-full flex items-center justify-center p-8">
|
||||
<div className="text-center space-y-4 max-w-md">
|
||||
<div className="mx-auto w-16 h-16 rounded-2xl bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center">
|
||||
<ShieldOff size={28} className="text-zinc-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-zinc-900 dark:text-white">Access Restricted</h2>
|
||||
<p className="text-sm text-zinc-500">You don't have permission to view the Leaderboard. Contact your org admin for access.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-full p-8 max-w-7xl mx-auto space-y-8 pb-20">
|
||||
{/* Header */}
|
||||
|
||||
+20
-10
@@ -3,9 +3,11 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
import { useMockStore } from '../data/mockStore';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import PermissionGate from '../components/PermissionGate';
|
||||
import {
|
||||
Plus, Search, MapPin, Phone, Zap, FileText,
|
||||
Clock, User, ChevronRight, Filter,
|
||||
Clock, User, ChevronRight, Filter, Lock,
|
||||
} from 'lucide-react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -183,6 +185,7 @@ function EmptyState({ isDark, onNewLead }) {
|
||||
export default function LeadsListPage() {
|
||||
const { theme } = useTheme();
|
||||
const { leads } = useMockStore();
|
||||
const { can } = usePermissions();
|
||||
const navigate = useNavigate();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
@@ -214,15 +217,22 @@ export default function LeadsListPage() {
|
||||
{leads.length} total lead{leads.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/emp/fa/leads/new')}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl font-bold text-sm uppercase tracking-widest text-white transition-all active:scale-[0.97] hover:opacity-90 shrink-0"
|
||||
style={{ backgroundColor: '#3B82F6' }}
|
||||
>
|
||||
<Plus size={14} />
|
||||
New Lead
|
||||
</button>
|
||||
{can('leads', 'log') ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/emp/fa/leads/new')}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl font-bold text-sm uppercase tracking-widest text-white transition-all active:scale-[0.97] hover:opacity-90 shrink-0"
|
||||
style={{ backgroundColor: '#3B82F6' }}
|
||||
>
|
||||
<Plus size={14} />
|
||||
New Lead
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 rounded-xl font-bold text-sm uppercase tracking-widest text-zinc-400 bg-zinc-200 dark:bg-zinc-800 cursor-not-allowed shrink-0" title="You don't have permission to create leads">
|
||||
<Lock size={14} />
|
||||
New Lead
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* --- Search bar --- */}
|
||||
|
||||
+329
-57
@@ -1,14 +1,16 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Settings, Building2, Shield, Users, DollarSign, ChevronDown, ChevronRight,
|
||||
Search, Check, X, Plus, Trash2, Edit3, Save, User, Crown,
|
||||
ToggleLeft, ToggleRight, Info, ArrowRight, Mail, Phone, MapPin, Clock,
|
||||
AlertTriangle, UserPlus, UserMinus, Eye, Pencil, Lock
|
||||
AlertTriangle, UserPlus, UserMinus, Eye, Pencil, Lock,
|
||||
RotateCcw, Filter, CheckSquare, Square, BarChart3, History
|
||||
} from 'lucide-react';
|
||||
import { useMockStore } from '../../data/mockStore';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { toast } from 'sonner';
|
||||
import { computeRolePermissionStats } from '../../utils/permissions';
|
||||
|
||||
const COMMISSION_TYPES = [
|
||||
{ value: 'flat', label: 'Flat Amount', hint: 'Fixed dollar amount' },
|
||||
@@ -225,35 +227,213 @@ const RoleManagementSection = ({ roles, members }) => {
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────
|
||||
// 3. Access Control Matrix
|
||||
// 3. Access Control Matrix (Full)
|
||||
// ────────────────────────────────────────────────────
|
||||
const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }) => {
|
||||
const togglePermission = (moduleKey, roleKey, action) => {
|
||||
const current = permissions[moduleKey]?.[roleKey] || [];
|
||||
const updated = current.includes(action)
|
||||
? current.filter(a => a !== action)
|
||||
: [...current, action];
|
||||
onUpdate(moduleKey, roleKey, updated);
|
||||
};
|
||||
const [filterModule, setFilterModule] = useState('all');
|
||||
const [filterRole, setFilterRole] = useState('all');
|
||||
const [changeLog, setChangeLog] = useState([]);
|
||||
const [showLog, setShowLog] = useState(false);
|
||||
const snapshotRef = useRef(JSON.stringify(permissions));
|
||||
|
||||
const moduleEntries = Object.entries(permissionModules);
|
||||
const moduleEntries = useMemo(() => {
|
||||
const entries = Object.entries(permissionModules);
|
||||
if (filterModule === 'all') return entries;
|
||||
return entries.filter(([key]) => key === filterModule);
|
||||
}, [permissionModules, filterModule]);
|
||||
|
||||
const visibleRoles = useMemo(() => {
|
||||
if (filterRole === 'all') return roles;
|
||||
return roles.filter(r => r.key === filterRole);
|
||||
}, [roles, filterRole]);
|
||||
|
||||
const isDirty = JSON.stringify(permissions) !== snapshotRef.current;
|
||||
|
||||
const togglePermission = useCallback((moduleKey, roleKey, action) => {
|
||||
const current = permissions[moduleKey]?.[roleKey] || [];
|
||||
const has = current.includes(action);
|
||||
const updated = has ? current.filter(a => a !== action) : [...current, action];
|
||||
onUpdate(moduleKey, roleKey, updated);
|
||||
|
||||
const modLabel = permissionModules[moduleKey]?.label || moduleKey;
|
||||
const roleName = roles.find(r => r.key === roleKey)?.name || roleKey;
|
||||
setChangeLog(prev => [
|
||||
{ id: Date.now(), module: modLabel, role: roleName, action, granted: !has, time: new Date().toLocaleTimeString() },
|
||||
...prev.slice(0, 49),
|
||||
]);
|
||||
}, [permissions, onUpdate, permissionModules, roles]);
|
||||
|
||||
const toggleAllForRole = useCallback((roleKey, grant) => {
|
||||
for (const [moduleKey, mod] of Object.entries(permissionModules)) {
|
||||
onUpdate(moduleKey, roleKey, grant ? [...mod.actions] : []);
|
||||
}
|
||||
const roleName = roles.find(r => r.key === roleKey)?.name || roleKey;
|
||||
toast.success(grant ? `All permissions granted for ${roleName}` : `All permissions revoked for ${roleName}`);
|
||||
}, [permissionModules, onUpdate, roles]);
|
||||
|
||||
const toggleAllForModule = useCallback((moduleKey, grant) => {
|
||||
const mod = permissionModules[moduleKey];
|
||||
if (!mod) return;
|
||||
for (const role of roles) {
|
||||
if (role.key === 'OWNER') continue;
|
||||
onUpdate(moduleKey, role.key, grant ? [...mod.actions] : []);
|
||||
}
|
||||
toast.success(grant ? `All roles granted access to ${mod.label}` : `All roles revoked from ${mod.label}`);
|
||||
}, [permissionModules, roles, onUpdate]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
const snap = JSON.parse(snapshotRef.current);
|
||||
for (const [moduleKey, roleMap] of Object.entries(snap)) {
|
||||
for (const [roleKey, actions] of Object.entries(roleMap)) {
|
||||
onUpdate(moduleKey, roleKey, actions);
|
||||
}
|
||||
}
|
||||
setChangeLog([]);
|
||||
toast.success('Permissions reset to last saved state');
|
||||
}, [onUpdate]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
snapshotRef.current = JSON.stringify(permissions);
|
||||
setChangeLog([]);
|
||||
toast.success('Access control permissions saved');
|
||||
}, [permissions]);
|
||||
|
||||
const roleStats = useMemo(() => {
|
||||
const map = {};
|
||||
for (const role of roles) {
|
||||
if (role.key === 'OWNER') {
|
||||
const total = Object.values(permissionModules).reduce((s, m) => s + m.actions.length, 0);
|
||||
map[role.key] = { granted: total, total };
|
||||
} else {
|
||||
map[role.key] = computeRolePermissionStats(role.key, permissions, permissionModules);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [roles, permissions, permissionModules]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-xs text-zinc-500">
|
||||
<Info size={12} /> Click cells to toggle permissions per role
|
||||
<div className="space-y-5">
|
||||
{/* ── Toolbar ── */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Module filter */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Filter size={12} className="text-zinc-400" />
|
||||
<select
|
||||
value={filterModule}
|
||||
onChange={e => setFilterModule(e.target.value)}
|
||||
className="bg-zinc-50 dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-1.5 text-xs text-zinc-700 dark:text-zinc-300 outline-none"
|
||||
>
|
||||
<option value="all">All Modules</option>
|
||||
{Object.entries(permissionModules).map(([key, mod]) => (
|
||||
<option key={key} value={key}>{mod.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* Role filter */}
|
||||
<select
|
||||
value={filterRole}
|
||||
onChange={e => setFilterRole(e.target.value)}
|
||||
className="bg-zinc-50 dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-lg px-3 py-1.5 text-xs text-zinc-700 dark:text-zinc-300 outline-none"
|
||||
>
|
||||
<option value="all">All Roles</option>
|
||||
{roles.map(r => (
|
||||
<option key={r.key} value={r.key}>{r.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Change log toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLog(p => !p)}
|
||||
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg border text-[10px] font-semibold transition-colors ${
|
||||
showLog
|
||||
? 'bg-blue-50 dark:bg-blue-500/10 border-blue-200 dark:border-blue-500/20 text-blue-600 dark:text-blue-400'
|
||||
: 'bg-zinc-50 dark:bg-white/[0.03] border-zinc-200 dark:border-white/10 text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<History size={10} />
|
||||
Log{changeLog.length > 0 && ` (${changeLog.length})`}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isDirty && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-white/10 text-xs font-semibold text-zinc-500 hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<RotateCcw size={11} /> Reset
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-emerald-100 dark:bg-emerald-500/20 border border-emerald-200 dark:border-emerald-500/30 text-xs font-bold text-emerald-700 dark:text-emerald-300 hover:bg-emerald-200 dark:hover:bg-emerald-500/30 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Save size={11} /> Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Matrix */}
|
||||
<div className="hidden lg:block overflow-x-auto">
|
||||
{/* ── Role Summary Stats ── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
|
||||
{roles.map(role => {
|
||||
const stats = roleStats[role.key];
|
||||
const pct = stats.total > 0 ? Math.round((stats.granted / stats.total) * 100) : 0;
|
||||
return (
|
||||
<div key={role.key} className="px-3 py-2.5 rounded-xl bg-zinc-50 dark:bg-white/[0.03] border border-zinc-200 dark:border-white/5">
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: role.color }} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-500">{role.name.split(' ')[0]}</span>
|
||||
</div>
|
||||
<div className="flex items-end justify-between">
|
||||
<span className="text-lg font-bold font-mono" style={{ color: role.color }}>{stats.granted}</span>
|
||||
<span className="text-[10px] text-zinc-400">/ {stats.total}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 h-1 rounded-full bg-zinc-200 dark:bg-white/10 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{ width: `${pct}%`, backgroundColor: role.color }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Desktop Matrix ── */}
|
||||
<div className="hidden lg:block overflow-x-auto rounded-xl border border-zinc-200 dark:border-white/[0.06]">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<tr className="bg-zinc-50/50 dark:bg-white/[0.02]">
|
||||
<th className="text-left text-[10px] font-bold uppercase tracking-wider text-zinc-500 px-4 py-3 border-b border-zinc-200 dark:border-white/5 w-48">Module</th>
|
||||
<th className="text-left text-[10px] font-bold uppercase tracking-wider text-zinc-500 px-4 py-3 border-b border-zinc-200 dark:border-white/5 w-28">Action</th>
|
||||
{roles.map(role => (
|
||||
<th key={role.key} className="text-center text-[10px] font-bold uppercase tracking-wider px-3 py-3 border-b border-zinc-200 dark:border-white/5 min-w-[90px]" style={{ color: role.color }}>
|
||||
{role.name.split(' ')[0]}
|
||||
{visibleRoles.map(role => (
|
||||
<th key={role.key} className="text-center text-[10px] font-bold uppercase tracking-wider px-3 py-3 border-b border-zinc-200 dark:border-white/5 min-w-[100px]">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span style={{ color: role.color }}>{role.name.split(' ')[0]}</span>
|
||||
{role.key !== 'OWNER' && (
|
||||
<div className="flex gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAllForRole(role.key, true)}
|
||||
title={`Grant all to ${role.name}`}
|
||||
className="p-0.5 rounded hover:bg-emerald-50 dark:hover:bg-emerald-500/10 text-zinc-400 hover:text-emerald-500 transition-colors"
|
||||
>
|
||||
<CheckSquare size={10} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAllForRole(role.key, false)}
|
||||
title={`Revoke all from ${role.name}`}
|
||||
className="p-0.5 rounded hover:bg-red-50 dark:hover:bg-red-500/10 text-zinc-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Square size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
@@ -267,14 +447,34 @@ const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }
|
||||
className="text-xs font-semibold text-zinc-900 dark:text-white px-4 py-2.5 border-b border-zinc-100 dark:border-white/5 align-top"
|
||||
rowSpan={module.actions.length}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield size={12} className="text-zinc-400 dark:text-zinc-600" />
|
||||
{module.label}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield size={12} className="text-zinc-400 dark:text-zinc-600" />
|
||||
{module.label}
|
||||
</div>
|
||||
<div className="flex gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAllForModule(moduleKey, true)}
|
||||
title={`Grant all roles for ${module.label}`}
|
||||
className="p-0.5 rounded hover:bg-emerald-50 dark:hover:bg-emerald-500/10 text-zinc-300 dark:text-zinc-700 hover:text-emerald-500 transition-colors"
|
||||
>
|
||||
<CheckSquare size={10} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAllForModule(moduleKey, false)}
|
||||
title={`Revoke all roles for ${module.label}`}
|
||||
className="p-0.5 rounded hover:bg-red-50 dark:hover:bg-red-500/10 text-zinc-300 dark:text-zinc-700 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Square size={10} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
<td className="text-[11px] text-zinc-500 dark:text-zinc-400 px-4 py-2.5 border-b border-zinc-100 dark:border-white/5 capitalize font-mono">{action}</td>
|
||||
{roles.map(role => {
|
||||
{visibleRoles.map(role => {
|
||||
const hasPermission = (permissions[moduleKey]?.[role.key] || []).includes(action);
|
||||
const isOwner = role.key === 'OWNER';
|
||||
return (
|
||||
@@ -289,13 +489,14 @@ const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => togglePermission(moduleKey, role.key, action)}
|
||||
className={`mx-auto w-6 h-6 rounded-md border flex items-center justify-center transition-all duration-150 ${
|
||||
className={`mx-auto w-7 h-7 rounded-lg border flex items-center justify-center transition-all duration-150 ${
|
||||
hasPermission
|
||||
? 'border-emerald-300 dark:border-emerald-500/30 bg-emerald-50 dark:bg-emerald-500/15 hover:bg-emerald-100 dark:hover:bg-emerald-500/25'
|
||||
? 'border-emerald-300 dark:border-emerald-500/30 bg-emerald-50 dark:bg-emerald-500/15 hover:bg-emerald-100 dark:hover:bg-emerald-500/25 shadow-sm shadow-emerald-500/10'
|
||||
: 'border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/[0.03] hover:bg-zinc-100 dark:hover:bg-white/[0.06]'
|
||||
}`}
|
||||
title={`${hasPermission ? 'Revoke' : 'Grant'} ${action} on ${module.label} for ${role.name}`}
|
||||
>
|
||||
{hasPermission && <Check size={12} className="text-emerald-500 dark:text-emerald-400" />}
|
||||
{hasPermission && <Check size={13} className="text-emerald-500 dark:text-emerald-400" strokeWidth={2.5} />}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
@@ -308,40 +509,111 @@ const AccessControlMatrix = ({ permissions, permissionModules, roles, onUpdate }
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile Cards */}
|
||||
{/* ── Mobile Cards ── */}
|
||||
<div className="lg:hidden space-y-3">
|
||||
{roles.filter(r => r.key !== 'OWNER').map(role => (
|
||||
<div key={role.key} className="rounded-xl border border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-white/[0.02] p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: role.color }} />
|
||||
<span className="text-sm font-bold text-zinc-900 dark:text-white">{role.name}</span>
|
||||
</div>
|
||||
{moduleEntries.map(([moduleKey, module]) => (
|
||||
<div key={moduleKey} className="space-y-1">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-500">{module.label}</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{module.actions.map(action => {
|
||||
const hasPermission = (permissions[moduleKey]?.[role.key] || []).includes(action);
|
||||
return (
|
||||
<button
|
||||
key={action}
|
||||
type="button"
|
||||
onClick={() => togglePermission(moduleKey, role.key, action)}
|
||||
className={`px-2.5 py-1 rounded-md text-[10px] font-semibold capitalize border transition-colors ${
|
||||
hasPermission
|
||||
? 'bg-emerald-50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20 text-emerald-600 dark:text-emerald-400'
|
||||
: 'bg-zinc-50 dark:bg-white/[0.03] border-zinc-200 dark:border-white/10 text-zinc-400 dark:text-zinc-600 hover:text-zinc-600 dark:hover:text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{action}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{visibleRoles.filter(r => r.key !== 'OWNER').map(role => {
|
||||
const stats = roleStats[role.key];
|
||||
return (
|
||||
<div key={role.key} className="rounded-xl border border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-white/[0.02] overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-zinc-50/50 dark:bg-white/[0.02] border-b border-zinc-100 dark:border-white/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: role.color }} />
|
||||
<span className="text-sm font-bold text-zinc-900 dark:text-white">{role.name}</span>
|
||||
<span className="text-[10px] font-mono text-zinc-400">{stats.granted}/{stats.total}</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button type="button" onClick={() => toggleAllForRole(role.key, true)} className="px-2 py-1 rounded-md text-[9px] font-bold bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-500/20">All</button>
|
||||
<button type="button" onClick={() => toggleAllForRole(role.key, false)} className="px-2 py-1 rounded-md text-[9px] font-bold bg-zinc-100 dark:bg-white/5 text-zinc-500 border border-zinc-200 dark:border-white/10">None</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="p-4 space-y-3">
|
||||
{moduleEntries.map(([moduleKey, module]) => (
|
||||
<div key={moduleKey} className="space-y-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-500">{module.label}</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{module.actions.map(action => {
|
||||
const hasPermission = (permissions[moduleKey]?.[role.key] || []).includes(action);
|
||||
return (
|
||||
<button
|
||||
key={action}
|
||||
type="button"
|
||||
onClick={() => togglePermission(moduleKey, role.key, action)}
|
||||
className={`px-2.5 py-1 rounded-md text-[10px] font-semibold capitalize border transition-colors ${
|
||||
hasPermission
|
||||
? 'bg-emerald-50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20 text-emerald-600 dark:text-emerald-400'
|
||||
: 'bg-zinc-50 dark:bg-white/[0.03] border-zinc-200 dark:border-white/10 text-zinc-400 dark:text-zinc-600 hover:text-zinc-600 dark:hover:text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{hasPermission && <Check size={8} className="inline mr-0.5 -mt-0.5" />}
|
||||
{action}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Change Log ── */}
|
||||
<AnimatePresence>
|
||||
{showLog && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="rounded-xl border border-blue-200 dark:border-blue-500/10 bg-blue-50/50 dark:bg-blue-500/[0.03] p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<History size={14} className="text-blue-500 dark:text-blue-400" />
|
||||
<span className="text-xs font-bold text-zinc-700 dark:text-white">Change Log</span>
|
||||
<span className="text-[10px] text-zinc-500">(unsaved session)</span>
|
||||
</div>
|
||||
{changeLog.length > 0 && (
|
||||
<button type="button" onClick={() => setChangeLog([])} className="text-[10px] text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300">Clear</button>
|
||||
)}
|
||||
</div>
|
||||
{changeLog.length === 0 ? (
|
||||
<p className="text-xs text-zinc-400 dark:text-zinc-600 italic">No changes yet</p>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto custom-scrollbar">
|
||||
{changeLog.map(entry => (
|
||||
<div key={entry.id} className="flex items-center gap-2 text-[11px] px-2 py-1 rounded-md bg-white/60 dark:bg-white/[0.03]">
|
||||
<span className="text-zinc-400 font-mono text-[9px] w-16 shrink-0">{entry.time}</span>
|
||||
<span className={`font-bold ${entry.granted ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-500 dark:text-red-400'}`}>
|
||||
{entry.granted ? '+' : '-'}
|
||||
</span>
|
||||
<span className="text-zinc-700 dark:text-zinc-300">
|
||||
<span className="font-semibold">{entry.role}</span>
|
||||
<span className="text-zinc-400 mx-1">/</span>
|
||||
<span>{entry.module}</span>
|
||||
<span className="text-zinc-400 mx-1">/</span>
|
||||
<span className="font-mono capitalize">{entry.action}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ── Info Footer ── */}
|
||||
<div className="p-4 bg-zinc-50 dark:bg-white/[0.02] rounded-xl border border-zinc-200 dark:border-white/5">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info size={14} className="text-zinc-400 dark:text-zinc-600 mt-0.5 shrink-0" />
|
||||
<div className="text-[11px] text-zinc-500 space-y-1">
|
||||
<p><span className="font-bold text-zinc-700 dark:text-zinc-400">Org Owner</span> always has full access to all modules and cannot be restricted.</p>
|
||||
<p>Permission changes apply immediately to all users with the affected role. Use the <span className="font-mono">Save</span> button to persist changes or <span className="font-mono">Reset</span> to revert.</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import ChangeOrderDrawer from '../../components/owner/ChangeOrderDrawer';
|
||||
import InvoiceDetailModal from '../../components/owner/InvoiceDetailModal';
|
||||
import CreateItemModal from '../../components/owner/CreateItemModal';
|
||||
import CommissionSettingsModal from '../../components/owner/CommissionSettingsModal';
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
|
||||
// --- NEOMORPHIC STYLING CONSTANTS ---
|
||||
const NEO_PANEL_CLASS = "dark:bg-zinc-900/60 dark:backdrop-blur-3xl border border-zinc-200 dark:border-white/5 dark:shadow-[8px_8px_16px_rgba(0,0,0,0.6),-8px_-8px_16px_rgba(255,255,255,0.02)] rounded-3xl relative overflow-hidden transition-all duration-300";
|
||||
@@ -141,6 +142,7 @@ const OwnerProjectDetail = () => {
|
||||
removeUserCommissionOverride, updateProject,
|
||||
} = useMockStore();
|
||||
const navigate = useNavigate();
|
||||
const { can } = usePermissions();
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [selectedCO, setSelectedCO] = useState(null);
|
||||
const [selectedInvoice, setSelectedInvoice] = useState(null);
|
||||
@@ -788,14 +790,16 @@ const OwnerProjectDetail = () => {
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setCommissionSettingsOpen(true); }}
|
||||
className="p-1.5 rounded-lg hover:bg-amber-500/10 transition-colors group"
|
||||
title="Commission Settings"
|
||||
>
|
||||
<Settings size={15} className="text-zinc-400 dark:text-zinc-500 group-hover:text-amber-500 dark:group-hover:text-[#fda913] transition-colors" />
|
||||
</button>
|
||||
{can('commission', 'edit') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setCommissionSettingsOpen(true); }}
|
||||
className="p-1.5 rounded-lg hover:bg-amber-500/10 transition-colors group"
|
||||
title="Commission Settings"
|
||||
>
|
||||
<Settings size={15} className="text-zinc-400 dark:text-zinc-500 group-hover:text-amber-500 dark:group-hover:text-[#fda913] transition-colors" />
|
||||
</button>
|
||||
)}
|
||||
<div className="relative">
|
||||
<button type="button" onClick={(e) => { e.stopPropagation(); setOpenTooltip(prev => prev === 'commission' ? null : 'commission'); }} className="p-1 rounded-lg hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors">
|
||||
<Info size={14} className="text-zinc-400 dark:text-zinc-500" />
|
||||
|
||||
Reference in New Issue
Block a user