feat(estimates): template access control — owner can grant edit rights by role or individual
- Add TemplateAccessModal: role-level toggles (All Admins / All Field Agents) + per-person overrides - Three-state access logic: role grant, individual direct grant, individual exclusion override - Excluded people (role ON but toggled off) pinned to top with amber 'Excluded' chip - Direct grants (role OFF but toggled on) show green 'Direct' chip - canEditTemplates replaces isOwner for all template CRUD guards in EstimatesPage - Owner retains exclusive access to Manage Access button - State persisted in mockStore: templateAccessRoles, templateAccessUsers, templateAccessExcluded
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Search, Users, Shield, User, LinkIcon, Unlink } from 'lucide-react';
|
||||
import { useMockStore } from '../../data/mockStore';
|
||||
|
||||
const ROLE_CONFIG = {
|
||||
FIELD_AGENT: { label: 'Field Agents', color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-500/10', badge: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300' },
|
||||
ADMIN: { label: 'Admins', color: 'text-purple-600 dark:text-purple-400', bg: 'bg-purple-50 dark:bg-purple-500/10', badge: 'bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-300' },
|
||||
};
|
||||
|
||||
function Toggle({ on, onChange }) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onChange(!on)}
|
||||
className={`relative w-10 h-5 rounded-full transition-colors duration-200 shrink-0 ${on ? 'bg-blue-600' : 'bg-zinc-200 dark:bg-zinc-700'}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform duration-200 ${on ? 'translate-x-5' : 'translate-x-0'}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StateChip({ label, variant }) {
|
||||
const styles = {
|
||||
inherited: 'bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400',
|
||||
excluded: 'bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400',
|
||||
direct: 'bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
||||
};
|
||||
return (
|
||||
<span className={`text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full ${styles[variant]}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TemplateAccessModal({ isOpen, onClose }) {
|
||||
const {
|
||||
users,
|
||||
templateAccessRoles,
|
||||
templateAccessUsers,
|
||||
templateAccessExcluded,
|
||||
setTemplateAccessRoles,
|
||||
setTemplateAccessUsers,
|
||||
setTemplateAccessExcluded,
|
||||
} = useMockStore();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
// Only non-owner employees
|
||||
const employees = useMemo(() =>
|
||||
(users || []).filter(u => u.type === 'employee' && u.role !== 'OWNER'),
|
||||
[users]);
|
||||
|
||||
const roleCounts = useMemo(() => {
|
||||
const counts = {};
|
||||
employees.forEach(e => {
|
||||
counts[e.role] = (counts[e.role] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
}, [employees]);
|
||||
|
||||
// Toggle a role on/off
|
||||
const toggleRole = (role) => {
|
||||
setTemplateAccessRoles(prev => {
|
||||
if (prev.includes(role)) {
|
||||
// Removing role: clear any excluded users of this role (no longer relevant)
|
||||
const roleUserIds = employees.filter(e => e.role === role).map(e => e.id);
|
||||
setTemplateAccessExcluded(ex => ex.filter(id => !roleUserIds.includes(id)));
|
||||
return prev.filter(r => r !== role);
|
||||
} else {
|
||||
return [...prev, role];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Toggle an individual employee
|
||||
const toggleEmployee = (emp) => {
|
||||
const roleOn = templateAccessRoles.includes(emp.role);
|
||||
|
||||
if (roleOn) {
|
||||
// Role is on — toggle adds/removes from excluded list
|
||||
setTemplateAccessExcluded(prev =>
|
||||
prev.includes(emp.id)
|
||||
? prev.filter(id => id !== emp.id) // un-exclude (restore role access)
|
||||
: [...prev, emp.id] // exclude this person
|
||||
);
|
||||
} else {
|
||||
// Role is off — toggle adds/removes from individual grant list
|
||||
setTemplateAccessUsers(prev =>
|
||||
prev.includes(emp.id)
|
||||
? prev.filter(id => id !== emp.id)
|
||||
: [...prev, emp.id]
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Determine if a person has effective access
|
||||
const effectiveAccess = (emp) => {
|
||||
if (templateAccessExcluded.includes(emp.id)) return false;
|
||||
if (templateAccessRoles.includes(emp.role)) return true;
|
||||
if (templateAccessUsers.includes(emp.id)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
// State chip for a person
|
||||
const stateChip = (emp) => {
|
||||
const roleOn = templateAccessRoles.includes(emp.role);
|
||||
const isExcluded = templateAccessExcluded.includes(emp.id);
|
||||
const isDirect = templateAccessUsers.includes(emp.id);
|
||||
|
||||
if (roleOn && !isExcluded) return <StateChip label="Via Role" variant="inherited" />;
|
||||
if (roleOn && isExcluded) return <StateChip label="Excluded" variant="excluded" />;
|
||||
if (!roleOn && isDirect) return <StateChip label="Direct" variant="direct" />;
|
||||
return null;
|
||||
};
|
||||
|
||||
const filteredEmployees = useMemo(() => {
|
||||
const q = search.toLowerCase();
|
||||
const list = employees.filter(e =>
|
||||
!q ||
|
||||
e.name.toLowerCase().includes(q) ||
|
||||
(e.empId || '').toLowerCase().includes(q)
|
||||
);
|
||||
|
||||
// Sort: overridden people (excluded or direct) pinned to top
|
||||
return [...list].sort((a, b) => {
|
||||
const aOverride = templateAccessExcluded.includes(a.id) || templateAccessUsers.includes(a.id);
|
||||
const bOverride = templateAccessExcluded.includes(b.id) || templateAccessUsers.includes(b.id);
|
||||
if (aOverride && !bOverride) return -1;
|
||||
if (!aOverride && bOverride) return 1;
|
||||
return 0;
|
||||
});
|
||||
}, [employees, search, templateAccessExcluded, templateAccessUsers]);
|
||||
|
||||
const totalGranted = useMemo(() => {
|
||||
return employees.filter(e => effectiveAccess(e)).length;
|
||||
}, [employees, templateAccessRoles, templateAccessUsers, templateAccessExcluded]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-[90] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||
<motion.div
|
||||
initial={{ scale: 0.96, opacity: 0, y: 8 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.96, opacity: 0, y: 8 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 rounded-2xl shadow-2xl w-full max-w-lg flex flex-col max-h-[85vh]"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-100 dark:border-white/[0.07] shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-xl bg-blue-50 dark:bg-blue-500/10 flex items-center justify-center">
|
||||
<Shield size={15} className="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-bold text-zinc-900 dark:text-white text-sm leading-none">Template Access</h2>
|
||||
<p className="text-[11px] text-zinc-400 mt-0.5">{totalGranted} {totalGranted === 1 ? 'person has' : 'people have'} editing access</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 px-5 py-4 space-y-5">
|
||||
{/* By Role */}
|
||||
<div>
|
||||
<p className="text-[11px] font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest mb-3">By Role</p>
|
||||
<div className="space-y-2">
|
||||
{['FIELD_AGENT', 'ADMIN'].map(role => {
|
||||
const cfg = ROLE_CONFIG[role];
|
||||
const isOn = templateAccessRoles.includes(role);
|
||||
const count = roleCounts[role] || 0;
|
||||
const excluded = employees.filter(e => e.role === role && templateAccessExcluded.includes(e.id)).length;
|
||||
return (
|
||||
<div key={role} className="flex items-center gap-3 p-3 rounded-xl border border-zinc-100 dark:border-white/[0.07] bg-zinc-50 dark:bg-zinc-800/40">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ${cfg.bg}`}>
|
||||
<Users size={14} className={cfg.color} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">{cfg.label}</p>
|
||||
<p className="text-[11px] text-zinc-400">
|
||||
{count} {count === 1 ? 'person' : 'people'}
|
||||
{isOn && excluded > 0 && <span className="text-amber-500 ml-1">· {excluded} excluded</span>}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle on={isOn} onChange={() => toggleRole(role)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-zinc-100 dark:border-white/[0.07]" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="px-3 bg-white dark:bg-zinc-900 text-[11px] font-bold text-zinc-400 uppercase tracking-widest">By Person</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search by name or employee ID..."
|
||||
className="w-full pl-8 pr-4 py-2 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 text-sm outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 transition-shadow"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Employee list */}
|
||||
<div className="space-y-1.5">
|
||||
{filteredEmployees.map(emp => {
|
||||
const roleOn = templateAccessRoles.includes(emp.role);
|
||||
const isExcluded = templateAccessExcluded.includes(emp.id);
|
||||
const isOn = effectiveAccess(emp);
|
||||
const chip = stateChip(emp);
|
||||
const roleCfg = ROLE_CONFIG[emp.role];
|
||||
const initials = emp.name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={emp.id}
|
||||
className={`flex items-center gap-3 p-3 rounded-xl border transition-colors ${
|
||||
isExcluded
|
||||
? 'border-amber-200 dark:border-amber-500/20 bg-amber-50/50 dark:bg-amber-500/5'
|
||||
: isOn
|
||||
? 'border-blue-100 dark:border-blue-500/20 bg-blue-50/30 dark:bg-blue-500/5'
|
||||
: 'border-zinc-100 dark:border-white/[0.05] bg-transparent hover:bg-zinc-50 dark:hover:bg-white/[0.03]'
|
||||
}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 text-[11px] font-bold ${roleCfg?.bg ?? 'bg-zinc-100 dark:bg-zinc-700'} ${roleCfg?.color ?? 'text-zinc-500'}`}>
|
||||
{initials}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-zinc-800 dark:text-zinc-100 truncate">{emp.name}</span>
|
||||
{chip}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-[11px] text-zinc-400">{emp.empId}</span>
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full ${roleCfg?.badge ?? ''}`}>
|
||||
{roleCfg?.label?.replace(/s$/, '') ?? emp.role}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle */}
|
||||
<Toggle on={isOn} onChange={() => toggleEmployee(emp)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredEmployees.length === 0 && (
|
||||
<p className="text-center text-sm text-zinc-400 py-6">No employees found</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-3 border-t border-zinc-100 dark:border-white/[0.07] shrink-0 flex items-center justify-between">
|
||||
<p className="text-xs text-zinc-400">
|
||||
Changes take effect immediately
|
||||
</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user