Initial Switch to V2. Completely Overhauled Backend, Frontend and General Structure.
This commit is contained in:
@@ -1,48 +0,0 @@
|
||||
export default function ConfirmDialog({ open, title, message, onConfirm, onCancel }) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black/60" onClick={onCancel} />
|
||||
<div
|
||||
className="relative rounded-lg shadow-xl p-6 w-full max-w-sm mx-4 border"
|
||||
style={{
|
||||
backgroundColor: "var(--bg-card)",
|
||||
borderColor: "var(--border-primary)",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
className="text-lg font-semibold mb-2"
|
||||
style={{ color: "var(--text-heading)" }}
|
||||
>
|
||||
{title || "Confirm"}
|
||||
</h3>
|
||||
<p className="text-sm mb-6" style={{ color: "var(--text-secondary)" }}>
|
||||
{message || "Are you sure?"}
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm rounded-md transition-colors"
|
||||
style={{
|
||||
backgroundColor: "var(--bg-card-hover)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-2 text-sm rounded-md transition-colors"
|
||||
style={{
|
||||
backgroundColor: "var(--danger)",
|
||||
color: "var(--text-white)",
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
export default function DataTable({ columns, data, onRowClick, emptyMessage = "No data found." }) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-8 text-center text-gray-500 text-sm">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="px-4 py-3 text-left font-medium text-gray-600"
|
||||
style={col.width ? { width: col.width } : {}}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, idx) => (
|
||||
<tr
|
||||
key={row.id || idx}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
className={`border-b border-gray-100 last:border-0 ${
|
||||
onRowClick ? "cursor-pointer hover:bg-gray-50" : ""
|
||||
}`}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="px-4 py-3 text-gray-700">
|
||||
{col.render ? col.render(row) : row[col.key]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
export default function SearchBar({ onSearch, placeholder = "Search...", style }) {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const handleChange = (e) => {
|
||||
setValue(e.target.value);
|
||||
onSearch(e.target.value);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setValue("");
|
||||
onSearch("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" style={style}>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
className="w-full px-3 py-2 rounded-md text-sm border"
|
||||
style={{
|
||||
backgroundColor: "var(--bg-input)",
|
||||
borderColor: "var(--border-primary)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
// TODO: Status badge component
|
||||
512
frontend/src/components/layout/Header.jsx
Normal file
512
frontend/src/components/layout/Header.jsx
Normal file
@@ -0,0 +1,512 @@
|
||||
// frontend/src/components/layout/Header.jsx
|
||||
// Fixed top bar — covers only the content area (left: --sidebar-width).
|
||||
//
|
||||
// Left: hamburger (mobile) + breadcrumb trail
|
||||
// Right: global search · notifications · settings · separator · profile w/ dropdown
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import api from '@/lib/api'
|
||||
import HeaderSearch from '@/components/ui/HeaderSearch'
|
||||
|
||||
// ─── Breadcrumb config ────────────────────────────────────────────────────────
|
||||
|
||||
const STATIC_LABELS = {
|
||||
'': 'Home',
|
||||
dashboard: 'Dashboard',
|
||||
devices: 'Devices',
|
||||
users: 'App Users',
|
||||
mqtt: 'MQTT',
|
||||
commands: 'Commands',
|
||||
logs: 'Logs',
|
||||
equipment: 'Equipment',
|
||||
notes: 'Issues & Notes',
|
||||
mail: 'Mail',
|
||||
crm: 'CRM',
|
||||
comms: 'Activity Log',
|
||||
customers: 'Customers',
|
||||
orders: 'Orders',
|
||||
products: 'Products',
|
||||
quotations: 'Quotations',
|
||||
new: 'New',
|
||||
edit: 'Edit',
|
||||
melodies: 'Melodies',
|
||||
archetypes: 'Archetypes',
|
||||
settings: 'Settings',
|
||||
composer: 'Composer',
|
||||
manufacturing: 'Manufacturing',
|
||||
batch: 'Batch',
|
||||
provision: 'Provision Device',
|
||||
firmware: 'Firmware',
|
||||
developer: 'Developer',
|
||||
api: 'API Reference',
|
||||
staff: 'Staff',
|
||||
sn: 'S/N Manager',
|
||||
'staff-log': 'Staff Log',
|
||||
'serial-logs': 'Log Viewer',
|
||||
'public-features': 'Public Features',
|
||||
}
|
||||
|
||||
const ENTITY_RESOLVERS = {
|
||||
customers: { path: (id) => `/crm/customers/${id}`, label: (d) => [d.name, d.surname].filter(Boolean).join(' ') || d.organization || null },
|
||||
products: { path: (id) => `/crm/products/${id}`, label: (d) => d.name || null },
|
||||
quotations: { path: (id) => `/crm/quotations/${id}`, label: (d) => d.quotation_number || null },
|
||||
devices: { path: (id) => `/devices/${id}`, label: (d) => d.name || d.device_id || null },
|
||||
orders: { path: (id) => `/crm/orders/${id}`, label: (d) => d.order_number || null },
|
||||
melodies: { path: (id) => `/melodies/${id}`, label: (d) => d.name || null },
|
||||
archetypes: { path: (id) => `/builder/melodies/${id}`, label: (d) => d.name || null },
|
||||
users: { path: (id) => `/users/${id}`, label: (d) => d.name || d.display_name || null },
|
||||
notes: { path: (id) => `/equipment/notes/${id}`, label: (d) => d.title || d.subject || null },
|
||||
staff: { path: (id) => `/staff/${id}`, label: (d) => d.name || null },
|
||||
}
|
||||
|
||||
const labelCache = {}
|
||||
|
||||
async function fetchEntityLabel(type, id) {
|
||||
const key = `${type}:${id}`
|
||||
if (labelCache[key]) return labelCache[key]
|
||||
const resolver = ENTITY_RESOLVERS[type]
|
||||
if (!resolver) return id
|
||||
try {
|
||||
const data = await api.get(resolver.path(id))
|
||||
const raw = resolver.label(data)
|
||||
if (raw) {
|
||||
const label = String(raw).length > 28 ? String(raw).slice(0, 26) + '…' : String(raw)
|
||||
labelCache[key] = label
|
||||
return label
|
||||
}
|
||||
} catch {
|
||||
// fall back to raw id
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
function parseSegments(pathname) {
|
||||
const parts = pathname.split('/').filter(Boolean)
|
||||
const segments = [{ label: 'Home', to: '/' }]
|
||||
let i = 0
|
||||
|
||||
while (i < parts.length) {
|
||||
const part = parts[i]
|
||||
const built = '/' + parts.slice(0, i + 1).join('/')
|
||||
|
||||
if (part === 'crm') {
|
||||
segments.push({ label: 'CRM', to: '/crm/customers' })
|
||||
i++; continue
|
||||
}
|
||||
if (part === 'equipment' && parts[i + 1] === 'notes') {
|
||||
segments.push({ label: 'Issues & Notes', to: '/equipment/notes' })
|
||||
i += 2; continue
|
||||
}
|
||||
if (part === 'manufacturing' && parts[i + 1] === 'provision') {
|
||||
segments.push({ label: 'Manufacturing', to: '/manufacturing' })
|
||||
segments.push({ label: 'Provision Device', to: '/manufacturing/provision' })
|
||||
i += 2; continue
|
||||
}
|
||||
|
||||
const staticLabel = STATIC_LABELS[part]
|
||||
if (staticLabel) {
|
||||
segments.push({ label: staticLabel, to: built })
|
||||
} else {
|
||||
const prevPart = parts[i - 1]
|
||||
const fetchType = ENTITY_RESOLVERS[prevPart] ? prevPart : null
|
||||
segments.push({ label: part, to: built, dynamicId: part, fetchType })
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
// ─── Breadcrumb ───────────────────────────────────────────────────────────────
|
||||
|
||||
function Breadcrumb() {
|
||||
const location = useLocation()
|
||||
const [segments, setSegments] = useState(() => parseSegments(location.pathname))
|
||||
|
||||
useEffect(() => {
|
||||
const parsed = parseSegments(location.pathname)
|
||||
setSegments(parsed)
|
||||
|
||||
const dynamics = parsed.filter((s) => s.fetchType && s.dynamicId)
|
||||
if (!dynamics.length) return
|
||||
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const resolved = [...parsed]
|
||||
for (const seg of dynamics) {
|
||||
const label = await fetchEntityLabel(seg.fetchType, seg.dynamicId)
|
||||
if (cancelled) return
|
||||
const idx = resolved.findIndex(
|
||||
(s) => s.dynamicId === seg.dynamicId && s.fetchType === seg.fetchType
|
||||
)
|
||||
if (idx !== -1) resolved[idx] = { ...resolved[idx], label }
|
||||
}
|
||||
if (!cancelled) setSegments([...resolved])
|
||||
})()
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [location.pathname])
|
||||
|
||||
if (segments.length <= 1) return null
|
||||
|
||||
const display = segments.slice(1)
|
||||
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className="header-breadcrumb-nav">
|
||||
{display.map((seg, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', minWidth: 0, flexShrink: i === display.length - 1 ? 1 : 0 }}
|
||||
>
|
||||
{i > 0 && (
|
||||
<span className="header-breadcrumb-sep" aria-hidden="true">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 2l4 3-4 3" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{i === display.length - 1 ? (
|
||||
<span aria-current="page" className="header-breadcrumb-current">
|
||||
{seg.label}
|
||||
</span>
|
||||
) : (
|
||||
<Link to={seg.to} className="header-breadcrumb-link">
|
||||
{seg.label}
|
||||
</Link>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Icons ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function HamburgerIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"
|
||||
aria-hidden="true" focusable="false"
|
||||
>
|
||||
<path d="M3 5h14M3 10h14M3 15h14" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function BellIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
aria-hidden="true" focusable="false"
|
||||
>
|
||||
<path d="M8 2a4 4 0 0 1 4 4v3l1 2H3l1-2V6a4 4 0 0 1 4-4z" />
|
||||
<path d="M6.5 13.5a1.5 1.5 0 0 0 3 0" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function GearIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
aria-hidden="true" focusable="false"
|
||||
>
|
||||
<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 0 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 0 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 0 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 0 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 0 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 0 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 0 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 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SignOutIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 15 15" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"
|
||||
aria-hidden="true" focusable="false"
|
||||
>
|
||||
<path d="M5.5 2.5H3A1 1 0 0 0 2 3.5v8A1 1 0 0 0 3 12.5h2.5" />
|
||||
<path d="M10 5l3 2.5L10 10" />
|
||||
<path d="M13 7.5H6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Profile dropdown ─────────────────────────────────────────────────────────
|
||||
|
||||
function ProfileMenu({ onClose, onSignOut }) {
|
||||
useEffect(() => {
|
||||
const handler = (e) => { if (e.key === 'Escape') onClose() }
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div className="profile-menu" role="menu">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="profile-menu-item profile-menu-item--danger"
|
||||
onClick={onSignOut}
|
||||
>
|
||||
<SignOutIcon />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Settings dropdown ────────────────────────────────────────────────────────
|
||||
|
||||
const SETTINGS_ITEMS = [
|
||||
{
|
||||
to: '/settings/staff',
|
||||
label: 'Staff Management',
|
||||
icon: (
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="8" cy="5" r="3"/>
|
||||
<path d="M2 14c0-3.31 2.69-6 6-6s6 2.69 6 6"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: '/settings/public-features',
|
||||
label: 'Public Features',
|
||||
icon: (
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M2 8a6 6 0 1 0 12 0A6 6 0 0 0 2 8z"/>
|
||||
<path d="M8 2a9 9 0 0 0 0 12M8 2a9 9 0 0 1 0 12M2 8h12"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: '/settings/serial-logs',
|
||||
label: 'Log Viewer',
|
||||
icon: (
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<rect x="2" y="2" width="12" height="12" rx="1.5"/>
|
||||
<path d="M5 6h6M5 9h4M5 12h2"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: '/settings/pages',
|
||||
label: 'Page Settings',
|
||||
placeholder: true,
|
||||
icon: (
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="2.5"/>
|
||||
<path d="M8 2v1.5M8 12.5V14M2 8h1.5M12.5 8H14M3.5 3.5l1 1M11.5 11.5l1 1M3.5 12.5l1-1M11.5 4.5l1-1"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
function SettingsMenu({ onClose }) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e) => { if (e.key === 'Escape') onClose() }
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 8px)',
|
||||
right: 0,
|
||||
zIndex: 9999,
|
||||
minWidth: 200,
|
||||
backgroundColor: 'var(--color-bg-float)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
WebkitBackdropFilter: 'blur(12px)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
role="menu"
|
||||
>
|
||||
<div style={{
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
color: 'var(--color-text-muted)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 'var(--tracking-wide)',
|
||||
}}>
|
||||
Console Settings
|
||||
</div>
|
||||
{SETTINGS_ITEMS.map((item) => (
|
||||
<button
|
||||
key={item.to}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={item.placeholder}
|
||||
onClick={() => {
|
||||
if (!item.placeholder) {
|
||||
navigate(item.to)
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
cursor: item.placeholder ? 'default' : 'pointer',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
color: item.placeholder ? 'var(--color-text-muted)' : 'var(--color-text-secondary)',
|
||||
textAlign: 'left',
|
||||
opacity: item.placeholder ? 0.5 : 1,
|
||||
transition: 'background-color 0.1s',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!item.placeholder) e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }}
|
||||
>
|
||||
{item.icon}
|
||||
<span style={{ flex: 1 }}>{item.label}</span>
|
||||
{item.placeholder && (
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
color: 'var(--color-text-muted)',
|
||||
backgroundColor: 'var(--color-bg-elevated)',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
padding: '1px 6px',
|
||||
letterSpacing: '0.04em',
|
||||
}}>
|
||||
soon
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Header ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Header({ onMenuOpen }) {
|
||||
const { user, logout, hasRole } = useAuth()
|
||||
const [search, setSearch] = useState('')
|
||||
const [profileOpen, setProfileOpen] = useState(false)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const profileRef = useRef(null)
|
||||
const settingsRef = useRef(null)
|
||||
const canManageSettings = hasRole('sysadmin', 'admin')
|
||||
|
||||
useEffect(() => {
|
||||
if (!profileOpen) return
|
||||
const handler = (e) => {
|
||||
if (!profileRef.current?.contains(e.target)) setProfileOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [profileOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsOpen) return
|
||||
const handler = (e) => {
|
||||
if (!settingsRef.current?.contains(e.target)) setSettingsOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [settingsOpen])
|
||||
|
||||
const initials = user?.name
|
||||
? user.name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase()
|
||||
: '?'
|
||||
|
||||
return (
|
||||
<header className="header">
|
||||
{/* ── Mobile hamburger ── */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMenuOpen}
|
||||
aria-label="Open navigation menu"
|
||||
className="header-hamburger"
|
||||
>
|
||||
<HamburgerIcon />
|
||||
</button>
|
||||
|
||||
{/* ── Breadcrumb ── */}
|
||||
<Breadcrumb />
|
||||
|
||||
{/* ── Flex spacer ── */}
|
||||
<div className="header-spacer" />
|
||||
|
||||
{/* ── Right controls ── */}
|
||||
<div className="header-right">
|
||||
|
||||
<div className="header-search">
|
||||
<HeaderSearch value={search} onChange={setSearch} placeholder="Search…" />
|
||||
</div>
|
||||
|
||||
<button type="button" className="header-icon-btn" aria-label="Notifications">
|
||||
<BellIcon />
|
||||
</button>
|
||||
|
||||
{canManageSettings && (
|
||||
<div style={{ position: 'relative' }} ref={settingsRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="header-icon-btn"
|
||||
aria-label="Console settings"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={settingsOpen}
|
||||
onClick={() => setSettingsOpen((p) => !p)}
|
||||
style={settingsOpen ? { backgroundColor: 'var(--color-bg-elevated)', color: 'var(--color-text-primary)' } : undefined}
|
||||
>
|
||||
<GearIcon />
|
||||
</button>
|
||||
{settingsOpen && (
|
||||
<SettingsMenu onClose={() => setSettingsOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="header-vsep" aria-hidden="true" />
|
||||
|
||||
<div className="header-profile-wrap" ref={profileRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="header-profile"
|
||||
onClick={() => setProfileOpen((p) => !p)}
|
||||
aria-expanded={profileOpen}
|
||||
aria-haspopup="menu"
|
||||
aria-label="Profile menu"
|
||||
>
|
||||
{user && (
|
||||
<div className="header-profile-info">
|
||||
<span className="header-profile-name">{user.name}</span>
|
||||
{user.role && (
|
||||
<span className="header-profile-role">{user.role}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="header-avatar" aria-hidden="true">
|
||||
{initials}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{profileOpen && (
|
||||
<ProfileMenu
|
||||
onClose={() => setProfileOpen(false)}
|
||||
onSignOut={logout}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
47
frontend/src/components/layout/MainLayout.jsx
Normal file
47
frontend/src/components/layout/MainLayout.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
// frontend/src/components/layout/MainLayout.jsx
|
||||
// Root shell for all authenticated pages.
|
||||
//
|
||||
// Structure (sidebar-first):
|
||||
// <Sidebar> — position: fixed, full height, left column (224px)
|
||||
// .main-area
|
||||
// <Header> — position: fixed, top-right (left: --sidebar-width)
|
||||
// <main> — scrollable content, padded for fixed header
|
||||
//
|
||||
// Mobile (< 768px): sidebar hidden, header goes full-width,
|
||||
// hamburger in header opens <MobileDrawer>.
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import Header from './Header'
|
||||
import Sidebar from './Sidebar'
|
||||
import MobileDrawer from './MobileDrawer'
|
||||
|
||||
export default function MainLayout() {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Fixed sidebar — full viewport height */}
|
||||
<Sidebar />
|
||||
|
||||
{/* Main area — offset from sidebar, full remaining width */}
|
||||
<div className="main-area">
|
||||
<Header onMenuOpen={() => setDrawerOpen(true)} />
|
||||
|
||||
<main
|
||||
id="main-content"
|
||||
className="main-content"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Mobile slide-over drawer */}
|
||||
<MobileDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
72
frontend/src/components/layout/MobileDrawer.jsx
Normal file
72
frontend/src/components/layout/MobileDrawer.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
// frontend/src/components/layout/MobileDrawer.jsx
|
||||
// Full-height slide-over navigation drawer for mobile viewports (< 768px).
|
||||
// Renders the Sidebar inside an overlay panel.
|
||||
//
|
||||
// Props:
|
||||
// open — boolean — controls visibility
|
||||
// onClose — fn — called on backdrop click, close button, or Escape
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Sidebar from './Sidebar'
|
||||
|
||||
function CloseIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"
|
||||
aria-hidden="true" focusable="false"
|
||||
>
|
||||
<path d="M3 3l10 10M13 3L3 13" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MobileDrawer({ open, onClose }) {
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e) => { if (e.key === 'Escape') onClose() }
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Scrim */}
|
||||
<div
|
||||
role="presentation"
|
||||
onClick={onClose}
|
||||
className="drawer-scrim"
|
||||
/>
|
||||
|
||||
{/* Drawer panel */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Navigation menu"
|
||||
className="drawer-panel"
|
||||
>
|
||||
<div className="drawer-titlebar">
|
||||
<span className="drawer-label">Navigation</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close navigation menu"
|
||||
className="drawer-close"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="drawer-body">
|
||||
<Sidebar />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
338
frontend/src/components/layout/Sidebar.jsx
Normal file
338
frontend/src/components/layout/Sidebar.jsx
Normal file
@@ -0,0 +1,338 @@
|
||||
// frontend/src/components/layout/Sidebar.jsx
|
||||
// Primary navigation sidebar — 224px wide, fixed, full height.
|
||||
//
|
||||
// Visual style (matches Stitch reference):
|
||||
// - Brand header with logo at top
|
||||
// - Section labels: plain uppercase text, generous padding, no rule lines
|
||||
// - Nav items: px-6 py-3, full width, 3px left bar + primary-subtle bg when active
|
||||
// - Collapsible groups: same row height as nav items
|
||||
// - Children: darker inset bg, deep left-indent, text-color hover
|
||||
// - Console Settings: pinned at bottom
|
||||
|
||||
import { useState } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import logoDark from '@/assets/logos/bell_systems_horizontal_darkMode.png'
|
||||
|
||||
// ─── Icon set ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const S = ({ children, ...p }) => (
|
||||
<svg
|
||||
width="16" height="16" viewBox="0 0 16 16"
|
||||
fill="none" stroke="currentColor" strokeWidth="1.5"
|
||||
strokeLinecap="round" strokeLinejoin="round"
|
||||
aria-hidden="true" focusable="false"
|
||||
style={{ flexShrink: 0 }}
|
||||
{...p}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
)
|
||||
|
||||
const Icons = {
|
||||
dashboard: () => <S><rect x="2" y="2" width="5" height="5" rx="1"/><rect x="9" y="2" width="5" height="5" rx="1"/><rect x="2" y="9" width="5" height="5" rx="1"/><rect x="9" y="9" width="5" height="5" rx="1"/></S>,
|
||||
devices: () => <S><rect x="2" y="3" width="12" height="8" rx="1.5"/><path d="M5 14h6M8 11v3"/></S>,
|
||||
deviceOverview: () => <S><circle cx="8" cy="6" r="3"/><path d="M3 13c0-2.76 2.24-5 5-5s5 2.24 5 5"/></S>,
|
||||
fleet: () => <S><path d="M2 5h12M2 8h9M2 11h6"/></S>,
|
||||
commandCenter: () => <S><rect x="2" y="2" width="12" height="12" rx="1.5"/><path d="M5 6l2 2-2 2M9 10h2"/></S>,
|
||||
blackBox: () => <S><rect x="2" y="4" width="12" height="8" rx="1"/><path d="M5 8h6"/></S>,
|
||||
appUsers: () => <S><circle cx="6" cy="5" r="2.5"/><path d="M2 13c0-2.2 1.8-4 4-4"/><circle cx="12" cy="7" r="2"/><path d="M9.5 13c0-1.65 1.12-3 2.5-3s2.5 1.35 2.5 3"/></S>,
|
||||
melodies: () => <S><path d="M9 3v7"/><path d="M9 3l4-1v7"/><circle cx="7" cy="10" r="2"/><circle cx="11" cy="9" r="2"/></S>,
|
||||
library: () => <S><rect x="2" y="2" width="4" height="12" rx="1"/><rect x="7" y="4" width="4" height="10" rx="1"/><rect x="12" y="2" width="2" height="12" rx="1"/></S>,
|
||||
composer: () => <S><path d="M2 12L10 4l2 2-8 8H2v-2z"/><path d="M8 6l2 2"/></S>,
|
||||
archetypes: () => <S><path d="M8 2l2 4h4l-3 3 1 4-4-2.5L4 13l1-4-3-3h4z"/></S>,
|
||||
melodySettings: () => <S><path d="M2 4h2"/><path d="M6 4h8"/><circle cx="5" cy="4" r="1.5" fill="currentColor" stroke="none"/><path d="M2 8h6"/><path d="M10 8h4"/><circle cx="9" cy="8" r="1.5" fill="currentColor" stroke="none"/><path d="M2 12h9"/><path d="M13 12h1"/><circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"/></S>,
|
||||
communications: () => <S><path d="M2 3h12v8H2z"/><path d="M5 14l3-3 3 3"/></S>,
|
||||
mail: () => <S><rect x="2" y="4" width="12" height="9" rx="1"/><path d="M2 5l6 4 6-4"/></S>,
|
||||
whatsapp: () => <S><path d="M8 2a6 6 0 0 1 6 6c0 3.31-2.69 6-6 6a5.97 5.97 0 0 1-3.1-.86L2 14l.86-2.9A5.97 5.97 0 0 1 2 8a6 6 0 0 1 6-6z"/></S>,
|
||||
sms: () => <S><path d="M2 3h12v8H8l-3 2.5V11H2z"/></S>,
|
||||
helpdesk: () => <S><path d="M8 2a4 4 0 0 0-4 4c0 1.5.82 2.8 2 3.46V11h4V9.46A4 4 0 0 0 8 2z"/><path d="M6 13h4"/><path d="M8 11v2"/></S>,
|
||||
commsLog: () => <S><circle cx="8" cy="8" r="6"/><path d="M8 5v3l2 2"/></S>,
|
||||
customers: () => <S><rect x="3" y="2" width="10" height="7" rx="1"/><path d="M1 14c0-2.76 3.13-5 7-5s7 2.24 7 5"/></S>,
|
||||
customerOverview: () => <S><path d="M2 12l3-5 3 3 2-4 4 6"/></S>,
|
||||
orders: () => <S><rect x="3" y="2" width="10" height="12" rx="1"/><path d="M6 6h4M6 9h4M6 12h2"/></S>,
|
||||
quotations: () => <S><rect x="3" y="2" width="10" height="12" rx="1"/><path d="M6 5h4M6 8h3M9 11l1-1 1 1 1-3"/></S>,
|
||||
products: () => <S><path d="M8 2L2 5v6l6 3 6-3V5z"/><path d="M8 2v9M2 5l6 3 6-3"/></S>,
|
||||
catalog: () => <S><rect x="2" y="2" width="5" height="5" rx="1"/><rect x="9" y="2" width="5" height="5" rx="1"/><rect x="2" y="9" width="5" height="5" rx="1"/><rect x="9" y="9" width="5" height="5" rx="1"/></S>,
|
||||
snManager: () => <S><rect x="2" y="5" width="12" height="6" rx="1"/><path d="M4 8h1M6 8h1M8 8h1M10 8h1"/></S>,
|
||||
staffLog: () => <S><rect x="4" y="2" width="8" height="2" rx="1"/><rect x="2" y="3" width="12" height="11" rx="1"/><path d="M5 8h6M5 11h4"/></S>,
|
||||
manufacturing: () => <S><path d="M2 12l3-6 3 3 2-5 4 8H2z"/><circle cx="5" cy="5" r="1.5"/></S>,
|
||||
inventory: () => <S><rect x="2" y="7" width="5" height="7" rx="1"/><rect x="5.5" y="4" width="5" height="10" rx="1"/><rect x="9" y="2" width="5" height="12" rx="1"/></S>,
|
||||
provisioning: () => <S><path d="M8 2v8"/><path d="M5 8l3 3 3-3"/><path d="M3 13h10"/></S>,
|
||||
firmware: () => <S><rect x="3" y="4" width="10" height="8" rx="1"/><path d="M6 7h4M7 10h2"/><path d="M6 2h4M6 14h4"/></S>,
|
||||
api: () => <S><path d="M4 6l-2 2 2 2M12 6l2 2-2 2M9 4l-2 8"/></S>,
|
||||
settings: () => <S><circle cx="8" cy="8" r="2.5"/><path d="M8 2v1.5M8 12.5V14M2 8h1.5M12.5 8H14M3.5 3.5l1 1M11.5 11.5l1 1M3.5 12.5l1-1M11.5 4.5l1-1"/></S>,
|
||||
staff: () => <S><circle cx="8" cy="5" r="3"/><path d="M2 14c0-3.31 2.69-6 6-6s6 2.69 6 6"/></S>,
|
||||
publicFeatures: () => <S><path d="M2 8a6 6 0 1 0 12 0A6 6 0 0 0 2 8z"/><path d="M8 2a9 9 0 0 0 0 12M8 2a9 9 0 0 1 0 12M2 8h12"/></S>,
|
||||
logViewer: () => <S><rect x="2" y="2" width="12" height="12" rx="1.5"/><path d="M5 6h6M5 9h4M5 12h2"/></S>,
|
||||
placeholder: () => <S><rect x="3" y="3" width="10" height="10" rx="2"/></S>,
|
||||
chevronRight: () => (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"
|
||||
aria-hidden="true" focusable="false" style={{ flexShrink: 0 }}
|
||||
>
|
||||
<path d="M4 2l4 4-4 4" />
|
||||
</svg>
|
||||
),
|
||||
lock: () => (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"
|
||||
aria-hidden="true" focusable="false" style={{ flexShrink: 0 }}
|
||||
>
|
||||
<rect x="2" y="5" width="8" height="6" rx="1" />
|
||||
<path d="M4 5V4a2 2 0 0 1 4 0v1" />
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
|
||||
// ─── Nav structure ────────────────────────────────────────────────────────────
|
||||
|
||||
const navSections = [
|
||||
{ type: 'separator', label: 'General' },
|
||||
{ to: '/', label: 'Dashboard', icon: 'dashboard' },
|
||||
|
||||
{ type: 'separator', label: 'Bell Cloud' },
|
||||
{
|
||||
label: 'Devices', icon: 'devices', permission: 'devices',
|
||||
children: [
|
||||
{ to: '/devices/overview', label: 'Overview', icon: 'deviceOverview', placeholder: true },
|
||||
{ to: '/devices', label: 'Fleet', icon: 'fleet', exact: true },
|
||||
{ to: '/mqtt/commands', label: 'Command Center', icon: 'commandCenter' },
|
||||
{ to: '/equipment/notes', label: 'BlackBox', icon: 'blackBox' },
|
||||
{ to: '/devices/settings', label: 'Device Settings', icon: 'settings', placeholder: true },
|
||||
],
|
||||
},
|
||||
{ to: '/users', label: 'App Users', icon: 'appUsers', permission: 'app_users' },
|
||||
{
|
||||
label: 'Melodies', icon: 'melodies', permission: 'melodies',
|
||||
children: [
|
||||
{ to: '/melodies', label: 'Library', icon: 'library', exact: true },
|
||||
{ to: '/melodies/composer', label: 'Composer', icon: 'composer' },
|
||||
{ to: '/melodies/archetypes', label: 'Archetypes', icon: 'archetypes' },
|
||||
{ to: '/melodies/settings', label: 'Melody Settings', icon: 'melodySettings' },
|
||||
],
|
||||
},
|
||||
|
||||
{ type: 'separator', label: 'Headquarters' },
|
||||
{
|
||||
label: 'Communications', icon: 'communications', permission: 'crm',
|
||||
children: [
|
||||
{ to: '/mail', label: 'Mailbox', icon: 'mail' },
|
||||
{ to: '/comms/whatsapp', label: 'WhatsApp', icon: 'whatsapp', placeholder: true },
|
||||
{ to: '/comms/sms', label: 'SMS', icon: 'sms', placeholder: true },
|
||||
{ to: '/crm/comms/helpdesk', label: 'Helpdesk', icon: 'helpdesk', exact: true },
|
||||
{ to: '/crm/comms', label: 'Comms Log', icon: 'commsLog', exact: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Customers', icon: 'customers', permission: 'crm',
|
||||
children: [
|
||||
{ to: '/crm/customers', label: 'Overview', icon: 'customerOverview' },
|
||||
{ to: '/crm/orders', label: 'Orders', icon: 'orders' },
|
||||
{ to: '/crm/quotations', label: 'Quotations', icon: 'quotations' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Products', icon: 'products', permission: 'crm',
|
||||
children: [
|
||||
{ to: '/crm/products', label: 'Catalog', icon: 'catalog' },
|
||||
{ to: '/crm/products/sn', label: 'S/N Manager', icon: 'snManager', placeholder: true },
|
||||
],
|
||||
},
|
||||
{ to: '/staff-log', label: 'Staff Log', icon: 'staffLog', placeholder: true },
|
||||
|
||||
{ type: 'separator', label: 'Engineering' },
|
||||
{
|
||||
label: 'Manufacturing', icon: 'manufacturing', permission: 'manufacturing',
|
||||
children: [
|
||||
{ to: '/manufacturing', label: 'Inventory', icon: 'inventory', exact: true },
|
||||
{ to: '/manufacturing/provision', label: 'Provisioning', icon: 'provisioning' },
|
||||
],
|
||||
},
|
||||
{ to: '/firmware', label: 'Firmware Manager', icon: 'firmware', permission: 'manufacturing' },
|
||||
{ to: '/developer/api', label: 'API Reference', icon: 'api', roleRequired: ['sysadmin', 'admin'] },
|
||||
]
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function isGroupActive(children, pathname) {
|
||||
return children.some((child) => {
|
||||
if (child.exact) return pathname === child.to
|
||||
return pathname === child.to || pathname.startsWith(child.to + '/')
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Section separator ────────────────────────────────────────────────────────
|
||||
|
||||
function SectionSeparator({ label, first }) {
|
||||
return (
|
||||
<div className={`nav-sep${first ? ' nav-sep--first' : ''}`}>
|
||||
<span className="nav-sep-label">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Placeholder item ─────────────────────────────────────────────────────────
|
||||
|
||||
function PlaceholderItem({ label, icon, child = false }) {
|
||||
const IconComp = Icons[icon] ?? Icons.placeholder
|
||||
return (
|
||||
<div className={`nav-placeholder ${child ? 'nav-placeholder--child' : 'nav-placeholder--root'}`}>
|
||||
<IconComp />
|
||||
<span style={{ flex: 1 }}>{label}</span>
|
||||
<span className="nav-soon-badge">soon</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Collapsible group ────────────────────────────────────────────────────────
|
||||
|
||||
function CollapsibleGroup({ label, icon, children, currentPath, locked, open, onToggle }) {
|
||||
const IconComp = Icons[icon] ?? Icons.placeholder
|
||||
const childActive = isGroupActive(children, currentPath)
|
||||
const isOpen = open || childActive
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !locked && onToggle()}
|
||||
disabled={locked}
|
||||
aria-expanded={isOpen}
|
||||
className={`nav-group-btn${childActive ? ' nav-group-btn--active' : ''}`}
|
||||
>
|
||||
<IconComp />
|
||||
<span style={{ flex: 1 }}>{label}</span>
|
||||
{locked
|
||||
? <Icons.lock />
|
||||
: (
|
||||
<span className={`nav-chevron${isOpen ? ' nav-chevron--open' : ''}`}>
|
||||
<Icons.chevronRight />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</button>
|
||||
|
||||
{!locked && isOpen && (
|
||||
<div className="nav-children">
|
||||
{children.map((child) =>
|
||||
child.placeholder ? (
|
||||
<PlaceholderItem key={child.to} label={child.label} icon={child.icon} child />
|
||||
) : (
|
||||
<NavLink
|
||||
key={child.to}
|
||||
to={child.to}
|
||||
end={child.exact === true}
|
||||
className={({ isActive }) => `nav-child-link${isActive ? ' active' : ''}`}
|
||||
>
|
||||
{({ isActive }) => {
|
||||
const ChildIcon = Icons[child.icon] ?? Icons.placeholder
|
||||
return (
|
||||
<>
|
||||
<ChildIcon />
|
||||
<span>{child.label}</span>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</NavLink>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main Sidebar ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Sidebar() {
|
||||
const { hasPermission, hasRole } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
const [openGroup, setOpenGroup] = useState(() => {
|
||||
for (const item of navSections) {
|
||||
if (item.children && isGroupActive(item.children, location.pathname)) {
|
||||
return item.label
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const canView = (item) => {
|
||||
if (item.roleRequired) return hasRole(...item.roleRequired)
|
||||
if (!item.permission) return true
|
||||
return hasPermission(item.permission, 'view')
|
||||
}
|
||||
|
||||
const handleToggle = (label) => setOpenGroup((prev) => (prev === label ? null : label))
|
||||
|
||||
return (
|
||||
<aside aria-label="Main navigation" className="sidebar">
|
||||
|
||||
{/* ── Brand header ── */}
|
||||
<div className="sidebar-brand">
|
||||
<img
|
||||
src={logoDark}
|
||||
alt="BellSystems"
|
||||
style={{ height: '18px', width: 'auto', objectFit: 'contain' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Navigation ── */}
|
||||
<nav className="sidebar-nav">
|
||||
{navSections.map((item, idx) => {
|
||||
if (item.type === 'separator') {
|
||||
return (
|
||||
<SectionSeparator
|
||||
key={`sep-${idx}`}
|
||||
label={item.label}
|
||||
first={idx === 0}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!canView(item)) return null
|
||||
|
||||
if (item.placeholder) {
|
||||
return (
|
||||
<PlaceholderItem
|
||||
key={item.to}
|
||||
label={item.label}
|
||||
icon={item.icon}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.children) {
|
||||
return (
|
||||
<CollapsibleGroup
|
||||
key={item.label}
|
||||
label={item.label}
|
||||
icon={item.icon}
|
||||
children={item.children}
|
||||
currentPath={location.pathname}
|
||||
locked={false}
|
||||
open={openGroup === item.label}
|
||||
onToggle={() => handleToggle(item.label)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const IconComp = Icons[item.icon] ?? Icons.placeholder
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) => `nav-link${isActive ? ' active' : ''}`}
|
||||
>
|
||||
<IconComp />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
4
frontend/src/components/shared/ActivityLog.jsx
Normal file
4
frontend/src/components/shared/ActivityLog.jsx
Normal file
@@ -0,0 +1,4 @@
|
||||
// TODO: implement
|
||||
export default function ActivityLog() {
|
||||
return null
|
||||
}
|
||||
4
frontend/src/components/shared/StaffNotesPanel.jsx
Normal file
4
frontend/src/components/shared/StaffNotesPanel.jsx
Normal file
@@ -0,0 +1,4 @@
|
||||
// TODO: implement
|
||||
export default function StaffNotesPanel() {
|
||||
return null
|
||||
}
|
||||
56
frontend/src/components/ui/Breadcrumbs.jsx
Normal file
56
frontend/src/components/ui/Breadcrumbs.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
// src/components/ui/Breadcrumbs.jsx
|
||||
// Standalone breadcrumb trail. Use on detail pages below the page title.
|
||||
// PageHeader has breadcrumbs built-in; use this component when you need
|
||||
// breadcrumbs independently of PageHeader.
|
||||
//
|
||||
// Props:
|
||||
// items — Array<{ label: string, href?: string }>
|
||||
// Last item is always rendered as current page (no link).
|
||||
// className — extra classes on the <nav> element
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
const ChevronSep = () => (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
className="breadcrumbs-sep"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<path d="M4 2l4 4-4 4" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default function Breadcrumbs({ items = [], className = '' }) {
|
||||
if (!items.length) return null
|
||||
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className={`breadcrumbs ${className}`}>
|
||||
{items.map((item, i) => {
|
||||
const isLast = i === items.length - 1
|
||||
return (
|
||||
<span key={i} className="contents">
|
||||
{i > 0 && <ChevronSep />}
|
||||
{isLast || !item.href ? (
|
||||
<span
|
||||
className="breadcrumbs-current"
|
||||
aria-current={isLast ? 'page' : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
) : (
|
||||
<Link to={item.href}>{item.label}</Link>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
50
frontend/src/components/ui/Button.jsx
Normal file
50
frontend/src/components/ui/Button.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
// src/components/ui/Button.jsx
|
||||
import { forwardRef } from 'react'
|
||||
import Spinner from '@/components/ui/Spinner'
|
||||
|
||||
const Button = forwardRef(function Button(
|
||||
{
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
disabled = false,
|
||||
icon = null,
|
||||
iconRight = null,
|
||||
children,
|
||||
className = '',
|
||||
as: Tag = 'button',
|
||||
type = 'button',
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const isDisabled = disabled || loading
|
||||
|
||||
return (
|
||||
<Tag
|
||||
ref={ref}
|
||||
type={Tag === 'button' ? type : undefined}
|
||||
disabled={Tag === 'button' ? isDisabled : undefined}
|
||||
aria-disabled={isDisabled || undefined}
|
||||
aria-busy={loading || undefined}
|
||||
className={['btn', `btn-${variant}`, `btn-${size}`, className].filter(Boolean).join(' ')}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner size={size === 'lg' ? 'md' : 'sm'} />
|
||||
) : icon ? (
|
||||
<span className="shrink-0 flex items-center" aria-hidden="true">{icon}</span>
|
||||
) : null}
|
||||
|
||||
{children != null && (
|
||||
<span className={loading ? 'opacity-0 absolute' : undefined}>{children}</span>
|
||||
)}
|
||||
|
||||
{!loading && iconRight != null && (
|
||||
<span className="shrink-0 flex items-center" aria-hidden="true">{iconRight}</span>
|
||||
)}
|
||||
</Tag>
|
||||
)
|
||||
})
|
||||
|
||||
export default Button
|
||||
57
frontend/src/components/ui/Card.jsx
Normal file
57
frontend/src/components/ui/Card.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
// src/components/ui/Card.jsx
|
||||
import { cloneElement, isValidElement } from 'react'
|
||||
|
||||
function neutralIcon(icon) {
|
||||
return isValidElement(icon) ? cloneElement(icon, { color: 'currentColor' }) : icon
|
||||
}
|
||||
// Surface container with optional header, footer, and elevation variants.
|
||||
//
|
||||
// Props:
|
||||
// title — string — card heading (renders header section)
|
||||
// subtitle — string — muted subtext below title
|
||||
// icon — ReactNode — optional icon rendered left of the title (use <Icon name="..." size={15} />)
|
||||
// action — ReactNode — optional element rendered right-aligned in the header row (e.g. a Button)
|
||||
// footer — ReactNode — rendered in the card footer
|
||||
// children — ReactNode — card body content
|
||||
// variant — 'flat' | 'elevated' | 'outlined' (default: 'flat')
|
||||
// padding — boolean — whether body has padding (default: true)
|
||||
// className — extra classes on the root element
|
||||
|
||||
export default function Card({
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
action,
|
||||
footer,
|
||||
children,
|
||||
variant = 'flat',
|
||||
padding = true,
|
||||
className = '',
|
||||
style,
|
||||
}) {
|
||||
const hasHeader = Boolean(title || subtitle)
|
||||
|
||||
return (
|
||||
<div className={['card', `card-${variant}`, className].filter(Boolean).join(' ')} style={style}>
|
||||
{hasHeader && (
|
||||
<div className={['card-header', !icon ? 'card-header--no-icon' : ''].filter(Boolean).join(' ')}>
|
||||
<div className="card-title-row">
|
||||
{neutralIcon(icon)}
|
||||
{title && <span className="card-title">{title}</span>}
|
||||
{action && <div className="card-header-action">{action}</div>}
|
||||
</div>
|
||||
{subtitle && <p className="card-subtitle">{subtitle}</p>}
|
||||
<div className="card-header-divider" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={padding ? 'card-body' : 'card-body-flush'}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<div className="card-footer">{footer}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
83
frontend/src/components/ui/ConfirmDialog.jsx
Normal file
83
frontend/src/components/ui/ConfirmDialog.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
// src/components/ui/ConfirmDialog.jsx
|
||||
// Confirmation modal for destructive or significant actions.
|
||||
// Wraps Modal with a centred icon + message layout.
|
||||
//
|
||||
// Props:
|
||||
// open — boolean
|
||||
// onClose — () => void
|
||||
// onConfirm — () => void
|
||||
// title — string — modal heading
|
||||
// message — string | ReactNode — body explanation
|
||||
// confirmLabel — string (default: 'Confirm')
|
||||
// cancelLabel — string (default: 'Cancel')
|
||||
// variant — 'danger' | 'primary' (default: 'danger')
|
||||
// loading — boolean — shows spinner on confirm button
|
||||
// persistent — boolean — prevent close on backdrop/Escape while loading
|
||||
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import Button from '@/components/ui/Button'
|
||||
|
||||
function DangerIcon() {
|
||||
return (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function PrimaryIcon() {
|
||||
return (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title = 'Are you sure?',
|
||||
message,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
variant = 'danger',
|
||||
loading = false,
|
||||
persistent = false,
|
||||
}) {
|
||||
const btnVariant = variant === 'danger' ? 'danger' : 'primary'
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
size="sm"
|
||||
persistent={persistent || loading}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose} disabled={loading}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button variant={btnVariant} onClick={onConfirm} loading={loading}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="confirm-body">
|
||||
<div className={`confirm-icon-wrap confirm-icon-wrap-${variant}`}>
|
||||
{variant === 'danger' ? <DangerIcon /> : <PrimaryIcon />}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<p className="confirm-message">{message}</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
442
frontend/src/components/ui/DataTable.jsx
Normal file
442
frontend/src/components/ui/DataTable.jsx
Normal file
@@ -0,0 +1,442 @@
|
||||
// src/components/ui/DataTable.jsx
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import Button from '@/components/ui/Button'
|
||||
|
||||
const SKELETON_ROWS = 5
|
||||
|
||||
// ─── Sort chevrons ────────────────────────────────────────────────────────────
|
||||
|
||||
function SortIcon({ columnKey, sortKey, sortDir }) {
|
||||
const active = columnKey === sortKey
|
||||
const up = active && sortDir === 'asc'
|
||||
const down = active && sortDir === 'desc'
|
||||
return (
|
||||
<span className="dt-sort-icon" aria-hidden="true">
|
||||
<svg width="8" height="5" viewBox="0 0 8 5" fill="none">
|
||||
<path d="M1 4l3-3 3 3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: up ? 1 : 0.3 }} />
|
||||
</svg>
|
||||
<svg width="8" height="5" viewBox="0 0 8 5" fill="none">
|
||||
<path d="M1 1l3 3 3-3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: down ? 1 : 0.3 }} />
|
||||
</svg>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Columns icon (grid) ──────────────────────────────────────────────────────
|
||||
|
||||
function ColumnsIcon() {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden="true" style={{ display: 'block' }}>
|
||||
<rect x="1" y="1" width="4" height="12" rx="1" stroke="currentColor" strokeWidth="1.4" />
|
||||
<rect x="7" y="1" width="6" height="5" rx="1" stroke="currentColor" strokeWidth="1.4" />
|
||||
<rect x="7" y="8" width="6" height="5" rx="1" stroke="currentColor" strokeWidth="1.4" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Column picker dropdown ───────────────────────────────────────────────────
|
||||
|
||||
function ColumnPicker({ allColumns, visibleKeys, onToggle }) {
|
||||
return (
|
||||
<div className="dt-col-picker" role="menu" aria-label="Toggle columns">
|
||||
{allColumns.map((col) => {
|
||||
const label = col.pickerLabel || col.label || 'Column'
|
||||
const checked = visibleKeys.includes(col.key)
|
||||
return (
|
||||
<label
|
||||
key={col.key}
|
||||
className={`dt-col-picker-item${col.alwaysOn ? ' dt-col-picker-item--disabled' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggle(col.key)}
|
||||
disabled={col.alwaysOn}
|
||||
style={{ accentColor: 'var(--color-primary)', flexShrink: 0 }}
|
||||
/>
|
||||
{typeof label === 'string' ? label : (col.pickerLabel || 'Column')}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── DataTable ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DataTable({
|
||||
// Core
|
||||
columns, data, loading = false, keyField = 'id',
|
||||
emptyMessage = 'No records found', emptyDescription,
|
||||
onRowClick, onRowMiddleClick, selectedIds,
|
||||
// Sorting
|
||||
sortKey, sortDir = 'asc', onSort,
|
||||
// Column visibility + reorder (pass all three to enable)
|
||||
allColumns, visibleKeys, onColumnChange,
|
||||
// Sub-rows: (row) => ReactNode[] — each element becomes its own <tr> spanning all columns
|
||||
renderSubRows,
|
||||
// When true, sub-rows get symmetric top+bottom padding to match main row padding
|
||||
expandedRowPadding = false,
|
||||
// Misc
|
||||
footer, skeletonRows = SKELETON_ROWS,
|
||||
}) {
|
||||
// ── Column picker ────────────────────────────────────────────────────────
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [pickerPos, setPickerPos] = useState({ top: 0, right: 0 })
|
||||
const pickerRef = useRef(null)
|
||||
const btnRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!pickerOpen) return
|
||||
function onMouseDown(e) {
|
||||
if (btnRef.current?.contains(e.target)) return
|
||||
if (pickerRef.current?.contains(e.target)) return
|
||||
setPickerOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onMouseDown)
|
||||
return () => document.removeEventListener('mousedown', onMouseDown)
|
||||
}, [pickerOpen])
|
||||
|
||||
function openPicker() {
|
||||
if (btnRef.current) {
|
||||
const r = btnRef.current.getBoundingClientRect()
|
||||
setPickerPos({ top: r.bottom + 4, right: window.innerWidth - r.right })
|
||||
}
|
||||
setPickerOpen((v) => !v)
|
||||
}
|
||||
|
||||
// ── Drag-to-reorder ──────────────────────────────────────────────────────
|
||||
// Strategy: track a *drop insertion index* (a gap between columns) rather
|
||||
// than highlighting the hovered column. We determine the gap by comparing
|
||||
// the cursor's X position against the midpoint of each draggable <th>.
|
||||
//
|
||||
// dropInsertIdx = 0 → insert before column 0
|
||||
// dropInsertIdx = 1 → insert between column 0 and 1
|
||||
// dropInsertIdx = n → insert after the last column
|
||||
//
|
||||
// A thin vertical line is rendered on the LEFT border of the column at
|
||||
// dropInsertIdx (or past the last column's right edge when idx === length).
|
||||
|
||||
const dragColKey = useRef(null) // key of the column being dragged
|
||||
const dropInsertIdx = useRef(null) // resolved insertion index
|
||||
const [dragInsertIdx, setDragInsertIdx] = useState(null) // for render
|
||||
const [dragActiveKey, setDragActiveKey] = useState(null) // which col is being dragged (for opacity)
|
||||
const theadRef = useRef(null)
|
||||
|
||||
// Returns the ordered list of draggable column keys currently rendered
|
||||
// (excludes __actions which is never draggable)
|
||||
function getDraggableKeys() {
|
||||
return columns.filter((c) => c.key !== '__actions').map((c) => c.key)
|
||||
}
|
||||
|
||||
function resolveInsertIdx(clientX) {
|
||||
if (!theadRef.current) return null
|
||||
const ths = Array.from(theadRef.current.querySelectorAll('th[draggable="true"]'))
|
||||
if (!ths.length) return null
|
||||
// Find which gap the cursor is in by comparing to each th's midpoint
|
||||
for (let i = 0; i < ths.length; i++) {
|
||||
const rect = ths[i].getBoundingClientRect()
|
||||
const mid = rect.left + rect.width / 2
|
||||
if (clientX < mid) return i
|
||||
}
|
||||
return ths.length // past all columns → insert at end
|
||||
}
|
||||
|
||||
function handleDragStart(e, key) {
|
||||
dragColKey.current = key
|
||||
setDragActiveKey(key)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
// Needed for Firefox
|
||||
e.dataTransfer.setData('text/plain', key)
|
||||
}
|
||||
|
||||
function handleDragOverHeader(e) {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
const idx = resolveInsertIdx(e.clientX)
|
||||
if (idx !== dropInsertIdx.current) {
|
||||
dropInsertIdx.current = idx
|
||||
setDragInsertIdx(idx)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeaveHeader(e) {
|
||||
// Only clear if leaving the entire thead, not just moving between cells
|
||||
if (theadRef.current && !theadRef.current.contains(e.relatedTarget)) {
|
||||
dropInsertIdx.current = null
|
||||
setDragInsertIdx(null)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(e) {
|
||||
e.preventDefault()
|
||||
const fromKey = dragColKey.current
|
||||
const toIdx = dropInsertIdx.current
|
||||
|
||||
dragColKey.current = null
|
||||
dropInsertIdx.current = null
|
||||
setDragInsertIdx(null)
|
||||
setDragActiveKey(null)
|
||||
|
||||
if (fromKey === null || toIdx === null || !onColumnChange || !allColumns) return
|
||||
|
||||
const draggableKeys = getDraggableKeys()
|
||||
const fromIdx = draggableKeys.indexOf(fromKey)
|
||||
if (fromIdx === -1) return
|
||||
|
||||
// Build new order: remove source, insert at target gap
|
||||
const next = [...draggableKeys]
|
||||
next.splice(fromIdx, 1)
|
||||
// After removal, toIdx may need adjustment
|
||||
const adjustedTo = fromIdx < toIdx ? toIdx - 1 : toIdx
|
||||
next.splice(adjustedTo, 0, fromKey)
|
||||
|
||||
if (next.join(',') === draggableKeys.join(',')) return // no change
|
||||
|
||||
// Preserve visibility: keep alwaysOn + previously visible keys in new order
|
||||
const visibleSet = new Set(visibleKeys || [])
|
||||
const alwaysOnSet = new Set((allColumns || []).filter((c) => c.alwaysOn).map((c) => c.key))
|
||||
const nextVisible = next.filter((k) => visibleSet.has(k) || alwaysOnSet.has(k))
|
||||
onColumnChange(nextVisible)
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
dragColKey.current = null
|
||||
dropInsertIdx.current = null
|
||||
setDragInsertIdx(null)
|
||||
setDragActiveKey(null)
|
||||
}
|
||||
|
||||
// ── Sort ─────────────────────────────────────────────────────────────────
|
||||
function handleSort(col) {
|
||||
if (!col.sortable || !onSort) return
|
||||
const nextDir = col.key === sortKey ? (sortDir === 'asc' ? 'desc' : 'asc') : 'asc'
|
||||
onSort(col.key, nextDir)
|
||||
}
|
||||
|
||||
function handleRowKeyDown(e, row) {
|
||||
if (!onRowClick) return
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onRowClick(row) }
|
||||
}
|
||||
|
||||
// ── Group hover (expanded rows hover together) ───────────────────────────
|
||||
const [hoveredGroup, setHoveredGroup] = useState(null)
|
||||
|
||||
const isSelected = (row) => selectedIds ? selectedIds.has?.(row[keyField]) ?? false : false
|
||||
const colAlign = (col) => col.align === 'right' ? 'right' : col.align === 'center' ? 'center' : 'left'
|
||||
const hasColMgmt = !!(allColumns && visibleKeys && onColumnChange)
|
||||
const draggable = hasColMgmt
|
||||
|
||||
const lastColKey = columns.length > 0 ? columns[columns.length - 1].key : null
|
||||
|
||||
// Cell padding for sub-rows — matches the main row td padding so top/bottom
|
||||
// space is visually even. The default td padding is --space-3 (12px) top/bottom.
|
||||
const subRowCellStyle = expandedRowPadding
|
||||
? { paddingTop: 0, paddingBottom: 'var(--space-1)', paddingLeft: 'var(--space-4)', paddingRight: 'var(--space-4)' }
|
||||
: { paddingTop: 0, paddingBottom: 'var(--space-1)', paddingLeft: 'var(--space-4)', paddingRight: 'var(--space-4)' }
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div>
|
||||
<div className="table-container">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table" role="grid">
|
||||
<thead
|
||||
className="table-thead"
|
||||
ref={theadRef}
|
||||
onDragOver={draggable ? handleDragOverHeader : undefined}
|
||||
onDragLeave={draggable ? handleDragLeaveHeader : undefined}
|
||||
onDrop={draggable ? handleDrop : undefined}
|
||||
>
|
||||
<tr>
|
||||
{columns.map((col, colIdx) => {
|
||||
const isLast = col.key === lastColKey
|
||||
const showEdit = hasColMgmt && isLast
|
||||
const isDraggable = draggable && col.key !== '__actions'
|
||||
|
||||
// Insertion line: show on left edge of this th when the drop gap
|
||||
// index matches this column's position in the draggable list,
|
||||
// OR on the right edge of the last draggable column when inserting at end.
|
||||
const draggableKeys = getDraggableKeys()
|
||||
const draggableIdx = draggableKeys.indexOf(col.key)
|
||||
const isDropBefore = dragInsertIdx !== null && draggableIdx === dragInsertIdx
|
||||
const isDropAfterEnd = dragInsertIdx !== null
|
||||
&& dragInsertIdx === draggableKeys.length
|
||||
&& draggableIdx === draggableKeys.length - 1
|
||||
|
||||
const headerClickHandler = showEdit
|
||||
? (e) => e.stopPropagation()
|
||||
: col.onHeaderClick
|
||||
? () => col.onHeaderClick()
|
||||
: () => handleSort(col)
|
||||
|
||||
return (
|
||||
<th
|
||||
key={col.key}
|
||||
scope="col"
|
||||
className={[
|
||||
(col.sortable || col.onHeaderClick) ? 'sortable' : '',
|
||||
isDraggable ? 'dt-th--draggable' : '',
|
||||
dragActiveKey === col.key ? 'dt-th--dragging' : '',
|
||||
showEdit ? 'dt-col-mgr-th' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
style={{
|
||||
width: col.width ?? undefined,
|
||||
textAlign: showEdit ? 'right' : colAlign(col),
|
||||
position: 'relative',
|
||||
// Insertion line rendered as box-shadow on left or right border
|
||||
boxShadow: isDropBefore
|
||||
? 'inset 2px 0 0 var(--color-primary)'
|
||||
: isDropAfterEnd
|
||||
? 'inset -2px 0 0 var(--color-primary)'
|
||||
: undefined,
|
||||
}}
|
||||
onClick={headerClickHandler}
|
||||
aria-sort={
|
||||
!showEdit && !col.onHeaderClick && col.key === sortKey
|
||||
? (sortDir === 'asc' ? 'ascending' : 'descending')
|
||||
: (col.sortable && !showEdit ? 'none' : undefined)
|
||||
}
|
||||
draggable={isDraggable}
|
||||
onDragStart={isDraggable ? (e) => handleDragStart(e, col.key) : undefined}
|
||||
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
||||
>
|
||||
{showEdit ? (
|
||||
<div style={{ position: 'relative', display: 'inline-flex', justifyContent: 'flex-end', width: '100%' }}>
|
||||
<Button
|
||||
ref={btnRef}
|
||||
variant="table-actions"
|
||||
size="sm"
|
||||
aria-label="Manage columns"
|
||||
aria-expanded={pickerOpen}
|
||||
aria-haspopup="true"
|
||||
onClick={openPicker}
|
||||
icon={<ColumnsIcon />}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
|
||||
{pickerOpen && createPortal(
|
||||
<div
|
||||
ref={pickerRef}
|
||||
className="dt-col-picker-wrap"
|
||||
style={{ position: 'fixed', top: pickerPos.top, right: pickerPos.right, zIndex: 9999 }}
|
||||
>
|
||||
<ColumnPicker
|
||||
allColumns={allColumns}
|
||||
visibleKeys={visibleKeys}
|
||||
onToggle={(key) => {
|
||||
const col = allColumns.find((c) => c.key === key)
|
||||
if (col?.alwaysOn) return
|
||||
const next = visibleKeys.includes(key)
|
||||
? visibleKeys.filter((k) => k !== key)
|
||||
: [...visibleKeys, key]
|
||||
onColumnChange(next)
|
||||
}}
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="inline-flex items-center">
|
||||
{col.label}
|
||||
{col.sortable && <SortIcon columnKey={col.key} sortKey={sortKey} sortDir={sortDir} />}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="table-tbody">
|
||||
{loading && Array.from({ length: skeletonRows }).map((_, rowIdx) => (
|
||||
<tr key={`skeleton-${rowIdx}`} aria-hidden="true">
|
||||
{columns.map((col) => (
|
||||
<td key={col.key}><span className="table-skeleton block" style={{ width: rowIdx % 2 === 0 ? '70%' : '55%' }} /></td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{!loading && data.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={columns.length}>
|
||||
<div className="table-empty">
|
||||
<p className="table-empty-title">{emptyMessage}</p>
|
||||
{emptyDescription && <p style={{ marginTop: 'var(--space-1)' }}>{emptyDescription}</p>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!loading && data.map((row, mainIdx) => {
|
||||
const selected = isSelected(row)
|
||||
const clickable = !!onRowClick
|
||||
const subRows = renderSubRows ? renderSubRows(row) : null
|
||||
const tint = mainIdx % 2 === 1 ? '1' : undefined
|
||||
const groupKey = row[keyField]
|
||||
const groupHovered = hoveredGroup === groupKey
|
||||
return [
|
||||
<tr
|
||||
key={groupKey ?? JSON.stringify(row)}
|
||||
className={[
|
||||
clickable ? 'clickable' : '',
|
||||
selected ? 'selected' : '',
|
||||
subRows && expandedRowPadding ? 'dt-has-subrows' : '',
|
||||
groupHovered ? 'dt-group-hover' : '',
|
||||
].filter(Boolean).join(' ') || undefined}
|
||||
data-row-tint={tint}
|
||||
data-row-group={subRows ? groupKey : undefined}
|
||||
onClick={clickable ? () => onRowClick(row) : undefined}
|
||||
onMouseDown={onRowMiddleClick ? (e) => { if (e.button === 1) { e.preventDefault(); onRowMiddleClick(row, e) } } : undefined}
|
||||
onKeyDown={clickable ? (e) => handleRowKeyDown(e, row) : undefined}
|
||||
onMouseEnter={() => setHoveredGroup(groupKey)}
|
||||
onMouseLeave={() => setHoveredGroup(null)}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
role={clickable ? 'button' : undefined}
|
||||
aria-selected={selected || undefined}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} style={{ textAlign: colAlign(col) }}>
|
||||
{col.render ? col.render(row) : row[col.key]}
|
||||
</td>
|
||||
))}
|
||||
</tr>,
|
||||
...(subRows ? subRows.map((content, i) => (
|
||||
<tr
|
||||
key={`${groupKey}-sub-${i}`}
|
||||
className={[
|
||||
'dt-sub-row',
|
||||
clickable ? 'clickable' : '',
|
||||
groupHovered ? 'dt-group-hover' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
data-row-tint={tint}
|
||||
data-row-group={groupKey}
|
||||
onClick={clickable ? () => onRowClick(row) : undefined}
|
||||
onMouseDown={onRowMiddleClick ? (e) => { if (e.button === 1) { e.preventDefault(); onRowMiddleClick(row, e) } } : undefined}
|
||||
onMouseEnter={() => setHoveredGroup(groupKey)}
|
||||
onMouseLeave={() => setHoveredGroup(null)}
|
||||
>
|
||||
<td colSpan={columns.length} style={
|
||||
i === 0 && subRows.length === 1
|
||||
? { ...subRowCellStyle, paddingTop: 'var(--space-2)', paddingBottom: 'var(--space-4)' }
|
||||
: i === 0
|
||||
? { ...subRowCellStyle, paddingTop: 'var(--space-2)' }
|
||||
: i === subRows.length - 1
|
||||
? { ...subRowCellStyle, paddingBottom: 'var(--space-4)' }
|
||||
: subRowCellStyle
|
||||
}>
|
||||
{content}
|
||||
</td>
|
||||
</tr>
|
||||
)) : []),
|
||||
]
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{footer && !loading && <div className="mt-3">{footer}</div>}
|
||||
{loading && <span className="sr-only" aria-live="polite">Loading data, please wait.</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
521
frontend/src/components/ui/DateTimePicker.jsx
Normal file
521
frontend/src/components/ui/DateTimePicker.jsx
Normal file
@@ -0,0 +1,521 @@
|
||||
// frontend/src/components/ui/DateTimePicker.jsx
|
||||
//
|
||||
// Two-column date+time picker popover.
|
||||
// Left — calendar with month/year navigation; click month-year header to enter year-scroll mode.
|
||||
// Right — Cupertino-style drum dials for HH and mm.
|
||||
// • Drag up/down to scroll
|
||||
// • Mouse-wheel over the dial
|
||||
// • Click the selected (centre) item to type a value directly
|
||||
//
|
||||
// Props:
|
||||
// value — ISO string or ''
|
||||
// onChange — (isoString) => void
|
||||
// label — string
|
||||
// name — string
|
||||
// hint — string
|
||||
// error — string
|
||||
// disabled — boolean
|
||||
// required — boolean
|
||||
// placeholder — string (default 'DD/MM/YYYY HH:mm')
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function pad(n) { return String(n).padStart(2, '0') }
|
||||
|
||||
function toDisplay(iso) {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
function makeIso(year, month, day, hours, minutes) {
|
||||
const d = new Date(year, month, day, hours, minutes)
|
||||
return isNaN(d.getTime()) ? null : d.toISOString()
|
||||
}
|
||||
|
||||
function buildGrid(year, month) {
|
||||
const firstDay = new Date(year, month, 1).getDay()
|
||||
const startOffset = (firstDay + 6) % 7 // Mon-first
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||
const daysInPrev = new Date(year, month, 0).getDate()
|
||||
const cells = []
|
||||
for (let i = startOffset - 1; i >= 0; i--)
|
||||
cells.push({ day: daysInPrev - i, month: month - 1, year: month === 0 ? year - 1 : year, current: false })
|
||||
for (let d = 1; d <= daysInMonth; d++)
|
||||
cells.push({ day: d, month, year, current: true })
|
||||
const rem = cells.length % 7
|
||||
if (rem > 0)
|
||||
for (let d = 1; d <= 7 - rem; d++)
|
||||
cells.push({ day: d, month: month + 1, year: month === 11 ? year + 1 : year, current: false })
|
||||
return cells
|
||||
}
|
||||
|
||||
const DAYS = ['Mo','Tu','We','Th','Fr','Sa','Su']
|
||||
const MONTHS = ['January','February','March','April','May','June',
|
||||
'July','August','September','October','November','December']
|
||||
const MONTHS_SHORT = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
|
||||
|
||||
// ─── Icons ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const IconCalendar = () => <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
const IconChevLeft = () => <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
const IconChevRight = () => <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
|
||||
// ─── Cupertino Drum Dial ──────────────────────────────────────────────────────
|
||||
// Shows 5 visible items centred on `value`. Supports drag, wheel, click-to-edit.
|
||||
|
||||
const ITEM_H = 28 // px — height of each drum item
|
||||
const VISIBLE = 7 // odd number: centre item is selected
|
||||
const HALF = Math.floor(VISIBLE / 2)
|
||||
|
||||
function Drum({ value, max, onChange }) {
|
||||
// value: 0-based integer (0–23 for hours, 0–59 for minutes)
|
||||
const wrapAt = max + 1 // 24 or 60
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editVal, setEditVal] = useState('')
|
||||
const inputRef = useRef(null)
|
||||
const drumRef = useRef(null)
|
||||
|
||||
// Drag state
|
||||
const drag = useRef({ active: false, startY: 0, startVal: 0 })
|
||||
|
||||
// Build the 5 visible items around current value
|
||||
const items = useMemo(() => {
|
||||
return Array.from({ length: VISIBLE }, (_, i) => {
|
||||
const offset = i - HALF
|
||||
const v = ((value + offset) % wrapAt + wrapAt) % wrapAt
|
||||
return { v, offset }
|
||||
})
|
||||
}, [value, wrapAt])
|
||||
|
||||
// ── Drag ────────────────────────────────────────────────────────────────────
|
||||
function onMouseDown(e) {
|
||||
if (editing) return
|
||||
e.preventDefault()
|
||||
drag.current = { active: true, startY: e.clientY, startVal: value }
|
||||
window.addEventListener('mousemove', onMouseMove)
|
||||
window.addEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
function onMouseMove(e) {
|
||||
if (!drag.current.active) return
|
||||
const delta = Math.round((drag.current.startY - e.clientY) / ITEM_H)
|
||||
const next = ((drag.current.startVal + delta) % wrapAt + wrapAt) % wrapAt
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
drag.current.active = false
|
||||
window.removeEventListener('mousemove', onMouseMove)
|
||||
window.removeEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
// ── Wheel ───────────────────────────────────────────────────────────────────
|
||||
function onWheel(e) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const dir = e.deltaY > 0 ? 1 : -1
|
||||
const next = ((value + dir) % wrapAt + wrapAt) % wrapAt
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
// ── Click to edit ────────────────────────────────────────────────────────────
|
||||
function onClickCenter() {
|
||||
setEditVal(pad(value))
|
||||
setEditing(true)
|
||||
setTimeout(() => inputRef.current?.select(), 0)
|
||||
}
|
||||
|
||||
function commitEdit() {
|
||||
const n = parseInt(editVal, 10)
|
||||
if (!isNaN(n) && n >= 0 && n < wrapAt) onChange(n)
|
||||
setEditing(false)
|
||||
}
|
||||
|
||||
function onEditKey(e) {
|
||||
if (e.key === 'Enter') commitEdit()
|
||||
if (e.key === 'Escape') setEditing(false)
|
||||
}
|
||||
|
||||
// ── Click on non-centre item ─────────────────────────────────────────────────
|
||||
function onClickItem(offset) {
|
||||
if (offset === 0) { onClickCenter(); return }
|
||||
const next = ((value + offset) % wrapAt + wrapAt) % wrapAt
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
// Opacity/scale per position
|
||||
const STYLE_BY_OFFSET = {
|
||||
'-3': { opacity: 0.10, fontSize: '0.64rem', fontWeight: 400 },
|
||||
'-2': { opacity: 0.22, fontSize: '0.72rem', fontWeight: 400 },
|
||||
'-1': { opacity: 0.50, fontSize: '0.85rem', fontWeight: 400 },
|
||||
'0': { opacity: 1, fontSize: '1.1rem', fontWeight: 700 },
|
||||
'1': { opacity: 0.50, fontSize: '0.85rem', fontWeight: 400 },
|
||||
'2': { opacity: 0.22, fontSize: '0.72rem', fontWeight: 400 },
|
||||
'3': { opacity: 0.10, fontSize: '0.64rem', fontWeight: 400 },
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={drumRef}
|
||||
className="dtp-drum"
|
||||
onMouseDown={onMouseDown}
|
||||
onWheel={onWheel}
|
||||
style={{ height: ITEM_H * VISIBLE }}
|
||||
>
|
||||
{/* Selection highlight band */}
|
||||
<div className="dtp-drum-band" style={{ top: ITEM_H * HALF, height: ITEM_H }} />
|
||||
|
||||
{/* Items */}
|
||||
{items.map(({ v, offset }) => {
|
||||
const s = STYLE_BY_OFFSET[String(offset)] || {}
|
||||
const isCenter = offset === 0
|
||||
return (
|
||||
<div
|
||||
key={offset}
|
||||
className={`dtp-drum-item${isCenter ? ' dtp-drum-item--center' : ''}`}
|
||||
style={{ height: ITEM_H, ...s }}
|
||||
onClick={() => onClickItem(offset)}
|
||||
>
|
||||
{isCenter && editing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="dtp-drum-edit"
|
||||
value={editVal}
|
||||
onChange={e => setEditVal(e.target.value)}
|
||||
onBlur={commitEdit}
|
||||
onKeyDown={onEditKey}
|
||||
onClick={e => e.stopPropagation()}
|
||||
maxLength={2}
|
||||
/>
|
||||
) : (
|
||||
pad(v)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Top/bottom fade masks */}
|
||||
<div className="dtp-drum-fade dtp-drum-fade--top" />
|
||||
<div className="dtp-drum-fade dtp-drum-fade--bottom" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Year Scroll Wheel ───────────────────────────────────────────────────────
|
||||
|
||||
function YearPicker({ year, onSelect }) {
|
||||
const listRef = useRef(null)
|
||||
const MIN_YEAR = 1970
|
||||
const MAX_YEAR = new Date().getFullYear() + 10
|
||||
const years = useMemo(() => {
|
||||
const arr = []
|
||||
for (let y = MIN_YEAR; y <= MAX_YEAR; y++) arr.push(y)
|
||||
return arr
|
||||
}, [])
|
||||
|
||||
// Scroll selected year into view on mount
|
||||
useEffect(() => {
|
||||
const el = listRef.current?.querySelector('[data-selected="true"]')
|
||||
el?.scrollIntoView({ block: 'center', behavior: 'instant' })
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={listRef} className="dtp-year-list">
|
||||
{years.map(y => (
|
||||
<button
|
||||
key={y}
|
||||
type="button"
|
||||
data-selected={y === year}
|
||||
className={`dtp-year-item${y === year ? ' dtp-year-item--selected' : ''}`}
|
||||
onClick={() => onSelect(y)}
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Popover ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function Popover({ triggerRef, onClose, value, onChange }) {
|
||||
const now = new Date()
|
||||
const initial = value ? new Date(value) : now
|
||||
|
||||
const [viewYear, setViewYear] = useState(initial.getFullYear())
|
||||
const [viewMonth, setViewMonth] = useState(initial.getMonth())
|
||||
const [yearMode, setYearMode] = useState(false) // true = year scroll wheel
|
||||
|
||||
const [selYear, setSelYear] = useState(value ? initial.getFullYear() : null)
|
||||
const [selMonth, setSelMonth] = useState(value ? initial.getMonth() : null)
|
||||
const [selDay, setSelDay] = useState(value ? initial.getDate() : null)
|
||||
const [hours, setHours] = useState(initial.getHours())
|
||||
const [minutes, setMinutes] = useState(initial.getMinutes())
|
||||
|
||||
const popRef = useRef(null)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
|
||||
// Position below trigger, flip up if would go off screen
|
||||
useEffect(() => {
|
||||
if (!triggerRef.current) return
|
||||
const r = triggerRef.current.getBoundingClientRect()
|
||||
const panelH = 320
|
||||
const top = r.bottom + 8 + panelH > window.innerHeight ? r.top - panelH - 8 : r.bottom + 8
|
||||
// Keep within viewport width
|
||||
const panelW = 520
|
||||
const left = Math.min(r.left, window.innerWidth - panelW - 12)
|
||||
setPos({ top, left })
|
||||
}, [triggerRef])
|
||||
|
||||
// Close on outside click / Escape
|
||||
useEffect(() => {
|
||||
function onDown(e) {
|
||||
if (popRef.current?.contains(e.target)) return
|
||||
if (triggerRef.current?.contains(e.target)) return
|
||||
onClose()
|
||||
}
|
||||
function onKey(e) { if (e.key === 'Escape') onClose() }
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [onClose, triggerRef])
|
||||
|
||||
function emit(sy, sm, sd, h, mi) {
|
||||
if (sy === null || sm === null || sd === null) return
|
||||
const iso = makeIso(sy, sm, sd, h, mi)
|
||||
if (iso) onChange(iso)
|
||||
}
|
||||
|
||||
function selectDay(cell) {
|
||||
setSelYear(cell.year); setSelMonth(cell.month); setSelDay(cell.day)
|
||||
if (!cell.current) { setViewYear(cell.year); setViewMonth(cell.month) }
|
||||
emit(cell.year, cell.month, cell.day, hours, minutes)
|
||||
}
|
||||
|
||||
function prevMonth() {
|
||||
if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1) }
|
||||
else setViewMonth(m => m - 1)
|
||||
}
|
||||
function nextMonth() {
|
||||
if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1) }
|
||||
else setViewMonth(m => m + 1)
|
||||
}
|
||||
|
||||
function handleYearSelect(y) {
|
||||
setViewYear(y)
|
||||
setYearMode(false)
|
||||
// If a day was already selected in same month, update its year
|
||||
if (selDay !== null) {
|
||||
setSelYear(y)
|
||||
emit(y, selMonth ?? viewMonth, selDay, hours, minutes)
|
||||
}
|
||||
}
|
||||
|
||||
function handleHours(h) {
|
||||
setHours(h)
|
||||
emit(selYear, selMonth, selDay, h, minutes)
|
||||
}
|
||||
function handleMinutes(mi) {
|
||||
setMinutes(mi)
|
||||
emit(selYear, selMonth, selDay, hours, mi)
|
||||
}
|
||||
|
||||
function setNow() {
|
||||
const n = new Date()
|
||||
setViewYear(n.getFullYear()); setViewMonth(n.getMonth()); setYearMode(false)
|
||||
setSelYear(n.getFullYear()); setSelMonth(n.getMonth()); setSelDay(n.getDate())
|
||||
setHours(n.getHours()); setMinutes(n.getMinutes())
|
||||
onChange(makeIso(n.getFullYear(), n.getMonth(), n.getDate(), n.getHours(), n.getMinutes()))
|
||||
}
|
||||
|
||||
const cells = buildGrid(viewYear, viewMonth)
|
||||
const todayY = now.getFullYear()
|
||||
const todayMo = now.getMonth()
|
||||
const todayD = now.getDate()
|
||||
|
||||
const displayDate = selDay !== null
|
||||
? `${pad(selDay)} ${MONTHS_SHORT[selMonth]} ${selYear}`
|
||||
: 'No date selected'
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={popRef}
|
||||
className="dtp-popover"
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
{/* ═══════════════════ TWO-COLUMN BODY ═══════════════════ */}
|
||||
<div className="dtp-body">
|
||||
|
||||
{/* ── LEFT: Calendar ───────────────────────────────────── */}
|
||||
<div className="dtp-left">
|
||||
|
||||
{/* Month/year nav */}
|
||||
<div className="dtp-cal-nav">
|
||||
{!yearMode && (
|
||||
<button type="button" className="dtp-nav-btn" onClick={prevMonth} aria-label="Previous month">
|
||||
<IconChevLeft />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`dtp-month-label${yearMode ? ' dtp-month-label--active' : ''}`}
|
||||
onClick={() => setYearMode(v => !v)}
|
||||
title={yearMode ? 'Back to calendar' : 'Pick a year'}
|
||||
>
|
||||
{MONTHS[viewMonth]} {viewYear}
|
||||
<span className="dtp-month-caret">{yearMode ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{!yearMode && (
|
||||
<button type="button" className="dtp-nav-btn" onClick={nextMonth} aria-label="Next month">
|
||||
<IconChevRight />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Calendar grid OR year picker */}
|
||||
{yearMode ? (
|
||||
<YearPicker year={viewYear} onSelect={handleYearSelect} />
|
||||
) : (
|
||||
<div className="dtp-grid">
|
||||
{DAYS.map(d => <div key={d} className="dtp-dow">{d}</div>)}
|
||||
{cells.map((cell, i) => {
|
||||
const isSel = selYear === cell.year && selMonth === cell.month && selDay === cell.day
|
||||
const isToday = todayY === cell.year && todayMo === cell.month && todayD === cell.day
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => selectDay(cell)}
|
||||
className={[
|
||||
'dtp-day',
|
||||
!cell.current ? 'dtp-day--other' : '',
|
||||
isSel ? 'dtp-day--selected' : '',
|
||||
isToday && !isSel ? 'dtp-day--today' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
aria-pressed={isSel}
|
||||
>
|
||||
{cell.day}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Divider ──────────────────────────────────────────── */}
|
||||
<div className="dtp-divider" />
|
||||
|
||||
{/* ── RIGHT: Time dials ────────────────────────────────── */}
|
||||
<div className="dtp-right">
|
||||
<div className="dtp-time-label-row">
|
||||
<span className="dtp-section-label">Set time</span>
|
||||
</div>
|
||||
|
||||
<div className="dtp-drums-wrap">
|
||||
<Drum value={hours} max={23} onChange={handleHours} />
|
||||
<span className="dtp-drums-sep">:</span>
|
||||
<Drum value={minutes} max={59} onChange={handleMinutes} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════════ FOOTER ═══════════════════ */}
|
||||
<div className="dtp-footer">
|
||||
<div className="dtp-footer-left">
|
||||
<span className="dtp-footer-date">{displayDate}</span>
|
||||
<div className="dtp-footer-time">{pad(hours)}:{pad(minutes)}</div>
|
||||
</div>
|
||||
<div className="dtp-footer-actions">
|
||||
<button type="button" className="dtp-now-btn" onClick={setNow}>
|
||||
Set to Now
|
||||
</button>
|
||||
<button type="button" className="dtp-done-btn" onClick={onClose}>Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main export ──────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DateTimePicker({
|
||||
value = '',
|
||||
onChange,
|
||||
label,
|
||||
name,
|
||||
hint,
|
||||
error,
|
||||
disabled = false,
|
||||
required = false,
|
||||
placeholder = 'DD/MM/YYYY HH:mm',
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const triggerRef = useRef(null)
|
||||
const inputId = `dtp-${name || 'field'}`
|
||||
const errorId = error ? `${inputId}-error` : undefined
|
||||
const hintId = hint ? `${inputId}-hint` : undefined
|
||||
|
||||
const handleOpen = useCallback(() => { if (!disabled) setOpen(true) }, [disabled])
|
||||
const handleClose = useCallback(() => setOpen(false), [])
|
||||
const handleChange = useCallback((iso) => onChange?.(iso), [onChange])
|
||||
|
||||
const display = toDisplay(value)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col" style={{ gap: 'var(--space-2)' }}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-secondary)', lineHeight: 'var(--line-height-tight)' }}
|
||||
>
|
||||
{label}
|
||||
{required && <span aria-hidden="true" style={{ color: 'var(--color-danger)', marginLeft: 'var(--space-1)' }}>*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<button
|
||||
ref={triggerRef}
|
||||
id={inputId}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={handleOpen}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={[errorId, hintId].filter(Boolean).join(' ') || undefined}
|
||||
className={['dtp-trigger', 'input', error ? 'input-error' : ''].filter(Boolean).join(' ')}
|
||||
>
|
||||
<span className={display ? 'dtp-trigger-value' : 'dtp-trigger-placeholder'}>
|
||||
{display || placeholder}
|
||||
</span>
|
||||
<span className="dtp-trigger-icon" aria-hidden="true"><IconCalendar /></span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<Popover
|
||||
triggerRef={triggerRef}
|
||||
onClose={handleClose}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hint && !error && (
|
||||
<span id={hintId} style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>{hint}</span>
|
||||
)}
|
||||
{error && (
|
||||
<span id={errorId} role="alert" style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-danger)', fontWeight: 'var(--font-weight-medium)' }}>{error}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
145
frontend/src/components/ui/FormField.jsx
Normal file
145
frontend/src/components/ui/FormField.jsx
Normal file
@@ -0,0 +1,145 @@
|
||||
// src/components/ui/FormField.jsx
|
||||
// Wraps every form control: label + input/textarea/select + hint + validation error.
|
||||
// Never place a raw <input> on a page — always use FormField.
|
||||
//
|
||||
// Props:
|
||||
// label — string — visible label (required for accessibility)
|
||||
// name — string — input id/name
|
||||
// type — 'text'|'email'|'password'|'number'|'tel'|'url'|'search'
|
||||
// 'textarea' | 'select' (default: 'text')
|
||||
// value — controlled value
|
||||
// onChange — change handler
|
||||
// error — string — validation error message (shown in danger color)
|
||||
// hint — string — helper text shown below input (muted)
|
||||
// required — boolean — adds * to label
|
||||
// disabled — boolean
|
||||
// placeholder — string
|
||||
// rows — number — textarea row count (default: 4)
|
||||
// children — ReactNode — <option> elements when type='select'
|
||||
// inputProps — extra props forwarded to the underlying input element
|
||||
// className — extra classes on the field wrapper
|
||||
//
|
||||
// Styles live in src/v2/styles/components.css (.input, .input-error)
|
||||
|
||||
import Select from '@/components/ui/Select'
|
||||
|
||||
export default function FormField({
|
||||
label,
|
||||
name,
|
||||
type = 'text',
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
hint,
|
||||
required = false,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
rows = 4,
|
||||
children,
|
||||
inputProps = {},
|
||||
className = '',
|
||||
}) {
|
||||
const inputId = `field-${name}`
|
||||
const errorId = error ? `${inputId}-error` : undefined
|
||||
const hintId = hint ? `${inputId}-hint` : undefined
|
||||
|
||||
const inputClassName = [
|
||||
'input',
|
||||
type === 'textarea' ? 'textarea' : '',
|
||||
error ? 'input-error' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
const sharedProps = {
|
||||
id: inputId,
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder,
|
||||
required,
|
||||
'aria-invalid': error ? true : undefined,
|
||||
'aria-describedby': [errorId, hintId].filter(Boolean).join(' ') || undefined,
|
||||
className: inputClassName,
|
||||
...inputProps,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col ${className}`}
|
||||
style={{ gap: 'var(--space-2)' }}
|
||||
>
|
||||
{/* Label */}
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
style={{
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontWeight: 'var(--font-weight-medium)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
lineHeight: 'var(--line-height-tight)',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{required && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{ color: 'var(--color-danger)', marginLeft: 'var(--space-1)' }}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Control */}
|
||||
{type === 'textarea' ? (
|
||||
<textarea rows={rows} {...sharedProps} />
|
||||
) : type === 'select' ? (
|
||||
<Select
|
||||
id={inputId}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
className={error ? 'input-error' : ''}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={[errorId, hintId].filter(Boolean).join(' ') || undefined}
|
||||
>
|
||||
{children}
|
||||
</Select>
|
||||
) : (
|
||||
<input type={type} {...sharedProps} />
|
||||
)}
|
||||
|
||||
{/* Hint text — shown when no error */}
|
||||
{hint && !error && (
|
||||
<span
|
||||
id={hintId}
|
||||
style={{
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
color: 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Validation error */}
|
||||
{error && (
|
||||
<span
|
||||
id={errorId}
|
||||
role="alert"
|
||||
style={{
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
color: 'var(--color-danger)',
|
||||
fontWeight: 'var(--font-weight-medium)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
46
frontend/src/components/ui/HeaderSearch.jsx
Normal file
46
frontend/src/components/ui/HeaderSearch.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
// frontend/src/components/ui/HeaderSearch.jsx
|
||||
// Minimal pill-shaped search input for the top bar.
|
||||
//
|
||||
// Intentionally different from <SearchBar>:
|
||||
// - Background: --color-bg-surface (not abyss/elevated)
|
||||
// - Shape: fully pill-rounded (--radius-full)
|
||||
// - No border, no shadow, no focus ring outline — purely minimal
|
||||
//
|
||||
// Props:
|
||||
// value — string — controlled value
|
||||
// onChange — fn(str) — called on every keystroke
|
||||
// placeholder — string — defaults to "Search…"
|
||||
|
||||
function SearchIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="13" height="13" viewBox="0 0 13 13"
|
||||
fill="none" stroke="currentColor"
|
||||
strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"
|
||||
aria-hidden="true" focusable="false"
|
||||
>
|
||||
<circle cx="5.5" cy="5.5" r="4" />
|
||||
<path d="M8.5 8.5L11 11" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HeaderSearch({ value, onChange, placeholder = 'Search…' }) {
|
||||
return (
|
||||
<div className="v2-topbar-search">
|
||||
<span className="v2-topbar-search-icon">
|
||||
<SearchIcon />
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
className="v2-topbar-search-input"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label={placeholder}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
264
frontend/src/components/ui/Icon.jsx
Normal file
264
frontend/src/components/ui/Icon.jsx
Normal file
@@ -0,0 +1,264 @@
|
||||
// src/components/ui/Icon.jsx
|
||||
// Renders a named inline SVG icon.
|
||||
// All icons are 1:1 square, strokeWidth 1.75, strokeLinecap/join round.
|
||||
//
|
||||
// Props:
|
||||
// name — string — icon name (see ICONS map below)
|
||||
// size — number — px dimension (default: 16)
|
||||
// className — extra classes
|
||||
// color — CSS color string (default: currentColor — inherits from parent)
|
||||
// aria-label — pass to make icon accessible when used standalone
|
||||
|
||||
const ICONS = {
|
||||
// ── Global actions ─────────────────────────────────────────────────────────
|
||||
edit: (
|
||||
<>
|
||||
<path d="M11 2l3 3-8 8H3v-3l8-8z" />
|
||||
</>
|
||||
),
|
||||
delete: (
|
||||
<>
|
||||
<path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6" />
|
||||
</>
|
||||
),
|
||||
download: (
|
||||
<>
|
||||
<path d="M12 2v10M8 8l4 4 4-4" />
|
||||
<path d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||
</>
|
||||
),
|
||||
upload: (
|
||||
<>
|
||||
<path d="M12 16V6M8 10l4-4 4 4" />
|
||||
<path d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||
</>
|
||||
),
|
||||
refresh: (
|
||||
<>
|
||||
<path d="M4 4v5h5" />
|
||||
<path d="M20 20v-5h-5" />
|
||||
<path d="M4 9a9 9 0 0115 0M20 15a9 9 0 01-15 0" />
|
||||
</>
|
||||
),
|
||||
expand: (
|
||||
<>
|
||||
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
|
||||
</>
|
||||
),
|
||||
reply: (
|
||||
<>
|
||||
<path d="M9 17l-5-5 5-5" />
|
||||
<path d="M4 12h10a5 5 0 015 5v1" />
|
||||
</>
|
||||
),
|
||||
video: (
|
||||
<>
|
||||
<rect x="2" y="5" width="14" height="14" rx="2" />
|
||||
<path d="M16 10l6-3v10l-6-3" />
|
||||
</>
|
||||
),
|
||||
search: (
|
||||
<>
|
||||
<circle cx="10.5" cy="10.5" r="6.5" />
|
||||
<path d="M15.5 15.5L21 21" />
|
||||
</>
|
||||
),
|
||||
close: (
|
||||
<>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</>
|
||||
),
|
||||
plus: (
|
||||
<>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</>
|
||||
),
|
||||
check: (
|
||||
<>
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</>
|
||||
),
|
||||
chevron_down: (
|
||||
<>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</>
|
||||
),
|
||||
chevron_right: (
|
||||
<>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</>
|
||||
),
|
||||
chevron_left: (
|
||||
<>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</>
|
||||
),
|
||||
more_horizontal: (
|
||||
<>
|
||||
<circle cx="5" cy="12" r="1" fill="currentColor" stroke="none" />
|
||||
<circle cx="12" cy="12" r="1" fill="currentColor" stroke="none" />
|
||||
<circle cx="19" cy="12" r="1" fill="currentColor" stroke="none" />
|
||||
</>
|
||||
),
|
||||
more_vertical: (
|
||||
<>
|
||||
<circle cx="12" cy="5" r="1" fill="currentColor" stroke="none" />
|
||||
<circle cx="12" cy="12" r="1" fill="currentColor" stroke="none" />
|
||||
<circle cx="12" cy="19" r="1" fill="currentColor" stroke="none" />
|
||||
</>
|
||||
),
|
||||
filter: (
|
||||
<>
|
||||
<path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z" />
|
||||
</>
|
||||
),
|
||||
copy: (
|
||||
<>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" />
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
||||
</>
|
||||
),
|
||||
link: (
|
||||
<>
|
||||
<path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71" />
|
||||
</>
|
||||
),
|
||||
warning: (
|
||||
<>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</>
|
||||
),
|
||||
info: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</>
|
||||
),
|
||||
// ── Navigation ─────────────────────────────────────────────────────────────
|
||||
dashboard: (
|
||||
<>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</>
|
||||
),
|
||||
devices: (
|
||||
<>
|
||||
<rect x="5" y="2" width="14" height="20" rx="2" />
|
||||
<circle cx="12" cy="17" r="1" fill="currentColor" stroke="none" />
|
||||
</>
|
||||
),
|
||||
customers: (
|
||||
<>
|
||||
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" />
|
||||
</>
|
||||
),
|
||||
manufacturing: (
|
||||
<>
|
||||
<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z" />
|
||||
</>
|
||||
),
|
||||
firmware: (
|
||||
<>
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||
</>
|
||||
),
|
||||
mqtt: (
|
||||
<>
|
||||
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 00-2.91-.09z" />
|
||||
<path d="M12 15l-3-3a22 22 0 012-3.95A12.88 12.88 0 0122 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 01-4 2z" />
|
||||
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0M15 19v-5s3.03-.55 4-2c1.08-1.62 0-5 0-5" />
|
||||
</>
|
||||
),
|
||||
melodies: (
|
||||
<>
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</>
|
||||
),
|
||||
settings: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z" />
|
||||
</>
|
||||
),
|
||||
staff: (
|
||||
<>
|
||||
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</>
|
||||
),
|
||||
mail: (
|
||||
<>
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
|
||||
<polyline points="22,6 12,13 2,6" />
|
||||
</>
|
||||
),
|
||||
api: (
|
||||
<>
|
||||
<path d="M8 9l-3 3 3 3M16 9l3 3-3 3M12 5l-2 14" />
|
||||
</>
|
||||
),
|
||||
eye: (
|
||||
<>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</>
|
||||
),
|
||||
eye_off: (
|
||||
<>
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</>
|
||||
),
|
||||
}
|
||||
|
||||
export default function Icon({
|
||||
name,
|
||||
size = 16,
|
||||
color = 'currentColor',
|
||||
className = '',
|
||||
'aria-label': ariaLabel,
|
||||
...props
|
||||
}) {
|
||||
const paths = ICONS[name]
|
||||
|
||||
if (!paths) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn(`[Icon] Unknown icon: "${name}". Available: ${Object.keys(ICONS).join(', ')}`)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-label={ariaLabel}
|
||||
aria-hidden={ariaLabel ? undefined : true}
|
||||
role={ariaLabel ? 'img' : undefined}
|
||||
{...props}
|
||||
>
|
||||
{paths}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Export icon names for autocomplete/documentation
|
||||
export const ICON_NAMES = Object.keys(ICONS)
|
||||
69
frontend/src/components/ui/IconButtonGroup.jsx
Normal file
69
frontend/src/components/ui/IconButtonGroup.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
// src/components/ui/IconButtonGroup.jsx
|
||||
// A compact grouped strip of icon-only action buttons.
|
||||
// Looks like: [ icon | icon | icon ]
|
||||
// Each slot is one Button (ghost/secondary) with only an icon and an aria-label.
|
||||
//
|
||||
// Props:
|
||||
// buttons — Array<{
|
||||
// icon: ReactNode — the SVG / element to render
|
||||
// label: string — aria-label (required for a11y)
|
||||
// onClick: () => void
|
||||
// loading?: boolean
|
||||
// disabled?: boolean
|
||||
// variant?: string — overrides group variant for this slot
|
||||
// }>
|
||||
// variant — default variant for all slots: 'ghost' | 'secondary' (default: 'secondary')
|
||||
// size — 'sm' | 'md' | 'lg' (default: 'md')
|
||||
// disabled — disables all buttons
|
||||
// className — extra class on wrapper
|
||||
|
||||
import Spinner from '@/components/ui/Spinner'
|
||||
|
||||
export default function IconButtonGroup({
|
||||
buttons = [],
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
disabled = false,
|
||||
className = '',
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`icon-btn-group ${className}`}
|
||||
role="group"
|
||||
>
|
||||
{buttons.map((btn, idx) => {
|
||||
const isFirst = idx === 0
|
||||
const isLast = idx === buttons.length - 1
|
||||
const v = btn.variant ?? variant
|
||||
const isDisabled = disabled || btn.disabled || btn.loading
|
||||
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
aria-disabled={isDisabled || undefined}
|
||||
aria-busy={btn.loading || undefined}
|
||||
aria-label={btn.label}
|
||||
title={btn.label}
|
||||
onClick={btn.onClick}
|
||||
className={[
|
||||
'btn',
|
||||
`btn-${v}`,
|
||||
`btn-${size}`,
|
||||
'icon-btn-group__btn',
|
||||
isFirst ? 'icon-btn-group__btn--first' : '',
|
||||
isLast ? 'icon-btn-group__btn--last' : '',
|
||||
!isFirst && !isLast ? 'icon-btn-group__btn--mid' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{btn.loading
|
||||
? <Spinner size={size === 'lg' ? 'md' : 'sm'} />
|
||||
: <span className="shrink-0 flex items-center" aria-hidden="true">{btn.icon}</span>
|
||||
}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
frontend/src/components/ui/Modal.jsx
Normal file
56
frontend/src/components/ui/Modal.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
// src/components/ui/Modal.jsx
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import Button from '@/components/ui/Button'
|
||||
|
||||
const FOCUSABLE = ['a[href]', 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])'].join(', ')
|
||||
|
||||
export default function Modal({ open, onClose, title, size = 'md', footer, persistent = false, children, className = '' }) {
|
||||
const modalRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onKeyDown(e) {
|
||||
if (e.key === 'Escape' && !persistent) { e.stopPropagation(); onClose(); return }
|
||||
if (e.key === 'Tab' && modalRef.current) {
|
||||
const focusable = [...modalRef.current.querySelectorAll(FOCUSABLE)]
|
||||
if (focusable.length === 0) return
|
||||
const first = focusable[0]; const last = focusable[focusable.length - 1]
|
||||
if (e.shiftKey) { if (document.activeElement === first) { e.preventDefault(); last.focus() } }
|
||||
else { if (document.activeElement === last) { e.preventDefault(); first.focus() } }
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [open, onClose, persistent])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !modalRef.current) return
|
||||
modalRef.current.querySelector(FOCUSABLE)?.focus()
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return createPortal(
|
||||
<div className="modal-overlay" onClick={(e) => { if (!persistent && e.target === e.currentTarget) onClose() }} aria-modal="true" role="dialog" aria-labelledby={title ? 'modal-title' : undefined}>
|
||||
<div ref={modalRef} className={['modal', `modal-${size}`, className].filter(Boolean).join(' ')}>
|
||||
<div className="modal-header">
|
||||
{title ? <h2 id="modal-title" className="modal-title">{title}</h2> : <span />}
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close dialog">
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<path d="M4 4l10 10M14 4L4 14" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">{children}</div>
|
||||
{footer && <div className="modal-footer">{footer}</div>}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
71
frontend/src/components/ui/PageHeader.jsx
Normal file
71
frontend/src/components/ui/PageHeader.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
// src/v2/components/ui/PageHeader.jsx
|
||||
// Standard page title block. Every page starts with this component.
|
||||
//
|
||||
// Props:
|
||||
// title — string (required) — main page heading
|
||||
// subtitle — string — muted description below the title
|
||||
// breadcrumbs — Array<{ label: string, href?: string }> — shown above the title
|
||||
// children — ReactNode — action buttons rendered top-right
|
||||
// className — extra classes on the wrapper
|
||||
//
|
||||
// Layout:
|
||||
// [breadcrumbs?]
|
||||
// [title] [children / actions →]
|
||||
// [subtitle?]
|
||||
//
|
||||
// Styles: src/v2/styles/components.css (.page-header*, .breadcrumbs*)
|
||||
|
||||
export default function PageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
breadcrumbs,
|
||||
children,
|
||||
className = '',
|
||||
}) {
|
||||
return (
|
||||
<header className={`page-header ${className}`}>
|
||||
{/* Breadcrumb trail — only on detail pages */}
|
||||
{breadcrumbs?.length > 0 && (
|
||||
<nav aria-label="Breadcrumb" className="breadcrumbs">
|
||||
{breadcrumbs.map((crumb, i) => {
|
||||
const isLast = i === breadcrumbs.length - 1
|
||||
return (
|
||||
<span key={i} className="contents">
|
||||
{i > 0 && (
|
||||
<span className="breadcrumbs-sep" aria-hidden="true">
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
{isLast || !crumb.href ? (
|
||||
<span
|
||||
className="breadcrumbs-current"
|
||||
aria-current={isLast ? 'page' : undefined}
|
||||
>
|
||||
{crumb.label}
|
||||
</span>
|
||||
) : (
|
||||
<a href={crumb.href}>{crumb.label}</a>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Title row */}
|
||||
<div className="page-header-row">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="page-header-title">{title}</h1>
|
||||
{subtitle && (
|
||||
<p className="page-header-subtitle">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action slot — top-right buttons */}
|
||||
{children && (
|
||||
<div className="page-header-actions">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
134
frontend/src/components/ui/Pagination.jsx
Normal file
134
frontend/src/components/ui/Pagination.jsx
Normal file
@@ -0,0 +1,134 @@
|
||||
// src/v2/components/ui/Pagination.jsx
|
||||
// Page navigation bar rendered below DataTable (and any paginated list).
|
||||
//
|
||||
// Props:
|
||||
// page — number — current page (1-based)
|
||||
// pageSize — number — rows per page
|
||||
// total — number — total record count
|
||||
// onPageChange— (page: number) => void
|
||||
// onSizeChange— (size: number) => void (optional — shows per-page selector)
|
||||
// pageSizes — number[] (default: [20, 50, 100])
|
||||
// className — extra wrapper classes
|
||||
//
|
||||
// Styles live in src/v2/styles/components.css (.pagination*)
|
||||
|
||||
const DEFAULT_PAGE_SIZES = [20, 50, 100]
|
||||
const MAX_VISIBLE_PAGES = 7 // pages shown before collapsing to ellipsis
|
||||
|
||||
function buildPageList(page, totalPages) {
|
||||
if (totalPages <= MAX_VISIBLE_PAGES) {
|
||||
return Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
}
|
||||
const pages = new Set([1, totalPages, page])
|
||||
// Show 2 neighbours on each side of current page
|
||||
for (let i = Math.max(2, page - 2); i <= Math.min(totalPages - 1, page + 2); i++) {
|
||||
pages.add(i)
|
||||
}
|
||||
const sorted = [...pages].sort((a, b) => a - b)
|
||||
const result = []
|
||||
let prev = null
|
||||
for (const p of sorted) {
|
||||
if (prev !== null && p - prev > 1) result.push('…')
|
||||
result.push(p)
|
||||
prev = p
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export default function Pagination({
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
total = 0,
|
||||
onPageChange,
|
||||
onSizeChange,
|
||||
pageSizes = DEFAULT_PAGE_SIZES,
|
||||
className = '',
|
||||
}) {
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const from = total === 0 ? 0 : (page - 1) * pageSize + 1
|
||||
const to = Math.min(page * pageSize, total)
|
||||
const pages = buildPageList(page, totalPages)
|
||||
|
||||
return (
|
||||
<div className={`pagination ${className}`}>
|
||||
{/* Row count summary */}
|
||||
<span className="pagination-info">
|
||||
{total === 0
|
||||
? 'No results'
|
||||
: `${from}–${to} of ${total.toLocaleString()}`}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Per-page selector */}
|
||||
{onSizeChange && (
|
||||
<div className="pagination-info flex items-center gap-2">
|
||||
<span>Rows</span>
|
||||
<div className="pagination-size-group" role="group" aria-label="Rows per page">
|
||||
{pageSizes.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
className={`pagination-size-btn${s === pageSize ? ' pagination-size-btn--active' : ''}`}
|
||||
onClick={() => { if (s !== pageSize) onSizeChange(s) }}
|
||||
aria-pressed={s === pageSize}
|
||||
aria-label={`${s} rows per page`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Page buttons */}
|
||||
<div className="pagination-controls">
|
||||
{/* Previous */}
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => onPageChange?.(page - 1)}
|
||||
disabled={page <= 1}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M10 12L6 8l4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{pages.map((p, i) =>
|
||||
p === '…' ? (
|
||||
<span
|
||||
key={`ellipsis-${i}`}
|
||||
className="pagination-btn"
|
||||
style={{ cursor: 'default', color: 'var(--color-text-muted)' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
className={`pagination-btn ${p === page ? 'active' : ''}`}
|
||||
onClick={() => onPageChange?.(p)}
|
||||
aria-label={`Page ${p}`}
|
||||
aria-current={p === page ? 'page' : undefined}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Next */}
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => onPageChange?.(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M6 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
frontend/src/components/ui/PillButton.jsx
Normal file
110
frontend/src/components/ui/PillButton.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
// frontend/src/components/ui/PillButton.jsx
|
||||
// An interactive pill that looks like a StatusBadge but opens a dropdown.
|
||||
// Sits inline with text/badges — same size and capsule shape.
|
||||
//
|
||||
// Props:
|
||||
// children — current label
|
||||
// options — Array<{ label: string, value: string }>
|
||||
// value — currently selected value
|
||||
// onChange — (value: string) => void
|
||||
// variant — 'neutral'|'primary'|'success'|'warning'|'danger'|'info' (default: 'neutral')
|
||||
// icon — ReactNode — optional leading icon
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
export default function PillButton({
|
||||
children,
|
||||
options = [],
|
||||
value,
|
||||
onChange,
|
||||
variant = 'neutral',
|
||||
icon,
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const btnRef = useRef(null)
|
||||
const menuRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onMouseDown(e) {
|
||||
if (btnRef.current?.contains(e.target)) return
|
||||
if (menuRef.current?.contains(e.target)) return
|
||||
setOpen(false)
|
||||
}
|
||||
function onScroll() { setOpen(false) }
|
||||
document.addEventListener('mousedown', onMouseDown)
|
||||
document.addEventListener('scroll', onScroll, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onMouseDown)
|
||||
document.removeEventListener('scroll', onScroll, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
function handleToggle(e) {
|
||||
e.stopPropagation()
|
||||
if (!open && btnRef.current) {
|
||||
const r = btnRef.current.getBoundingClientRect()
|
||||
setPos({ top: r.bottom + 4, left: r.left })
|
||||
}
|
||||
setOpen((v) => !v)
|
||||
}
|
||||
|
||||
function handleSelect(optValue) {
|
||||
setOpen(false)
|
||||
onChange?.(optValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
className={`pill-btn pill-btn-${variant}`}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{icon && <span className="pill-btn-icon" aria-hidden="true">{icon}</span>}
|
||||
<span>{children}</span>
|
||||
<svg
|
||||
width="8" height="8" viewBox="0 0 10 10"
|
||||
fill="none" stroke="currentColor" strokeWidth="1.75"
|
||||
strokeLinecap="round" strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
style={{ transition: 'transform var(--transition-fast)', transform: open ? 'rotate(180deg)' : 'none', flexShrink: 0 }}
|
||||
>
|
||||
<path d="M2 3.5l3 3 3-3" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="listbox"
|
||||
className="pill-btn-menu"
|
||||
style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 9999 }}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
role="option"
|
||||
aria-selected={opt.value === value}
|
||||
className={`pill-btn-option${opt.value === value ? ' pill-btn-option--active' : ''}`}
|
||||
onClick={(e) => { e.stopPropagation(); handleSelect(opt.value) }}
|
||||
>
|
||||
{opt.value === value && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
146
frontend/src/components/ui/ProfilePageHeader.jsx
Normal file
146
frontend/src/components/ui/ProfilePageHeader.jsx
Normal file
@@ -0,0 +1,146 @@
|
||||
// src/components/ui/ProfilePageHeader.jsx
|
||||
// A specialised page header for entity detail pages featuring a profile photo,
|
||||
// name, subtitle, and optional action buttons.
|
||||
//
|
||||
// Two named exports:
|
||||
// ProfilePageHeader — photo + title + subtitle (no actions)
|
||||
// ProfilePageHeaderActions — photo + title + subtitle + action buttons slot
|
||||
//
|
||||
// Props (both variants):
|
||||
// name — string (required) — primary heading
|
||||
// subtitle — string | ReactNode — muted line below the name
|
||||
// photoUrl — string — src for the photo; falls back to initials avatar
|
||||
// breadcrumbs — Array<{ label: string, href?: string }> — trail above the header
|
||||
// onPhotoClick — () => void — if provided, the avatar becomes a click target
|
||||
// badge — ReactNode — e.g. a <StatusBadge> rendered beside the name
|
||||
// className — extra class on the root element
|
||||
//
|
||||
// Additional prop (ProfilePageHeaderActions only):
|
||||
// children — ReactNode — action buttons rendered in the top-right slot
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
// ─── Shared avatar sub-component ─────────────────────────────────────────────
|
||||
|
||||
function Avatar({ name, photoUrl, onPhotoClick, size = 72 }) {
|
||||
const initial = (name || '?').charAt(0).toUpperCase()
|
||||
const clickable = typeof onPhotoClick === 'function'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`profile-header-avatar${clickable ? ' profile-header-avatar--clickable' : ''}`}
|
||||
style={{ width: size, height: size, minWidth: size }}
|
||||
onClick={clickable ? onPhotoClick : undefined}
|
||||
role={clickable ? 'button' : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
onKeyDown={clickable ? (e) => { if (e.key === 'Enter' || e.key === ' ') onPhotoClick() } : undefined}
|
||||
aria-label={clickable ? 'Change photo' : undefined}
|
||||
>
|
||||
{photoUrl ? (
|
||||
<img src={photoUrl} alt={name || 'Profile photo'} className="profile-header-avatar-img" />
|
||||
) : (
|
||||
<span className="profile-header-avatar-initial">{initial}</span>
|
||||
)}
|
||||
{clickable && (
|
||||
<div className="profile-header-avatar-overlay" aria-hidden="true">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z" />
|
||||
<circle cx="12" cy="13" r="4" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Breadcrumb trail ─────────────────────────────────────────────────────────
|
||||
|
||||
function BreadcrumbTrail({ items }) {
|
||||
if (!items?.length) return null
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className="breadcrumbs" style={{ marginBottom: 'var(--space-3)' }}>
|
||||
{items.map((item, i) => {
|
||||
const isLast = i === items.length - 1
|
||||
return (
|
||||
<span key={i} className="contents">
|
||||
{i > 0 && <span className="breadcrumbs-sep" aria-hidden="true">/</span>}
|
||||
{isLast || !item.href ? (
|
||||
<span className="breadcrumbs-current" aria-current={isLast ? 'page' : undefined}>
|
||||
{item.label}
|
||||
</span>
|
||||
) : (
|
||||
<Link to={item.href}>{item.label}</Link>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Variant 1: no actions ────────────────────────────────────────────────────
|
||||
|
||||
export function ProfilePageHeader({
|
||||
name,
|
||||
subtitle,
|
||||
photoUrl,
|
||||
breadcrumbs,
|
||||
onPhotoClick,
|
||||
badge,
|
||||
className = '',
|
||||
}) {
|
||||
return (
|
||||
<header className={`profile-header ${className}`}>
|
||||
{breadcrumbs?.length > 0 && <BreadcrumbTrail items={breadcrumbs} />}
|
||||
<div className="profile-header-inner">
|
||||
<Avatar name={name} photoUrl={photoUrl} onPhotoClick={onPhotoClick} />
|
||||
<div className="profile-header-text">
|
||||
<div className="profile-header-name-row">
|
||||
<h1 className="profile-header-name">{name || 'Unnamed'}</h1>
|
||||
{badge && <div className="profile-header-badge">{badge}</div>}
|
||||
</div>
|
||||
{subtitle && <p className="profile-header-subtitle">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Variant 2: with actions ──────────────────────────────────────────────────
|
||||
|
||||
export function ProfilePageHeaderActions({
|
||||
name,
|
||||
subtitle,
|
||||
photoUrl,
|
||||
breadcrumbs,
|
||||
onPhotoClick,
|
||||
badge,
|
||||
className = '',
|
||||
children,
|
||||
}) {
|
||||
return (
|
||||
<header className={`profile-header ${className}`}>
|
||||
{breadcrumbs?.length > 0 && <BreadcrumbTrail items={breadcrumbs} />}
|
||||
<div className="profile-header-inner profile-header-inner--with-actions">
|
||||
{/* Left: avatar + text */}
|
||||
<div className="profile-header-left">
|
||||
<Avatar name={name} photoUrl={photoUrl} onPhotoClick={onPhotoClick} />
|
||||
<div className="profile-header-text">
|
||||
<div className="profile-header-name-row">
|
||||
<h1 className="profile-header-name">{name || 'Unnamed'}</h1>
|
||||
{badge && <div className="profile-header-badge">{badge}</div>}
|
||||
</div>
|
||||
{subtitle && <p className="profile-header-subtitle">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{/* Right: action buttons */}
|
||||
{children && (
|
||||
<div className="profile-header-actions">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
// Default export is the actions variant (most commonly needed)
|
||||
export default ProfilePageHeaderActions
|
||||
105
frontend/src/components/ui/RowActions.jsx
Normal file
105
frontend/src/components/ui/RowActions.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
// src/components/ui/RowActions.jsx
|
||||
//
|
||||
// Standard "Actions" button for the last column of every DataTable row.
|
||||
// Opens a portal-based dropdown anchored to the button.
|
||||
//
|
||||
// Props:
|
||||
// actions — array of { label, icon?, color?, onClick, divider? }
|
||||
// `divider: true` adds a separator line above the item
|
||||
// disabled — grey out and prevent opening
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import Button from '@/components/ui/Button'
|
||||
|
||||
export default function RowActions({ actions = [], disabled = false, variant = 'table-actions', size = 'sm' }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, right: 0 })
|
||||
const btnRef = useRef(null)
|
||||
const menuRef = useRef(null)
|
||||
|
||||
// Close on any outside click or scroll
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onMouseDown(e) {
|
||||
if (btnRef.current?.contains(e.target)) return
|
||||
if (menuRef.current?.contains(e.target)) return
|
||||
setOpen(false)
|
||||
}
|
||||
function onScroll() { setOpen(false) }
|
||||
document.addEventListener('mousedown', onMouseDown)
|
||||
document.addEventListener('scroll', onScroll, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onMouseDown)
|
||||
document.removeEventListener('scroll', onScroll, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
function handleToggle(e) {
|
||||
e.stopPropagation()
|
||||
if (disabled) return
|
||||
if (!open && btnRef.current) {
|
||||
const r = btnRef.current.getBoundingClientRect()
|
||||
setPos({ top: r.bottom + 4, right: window.innerWidth - r.right })
|
||||
}
|
||||
setOpen((v) => !v)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
ref={btnRef}
|
||||
variant={variant}
|
||||
size={size}
|
||||
disabled={disabled}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
aria-label="Row actions"
|
||||
onClick={handleToggle}
|
||||
iconRight={
|
||||
<svg
|
||||
width="11" height="11" viewBox="0 0 12 12"
|
||||
fill="none" stroke="currentColor" strokeWidth="1.75"
|
||||
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"
|
||||
style={{ transition: 'transform var(--transition-fast)', transform: open ? 'rotate(180deg)' : 'none' }}
|
||||
>
|
||||
<path d="M2 4l4 4 4-4" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Actions
|
||||
</Button>
|
||||
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
className="row-actions-menu"
|
||||
style={{ position: 'fixed', top: pos.top, right: pos.right, zIndex: 9999 }}
|
||||
>
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
role="menuitem"
|
||||
className="row-actions-item"
|
||||
style={{
|
||||
color: action.color ?? 'var(--color-text-secondary)',
|
||||
borderTop: action.divider ? '1px solid var(--color-border)' : 'none',
|
||||
marginTop: action.divider ? 'var(--space-1)' : 0,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpen(false)
|
||||
action.onClick?.()
|
||||
}}
|
||||
>
|
||||
{action.icon && <span className="row-actions-item-icon">{action.icon}</span>}
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
101
frontend/src/components/ui/SearchBar.jsx
Normal file
101
frontend/src/components/ui/SearchBar.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
// src/components/ui/SearchBar.jsx
|
||||
// Debounced search input with a magnifier icon and animated clear button.
|
||||
//
|
||||
// Props:
|
||||
// value — string — controlled value (optional; uncontrolled if omitted)
|
||||
// onChange — (value: string) => void — called after debounce delay
|
||||
// placeholder — string (default: 'Search…')
|
||||
// debounce — number — ms delay before onChange fires (default: 300)
|
||||
// disabled — boolean
|
||||
// className — extra classes on the wrapper
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
export default function SearchBar({
|
||||
value: controlledValue,
|
||||
onChange,
|
||||
placeholder = 'Search…',
|
||||
debounce = 300,
|
||||
disabled = false,
|
||||
className = '',
|
||||
}) {
|
||||
const isControlled = controlledValue !== undefined
|
||||
const [localValue, setLocalValue] = useState(isControlled ? controlledValue : '')
|
||||
const timerRef = useRef(null)
|
||||
const inputRef = useRef(null)
|
||||
const prevControlledRef = useRef(controlledValue)
|
||||
|
||||
// Only sync from outside when the parent *explicitly* changes the value
|
||||
// (e.g. "Clear filters" resets it to ''). Do NOT sync on every re-render —
|
||||
// that's what caused the keystroke lag: the debounced parent setState was
|
||||
// writing back into this input and overwriting what the user just typed.
|
||||
useEffect(() => {
|
||||
if (isControlled && controlledValue !== prevControlledRef.current) {
|
||||
prevControlledRef.current = controlledValue
|
||||
setLocalValue(controlledValue)
|
||||
}
|
||||
}, [controlledValue, isControlled])
|
||||
|
||||
function handleChange(e) {
|
||||
const v = e.target.value
|
||||
setLocalValue(v)
|
||||
prevControlledRef.current = v // keep ref in sync so the effect doesn't clobber it
|
||||
if (debounce > 0) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => onChange?.(v), debounce)
|
||||
} else {
|
||||
onChange?.(v)
|
||||
}
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
setLocalValue('')
|
||||
prevControlledRef.current = ''
|
||||
clearTimeout(timerRef.current)
|
||||
onChange?.('')
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const displayValue = localValue
|
||||
|
||||
return (
|
||||
<div className={`searchbar ${className}`}>
|
||||
{/* Search icon */}
|
||||
<span className="searchbar-icon" aria-hidden="true">
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="6.5" cy="6.5" r="4.5" />
|
||||
<path d="M10 10l3.5 3.5" />
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
className="searchbar-input"
|
||||
value={displayValue}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
aria-label={placeholder}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
|
||||
{/* Clear button — only when there's text */}
|
||||
{displayValue && !disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="searchbar-clear"
|
||||
onClick={handleClear}
|
||||
aria-label="Clear search"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
||||
<path d="M1 1l8 8M9 1L1 9" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
80
frontend/src/components/ui/SegmentedControl.jsx
Normal file
80
frontend/src/components/ui/SegmentedControl.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
// src/components/ui/SegmentedControl.jsx
|
||||
// A row of mutually-exclusive toggle buttons — styled like Button variants,
|
||||
// grouped into a single visual unit with shared borders.
|
||||
//
|
||||
// Props:
|
||||
// options — Array<{
|
||||
// value: string
|
||||
// label: ReactNode
|
||||
// icon?: ReactNode
|
||||
// activeBg?: string — CSS value for active background (overrides variant)
|
||||
// activeText?:string — CSS value for active text color (overrides variant)
|
||||
// }>
|
||||
// value — currently selected value (string)
|
||||
// onChange — (value: string) => void
|
||||
// variant — button variant for active item when no per-option color: 'primary' | 'secondary' | 'ghost'
|
||||
// (default: 'secondary')
|
||||
// size — 'sm' | 'md' | 'lg' (default: 'md')
|
||||
// disabled — boolean
|
||||
// className — extra class on wrapper
|
||||
|
||||
export default function SegmentedControl({
|
||||
options = [],
|
||||
value,
|
||||
onChange,
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
disabled = false,
|
||||
className = '',
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`seg-ctrl ${className}`}
|
||||
role="group"
|
||||
aria-label="Segmented control"
|
||||
>
|
||||
{options.map((opt, idx) => {
|
||||
const isActive = opt.value === value
|
||||
const isFirst = idx === 0
|
||||
const isLast = idx === options.length - 1
|
||||
|
||||
// Per-option color override: inline style applied only when active
|
||||
const hasCustomColor = isActive && (opt.activeBg || opt.activeText)
|
||||
const customStyle = hasCustomColor
|
||||
? {
|
||||
backgroundColor: opt.activeBg || undefined,
|
||||
color: opt.activeText || undefined,
|
||||
borderColor: 'transparent',
|
||||
}
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={isActive}
|
||||
onClick={() => !disabled && onChange(opt.value)}
|
||||
style={customStyle}
|
||||
className={[
|
||||
'btn',
|
||||
isActive && !hasCustomColor ? `btn-${variant}` : isActive ? '' : 'btn-ghost',
|
||||
`btn-${size}`,
|
||||
'seg-ctrl__btn',
|
||||
isFirst ? 'seg-ctrl__btn--first' : '',
|
||||
isLast ? 'seg-ctrl__btn--last' : '',
|
||||
!isFirst && !isLast ? 'seg-ctrl__btn--mid' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{opt.icon && (
|
||||
<span className="shrink-0 flex items-center" aria-hidden="true">
|
||||
{opt.icon}
|
||||
</span>
|
||||
)}
|
||||
{opt.label != null && <span>{opt.label}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
194
frontend/src/components/ui/Select.jsx
Normal file
194
frontend/src/components/ui/Select.jsx
Normal file
@@ -0,0 +1,194 @@
|
||||
// src/components/ui/Select.jsx
|
||||
// Fully-styled custom select — replaces the native <select> so the option list
|
||||
// can be themed to match the rest of the design system.
|
||||
//
|
||||
// Used internally by FormField (type="select"). Can also be used standalone.
|
||||
//
|
||||
// Props:
|
||||
// value — controlled value string
|
||||
// onChange — (e) => void — called with a synthetic { target: { value } }
|
||||
// children — <option> elements (same API as native <select>)
|
||||
// placeholder — shown when value is empty (default: 'Select…')
|
||||
// disabled — boolean
|
||||
// className — extra classes on the trigger button
|
||||
// id — forwarded for label association
|
||||
// name — forwarded (unused visually, kept for form semantics)
|
||||
|
||||
import { useState, useRef, useEffect, Children, isValidElement } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
// Parse React <option> children into a flat [{value, label, disabled}] array
|
||||
function parseOptions(children) {
|
||||
const opts = []
|
||||
Children.forEach(children, child => {
|
||||
if (!isValidElement(child)) return
|
||||
if (child.type === 'option') {
|
||||
opts.push({
|
||||
value: String(child.props.value ?? ''),
|
||||
label: child.props.children ?? child.props.value ?? '',
|
||||
disabled: !!child.props.disabled,
|
||||
})
|
||||
}
|
||||
// Ignore <optgroup> for now — could be added later
|
||||
})
|
||||
return opts
|
||||
}
|
||||
|
||||
export default function Select({
|
||||
value,
|
||||
onChange,
|
||||
children,
|
||||
placeholder = 'Select…',
|
||||
disabled = false,
|
||||
className = '',
|
||||
id,
|
||||
name,
|
||||
...props
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0 })
|
||||
const triggerRef = useRef(null)
|
||||
const menuRef = useRef(null)
|
||||
|
||||
const options = parseOptions(children)
|
||||
const selected = options.find(o => o.value === String(value ?? ''))
|
||||
|
||||
// ── Close on outside click / scroll ────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function close(e) {
|
||||
if (triggerRef.current?.contains(e.target)) return
|
||||
if (menuRef.current?.contains(e.target)) return
|
||||
setOpen(false)
|
||||
}
|
||||
function closeOnScroll(e) {
|
||||
if (menuRef.current?.contains(e.target)) return
|
||||
if (triggerRef.current?.contains(e.target)) return
|
||||
setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', close)
|
||||
document.addEventListener('scroll', closeOnScroll, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', close)
|
||||
document.removeEventListener('scroll', closeOnScroll, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// ── Keyboard navigation ────────────────────────────────────────────────────
|
||||
function handleKeyDown(e) {
|
||||
if (disabled) return
|
||||
const curr = options.findIndex(o => o.value === String(value ?? ''))
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
if (!open) {
|
||||
openMenu()
|
||||
} else {
|
||||
setOpen(false)
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false)
|
||||
triggerRef.current?.focus()
|
||||
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
const dir = e.key === 'ArrowDown' ? 1 : -1
|
||||
let next = curr + dir
|
||||
// Skip disabled
|
||||
while (next >= 0 && next < options.length && options[next].disabled) {
|
||||
next += dir
|
||||
}
|
||||
if (next >= 0 && next < options.length) {
|
||||
onChange?.({ target: { value: options[next].value, name } })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openMenu() {
|
||||
if (disabled) return
|
||||
if (triggerRef.current) {
|
||||
const r = triggerRef.current.getBoundingClientRect()
|
||||
setMenuPos({ top: r.bottom + 4, left: r.left, width: r.width })
|
||||
}
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
function handleToggle() {
|
||||
if (open) {
|
||||
setOpen(false)
|
||||
} else {
|
||||
openMenu()
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(opt) {
|
||||
if (opt.disabled) return
|
||||
onChange?.({ target: { value: opt.value, name } })
|
||||
setOpen(false)
|
||||
triggerRef.current?.focus()
|
||||
}
|
||||
|
||||
const menu = open && createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="select-menu"
|
||||
style={{ top: menuPos.top, left: menuPos.left, width: menuPos.width }}
|
||||
role="listbox"
|
||||
aria-label="Options"
|
||||
>
|
||||
{options.map(opt => (
|
||||
<div
|
||||
key={opt.value}
|
||||
role="option"
|
||||
aria-selected={opt.value === String(value ?? '')}
|
||||
aria-disabled={opt.disabled || undefined}
|
||||
className={[
|
||||
'select-option',
|
||||
opt.value === String(value ?? '') ? 'select-option-selected' : '',
|
||||
opt.disabled ? 'select-option-disabled' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onMouseDown={e => { e.preventDefault(); handleSelect(opt) }}
|
||||
>
|
||||
{opt.value === String(value ?? '') && (
|
||||
<svg className="select-option-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{opt.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
id={id}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
onClick={handleToggle}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={[
|
||||
'input',
|
||||
'select-trigger',
|
||||
open ? 'select-trigger-open' : '',
|
||||
className,
|
||||
].filter(Boolean).join(' ')}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
{...props}
|
||||
>
|
||||
<span className={selected ? 'select-value' : 'select-placeholder'}>
|
||||
{selected ? selected.label : placeholder}
|
||||
</span>
|
||||
<span className="select-chevron" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
{menu}
|
||||
</>
|
||||
)
|
||||
}
|
||||
37
frontend/src/components/ui/Spinner.jsx
Normal file
37
frontend/src/components/ui/Spinner.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
// src/v2/components/ui/Spinner.jsx
|
||||
// Animated SVG spinner. Used by Button (loading state), DataTable, and any async UI.
|
||||
//
|
||||
// Props:
|
||||
// size — 'sm' | 'md' | 'lg' (default: 'md')
|
||||
// className — extra Tailwind layout classes (e.g. 'mx-auto')
|
||||
|
||||
export default function Spinner({ size = 'md', className = '' }) {
|
||||
const px = { sm: 14, md: 20, lg: 28 }[size] ?? 20
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={px}
|
||||
height={px}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className={`animate-spin shrink-0 ${className}`}
|
||||
aria-hidden="true"
|
||||
role="presentation"
|
||||
>
|
||||
{/* Faint full circle — the track */}
|
||||
<circle
|
||||
cx="12" cy="12" r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeOpacity="0.2"
|
||||
/>
|
||||
{/* Bright arc — the moving head */}
|
||||
<path
|
||||
d="M12 2a10 10 0 0 1 10 10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
77
frontend/src/components/ui/StatusBadge.jsx
Normal file
77
frontend/src/components/ui/StatusBadge.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
// src/v2/components/ui/StatusBadge.jsx
|
||||
// Pill-shaped "glowing ink" status indicator.
|
||||
// Never use raw <span> tags for status values — always use StatusBadge.
|
||||
//
|
||||
// Props:
|
||||
// variant — 'success'|'warning'|'danger'|'info'|'primary'|'neutral' (default: 'neutral')
|
||||
// dot — boolean — show a small leading dot (default: true)
|
||||
// label — string — text content (falls back to children)
|
||||
// children— ReactNode — alternative to label
|
||||
// size — 'sm' | 'md' (default: 'md')
|
||||
// className — extra classes
|
||||
//
|
||||
// Preset status → variant mappings are exported for convenience.
|
||||
// Styles live in src/v2/styles/components.css (.badge-*)
|
||||
|
||||
// Map common status strings to variants. Pages can import this to avoid
|
||||
// duplicating the mapping logic.
|
||||
export const STATUS_VARIANT = {
|
||||
// Device / operational
|
||||
online: 'success',
|
||||
active: 'success',
|
||||
connected: 'success',
|
||||
sold: 'success',
|
||||
claimed: 'success',
|
||||
provisioned: 'success',
|
||||
flashed: 'info',
|
||||
manufactured: 'info',
|
||||
pending: 'warning',
|
||||
warning: 'warning',
|
||||
maintenance: 'warning',
|
||||
offline: 'danger',
|
||||
error: 'danger',
|
||||
inactive: 'danger',
|
||||
deleted: 'danger',
|
||||
// CRM / order
|
||||
open: 'primary',
|
||||
draft: 'neutral',
|
||||
sent: 'info',
|
||||
accepted: 'success',
|
||||
rejected: 'danger',
|
||||
paid: 'success',
|
||||
unpaid: 'warning',
|
||||
overdue: 'danger',
|
||||
cancelled: 'neutral',
|
||||
completed: 'success',
|
||||
processing: 'info',
|
||||
shipped: 'primary',
|
||||
delivered: 'success',
|
||||
returned: 'warning',
|
||||
}
|
||||
|
||||
export default function StatusBadge({
|
||||
variant = 'neutral',
|
||||
dot = true,
|
||||
label,
|
||||
children,
|
||||
size = 'md',
|
||||
className = '',
|
||||
}) {
|
||||
const text = label ?? children
|
||||
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
'badge',
|
||||
`badge-${variant}`,
|
||||
size === 'sm' ? 'badge-sm' : '',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{dot && <span className="badge-dot" aria-hidden="true" />}
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
81
frontend/src/components/ui/Tabs.jsx
Normal file
81
frontend/src/components/ui/Tabs.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
// src/components/ui/Tabs.jsx
|
||||
// Horizontal tab navigation. Two visual variants: sliding underline or filled pill.
|
||||
//
|
||||
// Props:
|
||||
// tabs — Array<{ key: string, label: string, icon?: ReactNode, count?: number }>
|
||||
// active — string — key of the active tab
|
||||
// onChange — (key: string) => void
|
||||
// variant — 'line' | 'pill' (default: 'line')
|
||||
// className — extra classes on the wrapper
|
||||
|
||||
import { useRef, useState, useEffect, useLayoutEffect } from 'react'
|
||||
|
||||
export default function Tabs({
|
||||
tabs = [],
|
||||
active,
|
||||
onChange,
|
||||
variant = 'line',
|
||||
className = '',
|
||||
}) {
|
||||
const tabRefs = useRef({})
|
||||
const tabsInnerRef = useRef(null)
|
||||
const [indicator, setIndicator] = useState({ left: 0, width: 0 })
|
||||
|
||||
// Measure the active tab and update the sliding indicator
|
||||
const updateIndicator = () => {
|
||||
if (variant !== 'line') return
|
||||
const el = tabRefs.current[active]
|
||||
if (el) {
|
||||
setIndicator({ left: el.offsetLeft, width: el.offsetWidth })
|
||||
}
|
||||
}
|
||||
|
||||
useLayoutEffect(updateIndicator, [active, variant, tabs])
|
||||
|
||||
// Also re-measure on window resize
|
||||
useEffect(() => {
|
||||
if (variant !== 'line') return
|
||||
window.addEventListener('resize', updateIndicator)
|
||||
return () => window.removeEventListener('resize', updateIndicator)
|
||||
}, [active, variant])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[`tabs-${variant}`, className].filter(Boolean).join(' ')}
|
||||
role="tablist"
|
||||
>
|
||||
<div
|
||||
ref={tabsInnerRef}
|
||||
className={`tabs-${variant}__inner`}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
ref={(el) => { tabRefs.current[tab.key] = el }}
|
||||
role="tab"
|
||||
aria-selected={tab.key === active}
|
||||
className={['tab', tab.key === active ? 'active' : ''].filter(Boolean).join(' ')}
|
||||
onClick={() => onChange?.(tab.key)}
|
||||
>
|
||||
{tab.icon && (
|
||||
<span className="tab-icon" aria-hidden="true">{tab.icon}</span>
|
||||
)}
|
||||
{tab.label}
|
||||
{tab.count != null && (
|
||||
<span className="tab-badge">{tab.count}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Sliding underline — line variant only */}
|
||||
{variant === 'line' && (
|
||||
<span
|
||||
className="tabs-indicator"
|
||||
aria-hidden="true"
|
||||
style={{ left: indicator.left, width: indicator.width }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
157
frontend/src/components/ui/Toast.jsx
Normal file
157
frontend/src/components/ui/Toast.jsx
Normal file
@@ -0,0 +1,157 @@
|
||||
// src/components/ui/Toast.jsx
|
||||
// Toast notification system: provider, hook, and renderer.
|
||||
//
|
||||
// Usage:
|
||||
// 1. Wrap your app (or page) with <ToastProvider>
|
||||
// 2. In any child component: const { toast } = useToast()
|
||||
// 3. Call: toast.success('Saved!', 'Your changes have been saved.')
|
||||
// toast.danger('Error', 'Something went wrong.')
|
||||
// toast.warning('Warning', 'Disk space is low.')
|
||||
// toast.info('Info', 'A new version is available.')
|
||||
//
|
||||
// Each toast auto-dismisses after `duration` ms (default 4000).
|
||||
// ToastProvider renders the stack itself via a portal — no extra component needed.
|
||||
|
||||
import { createContext, useContext, useState, useCallback, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
// ---------------------------------------------------------------------------
|
||||
const ToastContext = createContext(null)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Icons (inline — no external dependency)
|
||||
// ---------------------------------------------------------------------------
|
||||
function ToastIcon({ variant }) {
|
||||
const props = {
|
||||
width: 16, height: 16, viewBox: '0 0 16 16',
|
||||
fill: 'none', stroke: 'currentColor',
|
||||
strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round',
|
||||
'aria-hidden': true,
|
||||
}
|
||||
|
||||
if (variant === 'success') return (
|
||||
<svg {...props}><path d="M13 4L6 11l-3-3" /></svg>
|
||||
)
|
||||
if (variant === 'danger') return (
|
||||
<svg {...props}><path d="M12 4L4 12M4 4l8 8" /></svg>
|
||||
)
|
||||
if (variant === 'warning') return (
|
||||
<svg {...props}><path d="M8 3v5M8 11v1" /><path d="M3.5 13.5h9L8 2.5l-4.5 11z" /></svg>
|
||||
)
|
||||
// info
|
||||
return (
|
||||
<svg {...props}><circle cx="8" cy="8" r="6" /><path d="M8 7v4M8 5.5v.5" /></svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single Toast item
|
||||
// ---------------------------------------------------------------------------
|
||||
function ToastItem({ id, variant = 'info', title, message, duration = 4000, onDismiss }) {
|
||||
const [exiting, setExiting] = useState(false)
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setExiting(true)
|
||||
setTimeout(() => onDismiss(id), 300)
|
||||
}, [id, onDismiss])
|
||||
|
||||
// Auto-dismiss timer
|
||||
const timerRef = useRef(null)
|
||||
const startTimer = () => {
|
||||
timerRef.current = setTimeout(dismiss, duration)
|
||||
}
|
||||
const clearTimer = () => clearTimeout(timerRef.current)
|
||||
|
||||
// Start timer on mount
|
||||
useState(() => { startTimer() })
|
||||
|
||||
return (
|
||||
<div
|
||||
className={['toast', `toast-${variant}`, exiting ? 'exiting' : ''].filter(Boolean).join(' ')}
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
onMouseEnter={clearTimer}
|
||||
onMouseLeave={startTimer}
|
||||
>
|
||||
<div className="toast-icon">
|
||||
<ToastIcon variant={variant} />
|
||||
</div>
|
||||
|
||||
<div className="toast-content">
|
||||
{title && <div className="toast-title">{title}</div>}
|
||||
{message && <div className="toast-message">{message}</div>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="toast-close"
|
||||
onClick={dismiss}
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
||||
<path d="M2 2l8 8M10 2L2 10" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Progress bar — shrinks over duration */}
|
||||
<div
|
||||
className="toast-progress"
|
||||
style={{ animationDuration: `${duration}ms` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
let _nextId = 1
|
||||
|
||||
export function ToastProvider({ children }) {
|
||||
const [toasts, setToasts] = useState([])
|
||||
|
||||
const addToast = useCallback((variant, title, message, duration) => {
|
||||
const id = _nextId++
|
||||
setToasts((prev) => [...prev, { id, variant, title, message, duration }])
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const dismiss = useCallback((id) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, [])
|
||||
|
||||
const toast = {
|
||||
success: (title, message, duration) => addToast('success', title, message, duration),
|
||||
danger: (title, message, duration) => addToast('danger', title, message, duration),
|
||||
warning: (title, message, duration) => addToast('warning', title, message, duration),
|
||||
info: (title, message, duration) => addToast('info', title, message, duration),
|
||||
dismiss,
|
||||
}
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={toast}>
|
||||
{children}
|
||||
{createPortal(
|
||||
<div className="toast-stack" aria-label="Notifications">
|
||||
{toasts.map((t) => (
|
||||
<ToastItem key={t.id} {...t} onDismiss={dismiss} />
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext)
|
||||
if (!ctx) throw new Error('useToast must be used inside <ToastProvider>')
|
||||
return { toast: ctx }
|
||||
}
|
||||
|
||||
// Default export — the provider (hook exported as named)
|
||||
export default ToastProvider
|
||||
Reference in New Issue
Block a user