Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
- **Customers — master data for work orders** *(phase 1)*: work orders can now be attached to a **customer**. A new **Admin → Orders → Customers** section provides full CRUD (list, create, edit, soft-delete, activate/deactivate) for customers carrying a **name**, optional **code**, a loyalty **tier** (bronze / silver / gold / VIP), a manual **payment score** (0–100), and app-maintained **total orders** / **total revenue** aggregates plus free-text notes. Work orders gain a nullable **customer** selector on the admin and supervisor create/edit forms; the customer name shows on the work-order list and detail (with a tier badge) and in the production **planner tooltip**. Deleting a customer never removes its work orders. Customers are soft-deletable (Trash + audit) and live-synced via a new `customers` Electric shape. New `customers` table, `work_orders.customer_id` FK (null-on-delete), `App\Models\Customer`, `App\Enums\Tier`, `CustomerController`. This is the foundation for automatic priority scoring (phase 2).

### Fixed
- **Planner poll hammered the server with 401s after a session expired** *(telemetry: `http_error` was ~57% one endpoint)*: the production planner short-polls `/admin/schedule/check-updates` every 5-10s to sync across tabs. When the session expired on a left-open planner tab, each poll returned **401** and the client silently retried forever, quietly generating hundreds of error events per stale tab. Both pollers (`LiveRefresh` and the planner's tracking poll) now **stop on a 401/419** (session gone) instead of retrying indefinitely — a real navigation still bounces the user to login.
- **Customers and Priority Settings links 403'd for non-admin roles** *(telemetry)*: `/admin/customers` and `/admin/priority-rules` sat outside every `TabRegistry` tab, so `TabAccessMiddleware` treated them as **Admin-only (403)** — yet the sidebar showed them under the **Orders** group to anyone holding the `orders` tab, so a Supervisor/Operator with Orders access saw the links and got a 403 on click. They now resolve to the **Orders tab**, matching where the nav places them, so access and visibility agree.
- **New MQTT connections weren't picked up until a manual listener restart** *([#174](https://github.com/Mes-Open/OpenMes/issues/174))*: `mqtt:listen` was a single-connection process pinned to one `--connection=<id>` at startup, so creating a connection in **Admin → Connectivity → MQTT** left it stuck at `disconnected` — the DB row existed but nothing was subscribed against it until you restarted the listener with that ID. It is now a **supervisor**: one process services **all** active MQTT connections at once (non-blocking `loopOnce()` per client) and **reconciles against the database every few seconds** — a newly created/activated connection is connected + subscribed automatically, a deactivated/deleted one is disconnected, and a connection whose broker settings or topics changed is reconnected with the new config. The `mqtt-listener` container now runs `mqtt:listen` with no pinned ID by default (pass `--connection=<id>` to supervise just one).
- **MQTT connection detail — Live Message Log went blank after the first message** *([#174](https://github.com/Mes-Open/OpenMes/issues/174))*: the message log's time formatter was a local `formatTime` that shadowed the imported i18n helper and **recursed into itself**, so the panel rendered fine while empty ("Waiting for messages…") but blew the call stack the moment a message arrived — blanking the log. Renamed the local helper so it calls the imported formatter instead of itself.

Expand Down
2 changes: 1 addition & 1 deletion backend/app/Support/TabRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class TabRegistry
'dashboard' => ['label' => 'Dashboard', 'prefixes' => ['/admin/dashboard']],
'alerts' => ['label' => 'Alerts', 'prefixes' => ['/admin/alerts']],
'schedule' => ['label' => 'Schedule', 'prefixes' => ['/admin/schedule']],
'orders' => ['label' => 'Orders', 'prefixes' => ['/admin/work-orders', '/admin/csv-import']],
'orders' => ['label' => 'Orders', 'prefixes' => ['/admin/work-orders', '/admin/customers', '/admin/priority-rules', '/admin/csv-import']],
'production' => ['label' => 'Production', 'prefixes' => [
'/admin/product-types', '/admin/lot-sequences', '/admin/lines', '/admin/line-statuses',
'/admin/view-templates', '/admin/shifts',
Expand Down
6 changes: 5 additions & 1 deletion backend/resources/js/Pages/admin/schedule/Planner.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,16 +281,20 @@ export default function Planner() {
setTrackingData(null);
if (!trackId) return undefined;
let alive = true;
let t = null;
const fetchIt = async () => {
try {
const r = await apiGet(`/admin/schedule/check-updates?track=${trackId}`);
// Session expired → stop this 5s poll instead of 401ing forever
// on a left-open planner tab (a real action will redirect to login).
if (r.status === 401 || r.status === 419) { clearInterval(t); return; }
if (!r.ok) return;
const d = await r.json();
if (alive && d.tracked_order) setTrackingData(d.tracked_order);
} catch { /* silent */ }
};
fetchIt();
const t = setInterval(fetchIt, 5000);
t = setInterval(fetchIt, 5000);
return () => { alive = false; clearInterval(t); };
}, [trackId]);

Expand Down
7 changes: 6 additions & 1 deletion backend/resources/js/components/LiveRefresh.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,16 @@ export default function LiveRefresh({

useEffect(() => {
if (!enabled || !pollUrl) return undefined;
let t = null;
const tick = async () => {
try {
const r = await fetch(pollUrl, {
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
});
// Session gone (expired / logged out in another tab). Keep polling
// and every tick 401s forever — a left-open tab quietly generates
// hundreds of errors. Stop; a real navigation will bounce to login.
if (r.status === 401 || r.status === 419) { clearInterval(t); return; }
if (!r.ok) return;
const d = await r.json();
if (!d.last_updated) return;
Expand All @@ -49,7 +54,7 @@ export default function LiveRefresh({
}
} catch { /* silent — try again next tick */ }
};
const t = setInterval(tick, intervalMs);
t = setInterval(tick, intervalMs);
return () => clearInterval(t);
}, [enabled, pollUrl, intervalMs]);

Expand Down
15 changes: 15 additions & 0 deletions backend/tests/Feature/Web/TabAccessTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Tests\Feature\Web;

use App\Models\User;
use App\Support\TabRegistry;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia;
use Spatie\Permission\Models\Role;
Expand Down Expand Up @@ -48,6 +49,20 @@ public function test_supervisor_without_grant_is_forbidden(): void
$this->actingAs($this->supervisor)->get('/admin/users')->assertForbidden();
}

public function test_customers_and_priority_rules_are_reachable_with_the_orders_tab(): void
{
// Regression: these sat outside every tab prefix, so tab.access treated
// them as Admin-only (403) while the sidebar still showed them under the
// Orders group to any tab:orders holder — a link that 403s on click. They
// now resolve to the Orders tab, matching where the nav puts them.
$this->assertSame('orders', TabRegistry::tabForPath('/admin/customers'));
$this->assertSame('orders', TabRegistry::tabForPath('/admin/priority-rules'));

// Supervisor holds tab:orders by default → both are now reachable (was 403).
$this->actingAs($this->supervisor)->get('/admin/customers')->assertOk();
$this->actingAs($this->supervisor)->get('/admin/priority-rules')->assertOk();
}

public function test_granting_a_tab_lets_the_role_in(): void
{
// hr is not a Supervisor default → granting it opens HR pages.
Expand Down
Loading