Add Roles/MQTT-settings/Backup pages, AdminLTE demo shells, and a separate mobile design system
Backend: real endpoints for role-permission overview, MQTT broker settings (with live reachability check), and SQLite backup download/restore (new system.manage permission, super-admin only; restore validates the SQLite file header and keeps a pre-restore safety copy). Desktop frontend: Roles (read-only permissions view), MQTT Settings, Backup & Restore wired to those endpoints; plus static AdminLTE-matching shells for Profile, Settings (embeds the real 2FA flow in its Security tab), Invoice, Calendar (FullCalendar), Chat, File Manager, and Projects. Mobile frontend: new frontend/src/styles/mobile.css, a from-scratch design system (purple theme, cards, slide-out sidebar, bottom tab bar) scoped entirely under a .mob-app root and mob- prefixed classes so it can never collide with the desktop Bootstrap/AdminLTE styling. Rebuilt TopBar/ BottomNav, added a new SlideOutNav, and reskinned the Dashboard and Users pages to it with real data.
This commit is contained in:
parent
84cd474bcb
commit
2a2fac9cdf
58
backend/app/Http/Controllers/Api/BackupController.php
Normal file
58
backend/app/Http/Controllers/Api/BackupController.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Audit\AuditLogger;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class BackupController extends Controller
|
||||||
|
{
|
||||||
|
private const SQLITE_HEADER = "SQLite format 3\000";
|
||||||
|
|
||||||
|
public function download(Request $request, AuditLogger $audit)
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('system.manage'), 403);
|
||||||
|
|
||||||
|
$path = config('database.connections.sqlite.database');
|
||||||
|
abort_unless($path && is_file($path), 404, 'No database file to back up.');
|
||||||
|
|
||||||
|
$audit->log($request->user(), 'system.backup_downloaded');
|
||||||
|
|
||||||
|
return response()->download($path, 'backup-'.now()->format('Y-m-d-His').'.sqlite');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(Request $request, AuditLogger $audit)
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('system.manage'), 403);
|
||||||
|
|
||||||
|
$request->validate(['backup' => ['required', 'file']]);
|
||||||
|
|
||||||
|
$uploaded = $request->file('backup');
|
||||||
|
$header = file_get_contents($uploaded->getRealPath(), false, null, 0, 16);
|
||||||
|
|
||||||
|
if ($header !== self::SQLITE_HEADER) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'backup' => 'That file is not a valid SQLite database.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = config('database.connections.sqlite.database');
|
||||||
|
|
||||||
|
// Release Laravel's open handle on the current file before swapping it.
|
||||||
|
DB::disconnect();
|
||||||
|
|
||||||
|
$safetyCopy = dirname($path).'/pre-restore-'.now()->format('Y-m-d-His').'.sqlite';
|
||||||
|
if (is_file($path)) {
|
||||||
|
copy($path, $safetyCopy);
|
||||||
|
}
|
||||||
|
|
||||||
|
$uploaded->move(dirname($path), basename($path));
|
||||||
|
|
||||||
|
$audit->log($request->user(), 'system.backup_restored');
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,8 @@ namespace App\Http\Controllers\Api;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use PhpMqtt\Client\ConnectionSettings;
|
||||||
|
use PhpMqtt\Client\MqttClient;
|
||||||
|
|
||||||
class MqttController extends Controller
|
class MqttController extends Controller
|
||||||
{
|
{
|
||||||
@ -25,4 +27,51 @@ class MqttController extends Controller
|
|||||||
'password' => $credentials['password'],
|
'password' => $credentials['password'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only broker configuration + a live reachability check, for the
|
||||||
|
* MQTT settings page. Never exposes the publisher/role credentials.
|
||||||
|
*/
|
||||||
|
public function settings(Request $request)
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('system.manage'), 403);
|
||||||
|
|
||||||
|
$host = config('mqtt-client.connections.default.host');
|
||||||
|
$port = config('mqtt-client.connections.default.port');
|
||||||
|
$tls = (bool) config('mqtt-client.connections.default.connection_settings.tls.enabled');
|
||||||
|
$username = config('mqtt-client.connections.default.connection_settings.auth.username');
|
||||||
|
$password = config('mqtt-client.connections.default.connection_settings.auth.password');
|
||||||
|
|
||||||
|
$connected = $this->checkBrokerReachable($host, $port, $tls, $username, $password);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'host' => $host,
|
||||||
|
'port' => $port,
|
||||||
|
'ws_url' => config('mqtt.ws_url'),
|
||||||
|
'tls_enabled' => $tls,
|
||||||
|
'connected' => $connected,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkBrokerReachable(?string $host, ?int $port, bool $tls, ?string $username, ?string $password): bool
|
||||||
|
{
|
||||||
|
if (! $host || ! $port) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$client = new MqttClient($host, $port, 'health-check-'.uniqid());
|
||||||
|
$settings = (new ConnectionSettings)
|
||||||
|
->setConnectTimeout(2)
|
||||||
|
->setUseTls($tls)
|
||||||
|
->setUsername($username)
|
||||||
|
->setPassword($password);
|
||||||
|
$client->connect($settings, true);
|
||||||
|
$client->disconnect();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,4 +17,18 @@ class RoleController extends Controller
|
|||||||
// seeding/tinker, never assignable through the Users module.
|
// seeding/tinker, never assignable through the Users module.
|
||||||
return RoleResource::collection(Role::where('name', '!=', 'super-admin')->orderBy('name')->get());
|
return RoleResource::collection(Role::where('name', '!=', 'super-admin')->orderBy('name')->get());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only view of every role (including Super Admin) and its
|
||||||
|
* permission set — for the Roles page, distinct from index() which
|
||||||
|
* feeds the assignable-role dropdown in the Users module.
|
||||||
|
*/
|
||||||
|
public function overview(Request $request)
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->can('roles.view'), 403);
|
||||||
|
|
||||||
|
$roles = Role::with('permissions')->orderBy('name')->get();
|
||||||
|
|
||||||
|
return RoleResource::collection($roles);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ class RoleResource extends JsonResource
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
|
'permissions' => $this->whenLoaded('permissions', fn () => $this->permissions->pluck('name')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ class RolePermissionSeeder extends Seeder
|
|||||||
'roles.view', 'roles.assign',
|
'roles.view', 'roles.assign',
|
||||||
'dashboard.view',
|
'dashboard.view',
|
||||||
'audit.view',
|
'audit.view',
|
||||||
|
'system.manage',
|
||||||
];
|
];
|
||||||
|
|
||||||
private const ROLE_PERMISSIONS = [
|
private const ROLE_PERMISSIONS = [
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\Api\AuthController;
|
use App\Http\Controllers\Api\AuthController;
|
||||||
|
use App\Http\Controllers\Api\BackupController;
|
||||||
use App\Http\Controllers\Api\DashboardController;
|
use App\Http\Controllers\Api\DashboardController;
|
||||||
use App\Http\Controllers\Api\MqttController;
|
use App\Http\Controllers\Api\MqttController;
|
||||||
use App\Http\Controllers\Api\RoleController;
|
use App\Http\Controllers\Api\RoleController;
|
||||||
@ -19,8 +20,10 @@ Route::prefix('v1')->middleware('throttle:api')->group(function () {
|
|||||||
Route::post('/auth/2fa/disable', [AuthController::class, 'disableTwoFactor']);
|
Route::post('/auth/2fa/disable', [AuthController::class, 'disableTwoFactor']);
|
||||||
|
|
||||||
Route::get('/mqtt/token', [MqttController::class, 'token']);
|
Route::get('/mqtt/token', [MqttController::class, 'token']);
|
||||||
|
Route::get('/mqtt/settings', [MqttController::class, 'settings']);
|
||||||
|
|
||||||
Route::get('/roles', [RoleController::class, 'index']);
|
Route::get('/roles', [RoleController::class, 'index']);
|
||||||
|
Route::get('/roles/overview', [RoleController::class, 'overview']);
|
||||||
|
|
||||||
Route::get('/users', [UserController::class, 'index']);
|
Route::get('/users', [UserController::class, 'index']);
|
||||||
Route::post('/users', [UserController::class, 'store']);
|
Route::post('/users', [UserController::class, 'store']);
|
||||||
@ -31,5 +34,8 @@ Route::prefix('v1')->middleware('throttle:api')->group(function () {
|
|||||||
|
|
||||||
Route::get('/dashboard/stats', [DashboardController::class, 'stats']);
|
Route::get('/dashboard/stats', [DashboardController::class, 'stats']);
|
||||||
Route::get('/dashboard/activity', [DashboardController::class, 'activity']);
|
Route::get('/dashboard/activity', [DashboardController::class, 'activity']);
|
||||||
|
|
||||||
|
Route::get('/backup/download', [BackupController::class, 'download']);
|
||||||
|
Route::post('/backup/restore', [BackupController::class, 'restore']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
63
frontend/package-lock.json
generated
63
frontend/package-lock.json
generated
@ -8,6 +8,11 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fullcalendar/core": "^6.1.21",
|
||||||
|
"@fullcalendar/daygrid": "^6.1.21",
|
||||||
|
"@fullcalendar/interaction": "^6.1.21",
|
||||||
|
"@fullcalendar/list": "^6.1.21",
|
||||||
|
"@fullcalendar/react": "^6.1.21",
|
||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
"admin-lte": "^4.1.0",
|
"admin-lte": "^4.1.0",
|
||||||
"axios": "^1.19.0",
|
"axios": "^1.19.0",
|
||||||
@ -40,6 +45,54 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fullcalendar/core": {
|
||||||
|
"version": "6.1.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.21.tgz",
|
||||||
|
"integrity": "sha512-t3u/+sqh3Iq7TWtUnVLcGDUE6OWZh0UD3c04bI/l7lSLAgAKr3kngBmhHiQD1QXpwC8ZN5iNqG7a7gOVixhSKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"preact": "~10.12.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fullcalendar/daygrid": {
|
||||||
|
"version": "6.1.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.21.tgz",
|
||||||
|
"integrity": "sha512-QYb1y40RGYLlOxKpYWg8O+7njEnKnFG8Tt7qjnubJGR35s1phQg67E+81y2TyAbbm59p2JFOCXGDk9t6KDujIA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@fullcalendar/core": "~6.1.21"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fullcalendar/interaction": {
|
||||||
|
"version": "6.1.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.21.tgz",
|
||||||
|
"integrity": "sha512-WPYpqtljDWmU0Xm2cOtFrLlocgxv7cgkOppj34Q6OUUat8a6Cnd6kYo2JR+irP223PE5lBYHFNp1qh7SIpJc0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@fullcalendar/core": "~6.1.21"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fullcalendar/list": {
|
||||||
|
"version": "6.1.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.21.tgz",
|
||||||
|
"integrity": "sha512-2rpIhs5pJmV7jyk4oX4bckNqurt6iHcsweE3FDYDdNpmRukPrARnyQYcaVFNVw2bnBFeR/jQW/St2MlauxF3GQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@fullcalendar/core": "~6.1.21"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fullcalendar/react": {
|
||||||
|
"version": "6.1.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.21.tgz",
|
||||||
|
"integrity": "sha512-TLpmGUd5k/PMdCh8XbeFC9PW9wuGvMms1oCxWgXyjK3EFPXAAd0PLfcvwKdyxoAS5eK1E4RJFkjMHvsYHpimcg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@fullcalendar/core": "~6.1.21",
|
||||||
|
"react": "^16.7.0 || ^17 || ^18 || ^19",
|
||||||
|
"react-dom": "^16.7.0 || ^17 || ^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
@ -2626,6 +2679,16 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/preact": {
|
||||||
|
"version": "10.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
|
||||||
|
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/process": {
|
"node_modules/process": {
|
||||||
"version": "0.11.10",
|
"version": "0.11.10",
|
||||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||||
|
|||||||
@ -10,6 +10,11 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fullcalendar/core": "^6.1.21",
|
||||||
|
"@fullcalendar/daygrid": "^6.1.21",
|
||||||
|
"@fullcalendar/interaction": "^6.1.21",
|
||||||
|
"@fullcalendar/list": "^6.1.21",
|
||||||
|
"@fullcalendar/react": "^6.1.21",
|
||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
"admin-lte": "^4.1.0",
|
"admin-lte": "^4.1.0",
|
||||||
"axios": "^1.19.0",
|
"axios": "^1.19.0",
|
||||||
|
|||||||
@ -1,25 +1,20 @@
|
|||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import { NAV_ITEMS } from '../../layouts/shell/navConfig'
|
import { MOBILE_NAV_ITEMS } from '../../layouts/shell/navConfig'
|
||||||
import { usePermission } from '../../hooks/usePermission'
|
import { usePermission } from '../../hooks/usePermission'
|
||||||
|
|
||||||
export function BottomNav() {
|
export function BottomNav() {
|
||||||
const { can } = usePermission()
|
const { can } = usePermission()
|
||||||
const items = NAV_ITEMS.filter((item) => !item.permission || can(item.permission))
|
const items = MOBILE_NAV_ITEMS.filter((item) => !item.permission || can(item.permission))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex h-14 items-stretch border-t border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
<nav className="mob-tab-bar">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) => `mob-tab-item ${isActive ? 'mob-active' : ''}`}
|
||||||
`flex flex-1 items-center justify-center text-sm font-medium ${
|
|
||||||
isActive
|
|
||||||
? 'text-blue-700 dark:text-blue-400'
|
|
||||||
: 'text-gray-600 dark:text-gray-400'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
|
<span className={`mob-nav-icon bi ${item.icon}`}></span>
|
||||||
{item.label}
|
{item.label}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
|
|||||||
48
frontend/src/components/mobile/SlideOutNav.tsx
Normal file
48
frontend/src/components/mobile/SlideOutNav.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { NavLink } from 'react-router-dom'
|
||||||
|
import { NAV_GROUPS } from '../../layouts/shell/navConfig'
|
||||||
|
import { usePermission } from '../../hooks/usePermission'
|
||||||
|
|
||||||
|
interface SlideOutNavProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SlideOutNav({ open, onClose }: SlideOutNavProps) {
|
||||||
|
const { can } = usePermission()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={`mob-sidebar-backdrop ${open ? 'mob-show' : ''}`} onClick={onClose} />
|
||||||
|
<nav className={`mob-sidebar ${open ? 'mob-open' : ''}`} aria-label="Main navigation">
|
||||||
|
<div className="mob-sidebar-header">
|
||||||
|
<span className="mob-sidebar-title">webappAIO</span>
|
||||||
|
<button className="mob-sidebar-close" onClick={onClose} aria-label="Close menu">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{NAV_GROUPS.map((group) => {
|
||||||
|
const items = group.items.filter((item) => !item.permission || can(item.permission))
|
||||||
|
if (items.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.header}>
|
||||||
|
<div className="mob-sidebar-section-label">{group.header}</div>
|
||||||
|
{items.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
onClick={onClose}
|
||||||
|
className={({ isActive }) => `mob-sidebar-link ${isActive ? 'mob-active' : ''}`}
|
||||||
|
>
|
||||||
|
<span className={`mob-nav-icon bi ${item.icon}`}></span>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,17 +1,34 @@
|
|||||||
import { useAuth } from '../../features/auth/AuthContext'
|
import { useAuth } from '../../features/auth/AuthContext'
|
||||||
|
import { useNotifications } from '../desktop/useNotifications'
|
||||||
|
|
||||||
export function TopBar() {
|
interface TopBarProps {
|
||||||
|
onMenuClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopBar({ onMenuClick }: TopBarProps) {
|
||||||
const { logout } = useAuth()
|
const { logout } = useAuth()
|
||||||
|
const notifications = useNotifications()
|
||||||
|
|
||||||
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">
|
<header className="mob-header">
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">webappAIO</span>
|
<div className="mob-header-left">
|
||||||
<button
|
<button className="mob-menu-btn" onClick={onMenuClick} aria-label="Open menu">
|
||||||
onClick={() => logout()}
|
<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"
|
</button>
|
||||||
>
|
<div className="mob-title">
|
||||||
Sign out
|
<span>webappAIO</span>
|
||||||
</button>
|
<span>Dashboard</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mob-header-actions">
|
||||||
|
<button className="mob-icon-btn" aria-label={`Notifications: ${notifications.length} unread`}>
|
||||||
|
<i className="bi bi-bell-fill"></i>
|
||||||
|
{notifications.length > 0 && <span className="mob-badge-dot"></span>}
|
||||||
|
</button>
|
||||||
|
<button className="mob-icon-btn" onClick={() => logout()} aria-label="Sign out">
|
||||||
|
<i className="bi bi-box-arrow-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
79
frontend/src/features/calendar/CalendarPage.tsx
Normal file
79
frontend/src/features/calendar/CalendarPage.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import FullCalendar from '@fullcalendar/react'
|
||||||
|
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||||
|
import interactionPlugin, { Draggable } from '@fullcalendar/interaction'
|
||||||
|
import listPlugin from '@fullcalendar/list'
|
||||||
|
import { PageHeader } from '../../components/desktop/PageHeader'
|
||||||
|
import { Card } from '../../components/desktop/Card'
|
||||||
|
|
||||||
|
const INITIAL_EVENTS = [
|
||||||
|
{ title: 'Team standup', date: new Date().toISOString().slice(0, 10) },
|
||||||
|
]
|
||||||
|
|
||||||
|
const DRAGGABLE_EVENTS = ['Customer call', 'Design review', 'Sprint planning', 'Deploy release']
|
||||||
|
|
||||||
|
export function CalendarPage() {
|
||||||
|
const draggableRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [removeAfterDrop, setRemoveAfterDrop] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!draggableRef.current) return
|
||||||
|
const draggable = new Draggable(draggableRef.current, {
|
||||||
|
itemSelector: '.draggable-event',
|
||||||
|
eventData: (el) => ({ title: el.innerText }),
|
||||||
|
})
|
||||||
|
return () => draggable.destroy()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Calendar" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Calendar' }]} />
|
||||||
|
<div className="app-content">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-3">
|
||||||
|
<Card title="Draggable events">
|
||||||
|
<div ref={draggableRef}>
|
||||||
|
{DRAGGABLE_EVENTS.map((title) => (
|
||||||
|
<div key={title} className="draggable-event fc-event p-2 mb-2 bg-primary text-white rounded">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="form-check mt-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="form-check-input"
|
||||||
|
checked={removeAfterDrop}
|
||||||
|
onChange={(e) => setRemoveAfterDrop(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<label className="form-check-label">Remove after drop</label>
|
||||||
|
</div>
|
||||||
|
<p className="text-secondary fs-7 mt-2 mb-0">
|
||||||
|
Static demo — dropped events aren't persisted to a backend.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-9">
|
||||||
|
<Card>
|
||||||
|
<FullCalendar
|
||||||
|
plugins={[dayGridPlugin, interactionPlugin, listPlugin]}
|
||||||
|
initialView="dayGridMonth"
|
||||||
|
headerToolbar={{ left: 'prev,next today', center: 'title', right: 'dayGridMonth,listWeek' }}
|
||||||
|
droppable
|
||||||
|
editable
|
||||||
|
events={INITIAL_EVENTS}
|
||||||
|
eventReceive={(info) => {
|
||||||
|
if (removeAfterDrop) {
|
||||||
|
info.draggedEl.parentElement?.removeChild(info.draggedEl)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
95
frontend/src/features/chat/ChatPage.tsx
Normal file
95
frontend/src/features/chat/ChatPage.tsx
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { PageHeader } from '../../components/desktop/PageHeader'
|
||||||
|
|
||||||
|
interface Contact {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
preview: string
|
||||||
|
time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
from: 'me' | 'them'
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTACTS: Contact[] = [
|
||||||
|
{ id: 1, name: 'Alice Manager', preview: 'Sounds good, thanks!', time: '2:30 PM' },
|
||||||
|
{ id: 2, name: 'Bob Admin', preview: 'Can you review the PR?', time: '11:05 AM' },
|
||||||
|
{ id: 3, name: 'Carol User', preview: 'See you tomorrow', time: 'Yesterday' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const MESSAGES: Record<number, Message[]> = {
|
||||||
|
1: [
|
||||||
|
{ from: 'them', text: 'Hey, got a minute?' },
|
||||||
|
{ from: 'me', text: 'Sure, what\'s up?' },
|
||||||
|
{ from: 'them', text: 'Sounds good, thanks!' },
|
||||||
|
],
|
||||||
|
2: [{ from: 'them', text: 'Can you review the PR?' }],
|
||||||
|
3: [{ from: 'them', text: 'See you tomorrow' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatPage() {
|
||||||
|
const [activeId, setActiveId] = useState(1)
|
||||||
|
const [draft, setDraft] = useState('')
|
||||||
|
const active = CONTACTS.find((c) => c.id === activeId)!
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Chat" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Chat' }]} />
|
||||||
|
<div className="app-content">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<div className="card">
|
||||||
|
<div className="row g-0" style={{ minHeight: '480px' }}>
|
||||||
|
<div className="col-md-4 border-end">
|
||||||
|
<div className="list-group list-group-flush">
|
||||||
|
{CONTACTS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => setActiveId(c.id)}
|
||||||
|
className={`list-group-item list-group-item-action d-flex justify-content-between align-items-start ${
|
||||||
|
c.id === activeId ? 'active' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="fw-semibold">{c.name}</div>
|
||||||
|
<div className="fs-7 text-truncate" style={{ maxWidth: '160px' }}>
|
||||||
|
{c.preview}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="fs-7 text-secondary">{c.time}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-8 d-flex flex-column">
|
||||||
|
<div className="p-3 border-bottom fw-semibold">{active.name}</div>
|
||||||
|
<div className="flex-grow-1 p-3" style={{ overflowY: 'auto' }}>
|
||||||
|
{(MESSAGES[activeId] ?? []).map((m, i) => (
|
||||||
|
<div key={i} className={`d-flex mb-2 ${m.from === 'me' ? 'justify-content-end' : ''}`}>
|
||||||
|
<span className={`px-3 py-2 rounded-3 ${m.from === 'me' ? 'bg-primary text-white' : 'bg-body-secondary'}`}>
|
||||||
|
{m.text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="p-3 border-top d-flex gap-2">
|
||||||
|
<input
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Type a message… (not sent anywhere)"
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" disabled>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-secondary fs-7 mt-2 mb-0">Static demo — no messaging backend exists yet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
94
frontend/src/features/dashboard/DashboardPage.mobile.tsx
Normal file
94
frontend/src/features/dashboard/DashboardPage.mobile.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useAuth } from '../auth/AuthContext'
|
||||||
|
import { usePermission } from '../../hooks/usePermission'
|
||||||
|
import { useMqttTopic } from '../../hooks/useMqtt'
|
||||||
|
import { fetchStats, fetchActivity } from './dashboardApi'
|
||||||
|
|
||||||
|
export function DashboardPageMobile() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const { can } = usePermission()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const { data: stats } = useQuery({ queryKey: ['dashboard', 'stats'], queryFn: fetchStats })
|
||||||
|
const { data: activity } = useQuery({
|
||||||
|
queryKey: ['dashboard', 'activity'],
|
||||||
|
queryFn: fetchActivity,
|
||||||
|
enabled: can('audit.view'),
|
||||||
|
})
|
||||||
|
|
||||||
|
useMqttTopic('module/users/events', () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
const roleEntries = Object.entries(stats?.users_by_role ?? {})
|
||||||
|
const maxRoleCount = Math.max(1, ...roleEntries.map(([, v]) => v))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p style={{ color: 'var(--mob-text-medium)', marginBottom: 14 }}>Welcome, {user?.name}.</p>
|
||||||
|
|
||||||
|
<div className="mob-kpi-grid">
|
||||||
|
<div className="mob-kpi-card">
|
||||||
|
<div className="mob-kpi-label">Total Users</div>
|
||||||
|
<div className="mob-kpi-value">{stats?.total_users ?? '—'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mob-section-card">
|
||||||
|
<div className="mob-section-header">
|
||||||
|
<h4>Users by role</h4>
|
||||||
|
</div>
|
||||||
|
{roleEntries.map(([label, value]) => (
|
||||||
|
<div key={label} style={{ marginBottom: 10 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
|
||||||
|
<span style={{ textTransform: 'capitalize' }}>{label}</span>
|
||||||
|
<span style={{ color: 'var(--mob-text-light)' }}>{value}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 7,
|
||||||
|
background: 'var(--mob-border)',
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${(value / maxRoleCount) * 100}%`,
|
||||||
|
background: 'linear-gradient(90deg, var(--mob-primary), var(--mob-primary-light))',
|
||||||
|
borderRadius: 999,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{can('audit.view') && (
|
||||||
|
<div className="mob-section-card">
|
||||||
|
<div className="mob-section-header">
|
||||||
|
<h4>Recent activity</h4>
|
||||||
|
</div>
|
||||||
|
{(activity ?? []).length === 0 ? (
|
||||||
|
<p style={{ color: 'var(--mob-text-light)', margin: 0 }}>No activity yet.</p>
|
||||||
|
) : (
|
||||||
|
(activity ?? []).map((item, i) => (
|
||||||
|
<div key={i} className="mob-list-item">
|
||||||
|
<span className="mob-avatar">{item.actor_name.slice(0, 2).toUpperCase()}</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600 }}>
|
||||||
|
{item.actor_name} — {item.action.replace('.', ' ')}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--mob-text-light)' }}>
|
||||||
|
{new Date(item.created_at).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
8
frontend/src/features/dashboard/DashboardRoute.tsx
Normal file
8
frontend/src/features/dashboard/DashboardRoute.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { useIsMobile } from '../../layouts/shell/useIsMobile'
|
||||||
|
import { DashboardPage } from './DashboardPage'
|
||||||
|
import { DashboardPageMobile } from './DashboardPage.mobile'
|
||||||
|
|
||||||
|
export function DashboardRoute() {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
return isMobile ? <DashboardPageMobile /> : <DashboardPage />
|
||||||
|
}
|
||||||
91
frontend/src/features/file-manager/FileManagerPage.tsx
Normal file
91
frontend/src/features/file-manager/FileManagerPage.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { PageHeader } from '../../components/desktop/PageHeader'
|
||||||
|
|
||||||
|
const CATEGORIES = ['My Drive', 'Documents', 'Shared with me', 'Starred', 'Recent', 'Trash']
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
name: string
|
||||||
|
type: 'folder' | 'file'
|
||||||
|
size?: string
|
||||||
|
modified: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENTRIES: Entry[] = [
|
||||||
|
{ name: 'Reports', type: 'folder', modified: 'Aug 1, 2026' },
|
||||||
|
{ name: 'Contracts', type: 'folder', modified: 'Jul 28, 2026' },
|
||||||
|
{ name: 'Q3-summary.pdf', type: 'file', size: '2.4 MB', modified: 'Aug 2, 2026' },
|
||||||
|
{ name: 'logo.png', type: 'file', size: '340 KB', modified: 'Jul 20, 2026' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function FileManagerPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="File Manager" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'File Manager' }]} />
|
||||||
|
<div className="app-content">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-3">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body">
|
||||||
|
<button className="btn btn-primary w-100 mb-2" disabled>
|
||||||
|
<i className="bi bi-upload me-1"></i>Upload
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline-secondary w-100 mb-3" disabled>
|
||||||
|
<i className="bi bi-folder-plus me-1"></i>New folder
|
||||||
|
</button>
|
||||||
|
<ul className="list-group list-group-flush">
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<li key={c} className="list-group-item">
|
||||||
|
<i className="bi bi-folder me-2"></i>
|
||||||
|
{c}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="fs-7 text-secondary mb-1">6.2 GB of 15 GB used</div>
|
||||||
|
<div className="progress">
|
||||||
|
<div className="progress-bar" style={{ width: '41%' }}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-9">
|
||||||
|
<nav aria-label="breadcrumb" className="mb-2">
|
||||||
|
<ol className="breadcrumb">
|
||||||
|
<li className="breadcrumb-item">My Drive</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<div className="card">
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Modified</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ENTRIES.map((entry) => (
|
||||||
|
<tr key={entry.name}>
|
||||||
|
<td>
|
||||||
|
<i className={`bi ${entry.type === 'folder' ? 'bi-folder-fill text-warning' : 'bi-file-earmark'} me-2`}></i>
|
||||||
|
{entry.name}
|
||||||
|
</td>
|
||||||
|
<td>{entry.size ?? '—'}</td>
|
||||||
|
<td>{entry.modified}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-secondary fs-7 mt-2 mb-0">Static demo — no file storage backend exists yet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
frontend/src/features/invoice/InvoicePage.tsx
Normal file
103
frontend/src/features/invoice/InvoicePage.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { PageHeader } from '../../components/desktop/PageHeader'
|
||||||
|
import { Card } from '../../components/desktop/Card'
|
||||||
|
|
||||||
|
const LINE_ITEMS = [
|
||||||
|
{ description: 'Premium support plan', qty: 1, unitPrice: 200, amount: 200 },
|
||||||
|
{ description: 'Extra seats (5)', qty: 5, unitPrice: 20, amount: 100 },
|
||||||
|
{ description: 'Onboarding session', qty: 1, unitPrice: 50, amount: 50 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function InvoicePage() {
|
||||||
|
const subtotal = LINE_ITEMS.reduce((sum, item) => sum + item.amount, 0)
|
||||||
|
const tax = subtotal * 0.1
|
||||||
|
const total = subtotal + tax
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Invoice" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Invoice' }]} />
|
||||||
|
<div className="app-content">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<Card>
|
||||||
|
<div className="row mb-4">
|
||||||
|
<div className="col-6">
|
||||||
|
<h4>webappAIO Inc.</h4>
|
||||||
|
<p className="text-secondary mb-0">
|
||||||
|
1234 Example Street
|
||||||
|
<br />
|
||||||
|
Manama, Bahrain
|
||||||
|
<br />
|
||||||
|
hello@webappaio.example
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-6 text-end">
|
||||||
|
<h4>Invoice #00123</h4>
|
||||||
|
<p className="text-secondary mb-0">
|
||||||
|
Bill to: Jane Doe
|
||||||
|
<br />
|
||||||
|
Date: {new Date().toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Description</th>
|
||||||
|
<th className="text-end">Qty</th>
|
||||||
|
<th className="text-end">Unit Price</th>
|
||||||
|
<th className="text-end">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{LINE_ITEMS.map((item) => (
|
||||||
|
<tr key={item.description}>
|
||||||
|
<td>{item.description}</td>
|
||||||
|
<td className="text-end">{item.qty}</td>
|
||||||
|
<td className="text-end">${item.unitPrice.toFixed(2)}</td>
|
||||||
|
<td className="text-end">${item.amount.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-4 offset-md-8">
|
||||||
|
<table className="table mb-0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>Subtotal</th>
|
||||||
|
<td className="text-end">${subtotal.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Tax (10%)</th>
|
||||||
|
<td className="text-end">${tax.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Total</th>
|
||||||
|
<td className="text-end fw-bold">${total.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<button className="btn btn-primary me-2" disabled>
|
||||||
|
<i className="bi bi-printer me-1"></i>Print
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline-secondary me-2" disabled>
|
||||||
|
<i className="bi bi-file-earmark-pdf me-1"></i>Download PDF
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline-secondary" disabled>
|
||||||
|
<i className="bi bi-send me-1"></i>Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-secondary fs-7 mt-2 mb-0">
|
||||||
|
Static template — no invoicing/billing backend exists yet.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
101
frontend/src/features/profile/ProfilePage.tsx
Normal file
101
frontend/src/features/profile/ProfilePage.tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useAuth } from '../auth/AuthContext'
|
||||||
|
import { PageHeader } from '../../components/desktop/PageHeader'
|
||||||
|
import { Card } from '../../components/desktop/Card'
|
||||||
|
|
||||||
|
type Tab = 'activity' | 'timeline' | 'about'
|
||||||
|
|
||||||
|
const ACTIVITY = [
|
||||||
|
{ text: 'Signed in to the dashboard', time: 'Just now' },
|
||||||
|
{ text: 'Updated a user record', time: '2 hours ago' },
|
||||||
|
{ text: 'Assigned a new role', time: 'Yesterday' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ProfilePage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [tab, setTab] = useState<Tab>('activity')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Profile" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Profile' }]} />
|
||||||
|
<div className="app-content">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-3">
|
||||||
|
<Card>
|
||||||
|
<div className="text-center">
|
||||||
|
<i className="bi bi-person-circle" style={{ fontSize: '5rem' }}></i>
|
||||||
|
<h3 className="mt-2 mb-0">{user?.name}</h3>
|
||||||
|
<p className="text-secondary">{user?.roles.join(', ')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex justify-content-around text-center border-top pt-3 mt-3">
|
||||||
|
<div>
|
||||||
|
<strong>24</strong>
|
||||||
|
<div className="text-secondary fs-7">Followers</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>13</strong>
|
||||||
|
<div className="text-secondary fs-7">Following</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>8</strong>
|
||||||
|
<div className="text-secondary fs-7">Friends</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-9">
|
||||||
|
<ul className="nav nav-tabs mb-3">
|
||||||
|
{(['activity', 'timeline', 'about'] as Tab[]).map((t) => (
|
||||||
|
<li key={t} className="nav-item">
|
||||||
|
<button
|
||||||
|
className={`nav-link text-capitalize ${tab === t ? 'active' : ''}`}
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{tab === 'activity' && (
|
||||||
|
<Card>
|
||||||
|
<ul className="list-group list-group-flush">
|
||||||
|
{ACTIVITY.map((item, i) => (
|
||||||
|
<li key={i} className="list-group-item px-0">
|
||||||
|
{item.text}
|
||||||
|
<div className="text-secondary fs-7">{item.time}</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'timeline' && (
|
||||||
|
<Card>
|
||||||
|
<p className="text-secondary mb-0">No timeline entries yet.</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'about' && (
|
||||||
|
<Card>
|
||||||
|
<p>
|
||||||
|
<strong>Education:</strong> —
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Location:</strong> —
|
||||||
|
</p>
|
||||||
|
<p className="mb-0">
|
||||||
|
<strong>Skills:</strong> —
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
96
frontend/src/features/projects/ProjectsPage.tsx
Normal file
96
frontend/src/features/projects/ProjectsPage.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { PageHeader } from '../../components/desktop/PageHeader'
|
||||||
|
import { SmallBox } from '../../components/desktop/SmallBox'
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
name: string
|
||||||
|
status: 'On track' | 'At risk' | 'Completed'
|
||||||
|
progress: number
|
||||||
|
team: string
|
||||||
|
budget: string
|
||||||
|
due: string
|
||||||
|
priority: 'High' | 'Medium' | 'Low'
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROJECTS: Project[] = [
|
||||||
|
{ name: 'Website redesign', status: 'On track', progress: 65, team: 'Alice, Bob', budget: '$12,000', due: 'Sep 1, 2026', priority: 'High' },
|
||||||
|
{ name: 'Mobile app v2', status: 'At risk', progress: 30, team: 'Carol, Dave', budget: '$25,000', due: 'Oct 15, 2026', priority: 'High' },
|
||||||
|
{ name: 'Internal tooling', status: 'Completed', progress: 100, team: 'Eve', budget: '$4,000', due: 'Jul 20, 2026', priority: 'Low' },
|
||||||
|
{ name: 'API v3 migration', status: 'On track', progress: 48, team: 'Frank, Grace', budget: '$18,000', due: 'Nov 1, 2026', priority: 'Medium' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<Project['status'], string> = {
|
||||||
|
'On track': 'text-bg-success',
|
||||||
|
'At risk': 'text-bg-danger',
|
||||||
|
Completed: 'text-bg-secondary',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectsPage() {
|
||||||
|
const total = PROJECTS.length
|
||||||
|
const onTrack = PROJECTS.filter((p) => p.status === 'On track').length
|
||||||
|
const atRisk = PROJECTS.filter((p) => p.status === 'At risk').length
|
||||||
|
const completed = PROJECTS.filter((p) => p.status === 'Completed').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Projects" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Projects' }]} />
|
||||||
|
<div className="app-content">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<div className="row mb-3">
|
||||||
|
<div className="col-lg-3 col-6">
|
||||||
|
<SmallBox value={total} label="Total Projects" icon="bi-kanban" color="text-bg-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-3 col-6">
|
||||||
|
<SmallBox value={onTrack} label="On Track" icon="bi-check-circle" color="text-bg-success" />
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-3 col-6">
|
||||||
|
<SmallBox value={atRisk} label="At Risk" icon="bi-exclamation-triangle" color="text-bg-danger" />
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-3 col-6">
|
||||||
|
<SmallBox value={completed} label="Completed" icon="bi-flag" color="text-bg-secondary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Progress</th>
|
||||||
|
<th>Team</th>
|
||||||
|
<th>Budget</th>
|
||||||
|
<th>Due</th>
|
||||||
|
<th>Priority</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{PROJECTS.map((p) => (
|
||||||
|
<tr key={p.name}>
|
||||||
|
<td>{p.name}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${STATUS_BADGE[p.status]}`}>{p.status}</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ minWidth: '120px' }}>
|
||||||
|
<div className="progress">
|
||||||
|
<div className="progress-bar" style={{ width: `${p.progress}%` }}>
|
||||||
|
{p.progress}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{p.team}</td>
|
||||||
|
<td>{p.budget}</td>
|
||||||
|
<td>{p.due}</td>
|
||||||
|
<td>{p.priority}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-secondary fs-7 mt-2 mb-0">Static demo — no project-tracking backend exists yet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
46
frontend/src/features/roles/RolesPage.tsx
Normal file
46
frontend/src/features/roles/RolesPage.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { PageHeader } from '../../components/desktop/PageHeader'
|
||||||
|
import { Card } from '../../components/desktop/Card'
|
||||||
|
import { fetchRoleOverview } from './rolesApi'
|
||||||
|
|
||||||
|
export function RolesPage() {
|
||||||
|
const { data: roles = [], isLoading } = useQuery({ queryKey: ['roles', 'overview'], queryFn: fetchRoleOverview })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Roles" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Roles' }]} />
|
||||||
|
<div className="app-content">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<p className="text-secondary">
|
||||||
|
Read-only view of each role's permissions, as seeded by <code>RolePermissionSeeder</code>. Permissions
|
||||||
|
aren't editable from here — they're fixed by the backend seed data.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-secondary">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<div className="row">
|
||||||
|
{roles.map((role) => (
|
||||||
|
<div key={role.name} className="col-md-6 col-lg-3 mb-4">
|
||||||
|
<Card title={role.name}>
|
||||||
|
{role.permissions?.length ? (
|
||||||
|
<div className="d-flex flex-wrap gap-1">
|
||||||
|
{role.permissions.map((p) => (
|
||||||
|
<span key={p} className="badge text-bg-secondary">
|
||||||
|
{p}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-secondary mb-0">No permissions.</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
7
frontend/src/features/roles/rolesApi.ts
Normal file
7
frontend/src/features/roles/rolesApi.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { apiClient } from '../../services/apiClient'
|
||||||
|
import type { Role } from '../../types/role'
|
||||||
|
|
||||||
|
export async function fetchRoleOverview(): Promise<Role[]> {
|
||||||
|
const { data } = await apiClient.get('/roles/overview')
|
||||||
|
return data.data as Role[]
|
||||||
|
}
|
||||||
102
frontend/src/features/settings/SettingsPage.tsx
Normal file
102
frontend/src/features/settings/SettingsPage.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useAuth } from '../auth/AuthContext'
|
||||||
|
import { PageHeader } from '../../components/desktop/PageHeader'
|
||||||
|
import { Card } from '../../components/desktop/Card'
|
||||||
|
import { TwoFactorSetupPage } from '../auth/TwoFactorSetupPage'
|
||||||
|
|
||||||
|
type Tab = 'account' | 'notifications' | 'security' | 'billing' | 'danger'
|
||||||
|
|
||||||
|
const TABS: { key: Tab; label: string }[] = [
|
||||||
|
{ key: 'account', label: 'Account' },
|
||||||
|
{ key: 'notifications', label: 'Notifications' },
|
||||||
|
{ key: 'security', label: 'Security' },
|
||||||
|
{ key: 'billing', label: 'Billing' },
|
||||||
|
{ key: 'danger', label: 'Danger Zone' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [tab, setTab] = useState<Tab>('account')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Settings" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Settings' }]} />
|
||||||
|
<div className="app-content">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<ul className="nav nav-tabs mb-3">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<li key={t.key} className="nav-item">
|
||||||
|
<button
|
||||||
|
className={`nav-link ${tab === t.key ? 'active' : ''}`}
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{tab === 'account' && (
|
||||||
|
<Card>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Name</label>
|
||||||
|
<input type="text" defaultValue={user?.name} className="form-control" disabled />
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Email</label>
|
||||||
|
<input type="email" defaultValue={user?.email} className="form-control" disabled />
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Timezone</label>
|
||||||
|
<select className="form-select" disabled>
|
||||||
|
<option>UTC</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p className="text-secondary fs-7 mb-0">
|
||||||
|
Account fields are managed via the Users module (or by an admin) — not editable here yet.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'notifications' && (
|
||||||
|
<Card>
|
||||||
|
{['Email notifications', 'Push notifications', 'Weekly digest', 'Security alerts'].map((label) => (
|
||||||
|
<div key={label} className="form-check form-switch mb-2">
|
||||||
|
<input className="form-check-input" type="checkbox" disabled defaultChecked />
|
||||||
|
<label className="form-check-label">{label}</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="text-secondary fs-7 mb-0 mt-2">Placeholder — no notification-preferences backend yet.</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'security' && <TwoFactorSetupPage />}
|
||||||
|
|
||||||
|
{tab === 'billing' && (
|
||||||
|
<Card title="Plan">
|
||||||
|
<p>
|
||||||
|
Current plan: <span className="badge text-bg-primary">Self-hosted</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-secondary fs-7 mb-0">
|
||||||
|
No billing integration — this app is self-hosted, so there's nothing to bill.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'danger' && (
|
||||||
|
<Card title="Danger zone">
|
||||||
|
<p className="text-secondary">Manage account deletion and data export here.</p>
|
||||||
|
<button className="btn btn-outline-danger" disabled>
|
||||||
|
Delete account
|
||||||
|
</button>
|
||||||
|
<p className="text-secondary fs-7 mb-0 mt-2">
|
||||||
|
Disabled placeholder — account deletion for the signed-in user isn't implemented.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
frontend/src/features/system/BackupRestorePage.tsx
Normal file
91
frontend/src/features/system/BackupRestorePage.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { PageHeader } from '../../components/desktop/PageHeader'
|
||||||
|
import { Card } from '../../components/desktop/Card'
|
||||||
|
import { useConfirm } from '../../components/shared/ConfirmDialog'
|
||||||
|
import { useToast } from '../../components/shared/Toast/ToastProvider'
|
||||||
|
import { downloadBackup, restoreBackup } from './systemApi'
|
||||||
|
|
||||||
|
export function BackupRestorePage() {
|
||||||
|
const confirm = useConfirm()
|
||||||
|
const toast = useToast()
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false)
|
||||||
|
const [isRestoring, setIsRestoring] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function handleDownload() {
|
||||||
|
setIsDownloading(true)
|
||||||
|
try {
|
||||||
|
await downloadBackup()
|
||||||
|
toast.success('Backup downloaded.')
|
||||||
|
} catch {
|
||||||
|
toast.error('Could not download the backup.')
|
||||||
|
} finally {
|
||||||
|
setIsDownloading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRestore() {
|
||||||
|
const file = fileInputRef.current?.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
const ok = await confirm({
|
||||||
|
title: 'Restore database',
|
||||||
|
message: `This will replace the current database with "${file.name}". This cannot be undone from here. Continue?`,
|
||||||
|
confirmLabel: 'Restore',
|
||||||
|
danger: true,
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
|
||||||
|
setIsRestoring(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await restoreBackup(file)
|
||||||
|
toast.success('Database restored. You may need to sign in again.')
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
|
} catch {
|
||||||
|
setError('That file could not be restored — make sure it is a valid SQLite backup.')
|
||||||
|
} finally {
|
||||||
|
setIsRestoring(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Backup & Restore"
|
||||||
|
breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Backup & Restore' }]}
|
||||||
|
/>
|
||||||
|
<div className="app-content">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<Card title="Download backup">
|
||||||
|
<p className="text-secondary">Download the current SQLite database file.</p>
|
||||||
|
<button onClick={handleDownload} disabled={isDownloading} className="btn btn-primary">
|
||||||
|
<i className="bi bi-download me-1"></i>
|
||||||
|
{isDownloading ? 'Downloading…' : 'Download backup'}
|
||||||
|
</button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-6">
|
||||||
|
<Card title="Restore from backup">
|
||||||
|
{error && <div className="alert alert-danger">{error}</div>}
|
||||||
|
<p className="text-secondary">
|
||||||
|
Upload a previously downloaded <code>.sqlite</code> file to replace the current database. A safety
|
||||||
|
copy of the current database is kept on the server before restoring.
|
||||||
|
</p>
|
||||||
|
<input ref={fileInputRef} type="file" accept=".sqlite" className="form-control mb-3" />
|
||||||
|
<button onClick={handleRestore} disabled={isRestoring} className="btn btn-danger">
|
||||||
|
<i className="bi bi-upload me-1"></i>
|
||||||
|
{isRestoring ? 'Restoring…' : 'Restore'}
|
||||||
|
</button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
73
frontend/src/features/system/MqttSettingsPage.tsx
Normal file
73
frontend/src/features/system/MqttSettingsPage.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { PageHeader } from '../../components/desktop/PageHeader'
|
||||||
|
import { Card } from '../../components/desktop/Card'
|
||||||
|
import { fetchMqttSettings } from './systemApi'
|
||||||
|
|
||||||
|
export function MqttSettingsPage() {
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['mqtt', 'settings'],
|
||||||
|
queryFn: fetchMqttSettings,
|
||||||
|
refetchInterval: 15000,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="MQTT Settings"
|
||||||
|
breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'MQTT Settings' }]}
|
||||||
|
/>
|
||||||
|
<div className="app-content">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<Card title="Broker connection">
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-secondary">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<table className="table table-borderless mb-0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Status</th>
|
||||||
|
<td>
|
||||||
|
{data?.connected ? (
|
||||||
|
<span className="badge text-bg-success">
|
||||||
|
<i className="bi bi-check-circle-fill me-1"></i>Connected
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge text-bg-danger">
|
||||||
|
<i className="bi bi-x-circle-fill me-1"></i>Unreachable
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Host</th>
|
||||||
|
<td>{data?.host}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Port</th>
|
||||||
|
<td>{data?.port}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">WebSocket URL</th>
|
||||||
|
<td>{data?.ws_url}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">TLS</th>
|
||||||
|
<td>{data?.tls_enabled ? 'Enabled' : 'Disabled'}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
<p className="text-secondary fs-7">
|
||||||
|
Broker credentials aren't shown here — role-scoped tokens are issued per-session via{' '}
|
||||||
|
<code>/api/v1/mqtt/token</code>. This page reflects config only super-admins can view.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
frontend/src/features/system/systemApi.ts
Normal file
32
frontend/src/features/system/systemApi.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { apiClient } from '../../services/apiClient'
|
||||||
|
|
||||||
|
export interface MqttSettings {
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
ws_url: string
|
||||||
|
tls_enabled: boolean
|
||||||
|
connected: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMqttSettings(): Promise<MqttSettings> {
|
||||||
|
const { data } = await apiClient.get('/mqtt/settings')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadBackup(): Promise<void> {
|
||||||
|
const response = await apiClient.get('/backup/download', { responseType: 'blob' })
|
||||||
|
const url = URL.createObjectURL(response.data)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `backup-${new Date().toISOString().replace(/[:.]/g, '-')}.sqlite`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
a.remove()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreBackup(file: File): Promise<void> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('backup', file)
|
||||||
|
await apiClient.post('/backup/restore', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
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 { 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'
|
||||||
@ -16,15 +17,22 @@ export function UsersListPageMobile() {
|
|||||||
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 [formUser, setFormUser] = useState<User | null | undefined>(undefined)
|
const [formUser, setFormUser] = useState<User | null | undefined>(undefined)
|
||||||
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: 'created_at', direction: 'desc' as const }],
|
queryKey: ['users', { page, sort: 'created_at', direction: 'desc' as const, search }],
|
||||||
queryFn: () => fetchUsers({ page, sort: 'created_at', direction: 'desc' }),
|
queryFn: () => fetchUsers({ page, sort: 'created_at', direction: 'desc', search: search || undefined }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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',
|
||||||
@ -41,68 +49,77 @@ export function UsersListPageMobile() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mob-search-bar">
|
||||||
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Users</h1>
|
<input
|
||||||
|
type="search"
|
||||||
|
defaultValue={search}
|
||||||
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
|
className="mob-search-input"
|
||||||
|
placeholder="🔍 Search users…"
|
||||||
|
/>
|
||||||
{can('users.create') && (
|
{can('users.create') && (
|
||||||
<button
|
<button onClick={() => setFormUser(null)} className="mob-pill-btn">
|
||||||
onClick={() => setFormUser(null)}
|
<span>➕</span> Add
|
||||||
className="rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
<div className="mob-section-card">
|
||||||
<p className="text-sm text-gray-500">Loading…</p>
|
{isLoading ? (
|
||||||
) : (
|
<p style={{ color: 'var(--mob-text-light)' }}>Loading…</p>
|
||||||
<div className="flex flex-col gap-3">
|
) : (data?.data ?? []).length === 0 ? (
|
||||||
{(data?.data ?? []).map((u) => (
|
<p style={{ color: 'var(--mob-text-light)' }}>No results.</p>
|
||||||
<div
|
) : (
|
||||||
key={u.id}
|
(data?.data ?? []).map((u) => (
|
||||||
className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900"
|
<div key={u.id} className="mob-list-item" style={{ alignItems: 'flex-start' }}>
|
||||||
>
|
<span className="mob-avatar">{u.name.slice(0, 2).toUpperCase()}</span>
|
||||||
<p className="font-medium text-gray-900 dark:text-gray-100">{u.name}</p>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-500">{u.email}</p>
|
<p style={{ margin: 0, fontWeight: 700, fontSize: 14 }}>{u.name}</p>
|
||||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-600">{u.roles.join(', ')}</p>
|
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'var(--mob-text-light)' }}>{u.email}</p>
|
||||||
<div className="mt-3 flex gap-4 text-sm">
|
<span className="mob-status-badge mob-neutral" style={{ marginTop: 6 }}>
|
||||||
{u.can_edit && (
|
{u.roles.join(', ')}
|
||||||
<button onClick={() => setFormUser(u)} className="text-blue-600 dark:text-blue-400">
|
</span>
|
||||||
Edit
|
<div style={{ marginTop: 8, display: 'flex', gap: 14 }}>
|
||||||
</button>
|
{u.can_edit && (
|
||||||
)}
|
<button onClick={() => setFormUser(u)} className="mob-text-btn">
|
||||||
{can('roles.assign') && (
|
Edit
|
||||||
<button onClick={() => setRoleUser(u)} className="text-blue-600 dark:text-blue-400">
|
</button>
|
||||||
Role
|
)}
|
||||||
</button>
|
{can('roles.assign') && (
|
||||||
)}
|
<button onClick={() => setRoleUser(u)} className="mob-text-btn">
|
||||||
{u.can_delete && (
|
Role
|
||||||
<button onClick={() => handleDelete(u)} className="text-red-600 dark:text-red-400">
|
</button>
|
||||||
Delete
|
)}
|
||||||
</button>
|
{u.can_delete && (
|
||||||
)}
|
<button onClick={() => handleDelete(u)} className="mob-text-btn mob-danger">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{data && data.meta.last_page > 1 && (
|
{data && data.meta.last_page > 1 && (
|
||||||
<div className="mt-4 flex items-center justify-between text-sm">
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
|
||||||
<button
|
<button
|
||||||
disabled={page <= 1}
|
disabled={page <= 1}
|
||||||
onClick={() => setPage((p) => p - 1)}
|
onClick={() => setPage((p) => p - 1)}
|
||||||
className="text-gray-600 disabled:opacity-40 dark:text-gray-400"
|
className="mob-text-btn"
|
||||||
|
style={{ opacity: page <= 1 ? 0.4 : 1 }}
|
||||||
>
|
>
|
||||||
Previous
|
Previous
|
||||||
</button>
|
</button>
|
||||||
<span className="text-gray-500">
|
<span style={{ color: 'var(--mob-text-light)' }}>
|
||||||
Page {page} of {data.meta.last_page}
|
Page {page} of {data.meta.last_page}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
disabled={page >= data.meta.last_page}
|
disabled={page >= data.meta.last_page}
|
||||||
onClick={() => setPage((p) => p + 1)}
|
onClick={() => setPage((p) => p + 1)}
|
||||||
className="text-gray-600 disabled:opacity-40 dark:text-gray-400"
|
className="mob-text-btn"
|
||||||
|
style={{ opacity: page >= data.meta.last_page ? 0.4 : 1 }}
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,12 +1,18 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { Outlet } from 'react-router-dom'
|
import { Outlet } from 'react-router-dom'
|
||||||
|
import '../../styles/mobile.css'
|
||||||
import { TopBar } from '../../components/mobile/TopBar'
|
import { TopBar } from '../../components/mobile/TopBar'
|
||||||
import { BottomNav } from '../../components/mobile/BottomNav'
|
import { BottomNav } from '../../components/mobile/BottomNav'
|
||||||
|
import { SlideOutNav } from '../../components/mobile/SlideOutNav'
|
||||||
|
|
||||||
export function MobileShell() {
|
export function MobileShell() {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="mob-app">
|
||||||
<TopBar />
|
<TopBar onMenuClick={() => setMenuOpen(true)} />
|
||||||
<main className="flex-1 overflow-y-auto bg-gray-50 p-4 dark:bg-gray-950">
|
<SlideOutNav open={menuOpen} onClose={() => setMenuOpen(false)} />
|
||||||
|
<main className="mob-content">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
<BottomNav />
|
<BottomNav />
|
||||||
|
|||||||
@ -17,8 +17,30 @@ export const NAV_GROUPS: NavGroup[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'ADMINISTRATION',
|
header: 'ADMINISTRATION',
|
||||||
items: [{ label: 'Users', to: '/users', icon: 'bi-people-fill', permission: 'users.view' }],
|
items: [
|
||||||
|
{ label: 'Users', to: '/users', icon: 'bi-people-fill', permission: 'users.view' },
|
||||||
|
{ label: 'Roles', to: '/roles', icon: 'bi-shield-lock-fill', permission: 'roles.view' },
|
||||||
|
{ label: 'MQTT Settings', to: '/system/mqtt', icon: 'bi-broadcast', permission: 'system.manage' },
|
||||||
|
{ label: 'Backup & Restore', to: '/system/backup', icon: 'bi-database-down', permission: 'system.manage' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'WORKSPACE',
|
||||||
|
items: [
|
||||||
|
{ label: 'Profile', to: '/profile', icon: 'bi-person-circle' },
|
||||||
|
{ label: 'Settings', to: '/settings', icon: 'bi-gear-fill' },
|
||||||
|
{ label: 'Calendar', to: '/calendar', icon: 'bi-calendar3' },
|
||||||
|
{ label: 'Chat', to: '/chat', icon: 'bi-chat-dots-fill' },
|
||||||
|
{ label: 'File Manager', to: '/files', icon: 'bi-folder-fill' },
|
||||||
|
{ label: 'Projects', to: '/projects', icon: 'bi-kanban-fill' },
|
||||||
|
{ label: 'Invoice', to: '/invoice', icon: 'bi-receipt' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const NAV_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items)
|
export const NAV_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items)
|
||||||
|
|
||||||
|
// The mobile bottom nav only has room for a handful of icons — cap it to
|
||||||
|
// the items most people need daily rather than flattening all 12.
|
||||||
|
const MOBILE_ITEM_LABELS = ['Dashboard', 'Users', 'Profile', 'Settings']
|
||||||
|
export const MOBILE_NAV_ITEMS: NavItem[] = NAV_ITEMS.filter((item) => MOBILE_ITEM_LABELS.includes(item.label))
|
||||||
|
|||||||
@ -2,8 +2,18 @@ import { createBrowserRouter, Navigate } from 'react-router-dom'
|
|||||||
import { LoginPage } from '../features/auth/LoginPage'
|
import { LoginPage } from '../features/auth/LoginPage'
|
||||||
import { TwoFactorChallengePage } from '../features/auth/TwoFactorChallengePage'
|
import { TwoFactorChallengePage } from '../features/auth/TwoFactorChallengePage'
|
||||||
import { TwoFactorSetupPage } from '../features/auth/TwoFactorSetupPage'
|
import { TwoFactorSetupPage } from '../features/auth/TwoFactorSetupPage'
|
||||||
import { DashboardPage } from '../features/dashboard/DashboardPage'
|
import { DashboardRoute } from '../features/dashboard/DashboardRoute'
|
||||||
import { UsersPage } from '../features/users/UsersPage'
|
import { UsersPage } from '../features/users/UsersPage'
|
||||||
|
import { RolesPage } from '../features/roles/RolesPage'
|
||||||
|
import { MqttSettingsPage } from '../features/system/MqttSettingsPage'
|
||||||
|
import { BackupRestorePage } from '../features/system/BackupRestorePage'
|
||||||
|
import { SettingsPage } from '../features/settings/SettingsPage'
|
||||||
|
import { ProfilePage } from '../features/profile/ProfilePage'
|
||||||
|
import { InvoicePage } from '../features/invoice/InvoicePage'
|
||||||
|
import { CalendarPage } from '../features/calendar/CalendarPage'
|
||||||
|
import { ChatPage } from '../features/chat/ChatPage'
|
||||||
|
import { FileManagerPage } from '../features/file-manager/FileManagerPage'
|
||||||
|
import { ProjectsPage } from '../features/projects/ProjectsPage'
|
||||||
import { AppShell } from '../layouts/shell/AppShell'
|
import { AppShell } from '../layouts/shell/AppShell'
|
||||||
import { ProtectedRoute } from './ProtectedRoute'
|
import { ProtectedRoute } from './ProtectedRoute'
|
||||||
import { RequirePermission } from './RequirePermission'
|
import { RequirePermission } from './RequirePermission'
|
||||||
@ -17,12 +27,30 @@ export const router = createBrowserRouter([
|
|||||||
{
|
{
|
||||||
element: <AppShell />,
|
element: <AppShell />,
|
||||||
children: [
|
children: [
|
||||||
{ path: '/dashboard', element: <DashboardPage /> },
|
{ path: '/dashboard', element: <DashboardRoute /> },
|
||||||
|
{ path: '/settings', element: <SettingsPage /> },
|
||||||
{ path: '/settings/2fa', element: <TwoFactorSetupPage /> },
|
{ path: '/settings/2fa', element: <TwoFactorSetupPage /> },
|
||||||
|
{ path: '/profile', element: <ProfilePage /> },
|
||||||
|
{ path: '/invoice', element: <InvoicePage /> },
|
||||||
|
{ path: '/calendar', element: <CalendarPage /> },
|
||||||
|
{ path: '/chat', element: <ChatPage /> },
|
||||||
|
{ path: '/files', element: <FileManagerPage /> },
|
||||||
|
{ path: '/projects', element: <ProjectsPage /> },
|
||||||
{
|
{
|
||||||
element: <RequirePermission permission="users.view" />,
|
element: <RequirePermission permission="users.view" />,
|
||||||
children: [{ path: '/users', element: <UsersPage /> }],
|
children: [{ path: '/users', element: <UsersPage /> }],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
element: <RequirePermission permission="roles.view" />,
|
||||||
|
children: [{ path: '/roles', element: <RolesPage /> }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: <RequirePermission permission="system.manage" />,
|
||||||
|
children: [
|
||||||
|
{ path: '/system/mqtt', element: <MqttSettingsPage /> },
|
||||||
|
{ path: '/system/backup', element: <BackupRestorePage /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
476
frontend/src/styles/mobile.css
Normal file
476
frontend/src/styles/mobile.css
Normal file
@ -0,0 +1,476 @@
|
|||||||
|
/*
|
||||||
|
* Mobile design system — entirely separate from the desktop AdminLTE/
|
||||||
|
* Bootstrap styling (frontend/src/index.css). Every selector is scoped
|
||||||
|
* under `.mob-app` and every class is prefixed `mob-` so it can never
|
||||||
|
* collide with or be affected by AdminLTE's global classes (e.g. both
|
||||||
|
* define an "app-sidebar" concept, but under different names/rules).
|
||||||
|
* Adapted from a provided reference design (purple theme, card system,
|
||||||
|
* slide-out sidebar, bottom tab bar).
|
||||||
|
*/
|
||||||
|
|
||||||
|
.mob-app {
|
||||||
|
--mob-primary: #6c63ff;
|
||||||
|
--mob-primary-light: #8a84ff;
|
||||||
|
--mob-bg: #f5f5f9;
|
||||||
|
--mob-white: #ffffff;
|
||||||
|
--mob-text-dark: #333333;
|
||||||
|
--mob-text-medium: #555555;
|
||||||
|
--mob-text-light: #999999;
|
||||||
|
--mob-border: #eeeeee;
|
||||||
|
--mob-light-gray-bg: #f7f7f7;
|
||||||
|
--mob-success: #4caf50;
|
||||||
|
--mob-warning: #ff9800;
|
||||||
|
--mob-danger: #f44336;
|
||||||
|
--mob-tab-bar-height: 62px;
|
||||||
|
--mob-padding: 16px;
|
||||||
|
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background-color: var(--mob-bg);
|
||||||
|
color: var(--mob-text-dark);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mob-app * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.mob-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 10px var(--mob-padding);
|
||||||
|
padding-top: 14px;
|
||||||
|
background: var(--mob-white);
|
||||||
|
border-bottom: 1px solid var(--mob-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.mob-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.mob-menu-btn {
|
||||||
|
border: none;
|
||||||
|
background: var(--mob-light-gray-bg);
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--mob-text-medium);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.mob-menu-btn:hover {
|
||||||
|
background: var(--mob-primary);
|
||||||
|
color: var(--mob-white);
|
||||||
|
}
|
||||||
|
.mob-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.mob-title span:first-child {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--mob-text-light);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.mob-title span:last-child {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--mob-primary);
|
||||||
|
}
|
||||||
|
.mob-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.mob-icon-btn {
|
||||||
|
border: none;
|
||||||
|
background: var(--mob-light-gray-bg);
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 17px;
|
||||||
|
color: var(--mob-text-medium);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.mob-icon-btn:hover {
|
||||||
|
background: var(--mob-primary);
|
||||||
|
color: var(--mob-white);
|
||||||
|
}
|
||||||
|
.mob-badge-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
background: var(--mob-danger);
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--mob-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slide-out sidebar */
|
||||||
|
.mob-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: -280px;
|
||||||
|
width: 280px;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--mob-white);
|
||||||
|
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.12);
|
||||||
|
z-index: 60;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 14px;
|
||||||
|
transition: left 0.25s ease-out;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.mob-sidebar.mob-open {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.mob-sidebar-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 55;
|
||||||
|
display: none;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
.mob-sidebar-backdrop.mob-show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.mob-sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 2px solid var(--mob-border);
|
||||||
|
}
|
||||||
|
.mob-sidebar-title {
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--mob-primary);
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
.mob-sidebar-close {
|
||||||
|
border: none;
|
||||||
|
background: var(--mob-light-gray-bg);
|
||||||
|
font-size: 18px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--mob-text-medium);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.mob-sidebar-close:hover {
|
||||||
|
background: var(--mob-danger);
|
||||||
|
color: var(--mob-white);
|
||||||
|
}
|
||||||
|
.mob-sidebar-section-label {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--mob-text-light);
|
||||||
|
margin-top: 14px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
.mob-sidebar-link {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 11px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--mob-text-medium);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.mob-sidebar-link .mob-nav-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.mob-sidebar-link:hover {
|
||||||
|
background: linear-gradient(135deg, rgba(108, 99, 255, 0.1), rgba(108, 99, 255, 0.05));
|
||||||
|
transform: translateX(3px);
|
||||||
|
}
|
||||||
|
.mob-sidebar-link.mob-active {
|
||||||
|
background: linear-gradient(135deg, var(--mob-primary), var(--mob-primary-light));
|
||||||
|
color: var(--mob-white);
|
||||||
|
box-shadow: 0 4px 12px rgba(108, 99, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.mob-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 14px var(--mob-padding);
|
||||||
|
padding-bottom: calc(var(--mob-tab-bar-height) + 14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom tab bar */
|
||||||
|
.mob-tab-bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: var(--mob-tab-bar-height);
|
||||||
|
background: var(--mob-white);
|
||||||
|
border-top: 1px solid var(--mob-border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 20;
|
||||||
|
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
.mob-tab-item {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--mob-text-light);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 0;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.mob-tab-item .mob-nav-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.mob-tab-item.mob-active {
|
||||||
|
color: var(--mob-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* KPI cards */
|
||||||
|
.mob-kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
@media (min-width: 540px) {
|
||||||
|
.mob-kpi-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.mob-kpi-card {
|
||||||
|
background: var(--mob-white);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
border-left: 5px solid var(--mob-primary);
|
||||||
|
}
|
||||||
|
.mob-kpi-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--mob-text-light);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.mob-kpi-value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.mob-kpi-change {
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.mob-kpi-change.mob-positive {
|
||||||
|
color: var(--mob-success);
|
||||||
|
}
|
||||||
|
.mob-kpi-change.mob-negative {
|
||||||
|
color: var(--mob-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section / generic cards */
|
||||||
|
.mob-section-card {
|
||||||
|
background: var(--mob-white);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 18px;
|
||||||
|
box-shadow: 0 3px 15px rgba(0, 0, 0, 0.06);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.mob-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.mob-section-header h4 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--mob-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.mob-section-header small {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mob-text-light);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mob-card {
|
||||||
|
background: var(--mob-white);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List rows (users, activity, etc.) */
|
||||||
|
.mob-list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px dashed var(--mob-border);
|
||||||
|
}
|
||||||
|
.mob-list-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.mob-avatar {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, var(--mob-primary), var(--mob-primary-light));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
font-size: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2px 8px rgba(108, 99, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.mob-status-badge {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.mob-status-badge.mob-success {
|
||||||
|
background: linear-gradient(135deg, #e8f5e9, #c8e6c9);
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
.mob-status-badge.mob-warning {
|
||||||
|
background: linear-gradient(135deg, #fff3e0, #ffe0b2);
|
||||||
|
color: #ef6c00;
|
||||||
|
}
|
||||||
|
.mob-status-badge.mob-danger {
|
||||||
|
background: linear-gradient(135deg, #ffebee, #ffcdd2);
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
.mob-status-badge.mob-neutral {
|
||||||
|
background: var(--mob-light-gray-bg);
|
||||||
|
color: var(--mob-text-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search + filters */
|
||||||
|
.mob-search-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.mob-search-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 160px;
|
||||||
|
padding: 11px 16px;
|
||||||
|
border: 2px solid var(--mob-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: white;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.mob-search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--mob-primary);
|
||||||
|
box-shadow: 0 0 0 4px rgba(108, 99, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.mob-pill-btn {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(135deg, var(--mob-primary), var(--mob-primary-light));
|
||||||
|
color: var(--mob-white);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 4px 12px rgba(108, 99, 255, 0.3);
|
||||||
|
}
|
||||||
|
.mob-pill-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(108, 99, 255, 0.4);
|
||||||
|
}
|
||||||
|
.mob-pill-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
.mob-text-btn {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--mob-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.mob-text-btn.mob-danger {
|
||||||
|
color: var(--mob-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.mob-app {
|
||||||
|
--mob-bg: #17181f;
|
||||||
|
--mob-white: #23242e;
|
||||||
|
--mob-text-dark: #e6e6f0;
|
||||||
|
--mob-text-medium: #b7b7c7;
|
||||||
|
--mob-text-light: #7d7d8f;
|
||||||
|
--mob-border: #33343f;
|
||||||
|
--mob-light-gray-bg: #2b2c37;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
export interface Role {
|
export interface Role {
|
||||||
name: string
|
name: string
|
||||||
|
permissions?: string[]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user