Initial Switch to V2. Completely Overhauled Backend, Frontend and General Structure.

This commit is contained in:
2026-04-17 14:37:36 +03:00
parent eb773c5531
commit 0a8a42d69b
447 changed files with 70696 additions and 492 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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)" }}
>
&times;
</button>
)}
</div>
);
}

View File

@@ -1 +0,0 @@
// TODO: Status badge component

View 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>
)
}

View 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)}
/>
</>
)
}

View 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>
</>
)
}

View 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>
)
}

View File

@@ -0,0 +1,4 @@
// TODO: implement
export default function ActivityLog() {
return null
}

View File

@@ -0,0 +1,4 @@
// TODO: implement
export default function StaffNotesPanel() {
return null
}

View 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>
)
}

View 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

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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 (023 for hours, 059 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>
)
}

View 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>
)
}

View 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>
)
}

View 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)

View 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>
)
}

View 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
)
}

View 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>
)
}

View 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>
)
}

View 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
)}
</>
)
}

View 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

View 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
)}
</>
)
}

View 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>
)
}

View 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>
)
}

View 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}
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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