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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3639,6 +3639,9 @@ export const MockStoreProvider = ({ children }) => {
|
|||||||
const [dispatchLeads, setDispatchLeads] = useState(DISPATCH_LEADS_INITIAL);
|
const [dispatchLeads, setDispatchLeads] = useState(DISPATCH_LEADS_INITIAL);
|
||||||
const [templates, setTemplates] = useState(MASTER_TEMPLATES_INITIAL);
|
const [templates, setTemplates] = useState(MASTER_TEMPLATES_INITIAL);
|
||||||
const [estimates, setEstimates] = useState(MOCK_ESTIMATES_INITIAL);
|
const [estimates, setEstimates] = useState(MOCK_ESTIMATES_INITIAL);
|
||||||
|
const [templateAccessRoles, setTemplateAccessRoles] = useState([]);
|
||||||
|
const [templateAccessUsers, setTemplateAccessUsers] = useState([]);
|
||||||
|
const [templateAccessExcluded, setTemplateAccessExcluded] = useState([]);
|
||||||
|
|
||||||
// Initialize properties once
|
// Initialize properties once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -3752,6 +3755,12 @@ export const MockStoreProvider = ({ children }) => {
|
|||||||
deleteTemplate: (id) => {
|
deleteTemplate: (id) => {
|
||||||
setTemplates(prev => prev.filter(t => t.id !== id));
|
setTemplates(prev => prev.filter(t => t.id !== id));
|
||||||
},
|
},
|
||||||
|
templateAccessRoles,
|
||||||
|
templateAccessUsers,
|
||||||
|
templateAccessExcluded,
|
||||||
|
setTemplateAccessRoles,
|
||||||
|
setTemplateAccessUsers,
|
||||||
|
setTemplateAccessExcluded,
|
||||||
|
|
||||||
// Estimates
|
// Estimates
|
||||||
estimates,
|
estimates,
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import {
|
|||||||
Plus, Search, FileText, Layers, Calendar, DollarSign,
|
Plus, Search, FileText, Layers, Calendar, DollarSign,
|
||||||
User, CheckCircle, Clock,
|
User, CheckCircle, Clock,
|
||||||
Pencil, Trash2, Copy, ChevronRight, Tag, Package,
|
Pencil, Trash2, Copy, ChevronRight, Tag, Package,
|
||||||
ArrowDownUp, ArrowUp, ArrowDown, X as XIcon
|
ArrowDownUp, ArrowUp, ArrowDown, X as XIcon, Shield
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import TemplateEditorModal from '../components/estimates/TemplateEditorModal';
|
import TemplateEditorModal from '../components/estimates/TemplateEditorModal';
|
||||||
import EstimateDetailModal from '../components/estimates/EstimateDetailModal';
|
import EstimateDetailModal from '../components/estimates/EstimateDetailModal';
|
||||||
|
import TemplateAccessModal from '../components/estimates/TemplateAccessModal';
|
||||||
import { ALL_STATUS_CONFIG } from '../components/estimates/EstimateDetailModal';
|
import { ALL_STATUS_CONFIG } from '../components/estimates/EstimateDetailModal';
|
||||||
import { computeGrandTotal } from '../utils/estimateExport';
|
import { computeGrandTotal } from '../utils/estimateExport';
|
||||||
|
|
||||||
@@ -225,11 +226,19 @@ export default function EstimatesPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const { user } = useAuth();
|
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 isOwner = user?.role === ROLES.OWNER;
|
||||||
const basePath = user?.role === 'OWNER' ? '/owner' : user?.role === 'ADMIN' ? '/admin' : '/emp/fa';
|
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 [activeTab, setActiveTab] = useState('estimates'); // 'estimates' | 'templates'
|
||||||
const [estimateSearch, setEstimateSearch] = useState('');
|
const [estimateSearch, setEstimateSearch] = useState('');
|
||||||
const [estimateStatusFilter, setEstimateStatusFilter] = useState('All');
|
const [estimateStatusFilter, setEstimateStatusFilter] = useState('All');
|
||||||
@@ -250,6 +259,9 @@ export default function EstimatesPage() {
|
|||||||
// Delete confirm
|
// Delete confirm
|
||||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
|
|
||||||
|
// Access management modal (Owner only)
|
||||||
|
const [accessModalOpen, setAccessModalOpen] = useState(false);
|
||||||
|
|
||||||
// ---- filtered + sorted estimates ----
|
// ---- filtered + sorted estimates ----
|
||||||
const STATUS_SORT_ORDER = ['Draft', 'Sent', 'Waiting Approval', 'Approved', 'Follow Up Required', 'Revision Required', 'Rejected', 'Expired'];
|
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}
|
{c}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{isOwner && (
|
{canEditTemplates && (
|
||||||
<button
|
<button
|
||||||
onClick={openCreate}
|
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"
|
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
|
<Plus size={13} /> New Template
|
||||||
</button>
|
</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>
|
||||||
</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">
|
<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.
|
Templates are managed by the Owner. You can apply these when creating a new estimate.
|
||||||
</div>
|
</div>
|
||||||
@@ -574,7 +594,7 @@ export default function EstimatesPage() {
|
|||||||
<div className="text-center py-16 text-zinc-400 dark:text-zinc-600">
|
<div className="text-center py-16 text-zinc-400 dark:text-zinc-600">
|
||||||
<Layers size={32} className="mx-auto mb-3 opacity-40" />
|
<Layers size={32} className="mx-auto mb-3 opacity-40" />
|
||||||
<p className="text-sm font-medium">No templates found</p>
|
<p className="text-sm font-medium">No templates found</p>
|
||||||
{isOwner && (
|
{canEditTemplates && (
|
||||||
<button
|
<button
|
||||||
onClick={openCreate}
|
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"
|
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
|
<TemplateCard
|
||||||
key={tpl.id}
|
key={tpl.id}
|
||||||
template={tpl}
|
template={tpl}
|
||||||
isOwner={isOwner}
|
isOwner={canEditTemplates}
|
||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
onDuplicate={openDuplicate}
|
onDuplicate={openDuplicate}
|
||||||
onDelete={setDeleteTarget}
|
onDelete={setDeleteTarget}
|
||||||
@@ -629,6 +649,12 @@ export default function EstimatesPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Template access modal — Owner only */}
|
||||||
|
<TemplateAccessModal
|
||||||
|
isOpen={accessModalOpen}
|
||||||
|
onClose={() => setAccessModalOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user