Org settings module and user roles
This commit is contained in:
@@ -19,6 +19,7 @@ import VendorDirectory from './pages/owner/VendorDirectory';
|
||||
import DocumentManagement from './pages/owner/DocumentManagement';
|
||||
import OwnerProjectList from './pages/owner/OwnerProjectList';
|
||||
import OwnerProjectDetail from './pages/owner/OwnerProjectDetail';
|
||||
import OrgSettings from './pages/owner/OrgSettings';
|
||||
import PlaceholderDashboard from './pages/PlaceholderDashboard';
|
||||
import ProCanvas from './pages/ProCanvas';
|
||||
import ProjectList from './pages/contractor/ProjectList';
|
||||
@@ -213,6 +214,11 @@ function App() {
|
||||
<EstimateBuilder />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/owner/settings" element={
|
||||
<ProtectedRoute allowedRoles={['OWNER']}>
|
||||
<OrgSettings />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* Protected Admin Routes — Owner has full admin access */}
|
||||
<Route
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTheme } from '../context/ThemeContext';
|
||||
import {
|
||||
LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare,
|
||||
ChevronLeft, ChevronRight, Sun, Moon, Trophy, Users, Briefcase,
|
||||
FileText, Menu, X, Calculator, PlusCircle, ClipboardList, Zap, LayoutGrid
|
||||
FileText, Menu, X, Calculator, PlusCircle, ClipboardList, Zap, LayoutGrid, Settings
|
||||
} from 'lucide-react';
|
||||
|
||||
import PageTransition from './PageTransition';
|
||||
@@ -149,6 +149,7 @@ const Layout = () => {
|
||||
{ to: "/owner/estimates", icon: Calculator, label: "Estimates" },
|
||||
{ to: "/admin/schedule", icon: Calendar, label: "Team Schedule" },
|
||||
{ to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" },
|
||||
{ to: "/owner/settings", icon: Settings, label: "Org Settings" },
|
||||
...commonItems,
|
||||
];
|
||||
case 'ADMIN':
|
||||
|
||||
+130
-18
@@ -4971,6 +4971,104 @@ export const MockStoreProvider = ({ children }) => {
|
||||
{ userId: 'p2', userName: 'Sarah Sales', enabled: true, type: 'flat', rate: 2000 },
|
||||
]);
|
||||
|
||||
// ── Org Settings & Access Control ──
|
||||
const [orgSettings, setOrgSettings] = useState({
|
||||
orgName: 'LynkedUp Pro Roofing',
|
||||
orgEmail: 'info@lynkeduppro.com',
|
||||
orgPhone: '214-555-1000',
|
||||
orgAddress: '5800 Legacy Dr, Plano, TX 75024',
|
||||
timezone: 'America/Chicago',
|
||||
});
|
||||
|
||||
const [orgRoles, setOrgRoles] = useState([
|
||||
{
|
||||
id: 'role_owner',
|
||||
name: 'Org Owner',
|
||||
key: 'OWNER',
|
||||
description: 'Full access to all modules, settings, billing, and access control',
|
||||
isSystem: true,
|
||||
memberCount: 2,
|
||||
color: '#fda913',
|
||||
},
|
||||
{
|
||||
id: 'role_admin',
|
||||
name: 'Admin / Manager',
|
||||
key: 'ADMIN',
|
||||
description: 'Manage jobs, view financials, assign team, verify leads, configure commissions',
|
||||
isSystem: true,
|
||||
memberCount: 3,
|
||||
color: '#3b82f6',
|
||||
},
|
||||
{
|
||||
id: 'role_sales',
|
||||
name: 'Sales Rep',
|
||||
key: 'SALES_REP',
|
||||
description: 'View/manage assigned jobs, verify canvasser leads, view own commission',
|
||||
isSystem: true,
|
||||
memberCount: 2,
|
||||
color: '#a855f7',
|
||||
},
|
||||
{
|
||||
id: 'role_canvasser',
|
||||
name: 'Canvasser',
|
||||
key: 'CANVASSER',
|
||||
description: 'Log door knocks, add leads, view leaderboard, view verified leads',
|
||||
isSystem: true,
|
||||
memberCount: 5,
|
||||
color: '#10b981',
|
||||
},
|
||||
{
|
||||
id: 'role_subcontractor',
|
||||
name: 'Subcontractor',
|
||||
key: 'SUBCONTRACTOR',
|
||||
description: 'Receive tasks, update status, reply to tasks, log expenses',
|
||||
isSystem: true,
|
||||
memberCount: 1,
|
||||
color: '#06b6d4',
|
||||
},
|
||||
]);
|
||||
|
||||
const [orgPermissions, setOrgPermissions] = useState({
|
||||
jobs: { OWNER: ['view','edit','delete'], ADMIN: ['view','edit','delete'], SALES_REP: ['view','edit'], CANVASSER: [], SUBCONTRACTOR: [] },
|
||||
financials: { OWNER: ['view','edit'], ADMIN: ['view','edit'], SALES_REP: ['view'], CANVASSER: [], SUBCONTRACTOR: [] },
|
||||
commission: { OWNER: ['view','edit'], ADMIN: ['view','edit'], SALES_REP: ['view'], CANVASSER: [], SUBCONTRACTOR: [] },
|
||||
team: { OWNER: ['view','invite','remove'], ADMIN: ['view','invite'], SALES_REP: ['view'], CANVASSER: [], SUBCONTRACTOR: [] },
|
||||
leads: { OWNER: ['view','log','verify'], ADMIN: ['view','log','verify'], SALES_REP: ['view','verify'], CANVASSER: ['view','log'], SUBCONTRACTOR: [] },
|
||||
leaderboard: { OWNER: ['view'], ADMIN: ['view'], SALES_REP: ['view'], CANVASSER: ['view'], SUBCONTRACTOR: [] },
|
||||
subcontractor_tasks: { OWNER: ['view','assign'], ADMIN: ['view','assign'], SALES_REP: [], CANVASSER: [], SUBCONTRACTOR: ['view','receive'] },
|
||||
});
|
||||
|
||||
const PERMISSION_MODULES = {
|
||||
jobs: { label: 'Jobs', actions: ['view','edit','delete'] },
|
||||
financials: { label: 'Financials', actions: ['view','edit'] },
|
||||
commission: { label: 'Commission Settings', actions: ['view','edit'] },
|
||||
team: { label: 'Team Members', actions: ['view','invite','remove'] },
|
||||
leads: { label: 'Leads & Door Knocks', actions: ['view','log','verify'] },
|
||||
leaderboard: { label: 'Leaderboard', actions: ['view'] },
|
||||
subcontractor_tasks: { label: 'Subcontractor Tasks', actions: ['view','assign','receive'] },
|
||||
};
|
||||
|
||||
const [roleCommissionOverrides, setRoleCommissionOverrides] = useState([
|
||||
{ roleKey: 'SALES_REP', enabled: true, type: 'percent_net', rate: 5 },
|
||||
{ roleKey: 'CANVASSER', enabled: true, type: 'flat', rate: 300 },
|
||||
]);
|
||||
|
||||
const [orgMembers, setOrgMembers] = useState([
|
||||
{ id: 'om_001', userId: 'owner_001', name: 'Justin Johnson', email: 'justin@lynkeduppro.com', roleKey: 'OWNER', status: 'active', joinedAt: '2024-01-15' },
|
||||
{ id: 'om_002', userId: 'owner_002', name: 'Maria Garcia', email: 'maria@lynkeduppro.com', roleKey: 'OWNER', status: 'active', joinedAt: '2024-01-15' },
|
||||
{ id: 'om_003', userId: 'ADM01', name: 'Admin One', email: 'admin1@lynkeduppro.com', roleKey: 'ADMIN', status: 'active', joinedAt: '2024-02-10' },
|
||||
{ id: 'om_004', userId: 'ADM02', name: 'Admin Two', email: 'admin2@lynkeduppro.com', roleKey: 'ADMIN', status: 'active', joinedAt: '2024-03-01' },
|
||||
{ id: 'om_005', userId: 'ADM03', name: 'Admin Three', email: 'admin3@lynkeduppro.com', roleKey: 'ADMIN', status: 'active', joinedAt: '2024-04-20' },
|
||||
{ id: 'om_006', userId: 'p1', name: 'Jesus Gonzales', email: 'jesus@lynkeduppro.com', roleKey: 'SALES_REP', status: 'active', joinedAt: '2024-03-15', individualCommission: { enabled: true, type: 'percent_gross', rate: 12 } },
|
||||
{ id: 'om_007', userId: 'p2', name: 'Sarah Sales', email: 'sarah@lynkeduppro.com', roleKey: 'SALES_REP', status: 'active', joinedAt: '2024-04-01', individualCommission: { enabled: true, type: 'flat', rate: 2000 } },
|
||||
{ id: 'om_008', userId: 'FA001', name: 'Frank Agent', email: 'agent1@plano.com', roleKey: 'CANVASSER', status: 'active', joinedAt: '2024-05-10' },
|
||||
{ id: 'om_009', userId: 'FA002', name: 'Fiona Field', email: 'agent2@plano.com', roleKey: 'CANVASSER', status: 'active', joinedAt: '2024-05-15' },
|
||||
{ id: 'om_010', userId: 'FA003', name: 'Fred Flyer', email: 'agent3@plano.com', roleKey: 'CANVASSER', status: 'active', joinedAt: '2024-06-01' },
|
||||
{ id: 'om_011', userId: 'FA004', name: 'Felicity Fast', email: 'agent4@plano.com', roleKey: 'CANVASSER', status: 'active', joinedAt: '2024-06-10' },
|
||||
{ id: 'om_012', userId: 'FA005', name: 'Felix Fixer', email: 'agent5@plano.com', roleKey: 'CANVASSER', status: 'active', joinedAt: '2024-07-01' },
|
||||
{ id: 'om_013', userId: 'sub_001', name: 'Carlos Subcontractor', email: 'carlos@electricpro.com', roleKey: 'SUBCONTRACTOR', status: 'active', joinedAt: '2024-08-01' },
|
||||
]);
|
||||
|
||||
// Initialize properties once
|
||||
useEffect(() => {
|
||||
const data = generateProperties();
|
||||
@@ -5048,8 +5146,6 @@ export const MockStoreProvider = ({ children }) => {
|
||||
users,
|
||||
meetings,
|
||||
salesHistory, // Exported for Leaderboard
|
||||
|
||||
// New Data
|
||||
owners,
|
||||
personnel,
|
||||
vendors,
|
||||
@@ -5057,7 +5153,6 @@ export const MockStoreProvider = ({ children }) => {
|
||||
projects,
|
||||
orders,
|
||||
vendorInvoices,
|
||||
|
||||
leads,
|
||||
addLead: (lead) => {
|
||||
const newLead = {
|
||||
@@ -5180,24 +5275,41 @@ export const MockStoreProvider = ({ children }) => {
|
||||
setProjects(prev => prev.map(p => p.id === projectId ? { ...p, ...data } : p));
|
||||
},
|
||||
|
||||
// Team Management (job-scoped)
|
||||
addJobTeamMember: (projectId, member) => {
|
||||
setProjects(prev => prev.map(p => {
|
||||
if (p.id !== projectId) return p;
|
||||
return { ...p, teamMembers: [...(p.teamMembers || []), { ...member, id: `jtm_${Date.now()}_${Math.random().toString(36).slice(2, 6)}` }] };
|
||||
// Org Settings & Access Control
|
||||
orgSettings,
|
||||
updateOrgSettings: (data) => setOrgSettings(prev => ({ ...prev, ...data })),
|
||||
orgRoles,
|
||||
updateOrgRole: (roleId, data) => setOrgRoles(prev => prev.map(r => r.id === roleId ? { ...r, ...data } : r)),
|
||||
orgPermissions,
|
||||
PERMISSION_MODULES,
|
||||
updateOrgPermissions: (moduleKey, roleKey, actions) => {
|
||||
setOrgPermissions(prev => ({
|
||||
...prev,
|
||||
[moduleKey]: { ...prev[moduleKey], [roleKey]: actions },
|
||||
}));
|
||||
},
|
||||
removeJobTeamMember: (projectId, memberId) => {
|
||||
setProjects(prev => prev.map(p => {
|
||||
if (p.id !== projectId) return p;
|
||||
return { ...p, teamMembers: (p.teamMembers || []).filter(m => m.id !== memberId) };
|
||||
}));
|
||||
roleCommissionOverrides,
|
||||
setRoleCommissionOverride: (override) => {
|
||||
setRoleCommissionOverrides(prev => {
|
||||
const idx = prev.findIndex(o => o.roleKey === override.roleKey);
|
||||
if (idx >= 0) return prev.map((o, i) => i === idx ? { ...o, ...override } : o);
|
||||
return [...prev, override];
|
||||
});
|
||||
},
|
||||
updateJobTeamMember: (projectId, memberId, updates) => {
|
||||
setProjects(prev => prev.map(p => {
|
||||
if (p.id !== projectId) return p;
|
||||
return { ...p, teamMembers: (p.teamMembers || []).map(m => m.id === memberId ? { ...m, ...updates } : m) };
|
||||
}));
|
||||
removeRoleCommissionOverride: (roleKey) => {
|
||||
setRoleCommissionOverrides(prev => prev.filter(o => o.roleKey !== roleKey));
|
||||
},
|
||||
orgMembers,
|
||||
updateOrgMember: (memberId, data) => {
|
||||
setOrgMembers(prev => prev.map(m => m.id === memberId ? { ...m, ...data } : m));
|
||||
},
|
||||
addOrgMember: (member) => {
|
||||
const newMember = { ...member, id: `om_${Date.now()}`, joinedAt: new Date().toISOString().slice(0, 10) };
|
||||
setOrgMembers(prev => [...prev, newMember]);
|
||||
return newMember;
|
||||
},
|
||||
removeOrgMember: (memberId) => {
|
||||
setOrgMembers(prev => prev.filter(m => m.id !== memberId));
|
||||
},
|
||||
}}>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,860 @@
|
||||
import React, { useState, useMemo } 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
|
||||
} from 'lucide-react';
|
||||
import { useMockStore } from '../../data/mockStore';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const COMMISSION_TYPES = [
|
||||
{ value: 'flat', label: 'Flat Amount', hint: 'Fixed dollar amount' },
|
||||
{ value: 'percent_gross', label: '% of Gross', hint: '% of Total Received' },
|
||||
{ value: 'percent_net', label: '% of Net', hint: '% of Gross Profit' },
|
||||
{ value: 'custom', label: 'Custom / Manual', hint: 'Manual entry' },
|
||||
];
|
||||
|
||||
const formatCurrency = (amt) =>
|
||||
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(Number(amt) || 0);
|
||||
|
||||
const formatCommission = (type, rate) => {
|
||||
if (type === 'percent_gross' || type === 'percent_net') return `${rate}%`;
|
||||
return formatCurrency(rate);
|
||||
};
|
||||
|
||||
const getCommissionLabel = (type) =>
|
||||
COMMISSION_TYPES.find(c => c.value === type)?.label || type;
|
||||
|
||||
// ────────────────────────────────────────────────────
|
||||
// Section Wrapper
|
||||
// ────────────────────────────────────────────────────
|
||||
const SettingsSection = ({ id, icon: Icon, title, subtitle, color, isOpen, onToggle, children }) => (
|
||||
<div className="rounded-2xl border border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-white/[0.02] overflow-hidden shadow-sm dark:shadow-none">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center justify-between px-6 py-5 hover:bg-zinc-50 dark:hover:bg-white/[0.02] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2.5 rounded-xl" style={{ backgroundColor: `${color}15` }}>
|
||||
<Icon size={20} style={{ color }} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h2 className="text-base font-bold text-zinc-900 dark:text-white tracking-wide">{title}</h2>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={18}
|
||||
className="text-zinc-400 dark:text-zinc-500 transition-transform duration-200"
|
||||
style={{ transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||
/>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-6 pb-6 pt-2 border-t border-zinc-100 dark:border-white/5">
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────
|
||||
// 1. Organization Info Section
|
||||
// ────────────────────────────────────────────────────
|
||||
const OrgInfoSection = ({ orgSettings, onSave }) => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [local, setLocal] = useState({ ...orgSettings });
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(local);
|
||||
setEditing(false);
|
||||
toast.success('Organization settings saved');
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setLocal({ ...orgSettings });
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const fields = [
|
||||
{ key: 'orgName', label: 'Organization Name', icon: Building2 },
|
||||
{ key: 'orgEmail', label: 'Email', icon: Mail },
|
||||
{ key: 'orgPhone', label: 'Phone', icon: Phone },
|
||||
{ key: 'orgAddress', label: 'Address', icon: MapPin },
|
||||
{ key: 'timezone', label: 'Timezone', icon: Clock },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-zinc-500">Manage your organization's core details</p>
|
||||
{!editing ? (
|
||||
<button onClick={() => setEditing(true)} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10 text-xs font-semibold text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors">
|
||||
<Edit3 size={12} /> Edit
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleCancel} className="px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-white/10 text-xs font-semibold text-zinc-500 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors">Cancel</button>
|
||||
<button onClick={handleSave} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-blue-50 dark:bg-blue-500/20 border border-blue-200 dark:border-blue-500/30 text-xs font-bold text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-500/30 transition-colors">
|
||||
<Save size={12} /> Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{fields.map(({ key, label, icon: FIcon }) => (
|
||||
<div key={key} className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 flex items-center gap-1.5">
|
||||
<FIcon size={11} className="text-zinc-400 dark:text-zinc-600" /> {label}
|
||||
</label>
|
||||
{editing ? (
|
||||
<input
|
||||
value={local[key]}
|
||||
onChange={e => setLocal(prev => ({ ...prev, [key]: e.target.value }))}
|
||||
className="w-full bg-zinc-50 dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-xl px-4 py-2.5 text-sm text-zinc-900 dark:text-white outline-none focus:border-blue-500/50 transition-colors"
|
||||
/>
|
||||
) : (
|
||||
<div className="px-4 py-2.5 bg-zinc-50 dark:bg-white/[0.03] border border-zinc-200 dark:border-white/5 rounded-xl text-sm text-zinc-700 dark:text-zinc-300">
|
||||
{orgSettings[key]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────
|
||||
// 2. Role Management Section
|
||||
// ────────────────────────────────────────────────────
|
||||
const RoleManagementSection = ({ roles, members }) => {
|
||||
const [expandedRole, setExpandedRole] = useState(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-zinc-500 mb-4">System roles define base-level access for your organization members</p>
|
||||
{roles.map(role => {
|
||||
const roleMemberCount = members.filter(m => m.roleKey === role.key).length;
|
||||
const roleMembers = members.filter(m => m.roleKey === role.key);
|
||||
const isExpanded = expandedRole === role.id;
|
||||
|
||||
return (
|
||||
<div key={role.id} className="rounded-xl border border-zinc-200 dark:border-white/[0.06] bg-white dark:bg-white/[0.02] overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedRole(isExpanded ? null : role.id)}
|
||||
className="w-full flex items-center justify-between px-5 py-4 hover:bg-zinc-50 dark:hover:bg-white/[0.02] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: role.color }} />
|
||||
<div className="text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-zinc-900 dark:text-white">{role.name}</span>
|
||||
{role.key === 'OWNER' && <Crown size={12} className="text-amber-400" />}
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10 text-zinc-500 font-mono">{role.key}</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-500 mt-0.5">{role.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-zinc-500 font-mono">{roleMemberCount} member{roleMemberCount !== 1 ? 's' : ''}</span>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className="text-zinc-400 dark:text-zinc-600 transition-transform duration-200"
|
||||
style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<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="px-5 pb-4 pt-1 border-t border-zinc-100 dark:border-white/5">
|
||||
{roleMembers.length === 0 ? (
|
||||
<p className="text-xs text-zinc-400 dark:text-zinc-600 italic py-2">No members assigned to this role</p>
|
||||
) : (
|
||||
<div className="space-y-2 mt-2">
|
||||
{roleMembers.map(member => (
|
||||
<div key={member.id} className="flex items-center justify-between px-3 py-2 rounded-lg bg-zinc-50 dark:bg-white/[0.03] border border-zinc-200 dark:border-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-bold" style={{ backgroundColor: `${role.color}20`, color: role.color }}>
|
||||
{member.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-zinc-900 dark:text-white">{member.name}</span>
|
||||
<span className="text-[10px] text-zinc-500 ml-2">{member.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full font-semibold ${member.status === 'active' ? 'bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-500/20' : 'bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-200 dark:border-amber-500/20'}`}>
|
||||
{member.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────
|
||||
// 3. Access Control Matrix
|
||||
// ────────────────────────────────────────────────────
|
||||
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 moduleEntries = Object.entries(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>
|
||||
|
||||
{/* Desktop Matrix */}
|
||||
<div className="hidden lg:block overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<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]}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{moduleEntries.map(([moduleKey, module]) =>
|
||||
module.actions.map((action, actionIdx) => (
|
||||
<tr key={`${moduleKey}-${action}`} className="hover:bg-zinc-50 dark:hover:bg-white/[0.02] transition-colors">
|
||||
{actionIdx === 0 && (
|
||||
<td
|
||||
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>
|
||||
</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 => {
|
||||
const hasPermission = (permissions[moduleKey]?.[role.key] || []).includes(action);
|
||||
const isOwner = role.key === 'OWNER';
|
||||
return (
|
||||
<td key={role.key} className="text-center px-3 py-2.5 border-b border-zinc-100 dark:border-white/5">
|
||||
{isOwner ? (
|
||||
<div className="flex justify-center">
|
||||
<div className="w-6 h-6 rounded-md bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 flex items-center justify-center" title="Owner always has full access">
|
||||
<Lock size={10} className="text-amber-500 dark:text-amber-400" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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 ${
|
||||
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-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/[0.03] hover:bg-zinc-100 dark:hover:bg-white/[0.06]'
|
||||
}`}
|
||||
>
|
||||
{hasPermission && <Check size={12} className="text-emerald-500 dark:text-emerald-400" />}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────
|
||||
// 4. Commission Rules Section
|
||||
// ────────────────────────────────────────────────────
|
||||
const CommissionRulesSection = ({
|
||||
orgDefaults, onSaveOrgDefaults,
|
||||
roleOverrides, roles, onSaveRoleOverride, onRemoveRoleOverride,
|
||||
}) => {
|
||||
const [localOrgDefaults, setLocalOrgDefaults] = useState({ ...orgDefaults });
|
||||
const [editingOrg, setEditingOrg] = useState(false);
|
||||
const [addingRole, setAddingRole] = useState(false);
|
||||
const [newRoleOverride, setNewRoleOverride] = useState({ roleKey: '', type: 'flat', rate: 0 });
|
||||
|
||||
const availableRolesForOverride = useMemo(() => {
|
||||
const overriddenKeys = new Set(roleOverrides.map(o => o.roleKey));
|
||||
return roles.filter(r => r.key !== 'OWNER' && !overriddenKeys.has(r.key));
|
||||
}, [roles, roleOverrides]);
|
||||
|
||||
const handleSaveOrg = () => {
|
||||
onSaveOrgDefaults({ type: localOrgDefaults.type, rate: Number(localOrgDefaults.rate) || 0 });
|
||||
setEditingOrg(false);
|
||||
toast.success('Org commission defaults updated');
|
||||
};
|
||||
|
||||
const handleAddRoleOverride = () => {
|
||||
if (!newRoleOverride.roleKey) return;
|
||||
onSaveRoleOverride({ ...newRoleOverride, enabled: true, rate: Number(newRoleOverride.rate) || 0 });
|
||||
setAddingRole(false);
|
||||
setNewRoleOverride({ roleKey: '', type: 'flat', rate: 0 });
|
||||
toast.success('Role commission override added');
|
||||
};
|
||||
|
||||
const inputClasses = "w-full bg-zinc-50 dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-xl px-4 py-2.5 text-sm text-zinc-900 dark:text-white outline-none focus:border-blue-400 dark:focus:border-blue-500/50 transition-colors";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Hierarchy Visualization */}
|
||||
<div className="flex items-center justify-center gap-2 text-[10px] font-bold uppercase tracking-widest py-3 flex-wrap">
|
||||
{[
|
||||
{ label: 'Org Default', color: '#00f0ff', icon: Building2 },
|
||||
{ label: 'Role Override', color: '#10b981', icon: Shield },
|
||||
{ label: 'Individual', color: '#a855f7', icon: User },
|
||||
{ label: 'Job Override', color: '#fda913', icon: DollarSign },
|
||||
].map((level, i) => (
|
||||
<React.Fragment key={level.label}>
|
||||
{i > 0 && <ArrowRight size={10} className="text-zinc-300 dark:text-zinc-700 mx-0.5" />}
|
||||
<div className="flex items-center gap-1 px-2.5 py-1 rounded-full border" style={{ backgroundColor: `${level.color}10`, borderColor: `${level.color}25`, color: level.color }}>
|
||||
<level.icon size={10} />
|
||||
<span>{level.label}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-center text-[10px] text-zinc-400 dark:text-zinc-600">Highest priority on the right. Job override supersedes all.</p>
|
||||
|
||||
{/* Org Default */}
|
||||
<div className="rounded-xl border border-cyan-200 dark:border-cyan-500/10 bg-cyan-50/50 dark:bg-cyan-500/[0.03] p-5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 size={16} className="text-cyan-500 dark:text-cyan-400" />
|
||||
<span className="text-sm font-bold text-zinc-900 dark:text-white">Org Default Commission</span>
|
||||
</div>
|
||||
{!editingOrg ? (
|
||||
<button onClick={() => setEditingOrg(true)} className="text-[10px] font-semibold text-cyan-600 dark:text-cyan-400 hover:text-cyan-700 dark:hover:text-cyan-300 transition-colors">Edit</button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => { setLocalOrgDefaults({ ...orgDefaults }); setEditingOrg(false); }} className="text-[10px] font-semibold text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300">Cancel</button>
|
||||
<button onClick={handleSaveOrg} className="text-[10px] font-bold text-cyan-600 dark:text-cyan-400 hover:text-cyan-700 dark:hover:text-cyan-300">Save</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{editingOrg ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 block mb-1.5">Type</label>
|
||||
<select value={localOrgDefaults.type} onChange={e => setLocalOrgDefaults(prev => ({ ...prev, type: e.target.value }))} className={inputClasses}>
|
||||
{COMMISSION_TYPES.map(ct => <option key={ct.value} value={ct.value}>{ct.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 block mb-1.5">Rate</label>
|
||||
<input type="number" value={localOrgDefaults.rate} onChange={e => setLocalOrgDefaults(prev => ({ ...prev, rate: e.target.value }))} className={inputClasses} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 px-4 py-3 bg-white/60 dark:bg-white/[0.03] rounded-xl border border-zinc-200 dark:border-white/5">
|
||||
<DollarSign size={14} className="text-cyan-500 dark:text-cyan-400" />
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-300 font-semibold">{getCommissionLabel(orgDefaults.type)}</span>
|
||||
<span className="text-sm font-mono text-cyan-600 dark:text-cyan-400">{formatCommission(orgDefaults.type, orgDefaults.rate)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role Overrides */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield size={16} className="text-emerald-500 dark:text-emerald-400" />
|
||||
<span className="text-sm font-bold text-zinc-900 dark:text-white">Role Overrides</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{roleOverrides.length === 0 && !addingRole && (
|
||||
<p className="text-xs text-zinc-400 dark:text-zinc-600 italic px-2">No role overrides — all roles use org default</p>
|
||||
)}
|
||||
|
||||
{roleOverrides.map(ov => {
|
||||
const role = roles.find(r => r.key === ov.roleKey);
|
||||
return (
|
||||
<div key={ov.roleKey} className="flex items-center justify-between px-4 py-3 rounded-xl bg-zinc-50 dark:bg-white/[0.03] border border-zinc-200 dark:border-white/5">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: role?.color || '#10b981' }} />
|
||||
<span className="text-sm font-semibold text-zinc-900 dark:text-white">{role?.name || ov.roleKey}</span>
|
||||
<ArrowRight size={12} className="text-zinc-400 dark:text-zinc-600" />
|
||||
<span className="text-xs font-mono text-emerald-600 dark:text-emerald-400">{getCommissionLabel(ov.type)} — {formatCommission(ov.type, ov.rate)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSaveRoleOverride({ ...ov, enabled: !ov.enabled });
|
||||
}}
|
||||
className="p-1 hover:bg-zinc-100 dark:hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
{ov.enabled ? <ToggleRight size={18} className="text-emerald-500 dark:text-emerald-400" /> : <ToggleLeft size={18} className="text-zinc-400 dark:text-zinc-600" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveRoleOverride(ov.roleKey)}
|
||||
className="p-1 hover:bg-red-50 dark:hover:bg-red-500/10 text-zinc-400 dark:text-zinc-600 hover:text-red-500 dark:hover:text-red-400 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{addingRole ? (
|
||||
<div className="rounded-xl border border-emerald-200 dark:border-emerald-500/20 bg-emerald-50/50 dark:bg-emerald-500/[0.03] p-4 space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 block mb-1.5">Role</label>
|
||||
<select value={newRoleOverride.roleKey} onChange={e => setNewRoleOverride(prev => ({ ...prev, roleKey: e.target.value }))} className={inputClasses}>
|
||||
<option value="">— Select Role —</option>
|
||||
{availableRolesForOverride.map(r => <option key={r.key} value={r.key}>{r.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 block mb-1.5">Type</label>
|
||||
<select value={newRoleOverride.type} onChange={e => setNewRoleOverride(prev => ({ ...prev, type: e.target.value }))} className={inputClasses}>
|
||||
{COMMISSION_TYPES.map(ct => <option key={ct.value} value={ct.value}>{ct.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 block mb-1.5">Rate</label>
|
||||
<input type="number" value={newRoleOverride.rate} onChange={e => setNewRoleOverride(prev => ({ ...prev, rate: e.target.value }))} className={inputClasses} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button onClick={() => setAddingRole(false)} className="flex-1 px-4 py-2 rounded-xl border border-zinc-200 dark:border-white/10 text-zinc-500 dark:text-zinc-400 text-xs font-semibold hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors">Cancel</button>
|
||||
<button onClick={handleAddRoleOverride} disabled={!newRoleOverride.roleKey} className="flex-1 px-4 py-2 rounded-xl bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-500/30 text-xs font-bold hover:bg-emerald-200 dark:hover:bg-emerald-500/30 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">Add Override</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddingRole(true)}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl border border-dashed border-emerald-300 dark:border-emerald-500/30 text-emerald-600 dark:text-emerald-400 text-xs font-bold hover:bg-emerald-50 dark:hover:bg-emerald-500/5 transition-colors"
|
||||
>
|
||||
<Plus size={14} /> Add Role Override
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<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">Commission Hierarchy:</span> Job Override → Individual Override → Role Override → Org Default</p>
|
||||
<p>The most specific level takes precedence. Job-level overrides (set on each project) always win.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────
|
||||
// 5. User Overrides Section
|
||||
// ────────────────────────────────────────────────────
|
||||
const UserOverridesSection = ({ members, roles, orgDefaults, roleOverrides, userCommissionOverrides, onSaveOverride, onRemoveOverride }) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [editingMember, setEditingMember] = useState(null);
|
||||
const [editForm, setEditForm] = useState({ type: 'flat', rate: 0 });
|
||||
|
||||
const filteredMembers = useMemo(() => {
|
||||
const term = searchTerm.toLowerCase();
|
||||
return members.filter(m =>
|
||||
m.name.toLowerCase().includes(term) ||
|
||||
m.email.toLowerCase().includes(term) ||
|
||||
m.roleKey.toLowerCase().includes(term)
|
||||
);
|
||||
}, [members, searchTerm]);
|
||||
|
||||
const getEffectiveCommission = (member) => {
|
||||
const userOv = userCommissionOverrides.find(o => o.userId === member.userId && o.enabled);
|
||||
if (userOv) return { source: 'individual', type: userOv.type, rate: userOv.rate };
|
||||
|
||||
const indOv = member.individualCommission;
|
||||
if (indOv?.enabled) return { source: 'individual', type: indOv.type, rate: indOv.rate };
|
||||
|
||||
const roleOv = roleOverrides.find(o => o.roleKey === member.roleKey && o.enabled);
|
||||
if (roleOv) return { source: 'role', type: roleOv.type, rate: roleOv.rate };
|
||||
|
||||
return { source: 'org', type: orgDefaults.type, rate: orgDefaults.rate };
|
||||
};
|
||||
|
||||
const sourceColors = {
|
||||
individual: { bg: 'bg-purple-50 dark:bg-purple-500/10', border: 'border-purple-200 dark:border-purple-500/20', text: 'text-purple-600 dark:text-purple-400', label: 'Individual' },
|
||||
role: { bg: 'bg-emerald-50 dark:bg-emerald-500/10', border: 'border-emerald-200 dark:border-emerald-500/20', text: 'text-emerald-600 dark:text-emerald-400', label: 'Role' },
|
||||
org: { bg: 'bg-cyan-50 dark:bg-cyan-500/10', border: 'border-cyan-200 dark:border-cyan-500/20', text: 'text-cyan-600 dark:text-cyan-400', label: 'Org Default' },
|
||||
};
|
||||
|
||||
const startEdit = (member) => {
|
||||
const effective = getEffectiveCommission(member);
|
||||
setEditingMember(member.id);
|
||||
setEditForm({ type: effective.type, rate: effective.rate });
|
||||
};
|
||||
|
||||
const handleSaveIndividual = (member) => {
|
||||
onSaveOverride({
|
||||
userId: member.userId,
|
||||
userName: member.name,
|
||||
enabled: true,
|
||||
type: editForm.type,
|
||||
rate: Number(editForm.rate) || 0,
|
||||
});
|
||||
setEditingMember(null);
|
||||
toast.success(`Individual commission set for ${member.name}`);
|
||||
};
|
||||
|
||||
const handleRemoveIndividual = (member) => {
|
||||
onRemoveOverride(member.userId);
|
||||
setEditingMember(null);
|
||||
toast.success(`Individual override removed for ${member.name}`);
|
||||
};
|
||||
|
||||
const inputClasses = "w-full bg-zinc-50 dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-xl px-4 py-2.5 text-sm text-zinc-900 dark:text-white outline-none focus:border-purple-400 dark:focus:border-purple-500/50 transition-colors";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400 dark:text-zinc-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search members by name, email, or role..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="w-full bg-zinc-50 dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-xl pl-9 pr-4 py-2.5 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 outline-none focus:border-purple-400 dark:focus:border-purple-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Member Cards */}
|
||||
<div className="space-y-2 max-h-[500px] overflow-y-auto custom-scrollbar pr-1">
|
||||
{filteredMembers.map(member => {
|
||||
const effective = getEffectiveCommission(member);
|
||||
const sc = sourceColors[effective.source];
|
||||
const role = roles.find(r => r.key === member.roleKey);
|
||||
const hasIndividualOverride = userCommissionOverrides.some(o => o.userId === member.userId && o.enabled) || member.individualCommission?.enabled;
|
||||
const isEditing = editingMember === member.id;
|
||||
|
||||
return (
|
||||
<div key={member.id} 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">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold shrink-0" style={{ backgroundColor: `${role?.color || '#3b82f6'}20`, color: role?.color || '#3b82f6' }}>
|
||||
{member.name.charAt(0)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-zinc-900 dark:text-white truncate">{member.name}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full border border-zinc-200 dark:border-white/10 text-zinc-500 font-mono shrink-0">{role?.name || member.roleKey}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-zinc-500">{member.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{/* Commission Source Badge */}
|
||||
<div className={`flex items-center gap-1.5 px-2 py-1 rounded-full border text-[10px] font-bold ${sc.bg} ${sc.border} ${sc.text}`}>
|
||||
<span>{sc.label}</span>
|
||||
<span className="font-mono">{formatCommission(effective.type, effective.rate)}</span>
|
||||
</div>
|
||||
{member.roleKey !== 'OWNER' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => isEditing ? setEditingMember(null) : startEdit(member)}
|
||||
className="p-1.5 rounded-lg hover:bg-zinc-100 dark:hover:bg-white/10 text-zinc-400 dark:text-zinc-500 hover:text-zinc-700 dark:hover:text-white transition-colors"
|
||||
>
|
||||
{isEditing ? <X size={14} /> : <Pencil size={12} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Form */}
|
||||
<AnimatePresence>
|
||||
{isEditing && (
|
||||
<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="px-4 pb-4 pt-2 border-t border-zinc-100 dark:border-white/5 space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 block mb-1.5">Commission Type</label>
|
||||
<select value={editForm.type} onChange={e => setEditForm(prev => ({ ...prev, type: e.target.value }))} className={inputClasses}>
|
||||
{COMMISSION_TYPES.map(ct => <option key={ct.value} value={ct.value}>{ct.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 block mb-1.5">
|
||||
{editForm.type.includes('percent') ? 'Rate (%)' : 'Amount ($)'}
|
||||
</label>
|
||||
<input type="number" value={editForm.rate} onChange={e => setEditForm(prev => ({ ...prev, rate: e.target.value }))} className={inputClasses} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{hasIndividualOverride && (
|
||||
<button onClick={() => handleRemoveIndividual(member)} className="px-3 py-2 rounded-xl border border-red-200 dark:border-red-500/20 text-red-600 dark:text-red-400 text-xs font-semibold hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors">
|
||||
Remove Override
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button onClick={() => setEditingMember(null)} className="px-4 py-2 rounded-xl border border-zinc-200 dark:border-white/10 text-zinc-500 dark:text-zinc-400 text-xs font-semibold hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors">Cancel</button>
|
||||
<button onClick={() => handleSaveIndividual(member)} className="px-4 py-2 rounded-xl bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-300 border border-purple-200 dark:border-purple-500/30 text-xs font-bold hover:bg-purple-200 dark:hover:bg-purple-500/30 transition-colors">Save Override</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredMembers.length === 0 && (
|
||||
<div className="text-center py-8 text-zinc-400 dark:text-zinc-600 text-sm">
|
||||
No members found matching "{searchTerm}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────
|
||||
// Main Page
|
||||
// ────────────────────────────────────────────────────
|
||||
const OrgSettings = () => {
|
||||
const { user } = useAuth();
|
||||
const store = useMockStore();
|
||||
|
||||
const [openSections, setOpenSections] = useState({
|
||||
org: true,
|
||||
roles: false,
|
||||
access: false,
|
||||
commission: false,
|
||||
overrides: false,
|
||||
});
|
||||
|
||||
const toggleSection = (key) => setOpenSections(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
const sections = [
|
||||
{
|
||||
id: 'org',
|
||||
icon: Building2,
|
||||
title: 'Organization Settings',
|
||||
subtitle: 'Company name, contact info, and general preferences',
|
||||
color: '#3b82f6',
|
||||
content: (
|
||||
<OrgInfoSection
|
||||
orgSettings={store.orgSettings}
|
||||
onSave={store.updateOrgSettings}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'roles',
|
||||
icon: Crown,
|
||||
title: 'Role Management',
|
||||
subtitle: `${store.orgRoles.length} system roles configured`,
|
||||
color: '#fda913',
|
||||
content: (
|
||||
<RoleManagementSection
|
||||
roles={store.orgRoles}
|
||||
members={store.orgMembers}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'access',
|
||||
icon: Shield,
|
||||
title: 'Access Control Matrix',
|
||||
subtitle: 'Configure module-level permissions per role',
|
||||
color: '#10b981',
|
||||
content: (
|
||||
<AccessControlMatrix
|
||||
permissions={store.orgPermissions}
|
||||
permissionModules={store.PERMISSION_MODULES}
|
||||
roles={store.orgRoles}
|
||||
onUpdate={store.updateOrgPermissions}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'commission',
|
||||
icon: DollarSign,
|
||||
title: 'Commission Rules',
|
||||
subtitle: 'Org defaults and role-level commission overrides',
|
||||
color: '#06b6d4',
|
||||
content: (
|
||||
<CommissionRulesSection
|
||||
orgDefaults={store.orgCommissionDefaults}
|
||||
onSaveOrgDefaults={store.updateOrgCommissionDefaults}
|
||||
roleOverrides={store.roleCommissionOverrides}
|
||||
roles={store.orgRoles}
|
||||
onSaveRoleOverride={store.setRoleCommissionOverride}
|
||||
onRemoveRoleOverride={store.removeRoleCommissionOverride}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'overrides',
|
||||
icon: Users,
|
||||
title: 'User Overrides',
|
||||
subtitle: `${store.orgMembers.length} members — manage individual commission overrides`,
|
||||
color: '#a855f7',
|
||||
content: (
|
||||
<UserOverridesSection
|
||||
members={store.orgMembers}
|
||||
roles={store.orgRoles}
|
||||
orgDefaults={store.orgCommissionDefaults}
|
||||
roleOverrides={store.roleCommissionOverrides}
|
||||
userCommissionOverrides={store.userCommissionOverrides}
|
||||
onSaveOverride={store.setUserCommissionOverride}
|
||||
onRemoveOverride={store.removeUserCommissionOverride}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white pb-12 transition-colors duration-300">
|
||||
{/* Header */}
|
||||
<div className="px-6 md:px-10 pt-8 pb-6">
|
||||
<div className="flex items-center gap-4 mb-1">
|
||||
<div className="p-3 rounded-2xl bg-gradient-to-br from-blue-100 to-purple-100 dark:from-blue-500/20 dark:to-purple-500/20 border border-zinc-200 dark:border-white/10">
|
||||
<Settings size={24} className="text-blue-500 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-zinc-900 dark:text-white tracking-tight">
|
||||
Org Settings
|
||||
</h1>
|
||||
<p className="text-sm text-zinc-500 mt-0.5">
|
||||
Manage roles, permissions, and commission rules for your organization
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-6">
|
||||
{[
|
||||
{ label: 'Roles', value: store.orgRoles.length, color: '#fda913' },
|
||||
{ label: 'Members', value: store.orgMembers.length, color: '#3b82f6' },
|
||||
{ label: 'Modules', value: Object.keys(store.PERMISSION_MODULES).length, color: '#10b981' },
|
||||
{ label: 'Commission Overrides', value: store.roleCommissionOverrides.length + store.userCommissionOverrides.length, color: '#a855f7' },
|
||||
].map(stat => (
|
||||
<div key={stat.label} className="px-4 py-3 rounded-xl bg-white dark:bg-white/[0.03] border border-zinc-200 dark:border-white/5 shadow-sm dark:shadow-none">
|
||||
<div className="text-2xl font-bold font-mono" style={{ color: stat.color }}>{stat.value}</div>
|
||||
<div className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 mt-0.5">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className="px-6 md:px-10 space-y-4">
|
||||
{sections.map(section => (
|
||||
<SettingsSection
|
||||
key={section.id}
|
||||
id={section.id}
|
||||
icon={section.icon}
|
||||
title={section.title}
|
||||
subtitle={section.subtitle}
|
||||
color={section.color}
|
||||
isOpen={openSections[section.id]}
|
||||
onToggle={() => toggleSection(section.id)}
|
||||
>
|
||||
{section.content}
|
||||
</SettingsSection>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrgSettings;
|
||||
Reference in New Issue
Block a user