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:
Ghassan Yusuf 2026-08-04 13:47:02 +03:00
parent 7198895444
commit 84cd474bcb
19 changed files with 528 additions and 162 deletions

View File

@ -9,7 +9,9 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.101.4", "@tanstack/react-query": "^5.101.4",
"admin-lte": "^4.1.0",
"axios": "^1.19.0", "axios": "^1.19.0",
"bootstrap-icons": "^1.13.1",
"mqtt": "^5.15.2", "mqtt": "^5.15.2",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.2.8", "react": "^19.2.8",
@ -421,6 +423,17 @@
"node": "^20.19.0 || >=22.12.0" "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": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz",
@ -1321,6 +1334,18 @@
"node": ">=6.5" "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": { "node_modules/agent-base": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@ -1407,6 +1432,42 @@
"readable-stream": "^4.2.0" "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": { "node_modules/broker-factory": {
"version": "3.1.15", "version": "3.1.15",
"resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.15.tgz", "resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.15.tgz",

View File

@ -11,7 +11,9 @@
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.101.4", "@tanstack/react-query": "^5.101.4",
"admin-lte": "^4.1.0",
"axios": "^1.19.0", "axios": "^1.19.0",
"bootstrap-icons": "^1.13.1",
"mqtt": "^5.15.2", "mqtt": "^5.15.2",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.2.8", "react": "^19.2.8",

View File

@ -14,20 +14,14 @@ export function ActivityTimeline({ items }: ActivityTimelineProps) {
return ( return (
<Card title="Recent activity"> <Card title="Recent activity">
{items.length === 0 ? ( {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) => ( {items.map((item, i) => (
<li key={i} className="flex items-start gap-3 text-sm"> <li key={i} className="list-group-item px-0">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-blue-500" /> <i className="bi bi-dot text-primary fs-4 align-middle"></i>
<div> <span className="fw-semibold">{item.actor_name}</span> {item.action.replace('.', ' ')}
<p className="text-gray-800 dark:text-gray-200"> <div className="text-secondary fs-7">{new Date(item.created_at).toLocaleString()}</div>
<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> </li>
))} ))}
</ul> </ul>

View File

@ -7,9 +7,13 @@ interface CardProps {
export function Card({ title, children }: CardProps) { export function Card({ title, children }: CardProps) {
return ( return (
<div className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900"> <div className="card">
{title && <h2 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">{title}</h2>} {title && (
{children} <div className="card-header">
<h3 className="card-title">{title}</h3>
</div>
)}
<div className="card-body">{children}</div>
</div> </div>
) )
} }

View File

@ -5,7 +5,7 @@ interface ChartPanelProps {
data: Record<string, number> 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) { export function ChartPanel({ title, data }: ChartPanelProps) {
const entries = Object.entries(data) const entries = Object.entries(data)
@ -13,17 +13,20 @@ export function ChartPanel({ title, data }: ChartPanelProps) {
return ( return (
<Card title={title}> <Card title={title}>
<div className="flex flex-col gap-2"> {entries.map(([label, value], i) => (
{entries.map(([label, value]) => ( <div key={label} className="mb-3">
<div key={label} className="flex items-center gap-3"> <div className="d-flex justify-content-between mb-1">
<span className="w-20 shrink-0 text-xs capitalize text-gray-600 dark:text-gray-400">{label}</span> <span className="text-capitalize">{label}</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800"> <span className="text-secondary">{value}</span>
<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>
</div> </div>
))} <div className="progress" role="progressbar" aria-valuenow={value} aria-valuemin={0} aria-valuemax={max}>
</div> <div
className={`progress-bar ${BAR_COLORS[i % BAR_COLORS.length]}`}
style={{ width: `${(value / max) * 100}%` }}
></div>
</div>
</div>
))}
</Card> </Card>
) )
} }

View 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 &copy; {new Date().getFullYear()} webappAIO.</strong> All rights reserved.
</footer>
)
}

View File

@ -1,13 +1,20 @@
interface InfoBoxProps { interface InfoBoxProps {
label: string label: string
value: string | number 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 ( return (
<div className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900"> <div className="info-box">
<p className="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-500">{label}</p> <span className={`info-box-icon shadow-sm ${color}`}>
<p className="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">{value}</p> <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> </div>
) )
} }

View File

@ -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 { useAuth } from '../../features/auth/AuthContext'
import { useNotifications } from './useNotifications'
export function Navbar() { export function Navbar() {
const { user, logout } = useAuth() 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 ( 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"> <nav className="app-header navbar navbar-expand bg-body">
<span className="font-semibold text-gray-900 dark:text-gray-100">webappAIO</span> <div className="container-fluid">
<div className="flex items-center gap-3"> <ul className="navbar-nav">
<span className="text-sm text-gray-600 dark:text-gray-400">{user?.name}</span> <li className="nav-item">
<Link <a className="nav-link" data-lte-toggle="sidebar" href="#" role="button" aria-label="Toggle sidebar">
to="/settings/2fa" <i className="bi bi-list"></i>
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" </a>
> </li>
2FA Settings </ul>
</Link>
<button <form className="d-none d-md-flex ms-3" role="search" onSubmit={handleSearch}>
onClick={() => logout()} <input
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" type="search"
> value={search}
Sign out onChange={(e) => setSearch(e.target.value)}
</button> 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> </div>
</header> </nav>
) )
} }

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

View File

@ -1,30 +1,43 @@
import { Fragment } from 'react'
import { NavLink } from 'react-router-dom' 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' import { usePermission } from '../../hooks/usePermission'
export function Sidebar() { export function Sidebar() {
const { can } = usePermission() const { can } = usePermission()
const items = NAV_ITEMS.filter((item) => !item.permission || can(item.permission))
return ( 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"> <aside className="app-sidebar bg-body-secondary shadow" data-bs-theme="dark">
<nav className="flex flex-col gap-1 p-3"> <div className="sidebar-brand">
{items.map((item) => ( <a href="/dashboard" className="brand-link">
<NavLink <span className="brand-text fw-light">webappAIO</span>
key={item.to} </a>
to={item.to} </div>
className={({ isActive }) =>
`rounded-md px-3 py-2 text-sm font-medium ${ <div className="sidebar-wrapper">
isActive <nav className="mt-2" aria-label="Main navigation">
? 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-400' <ul className="nav sidebar-menu flex-column" data-lte-toggle="treeview" data-accordion="false">
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800' {NAV_GROUPS.map((group) => {
}` const items = group.items.filter((item) => !item.permission || can(item.permission))
} if (items.length === 0) return null
>
{item.label} return (
</NavLink> <Fragment key={group.header}>
))} <li className="nav-header">{group.header}</li>
</nav> {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> </aside>
) )
} }

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

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

View File

@ -1,4 +1,5 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { Card } from '../desktop/Card'
export interface DataTableColumn<T> { export interface DataTableColumn<T> {
key: string key: string
@ -32,21 +33,22 @@ export function DataTable<T>({
onPageChange, onPageChange,
isLoading, isLoading,
}: DataTableProps<T>) { }: DataTableProps<T>) {
const pages = Array.from({ length: lastPage }, (_, i) => i + 1)
return ( return (
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"> <Card>
<div className="overflow-x-auto"> <div className="table-responsive">
<table className="w-full text-left text-sm"> <table className="table table-striped table-hover align-middle mb-0">
<thead className="border-b border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-800/50"> <thead>
<tr> <tr>
{columns.map((col) => ( {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 ? ( {col.sortable ? (
<button <button
onClick={() => onSortChange?.(col.key)} 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} {col.label} {sort === col.key && <i className={`bi bi-caret-${direction === 'asc' ? 'up' : 'down'}-fill`}></i>}
{sort === col.key && <span>{direction === 'asc' ? '↑' : '↓'}</span>}
</button> </button>
) : ( ) : (
col.label col.label
@ -58,23 +60,21 @@ export function DataTable<T>({
<tbody> <tbody>
{isLoading ? ( {isLoading ? (
<tr> <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 Loading
</td> </td>
</tr> </tr>
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<tr> <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. No results.
</td> </td>
</tr> </tr>
) : ( ) : (
rows.map((row) => ( 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) => ( {columns.map((col) => (
<td key={col.key} className="px-4 py-2 text-gray-800 dark:text-gray-200"> <td key={col.key}>{col.render(row)}</td>
{col.render(row)}
</td>
))} ))}
</tr> </tr>
)) ))
@ -84,26 +84,28 @@ export function DataTable<T>({
</div> </div>
{lastPage > 1 && ( {lastPage > 1 && (
<div className="flex items-center justify-between border-t border-gray-200 px-4 py-2 text-sm dark:border-gray-800"> <nav className="mt-3" aria-label="Table pagination">
<button <ul className="pagination pagination-sm justify-content-end mb-0">
disabled={page <= 1} <li className={`page-item ${page <= 1 ? 'disabled' : ''}`}>
onClick={() => onPageChange(page - 1)} <button className="page-link" 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>
Previous </li>
</button> {pages.map((p) => (
<span className="text-gray-500 dark:text-gray-500"> <li key={p} className={`page-item ${p === page ? 'active' : ''}`}>
Page {page} of {lastPage} <button className="page-link" onClick={() => onPageChange(p)}>
</span> {p}
<button </button>
disabled={page >= lastPage} </li>
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" <li className={`page-item ${page >= lastPage ? 'disabled' : ''}`}>
> <button className="page-link" onClick={() => onPageChange(page + 1)}>
Next Next
</button> </button>
</div> </li>
</ul>
</nav>
)} )}
</div> </Card>
) )
} }

View File

@ -2,7 +2,8 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { usePermission } from '../../hooks/usePermission' import { usePermission } from '../../hooks/usePermission'
import { useMqttTopic } from '../../hooks/useMqtt' 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 { ChartPanel } from '../../components/desktop/ChartPanel'
import { ActivityTimeline } from '../../components/desktop/ActivityTimeline' import { ActivityTimeline } from '../../components/desktop/ActivityTimeline'
import { fetchStats, fetchActivity } from './dashboardApi' import { fetchStats, fetchActivity } from './dashboardApi'
@ -24,20 +25,30 @@ export function DashboardPage() {
}) })
return ( return (
<div> <>
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Dashboard</h1> <PageHeader title="Dashboard" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Dashboard' }]} />
<p className="mt-1 mb-6 text-sm text-gray-600 dark:text-gray-400">Welcome, {user?.name}.</p> <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"> <div className="row">
<InfoBox label="Total users" value={stats?.total_users ?? '—'} /> <div className="col-lg-3 col-6">
{stats && <ChartPanel title="Users by role" data={stats.users_by_role} />} <SmallBox value={stats?.total_users ?? '—'} label="Total Users" icon="bi-people-fill" to="/users" />
</div> </div>
<div className="col-lg-6 col-12">
{stats && <ChartPanel title="Users by role" data={stats.users_by_role} />}
</div>
</div>
{can('audit.view') && ( {can('audit.view') && (
<div className="mt-4"> <div className="row">
<ActivityTimeline items={activity ?? []} /> <div className="col-12">
<ActivityTimeline items={activity ?? []} />
</div>
</div>
)}
</div> </div>
)} </div>
</div> </>
) )
} }

View File

@ -1,9 +1,11 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useSearchParams } from 'react-router-dom'
import { DataTable, type DataTableColumn } from '../../components/shared/DataTable' import { DataTable, type DataTableColumn } from '../../components/shared/DataTable'
import { useConfirm } from '../../components/shared/ConfirmDialog' import { useConfirm } from '../../components/shared/ConfirmDialog'
import { useToast } from '../../components/shared/Toast/ToastProvider' import { useToast } from '../../components/shared/Toast/ToastProvider'
import { usePermission } from '../../hooks/usePermission' import { usePermission } from '../../hooks/usePermission'
import { PageHeader } from '../../components/desktop/PageHeader'
import { fetchUsers, deleteUser } from './usersApi' import { fetchUsers, deleteUser } from './usersApi'
import { useUsersRealtime } from './useUsersRealtime' import { useUsersRealtime } from './useUsersRealtime'
import { UserFormModal } from './UserFormModal' import { UserFormModal } from './UserFormModal'
@ -17,6 +19,8 @@ export function UsersListPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
useUsersRealtime() useUsersRealtime()
const [searchParams, setSearchParams] = useSearchParams()
const search = searchParams.get('search') ?? ''
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [sort, setSort] = useState('created_at') const [sort, setSort] = useState('created_at')
const [direction, setDirection] = useState<'asc' | 'desc'>('desc') const [direction, setDirection] = useState<'asc' | 'desc'>('desc')
@ -24,8 +28,8 @@ export function UsersListPage() {
const [roleUser, setRoleUser] = useState<User | null>(null) const [roleUser, setRoleUser] = useState<User | null>(null)
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['users', { page, sort, direction }], queryKey: ['users', { page, sort, direction, search }],
queryFn: () => fetchUsers({ page, sort, direction }), queryFn: () => fetchUsers({ page, sort, direction, search: search || undefined }),
}) })
function handleSort(key: string) { 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) { async function handleDelete(user: User) {
const ok = await confirm({ const ok = await confirm({
title: 'Delete user', title: 'Delete user',
@ -55,24 +64,24 @@ export function UsersListPage() {
{ key: 'name', label: 'Name', sortable: true, render: (u) => u.name }, { key: 'name', label: 'Name', sortable: true, render: (u) => u.name },
{ key: 'username', label: 'Username', sortable: true, render: (u) => u.username }, { key: 'username', label: 'Username', sortable: true, render: (u) => u.username },
{ key: 'email', label: 'Email', sortable: true, render: (u) => u.email }, { 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', key: 'actions',
label: '', label: '',
render: (u) => ( render: (u) => (
<div className="flex gap-2"> <div className="btn-group btn-group-sm">
{u.can_edit && ( {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 Edit
</button> </button>
)} )}
{can('roles.assign') && ( {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 Role
</button> </button>
)} )}
{u.can_delete && ( {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 Delete
</button> </button>
)} )}
@ -82,36 +91,44 @@ export function UsersListPage() {
] ]
return ( return (
<div> <>
<div className="mb-4 flex items-center justify-between"> <PageHeader title="Users" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Users' }]} />
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Users</h1> <div className="app-content">
{can('users.create') && ( <div className="container-fluid">
<button <div className="d-flex justify-content-between align-items-center mb-3">
onClick={() => setFormUser(null)} <input
className="rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700" type="search"
> defaultValue={search}
Add user onChange={(e) => handleSearchChange(e.target.value)}
</button> className="form-control form-control-sm w-auto"
)} placeholder="Search users…"
</div> />
{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 <DataTable
columns={columns} columns={columns}
rows={data?.data ?? []} rows={data?.data ?? []}
rowKey={(u) => u.id} rowKey={(u) => u.id}
sort={sort} sort={sort}
direction={direction} direction={direction}
onSortChange={handleSort} onSortChange={handleSort}
page={page} page={page}
lastPage={data?.meta.last_page ?? 1} lastPage={data?.meta.last_page ?? 1}
onPageChange={setPage} onPageChange={setPage}
isLoading={isLoading} isLoading={isLoading}
/> />
</div>
</div>
{formUser !== undefined && ( {formUser !== undefined && (
<UserFormModal open onClose={() => setFormUser(undefined)} user={formUser ?? undefined} /> <UserFormModal open onClose={() => setFormUser(undefined)} user={formUser ?? undefined} />
)} )}
{roleUser && <AssignRoleModal open onClose={() => setRoleUser(null)} user={roleUser} />} {roleUser && <AssignRoleModal open onClose={() => setRoleUser(null)} user={roleUser} />}
</div> </>
) )
} }

View File

@ -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 { :root {
color-scheme: light dark; color-scheme: light dark;
} }
body {
margin: 0;
}

View File

@ -1,17 +1,28 @@
import { useEffect } from 'react'
import { Outlet } from 'react-router-dom' import { Outlet } from 'react-router-dom'
import { Navbar } from '../../components/desktop/Navbar' import { Navbar } from '../../components/desktop/Navbar'
import { Sidebar } from '../../components/desktop/Sidebar' 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() { 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 ( return (
<div className="flex h-screen flex-col"> <div className="app-wrapper">
<Navbar /> <Navbar />
<div className="flex flex-1 overflow-hidden"> <Sidebar />
<Sidebar /> <main className="app-main">
<main className="flex-1 overflow-y-auto bg-gray-50 p-6 dark:bg-gray-950"> <Outlet />
<Outlet /> </main>
</main> <Footer />
</div>
</div> </div>
) )
} }

View File

@ -1,10 +1,24 @@
export interface NavItem { export interface NavItem {
label: string label: string
to: string to: string
icon: string
permission?: string permission?: string
} }
export const NAV_ITEMS: NavItem[] = [ export interface NavGroup {
{ label: 'Dashboard', to: '/dashboard', permission: 'dashboard.view' }, header: string
{ label: 'Users', to: '/users', permission: 'users.view' }, 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)

View File

@ -1,6 +1,13 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' 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' import App from './App.tsx'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(