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 */}
+
+
+ {/* 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 && (
New Template
)}
+ {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 && (
)}
+
+ {/* Template access modal โ Owner only */}
+
setAccessModalOpen(false)}
+ />
);
}