Covers: overview, sheets, AI commands, brief extraction, export CSV, export templates, admin (clients, dropdowns, users), and login/emergency access. Admin-only sections are hidden for regular users. Accessible via sidebar "Help" link at /help. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
278 lines
12 KiB
TypeScript
278 lines
12 KiB
TypeScript
import { useState } from 'react'
|
|
import { useNavigate, useLocation } from 'react-router-dom'
|
|
import { useSheetStore } from '../../stores/useSheetStore'
|
|
import { useAuthStore } from '../../stores/useAuthStore'
|
|
|
|
// Inline SVGs — no extra deps
|
|
const IconFile = () => (
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
<polyline points="14 2 14 8 20 8"/>
|
|
</svg>
|
|
)
|
|
const IconGrid = () => (
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
|
|
<rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>
|
|
</svg>
|
|
)
|
|
const IconSettings = () => (
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="12" cy="12" r="3"/>
|
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
|
</svg>
|
|
)
|
|
const IconUpload = () => (
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
|
<polyline points="17 8 12 3 7 8"/>
|
|
<line x1="12" y1="3" x2="12" y2="15"/>
|
|
</svg>
|
|
)
|
|
const IconPlus = () => (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
|
</svg>
|
|
)
|
|
const IconHelp = () => (
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="12" cy="12" r="10"/>
|
|
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
|
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
|
</svg>
|
|
)
|
|
|
|
export default function Sidebar() {
|
|
const navigate = useNavigate()
|
|
const location = useLocation()
|
|
const { sheets, activeSheetId, loadSheet, createSheet, renameSheet, deleteSheet, duplicateSheet } = useSheetStore()
|
|
const user = useAuthStore(s => s.user)
|
|
const [renamingId, setRenamingId] = useState<string | null>(null)
|
|
const [renameValue, setRenameValue] = useState('')
|
|
const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null)
|
|
|
|
const isActive = (path: string) => location.pathname === path || location.pathname.startsWith(path + '/')
|
|
|
|
const handleNewSheet = async () => {
|
|
const name = `Sheet ${new Date().toLocaleDateString()}`
|
|
const id = await createSheet(name)
|
|
navigate(`/sheet/${id}`)
|
|
}
|
|
|
|
const handleSelect = async (id: string) => {
|
|
await loadSheet(id)
|
|
navigate(`/sheet/${id}`)
|
|
}
|
|
|
|
const handleContextMenu = (e: React.MouseEvent, id: string) => {
|
|
e.preventDefault()
|
|
setContextMenu({ id, x: e.clientX, y: e.clientY })
|
|
}
|
|
|
|
const handleRename = (id: string, current: string) => {
|
|
setRenamingId(id)
|
|
setRenameValue(current)
|
|
setContextMenu(null)
|
|
}
|
|
|
|
const commitRename = async (id: string) => {
|
|
if (renameValue.trim()) await renameSheet(id, renameValue.trim())
|
|
setRenamingId(null)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="flex flex-col w-60 flex-shrink-0 border-r overflow-hidden"
|
|
style={{ background: 'var(--bg-sidebar)', borderColor: 'var(--border)' }}
|
|
onClick={() => setContextMenu(null)}
|
|
>
|
|
{/* ── Logo ── */}
|
|
<div className="px-4 py-4 border-b" style={{ borderColor: 'var(--border)' }}>
|
|
<button
|
|
onClick={() => navigate('/')}
|
|
className="flex items-center gap-2.5 w-full text-left group"
|
|
style={{ background: 'transparent', border: 'none', padding: 0 }}
|
|
aria-label="Go to Dashboard"
|
|
>
|
|
<div
|
|
className="w-7 h-7 rounded-lg flex items-center justify-center font-black text-xs tracking-tight flex-shrink-0 group-hover:opacity-90 transition-opacity"
|
|
style={{ background: 'var(--accent)', color: '#000' }}
|
|
>
|
|
AC
|
|
</div>
|
|
<div>
|
|
<div className="font-bold text-sm leading-tight" style={{ color: 'var(--text-primary)' }}>AC Tool</div>
|
|
<div className="text-xs leading-none" style={{ color: 'var(--text-muted)' }}>Oliver Agency</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
{/* ── Nav items ── */}
|
|
<div className="px-2 pt-3 pb-1 space-y-0.5">
|
|
<button
|
|
onClick={() => navigate('/')}
|
|
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left transition-colors"
|
|
style={{
|
|
background: isActive('/') && !location.pathname.startsWith('/sheet') && !location.pathname.startsWith('/brief') && !location.pathname.startsWith('/admin') ? 'var(--accent-dim)' : 'transparent',
|
|
color: isActive('/') && !location.pathname.startsWith('/sheet') && !location.pathname.startsWith('/brief') && !location.pathname.startsWith('/admin') ? 'var(--accent)' : 'var(--text-secondary)',
|
|
}}
|
|
>
|
|
<IconGrid />
|
|
Dashboard
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => navigate('/brief/upload')}
|
|
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left transition-colors"
|
|
style={{
|
|
background: location.pathname.startsWith('/brief') ? 'var(--accent-dim)' : 'transparent',
|
|
color: location.pathname.startsWith('/brief') ? 'var(--accent)' : 'var(--text-secondary)',
|
|
}}
|
|
>
|
|
<IconUpload />
|
|
Upload Brief
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => navigate('/help')}
|
|
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left transition-colors"
|
|
style={{
|
|
background: location.pathname === '/help' ? 'var(--accent-dim)' : 'transparent',
|
|
color: location.pathname === '/help' ? 'var(--accent)' : 'var(--text-secondary)',
|
|
}}
|
|
>
|
|
<IconHelp />
|
|
Help
|
|
</button>
|
|
</div>
|
|
|
|
{/* ── Divider ── */}
|
|
<div className="mx-4 my-2" style={{ height: 1, background: 'var(--border)' }} />
|
|
|
|
{/* ── Sheets list ── */}
|
|
<div className="flex-1 overflow-y-auto px-2 pb-2">
|
|
<div className="flex items-center justify-between px-3 py-1.5 mb-1">
|
|
<span className="text-xs font-semibold uppercase tracking-widest" style={{ color: 'var(--text-muted)' }}>
|
|
Sheets
|
|
</span>
|
|
<button
|
|
onClick={handleNewSheet}
|
|
className="w-6 h-6 rounded-md flex items-center justify-center transition-colors hover:opacity-80"
|
|
style={{ color: 'var(--accent)', background: 'var(--accent-dim)' }}
|
|
title="New Sheet"
|
|
>
|
|
<IconPlus />
|
|
</button>
|
|
</div>
|
|
|
|
{sheets.map(sheet => {
|
|
const active = activeSheetId === sheet.id
|
|
return (
|
|
<div
|
|
key={sheet.id}
|
|
onContextMenu={e => handleContextMenu(e, sheet.id)}
|
|
className="group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer mb-0.5 transition-colors"
|
|
style={{
|
|
background: active ? 'var(--accent-dim)' : 'transparent',
|
|
borderLeft: active ? '2px solid var(--accent)' : '2px solid transparent',
|
|
}}
|
|
onClick={() => handleSelect(sheet.id)}
|
|
>
|
|
{renamingId === sheet.id ? (
|
|
<input
|
|
autoFocus
|
|
value={renameValue}
|
|
onChange={e => setRenameValue(e.target.value)}
|
|
onBlur={() => commitRename(sheet.id)}
|
|
onKeyDown={e => {
|
|
if (e.key === 'Enter') commitRename(sheet.id)
|
|
if (e.key === 'Escape') setRenamingId(null)
|
|
}}
|
|
className="flex-1 bg-transparent text-sm outline-none border-b"
|
|
style={{ color: 'var(--text-primary)', borderColor: 'var(--accent)' }}
|
|
onClick={e => e.stopPropagation()}
|
|
/>
|
|
) : (
|
|
<>
|
|
<span style={{ color: active ? 'var(--accent)' : 'var(--text-muted)', flexShrink: 0 }}>
|
|
<IconFile />
|
|
</span>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-xs font-medium truncate" style={{ color: active ? 'var(--accent)' : 'var(--text-primary)' }}>
|
|
{sheet.name}
|
|
</div>
|
|
<div className="text-xs" style={{ color: 'var(--text-muted)' }}>{sheet.itemCount} items</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{sheets.length === 0 && (
|
|
<div className="px-3 py-6 text-center" style={{ color: 'var(--text-muted)' }}>
|
|
<div className="text-2xl mb-2 opacity-30">
|
|
<IconFile />
|
|
</div>
|
|
<div className="text-xs">No sheets yet</div>
|
|
<div className="text-xs mt-0.5 opacity-70">Click + to create one</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Admin ── */}
|
|
{user?.role === 'admin' && (
|
|
<div className="px-2 py-2 border-t space-y-0.5" style={{ borderColor: 'var(--border)' }}>
|
|
{[
|
|
{ label: 'Users', path: '/admin/users' },
|
|
{ label: 'Clients', path: '/admin/clients' },
|
|
{ label: 'Dropdowns', path: '/admin/dropdowns' },
|
|
].map(item => (
|
|
<button
|
|
key={item.path}
|
|
onClick={() => navigate(item.path)}
|
|
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left transition-colors"
|
|
style={{
|
|
background: location.pathname === item.path ? 'var(--accent-dim)' : 'transparent',
|
|
color: location.pathname === item.path ? 'var(--accent)' : 'var(--text-muted)',
|
|
}}
|
|
>
|
|
<IconSettings />
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Context menu ── */}
|
|
{contextMenu && (
|
|
<div
|
|
className="fixed z-50 py-1 shadow-2xl"
|
|
style={{
|
|
top: contextMenu.y, left: contextMenu.x,
|
|
background: 'var(--bg-elevated)',
|
|
border: '1px solid var(--border-light)',
|
|
borderRadius: 'var(--radius-md)',
|
|
minWidth: 148,
|
|
}}
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
{[
|
|
{ label: 'Rename', action: () => { const s = sheets.find(sh => sh.id === contextMenu.id); if (s) handleRename(s.id, s.name) } },
|
|
{ label: 'Duplicate', action: async () => { await duplicateSheet(contextMenu.id); setContextMenu(null) } },
|
|
{ label: 'Delete', action: async () => { if (confirm('Delete this sheet?')) { await deleteSheet(contextMenu.id); setContextMenu(null) } }, danger: true },
|
|
].map(item => (
|
|
<button
|
|
key={item.label}
|
|
onClick={item.action}
|
|
className="w-full text-left px-3 py-2 text-xs font-medium hover:opacity-70 transition-opacity"
|
|
style={{ color: item.danger ? 'var(--danger)' : 'var(--text-primary)', background: 'transparent', border: 'none' }}
|
|
>
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|