diff --git a/src/components/estimates/TemplateAccessModal.jsx b/src/components/estimates/TemplateAccessModal.jsx new file mode 100644 index 0000000..b684197 --- /dev/null +++ b/src/components/estimates/TemplateAccessModal.jsx @@ -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 ( + + ); +} + +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 ( + + {label} + + ); +} + +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 ; + if (roleOn && isExcluded) return ; + if (!roleOn && isDirect) return ; + 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( + + {isOpen && ( +
+ e.stopPropagation()} + > + {/* Header */} +
+
+
+ +
+
+

Template Access

+

{totalGranted} {totalGranted === 1 ? 'person has' : 'people have'} editing access

+
+
+ +
+ +
+ {/* By Role */} +
+

By Role

+
+ {['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 ( +
+
+ +
+
+

{cfg.label}

+

+ {count} {count === 1 ? 'person' : 'people'} + {isOn && excluded > 0 && ยท {excluded} excluded} +

+
+ toggleRole(role)} /> +
+ ); + })} +
+
+ + {/* Divider */} +
+
+
+
+
+ By Person +
+
+ + {/* Search */} +
+ + 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" + /> +
+ + {/* Employee list */} +
+ {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 ( +
+ {/* Avatar */} +
+ {initials} +
+ + {/* Info */} +
+
+ {emp.name} + {chip} +
+
+ {emp.empId} + + {roleCfg?.label?.replace(/s$/, '') ?? emp.role} + +
+
+ + {/* Toggle */} + toggleEmployee(emp)} /> +
+ ); + })} + + {filteredEmployees.length === 0 && ( +

No employees found

+ )} +
+
+ + {/* Footer */} +
+

+ Changes take effect immediately +

+ +
+ +
+ )} + , + document.body + ); +} diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx index 8039efa..b1a053b 100644 --- a/src/data/mockStore.jsx +++ b/src/data/mockStore.jsx @@ -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, diff --git a/src/pages/EstimatesPage.jsx b/src/pages/EstimatesPage.jsx index 7ffc81b..f991c35 100644 --- a/src/pages/EstimatesPage.jsx +++ b/src/pages/EstimatesPage.jsx @@ -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} ))} - {isOwner && ( + {canEditTemplates && ( )} + {isOwner && ( + + )}
- {!isOwner && ( + {!canEditTemplates && (
Templates are managed by the Owner. You can apply these when creating a new estimate.
@@ -574,7 +594,7 @@ export default function EstimatesPage() {

No templates found

- {isOwner && ( + {canEditTemplates && (
); }