Rebuild desktop shell on the real AdminLTE 4 package
Install admin-lte@4.1.0 (+ bootstrap 5.3.8, bootstrap-icons as peer deps) and rebuild every desktop component against actual AdminLTE markup pulled from its live docs/demo, rather than a Tailwind approximation: - Real app-wrapper/app-header/app-sidebar/app-main/app-footer structure - Working PushMenu (sidebar collapse), Treeview, ColorMode (light/dark/auto) - Navbar: functional search (wired to Users list ?search=), notifications dropdown (MQTT-driven), user menu dropdown - Sidebar: grouped nav-header sections, active-route highlighting - Breadcrumbs, footer, Bootstrap card/table/pagination/progress widgets Tailwind is now imported utilities-only (no preflight) so it coexists with Bootstrap's reset; mobile shell and modals are untouched and still Tailwind.
This commit is contained in:
parent
7198895444
commit
84cd474bcb
61
frontend/package-lock.json
generated
61
frontend/package-lock.json
generated
@ -9,7 +9,9 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"admin-lte": "^4.1.0",
|
||||
"axios": "^1.19.0",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"mqtt": "^5.15.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.8",
|
||||
@ -421,6 +423,17 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz",
|
||||
@ -1321,6 +1334,18 @@
|
||||
"node": ">=6.5"
|
||||
}
|
||||
},
|
||||
"node_modules/admin-lte": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/admin-lte/-/admin-lte-4.1.0.tgz",
|
||||
"integrity": "sha512-XsBJSYMYjUD0PYgTeI2/3WIeYGPtr2tUAsd7ei8Zgh5bYhGzLkTxAy7aIV/k19zifmHY9llaEZ0dPIUOKCzArA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bootstrap": "^5.3.8"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
@ -1407,6 +1432,42 @@
|
||||
"readable-stream": "^4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bootstrap": {
|
||||
"version": "5.3.8",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
|
||||
"integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/twbs"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/bootstrap"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@popperjs/core": "^2.11.8"
|
||||
}
|
||||
},
|
||||
"node_modules/bootstrap-icons": {
|
||||
"version": "1.13.1",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz",
|
||||
"integrity": "sha512-ijombt4v6bv5CLeXvRWKy7CuM3TRTuPEuGaGKvTV5cz65rQSY8RQ2JcHt6b90cBBAC7s8fsf2EkQDldzCoXUjw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/twbs"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/bootstrap"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/broker-factory": {
|
||||
"version": "3.1.15",
|
||||
"resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.15.tgz",
|
||||
|
||||
@ -11,7 +11,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"admin-lte": "^4.1.0",
|
||||
"axios": "^1.19.0",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"mqtt": "^5.15.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.8",
|
||||
|
||||
@ -14,20 +14,14 @@ export function ActivityTimeline({ items }: ActivityTimelineProps) {
|
||||
return (
|
||||
<Card title="Recent activity">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500">No activity yet.</p>
|
||||
<p className="text-secondary mb-0">No activity yet.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-3">
|
||||
<ul className="list-group list-group-flush">
|
||||
{items.map((item, i) => (
|
||||
<li key={i} className="flex items-start gap-3 text-sm">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-blue-500" />
|
||||
<div>
|
||||
<p className="text-gray-800 dark:text-gray-200">
|
||||
<span className="font-medium">{item.actor_name}</span> — {item.action.replace('.', ' ')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
||||
{new Date(item.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<li key={i} className="list-group-item px-0">
|
||||
<i className="bi bi-dot text-primary fs-4 align-middle"></i>
|
||||
<span className="fw-semibold">{item.actor_name}</span> — {item.action.replace('.', ' ')}
|
||||
<div className="text-secondary fs-7">{new Date(item.created_at).toLocaleString()}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@ -7,9 +7,13 @@ interface CardProps {
|
||||
|
||||
export function Card({ title, children }: CardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
|
||||
{title && <h2 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">{title}</h2>}
|
||||
{children}
|
||||
<div className="card">
|
||||
{title && (
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">{title}</h3>
|
||||
</div>
|
||||
)}
|
||||
<div className="card-body">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ interface ChartPanelProps {
|
||||
data: Record<string, number>
|
||||
}
|
||||
|
||||
const BAR_COLOR = 'bg-blue-500'
|
||||
const BAR_COLORS = ['bg-primary', 'bg-success', 'bg-warning', 'bg-info', 'bg-danger']
|
||||
|
||||
export function ChartPanel({ title, data }: ChartPanelProps) {
|
||||
const entries = Object.entries(data)
|
||||
@ -13,17 +13,20 @@ export function ChartPanel({ title, data }: ChartPanelProps) {
|
||||
|
||||
return (
|
||||
<Card title={title}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{entries.map(([label, value]) => (
|
||||
<div key={label} className="flex items-center gap-3">
|
||||
<span className="w-20 shrink-0 text-xs capitalize text-gray-600 dark:text-gray-400">{label}</span>
|
||||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div className={`h-full ${BAR_COLOR}`} style={{ width: `${(value / max) * 100}%` }} />
|
||||
</div>
|
||||
<span className="w-6 shrink-0 text-right text-xs text-gray-500 dark:text-gray-500">{value}</span>
|
||||
{entries.map(([label, value], i) => (
|
||||
<div key={label} className="mb-3">
|
||||
<div className="d-flex justify-content-between mb-1">
|
||||
<span className="text-capitalize">{label}</span>
|
||||
<span className="text-secondary">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="progress" role="progressbar" aria-valuenow={value} aria-valuemin={0} aria-valuemax={max}>
|
||||
<div
|
||||
className={`progress-bar ${BAR_COLORS[i % BAR_COLORS.length]}`}
|
||||
style={{ width: `${(value / max) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
8
frontend/src/components/desktop/Footer.tsx
Normal file
8
frontend/src/components/desktop/Footer.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="app-footer">
|
||||
<div className="float-end d-none d-sm-inline">v1.0.0</div>
|
||||
<strong>Copyright © {new Date().getFullYear()} webappAIO.</strong> All rights reserved.
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@ -1,13 +1,20 @@
|
||||
interface InfoBoxProps {
|
||||
label: string
|
||||
value: string | number
|
||||
icon: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export function InfoBox({ label, value }: InfoBoxProps) {
|
||||
export function InfoBox({ label, value, icon, color = 'text-bg-primary' }: InfoBoxProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-500">{label}</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">{value}</p>
|
||||
<div className="info-box">
|
||||
<span className={`info-box-icon shadow-sm ${color}`}>
|
||||
<i className={`bi ${icon}`}></i>
|
||||
</span>
|
||||
<div className="info-box-content">
|
||||
<span className="info-box-text">{label}</span>
|
||||
<span className="info-box-number">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,27 +1,132 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../../features/auth/AuthContext'
|
||||
import { useNotifications } from './useNotifications'
|
||||
|
||||
export function Navbar() {
|
||||
const { user, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [search, setSearch] = useState('')
|
||||
const notifications = useNotifications()
|
||||
|
||||
function handleSearch(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (search.trim()) {
|
||||
navigate(`/users?search=${encodeURIComponent(search.trim())}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="flex h-14 items-center justify-between border-b border-gray-200 bg-white px-4 dark:border-gray-800 dark:bg-gray-900">
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">webappAIO</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">{user?.name}</span>
|
||||
<Link
|
||||
to="/settings/2fa"
|
||||
className="rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
>
|
||||
2FA Settings
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => logout()}
|
||||
className="rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
<nav className="app-header navbar navbar-expand bg-body">
|
||||
<div className="container-fluid">
|
||||
<ul className="navbar-nav">
|
||||
<li className="nav-item">
|
||||
<a className="nav-link" data-lte-toggle="sidebar" href="#" role="button" aria-label="Toggle sidebar">
|
||||
<i className="bi bi-list"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<form className="d-none d-md-flex ms-3" role="search" onSubmit={handleSearch}>
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Search users…"
|
||||
aria-label="Search"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<ul className="navbar-nav ms-auto">
|
||||
<li className="nav-item dropdown">
|
||||
<a
|
||||
className="nav-link"
|
||||
data-bs-toggle="dropdown"
|
||||
href="#"
|
||||
aria-label={`Notifications: ${notifications.length} unread`}
|
||||
>
|
||||
<i className="bi bi-bell-fill"></i>
|
||||
{notifications.length > 0 && (
|
||||
<span className="navbar-badge badge text-bg-warning">{notifications.length}</span>
|
||||
)}
|
||||
</a>
|
||||
<div className="dropdown-menu dropdown-menu-lg dropdown-menu-end">
|
||||
<span className="dropdown-item dropdown-header">{notifications.length} Notifications</span>
|
||||
<div className="dropdown-divider"></div>
|
||||
{notifications.length === 0 ? (
|
||||
<span className="dropdown-item text-secondary">No new notifications</span>
|
||||
) : (
|
||||
notifications.map((n, i) => (
|
||||
<div key={i}>
|
||||
<a href="#" className="dropdown-item">
|
||||
<i className="bi bi-person-fill me-2"></i>
|
||||
{n.message}
|
||||
</a>
|
||||
{i < notifications.length - 1 && <div className="dropdown-divider"></div>}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="nav-item dropdown">
|
||||
<a
|
||||
className="nav-link"
|
||||
href="#"
|
||||
id="bd-theme"
|
||||
aria-label="Toggle color scheme"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<i className="bi bi-sun-fill d-none" data-lte-theme-icon="light"></i>
|
||||
<i className="bi bi-moon-fill" data-lte-theme-icon="dark"></i>
|
||||
<i className="bi bi-circle-half d-none" data-lte-theme-icon="auto"></i>
|
||||
</a>
|
||||
<ul className="dropdown-menu dropdown-menu-end" aria-labelledby="bd-theme">
|
||||
<li>
|
||||
<button type="button" className="dropdown-item d-flex align-items-center" data-bs-theme-value="light">
|
||||
<i className="bi bi-sun-fill me-2"></i>Light
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" className="dropdown-item d-flex align-items-center" data-bs-theme-value="dark">
|
||||
<i className="bi bi-moon-fill me-2"></i>Dark
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" className="dropdown-item d-flex align-items-center" data-bs-theme-value="auto">
|
||||
<i className="bi bi-circle-half me-2"></i>Auto
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li className="nav-item dropdown user-menu">
|
||||
<a href="#" className="nav-link dropdown-toggle" data-bs-toggle="dropdown">
|
||||
<i className="bi bi-person-circle fs-5"></i>
|
||||
<span className="d-none d-md-inline ms-1">{user?.name}</span>
|
||||
</a>
|
||||
<ul className="dropdown-menu dropdown-menu-lg dropdown-menu-end">
|
||||
<li className="user-header text-bg-primary">
|
||||
<i className="bi bi-person-circle" style={{ fontSize: '3rem' }}></i>
|
||||
<p>
|
||||
{user?.name}
|
||||
<small>{user?.roles.join(', ')}</small>
|
||||
</p>
|
||||
</li>
|
||||
<li className="user-footer">
|
||||
<Link to="/settings/2fa" className="btn btn-outline-secondary">
|
||||
2FA Settings
|
||||
</Link>
|
||||
<button onClick={() => logout()} className="btn btn-outline-danger float-end">
|
||||
Sign out
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
43
frontend/src/components/desktop/PageHeader.tsx
Normal file
43
frontend/src/components/desktop/PageHeader.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export interface Breadcrumb {
|
||||
label: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string
|
||||
breadcrumbs: Breadcrumb[]
|
||||
}
|
||||
|
||||
export function PageHeader({ title, breadcrumbs }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="app-content-header">
|
||||
<div className="container-fluid">
|
||||
<div className="row">
|
||||
<div className="col-sm-6">
|
||||
<h1 className="mb-0 fs-3">{title}</h1>
|
||||
</div>
|
||||
<div className="col-sm-6">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol className="breadcrumb float-sm-end">
|
||||
{breadcrumbs.map((crumb, i) => {
|
||||
const isLast = i === breadcrumbs.length - 1
|
||||
return (
|
||||
<li
|
||||
key={crumb.label}
|
||||
className={`breadcrumb-item ${isLast ? 'active' : ''}`}
|
||||
aria-current={isLast ? 'page' : undefined}
|
||||
>
|
||||
{!isLast && crumb.to ? <Link to={crumb.to}>{crumb.label}</Link> : crumb.label}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,30 +1,43 @@
|
||||
import { Fragment } from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { NAV_ITEMS } from '../../layouts/shell/navConfig'
|
||||
import { NAV_GROUPS } from '../../layouts/shell/navConfig'
|
||||
import { usePermission } from '../../hooks/usePermission'
|
||||
|
||||
export function Sidebar() {
|
||||
const { can } = usePermission()
|
||||
const items = NAV_ITEMS.filter((item) => !item.permission || can(item.permission))
|
||||
|
||||
return (
|
||||
<aside className="hidden w-56 shrink-0 border-r border-gray-200 bg-white lg:block dark:border-gray-800 dark:bg-gray-900">
|
||||
<nav className="flex flex-col gap-1 p-3">
|
||||
{items.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`rounded-md px-3 py-2 text-sm font-medium ${
|
||||
isActive
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-400'
|
||||
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<aside className="app-sidebar bg-body-secondary shadow" data-bs-theme="dark">
|
||||
<div className="sidebar-brand">
|
||||
<a href="/dashboard" className="brand-link">
|
||||
<span className="brand-text fw-light">webappAIO</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-wrapper">
|
||||
<nav className="mt-2" aria-label="Main navigation">
|
||||
<ul className="nav sidebar-menu flex-column" data-lte-toggle="treeview" data-accordion="false">
|
||||
{NAV_GROUPS.map((group) => {
|
||||
const items = group.items.filter((item) => !item.permission || can(item.permission))
|
||||
if (items.length === 0) return null
|
||||
|
||||
return (
|
||||
<Fragment key={group.header}>
|
||||
<li className="nav-header">{group.header}</li>
|
||||
{items.map((item) => (
|
||||
<li key={item.to} className="nav-item">
|
||||
<NavLink to={item.to} className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}>
|
||||
<i className={`nav-icon bi ${item.icon}`}></i>
|
||||
<p>{item.label}</p>
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
26
frontend/src/components/desktop/SmallBox.tsx
Normal file
26
frontend/src/components/desktop/SmallBox.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
interface SmallBoxProps {
|
||||
value: string | number
|
||||
label: string
|
||||
icon: string
|
||||
color?: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
export function SmallBox({ value, label, icon, color = 'text-bg-primary', to }: SmallBoxProps) {
|
||||
return (
|
||||
<div className={`small-box ${color}`}>
|
||||
<div className="inner">
|
||||
<h3>{value}</h3>
|
||||
<p>{label}</p>
|
||||
</div>
|
||||
<i className={`bi ${icon} small-box-icon`}></i>
|
||||
{to && (
|
||||
<Link to={to} className="small-box-footer link-light link-underline-opacity-0 link-underline-opacity-50-hover">
|
||||
More info <i className="bi bi-arrow-right-short"></i>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
frontend/src/components/desktop/useNotifications.ts
Normal file
33
frontend/src/components/desktop/useNotifications.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { useState } from 'react'
|
||||
import { useMqttTopic } from '../../hooks/useMqtt'
|
||||
|
||||
interface Notification {
|
||||
message: string
|
||||
}
|
||||
|
||||
interface UsersEventPayload {
|
||||
type: 'UserCreated' | 'UserUpdated' | 'UserDeleted' | 'RoleAssigned'
|
||||
user: { name: string }
|
||||
}
|
||||
|
||||
const MESSAGES: Record<UsersEventPayload['type'], (name: string) => string> = {
|
||||
UserCreated: (name) => `${name} was added.`,
|
||||
UserUpdated: (name) => `${name} was updated.`,
|
||||
UserDeleted: (name) => `${name} was removed.`,
|
||||
RoleAssigned: (name) => `${name}'s role was changed.`,
|
||||
}
|
||||
|
||||
const MAX_NOTIFICATIONS = 10
|
||||
|
||||
/** Rolling list of recent Users-module events shown in the navbar bell. */
|
||||
export function useNotifications(): Notification[] {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||
|
||||
useMqttTopic('module/users/events', (payload) => {
|
||||
const event = payload as UsersEventPayload
|
||||
const message = MESSAGES[event.type]?.(event.user.name) ?? 'Users list updated.'
|
||||
setNotifications((prev) => [{ message }, ...prev].slice(0, MAX_NOTIFICATIONS))
|
||||
})
|
||||
|
||||
return notifications
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Card } from '../desktop/Card'
|
||||
|
||||
export interface DataTableColumn<T> {
|
||||
key: string
|
||||
@ -32,21 +33,22 @@ export function DataTable<T>({
|
||||
onPageChange,
|
||||
isLoading,
|
||||
}: DataTableProps<T>) {
|
||||
const pages = Array.from({ length: lastPage }, (_, i) => i + 1)
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-800/50">
|
||||
<Card>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th key={col.key} className="px-4 py-2 font-medium text-gray-700 dark:text-gray-300">
|
||||
<th key={col.key}>
|
||||
{col.sortable ? (
|
||||
<button
|
||||
onClick={() => onSortChange?.(col.key)}
|
||||
className="flex items-center gap-1 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
className="btn btn-link p-0 text-decoration-none fw-semibold"
|
||||
>
|
||||
{col.label}
|
||||
{sort === col.key && <span>{direction === 'asc' ? '↑' : '↓'}</span>}
|
||||
{col.label} {sort === col.key && <i className={`bi bi-caret-${direction === 'asc' ? 'up' : 'down'}-fill`}></i>}
|
||||
</button>
|
||||
) : (
|
||||
col.label
|
||||
@ -58,23 +60,21 @@ export function DataTable<T>({
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-4 py-6 text-center text-gray-500">
|
||||
<td colSpan={columns.length} className="text-center text-secondary py-4">
|
||||
Loading…
|
||||
</td>
|
||||
</tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-4 py-6 text-center text-gray-500">
|
||||
<td colSpan={columns.length} className="text-center text-secondary py-4">
|
||||
No results.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<tr key={rowKey(row)} className="border-b border-gray-100 last:border-0 dark:border-gray-800">
|
||||
<tr key={rowKey(row)}>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="px-4 py-2 text-gray-800 dark:text-gray-200">
|
||||
{col.render(row)}
|
||||
</td>
|
||||
<td key={col.key}>{col.render(row)}</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
@ -84,26 +84,28 @@ export function DataTable<T>({
|
||||
</div>
|
||||
|
||||
{lastPage > 1 && (
|
||||
<div className="flex items-center justify-between border-t border-gray-200 px-4 py-2 text-sm dark:border-gray-800">
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
className="rounded-md px-2 py-1 text-gray-600 hover:bg-gray-100 disabled:opacity-40 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-gray-500 dark:text-gray-500">
|
||||
Page {page} of {lastPage}
|
||||
</span>
|
||||
<button
|
||||
disabled={page >= lastPage}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
className="rounded-md px-2 py-1 text-gray-600 hover:bg-gray-100 disabled:opacity-40 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<nav className="mt-3" aria-label="Table pagination">
|
||||
<ul className="pagination pagination-sm justify-content-end mb-0">
|
||||
<li className={`page-item ${page <= 1 ? 'disabled' : ''}`}>
|
||||
<button className="page-link" onClick={() => onPageChange(page - 1)}>
|
||||
Previous
|
||||
</button>
|
||||
</li>
|
||||
{pages.map((p) => (
|
||||
<li key={p} className={`page-item ${p === page ? 'active' : ''}`}>
|
||||
<button className="page-link" onClick={() => onPageChange(p)}>
|
||||
{p}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<li className={`page-item ${page >= lastPage ? 'disabled' : ''}`}>
|
||||
<button className="page-link" onClick={() => onPageChange(page + 1)}>
|
||||
Next
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,7 +2,8 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { usePermission } from '../../hooks/usePermission'
|
||||
import { useMqttTopic } from '../../hooks/useMqtt'
|
||||
import { InfoBox } from '../../components/desktop/InfoBox'
|
||||
import { PageHeader } from '../../components/desktop/PageHeader'
|
||||
import { SmallBox } from '../../components/desktop/SmallBox'
|
||||
import { ChartPanel } from '../../components/desktop/ChartPanel'
|
||||
import { ActivityTimeline } from '../../components/desktop/ActivityTimeline'
|
||||
import { fetchStats, fetchActivity } from './dashboardApi'
|
||||
@ -24,20 +25,30 @@ export function DashboardPage() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Dashboard</h1>
|
||||
<p className="mt-1 mb-6 text-sm text-gray-600 dark:text-gray-400">Welcome, {user?.name}.</p>
|
||||
<>
|
||||
<PageHeader title="Dashboard" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Dashboard' }]} />
|
||||
<div className="app-content">
|
||||
<div className="container-fluid">
|
||||
<p className="text-secondary">Welcome, {user?.name}.</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<InfoBox label="Total users" value={stats?.total_users ?? '—'} />
|
||||
{stats && <ChartPanel title="Users by role" data={stats.users_by_role} />}
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-lg-3 col-6">
|
||||
<SmallBox value={stats?.total_users ?? '—'} label="Total Users" icon="bi-people-fill" to="/users" />
|
||||
</div>
|
||||
<div className="col-lg-6 col-12">
|
||||
{stats && <ChartPanel title="Users by role" data={stats.users_by_role} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{can('audit.view') && (
|
||||
<div className="mt-4">
|
||||
<ActivityTimeline items={activity ?? []} />
|
||||
{can('audit.view') && (
|
||||
<div className="row">
|
||||
<div className="col-12">
|
||||
<ActivityTimeline items={activity ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { DataTable, type DataTableColumn } from '../../components/shared/DataTable'
|
||||
import { useConfirm } from '../../components/shared/ConfirmDialog'
|
||||
import { useToast } from '../../components/shared/Toast/ToastProvider'
|
||||
import { usePermission } from '../../hooks/usePermission'
|
||||
import { PageHeader } from '../../components/desktop/PageHeader'
|
||||
import { fetchUsers, deleteUser } from './usersApi'
|
||||
import { useUsersRealtime } from './useUsersRealtime'
|
||||
import { UserFormModal } from './UserFormModal'
|
||||
@ -17,6 +19,8 @@ export function UsersListPage() {
|
||||
const queryClient = useQueryClient()
|
||||
useUsersRealtime()
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const search = searchParams.get('search') ?? ''
|
||||
const [page, setPage] = useState(1)
|
||||
const [sort, setSort] = useState('created_at')
|
||||
const [direction, setDirection] = useState<'asc' | 'desc'>('desc')
|
||||
@ -24,8 +28,8 @@ export function UsersListPage() {
|
||||
const [roleUser, setRoleUser] = useState<User | null>(null)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['users', { page, sort, direction }],
|
||||
queryFn: () => fetchUsers({ page, sort, direction }),
|
||||
queryKey: ['users', { page, sort, direction, search }],
|
||||
queryFn: () => fetchUsers({ page, sort, direction, search: search || undefined }),
|
||||
})
|
||||
|
||||
function handleSort(key: string) {
|
||||
@ -37,6 +41,11 @@ export function UsersListPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearchChange(value: string) {
|
||||
setPage(1)
|
||||
setSearchParams(value ? { search: value } : {})
|
||||
}
|
||||
|
||||
async function handleDelete(user: User) {
|
||||
const ok = await confirm({
|
||||
title: 'Delete user',
|
||||
@ -55,24 +64,24 @@ export function UsersListPage() {
|
||||
{ key: 'name', label: 'Name', sortable: true, render: (u) => u.name },
|
||||
{ key: 'username', label: 'Username', sortable: true, render: (u) => u.username },
|
||||
{ key: 'email', label: 'Email', sortable: true, render: (u) => u.email },
|
||||
{ key: 'roles', label: 'Role', render: (u) => u.roles.join(', ') },
|
||||
{ key: 'roles', label: 'Role', render: (u) => <span className="badge text-bg-secondary">{u.roles.join(', ')}</span> },
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
render: (u) => (
|
||||
<div className="flex gap-2">
|
||||
<div className="btn-group btn-group-sm">
|
||||
{u.can_edit && (
|
||||
<button onClick={() => setFormUser(u)} className="text-blue-600 hover:underline dark:text-blue-400">
|
||||
<button onClick={() => setFormUser(u)} className="btn btn-outline-primary">
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{can('roles.assign') && (
|
||||
<button onClick={() => setRoleUser(u)} className="text-blue-600 hover:underline dark:text-blue-400">
|
||||
<button onClick={() => setRoleUser(u)} className="btn btn-outline-secondary">
|
||||
Role
|
||||
</button>
|
||||
)}
|
||||
{u.can_delete && (
|
||||
<button onClick={() => handleDelete(u)} className="text-red-600 hover:underline dark:text-red-400">
|
||||
<button onClick={() => handleDelete(u)} className="btn btn-outline-danger">
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
@ -82,36 +91,44 @@ export function UsersListPage() {
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Users</h1>
|
||||
{can('users.create') && (
|
||||
<button
|
||||
onClick={() => setFormUser(null)}
|
||||
className="rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Add user
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
<PageHeader title="Users" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Users' }]} />
|
||||
<div className="app-content">
|
||||
<div className="container-fluid">
|
||||
<div className="d-flex justify-content-between align-items-center mb-3">
|
||||
<input
|
||||
type="search"
|
||||
defaultValue={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
className="form-control form-control-sm w-auto"
|
||||
placeholder="Search users…"
|
||||
/>
|
||||
{can('users.create') && (
|
||||
<button onClick={() => setFormUser(null)} className="btn btn-primary btn-sm">
|
||||
<i className="bi bi-plus-lg me-1"></i>Add user
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={data?.data ?? []}
|
||||
rowKey={(u) => u.id}
|
||||
sort={sort}
|
||||
direction={direction}
|
||||
onSortChange={handleSort}
|
||||
page={page}
|
||||
lastPage={data?.meta.last_page ?? 1}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={data?.data ?? []}
|
||||
rowKey={(u) => u.id}
|
||||
sort={sort}
|
||||
direction={direction}
|
||||
onSortChange={handleSort}
|
||||
page={page}
|
||||
lastPage={data?.meta.last_page ?? 1}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formUser !== undefined && (
|
||||
<UserFormModal open onClose={() => setFormUser(undefined)} user={formUser ?? undefined} />
|
||||
)}
|
||||
{roleUser && <AssignRoleModal open onClose={() => setRoleUser(null)} user={roleUser} />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
@import "tailwindcss";
|
||||
/* AdminLTE (Bootstrap 5) supplies the base/reset layer for the desktop shell.
|
||||
Tailwind is imported utilities-only (no preflight) so its reset doesn't
|
||||
fight Bootstrap's — both utility systems coexist: AdminLTE/Bootstrap
|
||||
classes drive the desktop dashboard, Tailwind utilities style everything
|
||||
else (mobile shell, modals, forms, login page). */
|
||||
@import "bootstrap-icons/font/bootstrap-icons.min.css";
|
||||
@import "admin-lte/dist/css/adminlte.min.css";
|
||||
|
||||
@import "tailwindcss/theme.css" layer(theme);
|
||||
@import "tailwindcss/utilities.css" layer(utilities);
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@ -1,17 +1,28 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Navbar } from '../../components/desktop/Navbar'
|
||||
import { Sidebar } from '../../components/desktop/Sidebar'
|
||||
import { Footer } from '../../components/desktop/Footer'
|
||||
|
||||
const BODY_CLASSES = ['layout-fixed', 'sidebar-expand-lg', 'bg-body-tertiary']
|
||||
|
||||
export function DesktopShell() {
|
||||
// AdminLTE's layout CSS keys off classes on <body>, not a scoped wrapper —
|
||||
// add them only while the desktop shell is mounted so the mobile shell
|
||||
// (Tailwind-only) isn't affected.
|
||||
useEffect(() => {
|
||||
document.body.classList.add(...BODY_CLASSES)
|
||||
return () => document.body.classList.remove(...BODY_CLASSES)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<div className="app-wrapper">
|
||||
<Navbar />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-y-auto bg-gray-50 p-6 dark:bg-gray-950">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
<Sidebar />
|
||||
<main className="app-main">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,10 +1,24 @@
|
||||
export interface NavItem {
|
||||
label: string
|
||||
to: string
|
||||
icon: string
|
||||
permission?: string
|
||||
}
|
||||
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{ label: 'Dashboard', to: '/dashboard', permission: 'dashboard.view' },
|
||||
{ label: 'Users', to: '/users', permission: 'users.view' },
|
||||
export interface NavGroup {
|
||||
header: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
header: 'MAIN',
|
||||
items: [{ label: 'Dashboard', to: '/dashboard', icon: 'bi-speedometer', permission: 'dashboard.view' }],
|
||||
},
|
||||
{
|
||||
header: 'ADMINISTRATION',
|
||||
items: [{ label: 'Users', to: '/users', icon: 'bi-people-fill', permission: 'users.view' }],
|
||||
},
|
||||
]
|
||||
|
||||
export const NAV_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items)
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
// Bootstrap's own JS (dropdowns, the navbar/user-menu use data-bs-toggle)
|
||||
// is a separate concern from AdminLTE's plugins below.
|
||||
import 'bootstrap/dist/js/bootstrap.bundle.min.js'
|
||||
// Side-effect import: binds AdminLTE's delegated document-level click
|
||||
// listeners (sidebar toggle, treeview, card widgets, fullscreen, color
|
||||
// mode) once at app startup — works across all client-side route changes.
|
||||
import 'admin-lte'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user