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 DocumentManagement from './pages/owner/DocumentManagement';
|
||||||
import OwnerProjectList from './pages/owner/OwnerProjectList';
|
import OwnerProjectList from './pages/owner/OwnerProjectList';
|
||||||
import OwnerProjectDetail from './pages/owner/OwnerProjectDetail';
|
import OwnerProjectDetail from './pages/owner/OwnerProjectDetail';
|
||||||
|
import OrgSettings from './pages/owner/OrgSettings';
|
||||||
import PlaceholderDashboard from './pages/PlaceholderDashboard';
|
import PlaceholderDashboard from './pages/PlaceholderDashboard';
|
||||||
import ProCanvas from './pages/ProCanvas';
|
import ProCanvas from './pages/ProCanvas';
|
||||||
import ProjectList from './pages/contractor/ProjectList';
|
import ProjectList from './pages/contractor/ProjectList';
|
||||||
@@ -213,6 +214,11 @@ function App() {
|
|||||||
<EstimateBuilder />
|
<EstimateBuilder />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/owner/settings" element={
|
||||||
|
<ProtectedRoute allowedRoles={['OWNER']}>
|
||||||
|
<OrgSettings />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
{/* Protected Admin Routes — Owner has full admin access */}
|
{/* Protected Admin Routes — Owner has full admin access */}
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTheme } from '../context/ThemeContext';
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare,
|
LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare,
|
||||||
ChevronLeft, ChevronRight, Sun, Moon, Trophy, Users, Briefcase,
|
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';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import PageTransition from './PageTransition';
|
import PageTransition from './PageTransition';
|
||||||
@@ -149,6 +149,7 @@ const Layout = () => {
|
|||||||
{ to: "/owner/estimates", icon: Calculator, label: "Estimates" },
|
{ to: "/owner/estimates", icon: Calculator, label: "Estimates" },
|
||||||
{ to: "/admin/schedule", icon: Calendar, label: "Team Schedule" },
|
{ to: "/admin/schedule", icon: Calendar, label: "Team Schedule" },
|
||||||
{ to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" },
|
{ to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" },
|
||||||
|
{ to: "/owner/settings", icon: Settings, label: "Org Settings" },
|
||||||
...commonItems,
|
...commonItems,
|
||||||
];
|
];
|
||||||
case 'ADMIN':
|
case 'ADMIN':
|
||||||
|
|||||||
+130
-18
@@ -4971,6 +4971,104 @@ export const MockStoreProvider = ({ children }) => {
|
|||||||
{ userId: 'p2', userName: 'Sarah Sales', enabled: true, type: 'flat', rate: 2000 },
|
{ 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
|
// Initialize properties once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const data = generateProperties();
|
const data = generateProperties();
|
||||||
@@ -5048,8 +5146,6 @@ export const MockStoreProvider = ({ children }) => {
|
|||||||
users,
|
users,
|
||||||
meetings,
|
meetings,
|
||||||
salesHistory, // Exported for Leaderboard
|
salesHistory, // Exported for Leaderboard
|
||||||
|
|
||||||
// New Data
|
|
||||||
owners,
|
owners,
|
||||||
personnel,
|
personnel,
|
||||||
vendors,
|
vendors,
|
||||||
@@ -5057,7 +5153,6 @@ export const MockStoreProvider = ({ children }) => {
|
|||||||
projects,
|
projects,
|
||||||
orders,
|
orders,
|
||||||
vendorInvoices,
|
vendorInvoices,
|
||||||
|
|
||||||
leads,
|
leads,
|
||||||
addLead: (lead) => {
|
addLead: (lead) => {
|
||||||
const newLead = {
|
const newLead = {
|
||||||
@@ -5180,24 +5275,41 @@ export const MockStoreProvider = ({ children }) => {
|
|||||||
setProjects(prev => prev.map(p => p.id === projectId ? { ...p, ...data } : p));
|
setProjects(prev => prev.map(p => p.id === projectId ? { ...p, ...data } : p));
|
||||||
},
|
},
|
||||||
|
|
||||||
// Team Management (job-scoped)
|
// Org Settings & Access Control
|
||||||
addJobTeamMember: (projectId, member) => {
|
orgSettings,
|
||||||
setProjects(prev => prev.map(p => {
|
updateOrgSettings: (data) => setOrgSettings(prev => ({ ...prev, ...data })),
|
||||||
if (p.id !== projectId) return p;
|
orgRoles,
|
||||||
return { ...p, teamMembers: [...(p.teamMembers || []), { ...member, id: `jtm_${Date.now()}_${Math.random().toString(36).slice(2, 6)}` }] };
|
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) => {
|
roleCommissionOverrides,
|
||||||
setProjects(prev => prev.map(p => {
|
setRoleCommissionOverride: (override) => {
|
||||||
if (p.id !== projectId) return p;
|
setRoleCommissionOverrides(prev => {
|
||||||
return { ...p, teamMembers: (p.teamMembers || []).filter(m => m.id !== memberId) };
|
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) => {
|
removeRoleCommissionOverride: (roleKey) => {
|
||||||
setProjects(prev => prev.map(p => {
|
setRoleCommissionOverrides(prev => prev.filter(o => o.roleKey !== roleKey));
|
||||||
if (p.id !== projectId) return p;
|
},
|
||||||
return { ...p, teamMembers: (p.teamMembers || []).map(m => m.id === memberId ? { ...m, ...updates } : m) };
|
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}
|
{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