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:
Satyam-Rastogi
2026-04-06 18:33:48 +05:30
parent fe9ddda941
commit c927f4c44d
3 changed files with 335 additions and 6 deletions
@@ -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
);
}
+9
View File
@@ -3639,6 +3639,9 @@ export const MockStoreProvider = ({ children }) => {
const [dispatchLeads, setDispatchLeads] = useState(DISPATCH_LEADS_INITIAL);
const [templates, setTemplates] = useState(MASTER_TEMPLATES_INITIAL);
const [estimates, setEstimates] = useState(MOCK_ESTIMATES_INITIAL);
const [templateAccessRoles, setTemplateAccessRoles] = useState([]);
const [templateAccessUsers, setTemplateAccessUsers] = useState([]);
const [templateAccessExcluded, setTemplateAccessExcluded] = useState([]);
// Initialize properties once
useEffect(() => {
@@ -3752,6 +3755,12 @@ export const MockStoreProvider = ({ children }) => {
deleteTemplate: (id) => {
setTemplates(prev => prev.filter(t => t.id !== id));
},
templateAccessRoles,
templateAccessUsers,
templateAccessExcluded,
setTemplateAccessRoles,
setTemplateAccessUsers,
setTemplateAccessExcluded,
// Estimates
estimates,
+32 -6
View File
@@ -9,11 +9,12 @@ import {
Plus, Search, FileText, Layers, Calendar, DollarSign,
User, CheckCircle, Clock,
Pencil, Trash2, Copy, ChevronRight, Tag, Package,
ArrowDownUp, ArrowUp, ArrowDown, X as XIcon
ArrowDownUp, ArrowUp, ArrowDown, X as XIcon, Shield
} from 'lucide-react';
import { toast } from 'sonner';
import TemplateEditorModal from '../components/estimates/TemplateEditorModal';
import EstimateDetailModal from '../components/estimates/EstimateDetailModal';
import TemplateAccessModal from '../components/estimates/TemplateAccessModal';
import { ALL_STATUS_CONFIG } from '../components/estimates/EstimateDetailModal';
import { computeGrandTotal } from '../utils/estimateExport';
@@ -225,11 +226,19 @@ export default function EstimatesPage() {
const navigate = useNavigate();
const { theme } = useTheme();
const { user } = useAuth();
const { estimates, templates, addTemplate, updateTemplate, deleteTemplate } = useMockStore();
const {
estimates, templates, addTemplate, updateTemplate, deleteTemplate,
templateAccessRoles, templateAccessUsers, templateAccessExcluded,
} = useMockStore();
const isOwner = user?.role === ROLES.OWNER;
const basePath = user?.role === 'OWNER' ? '/owner' : user?.role === 'ADMIN' ? '/admin' : '/emp/fa';
// Effective template edit permission (Owner always; others via role/individual grant minus exclusions)
const canEditTemplates = isOwner ||
(templateAccessRoles.includes(user?.role) && !templateAccessExcluded.includes(user?.id)) ||
templateAccessUsers.includes(user?.id);
const [activeTab, setActiveTab] = useState('estimates'); // 'estimates' | 'templates'
const [estimateSearch, setEstimateSearch] = useState('');
const [estimateStatusFilter, setEstimateStatusFilter] = useState('All');
@@ -250,6 +259,9 @@ export default function EstimatesPage() {
// Delete confirm
const [deleteTarget, setDeleteTarget] = useState(null);
// Access management modal (Owner only)
const [accessModalOpen, setAccessModalOpen] = useState(false);
// ---- filtered + sorted estimates ----
const STATUS_SORT_ORDER = ['Draft', 'Sent', 'Waiting Approval', 'Approved', 'Follow Up Required', 'Revision Required', 'Rejected', 'Expired'];
@@ -553,7 +565,7 @@ export default function EstimatesPage() {
{c}
</button>
))}
{isOwner && (
{canEditTemplates && (
<button
onClick={openCreate}
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold transition-colors shadow-sm ml-1"
@@ -561,10 +573,18 @@ export default function EstimatesPage() {
<Plus size={13} /> New Template
</button>
)}
{isOwner && (
<button
onClick={() => setAccessModalOpen(true)}
className="flex items-center gap-1.5 px-4 py-2 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-zinc-600 dark:text-zinc-300 text-xs font-semibold hover:border-zinc-300 dark:hover:border-zinc-600 transition-colors"
>
<Shield size={13} /> Manage Access
</button>
)}
</div>
</div>
{!isOwner && (
{!canEditTemplates && (
<div className="mb-4 px-4 py-3 rounded-xl bg-zinc-100 dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700 text-xs text-zinc-500 dark:text-zinc-400">
Templates are managed by the Owner. You can apply these when creating a new estimate.
</div>
@@ -574,7 +594,7 @@ export default function EstimatesPage() {
<div className="text-center py-16 text-zinc-400 dark:text-zinc-600">
<Layers size={32} className="mx-auto mb-3 opacity-40" />
<p className="text-sm font-medium">No templates found</p>
{isOwner && (
{canEditTemplates && (
<button
onClick={openCreate}
className="mt-4 flex items-center gap-1.5 mx-auto px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold transition-colors"
@@ -589,7 +609,7 @@ export default function EstimatesPage() {
<TemplateCard
key={tpl.id}
template={tpl}
isOwner={isOwner}
isOwner={canEditTemplates}
onEdit={openEdit}
onDuplicate={openDuplicate}
onDelete={setDeleteTarget}
@@ -629,6 +649,12 @@ export default function EstimatesPage() {
/>
)}
</AnimatePresence>
{/* Template access modal — Owner only */}
<TemplateAccessModal
isOpen={accessModalOpen}
onClose={() => setAccessModalOpen(false)}
/>
</div>
);
}