07 — Frontend Architecture¶
System: PyroRadius ISP Billing & RADIUS Management
Stack: Inertia.js v2 · React 18 · Tailwind CSS v3 · lucide-react · Recharts
Last Updated: 2026-07-13
Overview¶
PyroRadius uses Inertia.js v2 as the bridge between Laravel 13 (backend) and React 18 (frontend). There is no separate REST API for page rendering — the server responds to Inertia requests by returning a JSON payload of props, and React renders the appropriate page component client-side without a full page reload.
This architecture is sometimes called a "monolith with a modern frontend": you get the ergonomics of a SPA (client-side routing, no page flashes, component state preserved between navigations) with the simplicity of server-driven routing (no separate API to design and maintain for basic CRUD).
How Inertia.js v2 Works¶
Initial Page Load¶
- Browser requests
/customers(first load, no Inertia headers). - Laravel recognises this as a standard HTTP request and returns a full HTML document.
- The HTML includes a single
<div id="app" data-page="...">element where thedata-pageattribute contains a JSON-encoded payload with: component: the React page name (e.g."Customers/Index")props: the data the controller passed toInertia::render()url: the current URLversion: the Inertia asset version (MD5 ofmix-manifest.json)- The React entrypoint (
resources/js/app.jsx) bootstraps the Inertia app, readsdata-page, and mounts the correct page component.
Subsequent Navigation (Inertia Visit)¶
When a user clicks an Inertia <Link> or the code calls router.visit():
- Inertia intercepts the navigation.
- It sends an XHR/Fetch request to the same URL with the
X-Inertia: trueheader. - Laravel detects the header and responds with JSON only (no HTML wrapper):
- Inertia swaps the page component in the browser without a full reload. The URL updates via the History API.
- React re-renders only the new page component; the persistent layout (sidebar, topbar) is preserved in the React tree.
Asset Version Check¶
If the version in the Inertia response differs from the one the client loaded (e.g. after a deployment), Inertia performs a hard redirect to force the browser to reload the page and pick up new assets. This is configured via Inertia::version() in HandleInertiaRequests.
File Structure¶
resources/js/
├── app.jsx # Inertia bootstrap + React.createRoot()
├── bootstrap.js # Axios defaults (CSRF header, base URL)
├── Pages/ # One file per Inertia page (matches component names)
│ ├── Auth/
│ │ └── Login.jsx
│ ├── Dashboard/
│ │ └── Index.jsx
│ ├── Customers/
│ │ ├── Index.jsx
│ │ ├── Create.jsx
│ │ ├── Edit.jsx
│ │ ├── Show.jsx
│ │ └── Ledger.jsx
│ ├── Billing/
│ │ ├── Invoices/
│ │ ├── Payments/
│ │ ├── Archive/
│ │ └── Batches/
│ ├── Packages/
│ ├── Nas/
│ ├── Olt/
│ ├── WhatsApp/
│ ├── Reports/
│ ├── Settings/
│ ├── Users/
│ ├── Roles/
│ ├── Audit/
│ ├── Import/
│ ├── Tickets/
│ ├── Graphs/
│ └── Engineer/
├── Components/ # Reusable UI components (not full pages)
│ ├── ui/ # Primitive components (Button, Input, Badge, Modal…)
│ ├── tables/ # DataTable, TablePagination, SortableHeader
│ ├── forms/ # FormField, SelectInput, DatePicker, PhoneInput
│ ├── charts/ # Wrappers around Recharts components
│ ├── billing/ # InvoiceCard, PaymentForm, LedgerTable
│ ├── customers/ # CustomerStatusBadge, RechargeModal, CustomerMeta
│ ├── radius/ # RadiusStatusIndicator, SessionTable
│ └── shared/ # FlashMessage, ConfirmModal, PageHeader, Breadcrumb
├── Layouts/
│ ├── AppLayout.jsx # Main layout: sidebar + topbar + slot for page content
│ ├── AuthLayout.jsx # Centred card layout for login/password reset
│ └── CustomerPortalLayout.jsx # Simplified layout for customer self-service
└── lib/
├── can.js # Permission helper
├── currency.js # PKR formatting utility
├── dates.js # Date formatting (day.js wrappers)
└── api.js # Axios instance with CSRF defaults
Shared Inertia Props¶
The HandleInertiaRequests middleware runs on every Inertia request and appends these props to every page's prop object. Components can access them without the controller explicitly passing them.
// app/Http/Middleware/HandleInertiaRequests.php
public function share(Request $request): array
{
return array_merge(parent::share($request), [
'auth' => [
'user' => $request->user() ? [
'id' => $request->user()->id,
'name' => $request->user()->name,
'email' => $request->user()->email,
'role' => $request->user()->role?->name,
'permissions' => $request->user()->allPermissions()->pluck('name'),
'avatar_url' => $request->user()->avatar_url,
] : null,
],
'flash' => [
'success' => $request->session()->get('success'),
'error' => $request->session()->get('error'),
'warning' => $request->session()->get('warning'),
'info' => $request->session()->get('info'),
],
'app_name' => config('app.name'),
'company_profile' => cache()->remember('company_profile', 300, fn() =>
CompanySetting::first()
),
]);
}
| Shared Prop | Type | Purpose |
|---|---|---|
auth.user |
object | null | Current logged-in user with role and permission list |
auth.user.permissions |
string[] | Array of all permission slugs the user has |
flash.success |
string | null | Success message to show after a redirect |
flash.error |
string | null | Error message to show after a redirect |
flash.warning |
string | null | Warning message |
flash.info |
string | null | Info message |
app_name |
string | "PyroRadius" — used in page titles |
company_profile |
object | Company name, logo, contact info — shown in PDF headers and portal |
Accessing Shared Props in React¶
import { usePage } from '@inertiajs/react';
export default function MyComponent() {
const { auth, flash, company_profile } = usePage().props;
return <div>Hello, {auth.user.name}</div>;
}
Permission Helper (can())¶
Because every page has auth.user.permissions available via usePage().props, a thin helper function provides a readable API for permission checks in React components.
// resources/js/lib/can.js
import { usePage } from '@inertiajs/react';
export function useCan() {
const { auth } = usePage().props;
return (permission) => auth.user?.permissions?.includes(permission) ?? false;
}
Usage in a page component:
import { useCan } from '@/lib/can';
export default function CustomersIndex({ customers }) {
const can = useCan();
return (
<div>
{can('create_customers') && (
<Link href="/customers/create">Add Customer</Link>
)}
{can('delete_customers') && (
<button onClick={handleDelete}>Delete</button>
)}
</div>
);
}
The can() helper is a pure client-side check used to show or hide UI elements. It does not replace server-side authorisation — the controller always re-checks permissions regardless of what the client sends.
Forms: useForm and Mutations¶
useForm (Inertia-managed forms)¶
For standard CRUD forms, PyroRadius uses Inertia's useForm hook. It manages field state, submits via XHR with CSRF handling, tracks processing state (for disabling buttons), and collects errors from the server's validation response.
import { useForm } from '@inertiajs/react';
export default function CreateCustomer() {
const { data, setData, post, processing, errors } = useForm({
name: '',
username: '',
phone: '',
package_id: '',
area: '',
address: '',
});
const handleSubmit = (e) => {
e.preventDefault();
post('/customers'); // POST /customers → CustomerController@store
};
return (
<form onSubmit={handleSubmit}>
<input
value={data.name}
onChange={e => setData('name', e.target.value)}
/>
{errors.name && <p className="text-red-500">{errors.name}</p>}
<button disabled={processing}>Save</button>
</form>
);
}
router.put / router.delete (Inertia router)¶
For actions that don't have a dedicated form (e.g. toggling a status, deleting a record):
import { router } from '@inertiajs/react';
// Toggle customer status
const handleStatusChange = (status) => {
router.post(`/customers/${customer.id}/status`, { status }, {
preserveScroll: true,
onSuccess: () => console.log('Done'),
});
};
// Delete a record
const handleDelete = () => {
router.delete(`/customers/${customer.id}`, {
onBefore: () => confirm('Are you sure?'),
});
};
Axios (for AJAX/non-navigating requests)¶
Real-time data fetches (NAS traffic, online count, typeahead search) use Axios directly and do not trigger an Inertia navigation:
import axios from 'axios';
import { useEffect, useState } from 'react';
export default function OnlineCount() {
const [count, setCount] = useState(null);
useEffect(() => {
const fetch = () =>
axios.get('/api/dashboard/online').then(r => setCount(r.data.count));
fetch();
const interval = setInterval(fetch, 30000); // refresh every 30s
return () => clearInterval(interval);
}, []);
return <span>{count ?? '…'}</span>;
}
The Axios instance in bootstrap.js automatically adds:
- X-CSRF-TOKEN: from the <meta name="csrf-token"> in the HTML head
- X-Requested-With: XMLHttpRequest
- Accept: application/json
Flash Messages¶
After a server-side action (create, update, delete), the controller sets a flash message in the session and redirects. Inertia carries the flash data as shared props on the next page load. The AppLayout renders a persistent <FlashMessage> component that watches for changes.
// Components/shared/FlashMessage.jsx
import { usePage } from '@inertiajs/react';
import { useEffect, useState } from 'react';
export default function FlashMessage() {
const { flash } = usePage().props;
const [visible, setVisible] = useState(false);
const [message, setMessage] = useState(null);
const [type, setType] = useState('success');
useEffect(() => {
const msg = flash.success || flash.error || flash.warning || flash.info;
if (msg) {
setMessage(msg);
setType(flash.success ? 'success' : flash.error ? 'error' : flash.warning ? 'warning' : 'info');
setVisible(true);
const timer = setTimeout(() => setVisible(false), 4000);
return () => clearTimeout(timer);
}
}, [flash]);
if (!visible || !message) return null;
return (
<div className={`toast toast-${type}`}>
{message}
</div>
);
}
Pagination¶
Laravel's paginate() returns a LengthAwarePaginator. When passed to Inertia::render(), it serialises to:
{
"data": [...],
"links": {
"first": "...", "last": "...", "prev": null, "next": "..."
},
"meta": {
"current_page": 1, "from": 1, "to": 15, "total": 120,
"last_page": 8, "per_page": 15, "path": "..."
}
}
The TablePagination component reads links and meta from props and renders previous/next and numbered page links as Inertia <Link> components (so navigating pages is a client-side Inertia visit, not a full reload):
import { Link } from '@inertiajs/react';
export default function TablePagination({ links, meta }) {
return (
<div className="flex items-center gap-2">
<span>Page {meta.current_page} of {meta.last_page}</span>
{links.prev && <Link href={links.prev}>← Prev</Link>}
{links.next && <Link href={links.next}>Next →</Link>}
</div>
);
}
Dark Mode¶
Dark mode is implemented using Tailwind's class strategy. When dark mode is active, a dark class is added to the <html> element. Components use dark: variants for alternative styles.
The user's preference is stored in localStorage under theme. The AppLayout reads this on mount and applies/removes the dark class:
useEffect(() => {
const saved = localStorage.getItem('theme');
if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
}, []);
A toggle button in the topbar writes back to localStorage and flips the class. No server-side preference is persisted (pure client-side).
Tailwind dark mode example in a component:
<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 rounded-lg shadow">
...
</div>
Responsive Design¶
PyroRadius targets a desktop-first ISP admin interface but supports tablet viewports. Key responsive patterns:
- Sidebar collapses to a drawer on viewports below
lg(1024px). - Tables use
overflow-x-autowrappers; columns are progressively hidden withhidden md:table-cell. - Stat cards use
grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4. - Modals are full-screen on mobile, centred card on desktop.
- No mobile-first customer-facing design in the admin panel (customer portal has its own simplified layout).
Icons¶
Only lucide-react is used for icons. No Heroicons, FontAwesome, or other libraries. This keeps the bundle consistent and tree-shakeable.
import { Users, CreditCard, Wifi, AlertTriangle, ChevronRight } from 'lucide-react';
// Usage
<Users className="h-5 w-5 text-gray-500" />
All icons receive explicit size classes (h-4 w-4, h-5 w-5, h-6 w-6) and a colour class. Never use raw <img> tags for icons.
Charts: Recharts¶
Network traffic graphs, revenue charts, and customer growth charts use Recharts. Recharts components are wrapped in thin helper components under Components/charts/ to enforce consistent styling (colours, tooltip format, axis format).
// Components/charts/TrafficChart.jsx
import {
ResponsiveContainer, AreaChart, Area,
XAxis, YAxis, Tooltip, CartesianGrid
} from 'recharts';
export default function TrafficChart({ data }) {
return (
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data}>
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-200 dark:stroke-gray-700" />
<XAxis dataKey="time" />
<YAxis tickFormatter={v => `${(v / 1e6).toFixed(1)}M`} />
<Tooltip formatter={(v) => [`${(v / 1e6).toFixed(2)} Mbps`]} />
<Area type="monotone" dataKey="rx" stroke="#3b82f6" fill="#bfdbfe" />
<Area type="monotone" dataKey="tx" stroke="#10b981" fill="#a7f3d0" />
</AreaChart>
</ResponsiveContainer>
);
}
AppLayout — Sidebar Navigation Structure¶
AppLayout is the persistent shell for all authenticated admin pages. It does not remount between Inertia navigations — only the {children} slot changes.
AppLayout
├── Sidebar
│ ├── Logo + Company Name
│ ├── Nav Section: Main
│ │ ├── Dashboard (/)
│ │ └── Health (/health)
│ ├── Nav Section: Customers
│ │ ├── All Customers (/customers)
│ │ ├── Import (/import)
│ │ └── Tickets (/tickets)
│ ├── Nav Section: Billing
│ │ ├── Invoices (/billing/invoices)
│ │ ├── Payments (/billing/payments)
│ │ ├── Archive (/billing/archive)
│ │ └── Batches (/billing/batches)
│ ├── Nav Section: Network
│ │ ├── Packages (/packages)
│ │ ├── NAS (/nas)
│ │ ├── OLT (/olt)
│ │ ├── Graphs (/graphs)
│ │ └── TR-069 (/tr069)
│ ├── Nav Section: Communication
│ │ ├── WhatsApp (/whatsapp)
│ │ ├── Notifications (/notifications)
│ │ └── Campaigns (/campaigns)
│ ├── Nav Section: Reports
│ │ └── Reports (/reports)
│ └── Nav Section: Administration
│ ├── Users (/users)
│ ├── Roles (/roles)
│ ├── Audit Log (/audit)
│ └── Settings (/settings)
├── Topbar
│ ├── Sidebar toggle (mobile)
│ ├── Page title (passed as prop)
│ ├── Dark mode toggle
│ └── User menu (avatar, name, logout)
└── Main Content Area
├── FlashMessage (rendered here, watches usePage().props.flash)
└── {children} ← the current Inertia page component
Each nav item uses <Link href="..." className={isActive ? 'bg-indigo-700' : ''}>
isActive is determined by comparing the nav item's href against usePage().url.
Auth State in Components¶
Auth state is never stored in component-level state or a React Context — it comes from Inertia's shared props on every page render.
const { auth } = usePage().props;
// Check if logged in
if (!auth.user) return <Redirect to="/login" />;
// Get current user's name
const name = auth.user.name;
// Check role
const isSuperAdmin = auth.user.role === 'super_admin';
// Check a specific permission
const canEditCustomers = auth.user.permissions.includes('edit_customers');
Because Inertia re-injects shared props on every navigation, the auth object is always fresh. No useEffect + fetch pattern is needed to "load" the current user.
Page Component Template¶
A minimal example of a PyroRadius Inertia page component:
import AppLayout from '@/Layouts/AppLayout';
import { usePage, Link, router } from '@inertiajs/react';
import { useCan } from '@/lib/can';
import { Plus } from 'lucide-react';
import TablePagination from '@/Components/tables/TablePagination';
export default function CustomersIndex({ customers }) {
const { auth } = usePage().props;
const can = useCan();
return (
<AppLayout title="Customers">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Customers
</h1>
{can('create_customers') && (
<Link
href="/customers/create"
className="btn btn-primary flex items-center gap-2"
>
<Plus className="h-4 w-4" /> Add Customer
</Link>
)}
</div>
<table className="w-full ...">
<tbody>
{customers.data.map(c => (
<tr key={c.id}>
<td>{c.name}</td>
<td>{c.username}</td>
{can('edit_customers') && (
<td>
<Link href={`/customers/${c.id}/edit`}>Edit</Link>
</td>
)}
</tr>
))}
</tbody>
</table>
<TablePagination links={customers.links} meta={customers.meta} />
</AppLayout>
);
}
Build System¶
Assets are compiled with Vite (Laravel Vite plugin). The entrypoint is resources/js/app.jsx.
The @ alias in imports resolves to resources/js/, configured in vite.config.js.