Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
7edb8aa
feat: work orders, material lots and pallets
Svannte Sep 22, 2026
1049d49
feat: unit labels, station
svantedev Sep 24, 2026
efb94c3
refactor(users): move user administration validation into Form Requests
jakub-przepiora Sep 25, 2026
72f5b9f
feat(extension): let a module add fields to the user and worker forms
jakub-przepiora Sep 25, 2026
eebdf69
feat(extension): render and store the form fields a module contributes
jakub-przepiora Sep 25, 2026
fa94c88
Merge pull request #313 from Mes-Open/feat/user-form-requests
jakub-przepiora Sep 25, 2026
443e43a
docs: say that English-first covers pull requests, issues and commits
jakub-przepiora Sep 25, 2026
cbcb297
fix(packaging): honour the scanner mode setting, and extract the read…
jakub-przepiora Sep 25, 2026
9070157
Merge pull request #316 from Mes-Open/feat/module-form-field-hooks
jakub-przepiora Sep 26, 2026
cc8609d
Merge pull request #314 from Mes-Open/feat/scan-buffer-hook
jakub-przepiora Sep 26, 2026
82f8ca6
Merge pull request #317 from Mes-Open/docs/english-first-covers-publi…
jakub-przepiora Sep 26, 2026
0175879
feat(modules): enforce requires_core, and run a module's uninstall hook
jakub-przepiora Sep 26, 2026
eff476b
Merge pull request #318 from Mes-Open/feat/module-lifecycle-guards
jakub-przepiora Sep 26, 2026
c453a09
feat(extension): let a module reach the operator's station screen
jakub-przepiora Sep 26, 2026
7c793ed
Merge pull request #319 from Mes-Open/feat/operator-station-hook
jakub-przepiora Sep 26, 2026
abe9d3b
feat(modules): operator-panel tabs, module translations, import entit…
JanKolo04 Sep 17, 2026
a2de4ef
feat: hooks in core fo modules
JanKolo04 Sep 27, 2026
61db42f
fix(extension): address review findings on the module seams
JanKolo04 Sep 27, 2026
cb450da
Merge pull request #321 from Mes-Open/feat/module-operator-hooks
jakub-przepiora Sep 28, 2026
992b1c2
fix(docker): take the RoadRunner binary from its image, not from GitHub
jakub-przepiora Sep 28, 2026
8a0926b
Merge pull request #322 from Mes-Open/fix/roadrunner-binary-without-g…
jakub-przepiora Sep 28, 2026
e3eb09f
fix(release): stop the package from stripping the Modules admin screen
jakub-przepiora Sep 28, 2026
2b481b1
feat(install): ask about usage reports in the install scripts
jakub-przepiora Sep 28, 2026
d200222
Load module pages at runtime, without rebuilding the frontend
jakub-przepiora Sep 28, 2026
64453b3
Merge pull request #323 from Mes-Open/fix/release-package-strips-modu…
jakub-przepiora Sep 28, 2026
9170490
Merge pull request #324 from Mes-Open/feat/ask-about-telemetry-in-ins…
jakub-przepiora Sep 28, 2026
efd34c4
Merge pull request #325 from Mes-Open/feat/module-runtime
jakub-przepiora Sep 28, 2026
1a339fb
Keep a failing module from taking the application down
jakub-przepiora Sep 28, 2026
476f639
Merge pull request #326 from Mes-Open/fix/module-enable-lifecycle
jakub-przepiora Sep 28, 2026
722626b
feat: tracebility
Svannte Sep 29, 2026
70362c1
merge
Svannte Sep 29, 2026
fbfc35c
Merge pull request #327 from Mes-Open/dev/tracebility
jakub-przepiora Sep 29, 2026
b2bfaf4
docs(changelog): record the two module changes that shipped without a…
jakub-przepiora Sep 29, 2026
652c01c
feat: import log
Svannte Sep 29, 2026
449f9b3
Merge pull request #328 from Mes-Open/dev/tracebility
Svannte Sep 29, 2026
81f87ae
Merge pull request #329 from Mes-Open/docs/changelog-gaps
jakub-przepiora Sep 29, 2026
2abef83
chore(release): 0.25.0
jakub-przepiora Sep 29, 2026
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
42 changes: 36 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,21 @@ jobs:
# along in a release built from it. The three bundled examples are
# tracked source and stay — the include rules come first because rsync
# takes the first matching rule.
#
# Every pattern is ANCHORED with a leading slash, meaning
# backend/modules and nothing else. Without it rsync matches a
# directory called `modules` at any depth, and this repository has a
# second one: resources/js/Pages/admin/modules. Unanchored, these rules
# stripped the Modules admin screen out of every release — so the one
# screen that installs a module was missing from the package, and the
# UI answered "Page unavailable" with no way to tell why.
rsync -a backend/ "${DIST}/backend/" \
--include='modules/' \
--include='modules/README.md' \
--include='modules/ExampleHooks/***' \
--include='modules/ExampleShowcase/***' \
--include='modules/OrderPinger/***' \
--exclude='modules/*' \
--include='/modules/' \
--include='/modules/README.md' \
--include='/modules/ExampleHooks/***' \
--include='/modules/ExampleShowcase/***' \
--include='/modules/OrderPinger/***' \
--exclude='/modules/*' \
--exclude='.env' \
--exclude='node_modules' \
--exclude='tests' \
Expand All @@ -172,6 +180,28 @@ jobs:
mkdir -p "${DIST}/backend/storage/framework/views"
touch "${DIST}/backend/storage/logs/.gitkeep"

# Every tracked file under backend/resources/ has to be in the package.
#
# This is the check that was missing when an unanchored rsync exclude
# quietly dropped resources/js/Pages/admin/modules — three files out of
# 228, in a package nobody counts by hand. The Dockerfile check below
# did not catch it: the directory it copies was there, only emptied.
#
# resources/ is entirely source that ships; anything excluded from it
# is a mistake by definition, which is what makes this safe to assert.
missing_resources=0
while read -r tracked; do
rel="${tracked#backend/}"
if [ ! -e "${DIST}/backend/${rel}" ]; then
echo "::error::${tracked} is tracked but missing from the release package"
missing_resources=$((missing_resources + 1))
fi
done < <(git ls-files backend/resources)
if [ "$missing_resources" -ne 0 ]; then
echo "::error::${missing_resources} tracked file(s) under backend/resources did not make it into the package"
exit 1
fi

# The package promises `docker compose up -d --build` works from the
# unpacked folder, so every path the Dockerfile copies has to be in it.
# Nothing compared these two files before, which is how v0.22.0 shipped
Expand Down
323 changes: 323 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d # dev ove

## Hard rules

1. **English-first** — all code, Blade/JSX text, validation messages, seeders, comments are English. Other languages exist only as translations in `backend/lang/*.json`.
1. **English-first** — all code, Blade/JSX text, validation messages, seeders, comments are English. Other languages exist only as translations in `backend/lang/*.json`. This covers **everything published to this repository**, not only code: pull request titles *and* descriptions, issue comments, commit messages, README and docs. This repo is public and read by people who do not share your first language.
2. **i18n parity** — `lang/en.json` and `lang/pl.json` must contain the same key set. Adding a UI string means adding the key to **both** files (English value = key itself in `en.json`). These are strict JSON: **no trailing comma** on the last entry, or `json_decode` fails and the whole UI silently falls back to English. New keys are appended, so the files conflict on almost every merge — resolve with `.claude/skills/i18n-lang-files/`, never by hand-editing the conflict markers (a text-level union resurrects deliberately deleted keys and duplicates others).
3. **Form Requests for validation** — never validate inline in controllers. Frontend validation is UX only; the backend rule set is authoritative.
4. **Never rename migration filenames after merge** — the filename is the migration's identity; renaming breaks every existing database on upgrade (duplicate-table crash in the entrypoint migrate).
Expand Down
141 changes: 141 additions & 0 deletions HOOKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Three reference modules ship in the repo (all disabled by default):
- [Scheduling hook — `WorkOrderScheduled`](#scheduling-hook--workorderscheduled)
- [2. Menu hooks](#2-menu-hooks)
- [3. Dashboard widget hooks](#3-dashboard-widget-hooks)
- [4. Page display hooks and filters](#4-page-display-hooks-and-filters)
- [Enabling a module](#enabling-a-module)
- [Best practices](#best-practices)
- [Complete hook reference](#complete-hook-reference)
Expand Down Expand Up @@ -227,6 +228,63 @@ $menu->addGroupItem('yourmod', 'Overview', url('/modules/your-module'), order: 1
Resolve URLs with `url()` (not `route()`) so registration never depends on route
load order or a cached route table.

Every entry a module contributes is tinted with the accent colour in the sidebar,
and lights up as active on its own pages (the registered URL is matched by path).

### Operator panel tabs

A module that ships an operator screen registers it as a tab on the operator
top bar, next to Queue / Workstation:

```php
// label, url, order (built-in tabs are 10 and 20), optional path prefix that
// keeps the tab highlighted (defaults to the link's own path).
$menu->addOperatorItem('Team', url('/operator/team'), order: 30);
```

Operator tabs are Inertia links — the page behind them is a React page under the
module's `resources/js/Pages/`. They arrive in the browser as `moduleNav.operator`
and render tinted like sidebar entries.

### Translations

A module's own strings live in `modules/<Name>/lang/<locale>.json` (the same
source-string-keyed shape as core's `lang/*.json`). The frontend merges them
under the core file at bootstrap — a module can add strings, never redefine a
core one — and the provider loads the same directory for PHP:

```php
$this->loadJsonTranslationsFrom(__DIR__.'/../lang');
```

### Import entities

`ImportRegistry` (Admin → Import) accepts importers from modules through the
`import.entities` filter:

```php
app(FilterRegistry::class)->addFilter('import.entities', fn ($e) => [...$e, MyImporter::class]);
```

`MyImporter` extends `App\Import\AbstractEntityImporter`; the screen, the queued
job, the sample file and the routes pick it up.

### Testing a module

`Tests\Support\ModuleTestCase` registers the module's provider on each test's
fresh application and migrates the module's directory inside the test
transaction (the suite's `migrate:fresh` runs before any provider boots, so a
module's tables are never part of that schema):

```php
class MyModuleTest extends \Tests\Support\ModuleTestCase
{
protected string $module = 'MyModule';
protected string $provider = \Modules\MyModule\Providers\MyModuleServiceProvider::class;
protected string $probeTable = 'my_module_things';
}
```

## 3. Dashboard widget hooks

`App\Services\WidgetRegistry` lets a module add cards to the admin dashboard.
Expand All @@ -251,6 +309,81 @@ KPIs; `main` renders as a full-width card at the bottom of the dashboard column.

---

## 4. Page display hooks and filters

`App\Extension\HookRegistry` carries contributions to named points on a page; the
controller that owns the page resolves its points with `renderMany()` and hands them
over as the `hooks` prop, and the page renders `<Hook name=… hooks={hooks} …context />`
from `resources/js/lib/hooks.jsx`. `App\Extension\FilterRegistry` lets a module
change a value core computed. With no module listening, a page is sent `{}` and every
filter returns its default — a community install renders exactly what it did before.

A point that **replaces** a core control (marked below) checks `hasHook()` first and
skips its own control when a module contributed.

```php
app(HookRegistry::class)->listen('display.operator.work_order.sections', fn (array $ctx) => [
'title' => 'Recipe vs weighed',
'body' => Recipe::summary($ctx['workOrderId']),
]);

app(FilterRegistry::class)->addFilter('operator.can_logout', fn (bool $can, array $ctx) => $can && ! Panel::isShared($ctx['user']));
```

> A `component` (`ext:<Dir>/<Name>`, resolved under
> `modules/<Name>/resources/js/Components/`) only exists in a build that contained
> the module. A module installed from a ZIP into a released install must contribute
> the plain card fields instead.

### Display hooks

```
display.operator.workstation.shift_cell context: entry, workOrder, shift, canCorrect (replaces the whole shift cell)
display.operator.work_order.sections context: workOrder (rendered after the BOM section)
display.operator.quantity_field props: value, onChange, variant (replaces the operator's number input, page-wide)
display.operator.layout (no context) (rendered at the top of every operator screen)
display.settings.system.tabs contribution: slot, title, component (one tab each on Settings → System)
```

The PHP-side context a listener receives:

| Hook | Resolved by | PHP context |
|---|---|---|
| `display.operator.workstation.shift_cell` | `Operator\WorkstationController::index` | `line`, `workstation`, `lineId`, `workstationId` |
| `display.operator.work_order.sections` | `Operator\WorkOrderController::show` | `workOrderId`, `workstationId` |
| `display.operator.quantity_field` | `WorkstationController::index`, `WorkOrderController::show` + `::queue` | same as the page's other points |
| `display.operator.layout` | `HandleInertiaRequests` (shared `operatorHooks`) | `user` |
| `display.settings.system.tabs` | `SettingsController::showSystemSettings` | — |

Notes:

- **`quantity_field`** — `QuantityField.jsx` renders the first contribution's
component with `value`, `onChange(value)` (the value, not an event), `variant`
(`big` for a modal's single field, `compact` inline) and every other input prop
(`min`, `max`, `step`, `aria-label`, …) passed through.
- **`settings.system.tabs`** — each contribution becomes a tab with value
`ext-<slot>` (linkable as `?tab=ext-<slot>`) labelled `title`. Its component
renders outside the core settings form, so core's Save button is not shown
there: the module saves through its own route.
- **`operator.layout`** is resolved on every Inertia response, like any shared
prop — a listener should return `null` cheaply where it has nothing to show.

### Filters

```
operator.tabs list of {key, label, url, prefixes} the operator top bar's core tabs
operator.module_tabs list of {label, url, order, prefix} module tabs, per request
operator.can_logout bool (default true) whether the operator chrome shows its logout button
```

`operator.tabs` and `operator.can_logout` receive `['user' => $user]` as context.
`operator.module_tabs` runs over what `addOperatorItem()` registered, on every
request, so a module can show a tab only where it applies without re-registering.
`operator.can_logout` hides the button only — a module that forbids logout must
still refuse the `POST /logout` itself.

---

## Enabling a module

1. **Admin → Modules → your module → Enable** (or add its name to the
Expand Down Expand Up @@ -311,6 +444,14 @@ class SyncToErp implements ShouldQueue
**Widgets** (`App\Services\WidgetRegistry`): `register` — zones `kpi`, `main`,
`sidebar`.

**Display hooks** (`App\Extension\HookRegistry`): `display.operator.workstation.actor`,
`display.operator.workstation.shift_cell`, `display.operator.work_order.sections`,
`display.operator.quantity_field`, `display.operator.layout`,
`display.settings.system.tabs`.

**Filters** (`App\Extension\FilterRegistry`): `operator.tabs`,
`operator.module_tabs`, `operator.can_logout`, `import.entities`.

---

## Support
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ Dedicated station for scanning finished products with a barcode reader (EAN/QR)

**How it works:**

1. Operator opens `/packaging/station` on a dedicated workstation or tablet
1. Operator opens `/operator/packaging` on a dedicated workstation or tablet (supervisors and admins reach the same screen at `/packaging/station`)
2. Scans an EAN barcode with a USB/Bluetooth reader (or types it manually)
3. The system looks up which work order the EAN belongs to and increments its `packed_qty` counter
4. Live stats update every 3 seconds: packed today, plan, backlog, realisation %
Expand All @@ -187,7 +187,8 @@ Dedicated station for scanning finished products with a barcode reader (EAN/QR)

| URL | Access | Description |
|---|---|---|
| `/packaging/station` | Operator, Supervisor, Admin | Scanning station |
| `/operator/packaging` | Operator | Scanning station (operator shell) |
| `/packaging/station` | Supervisor, Admin | Scanning station (admin shell; operators are redirected to `/operator/packaging`) |
| `/packaging/` | Supervisor, Admin | Admin overview |
| `/packaging/eans` | Supervisor, Admin | EAN code management |

Expand Down
5 changes: 5 additions & 0 deletions backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,16 @@
/auth.json
/node_modules
/public/build
# Compiled frontends of installed modules, copied here by ModuleManager when a
# module is enabled. Belongs to the installation, not to the repository.
/public/modules
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
# Generated on every Vite build by openmesRuntime() from packages/module/contract.js.
/resources/js/runtime.generated.js
Homestead.json
Homestead.yaml
Thumbs.db
Expand Down
18 changes: 14 additions & 4 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,20 @@ COPY packages/ /var/www/packages/
# Install PHP dependencies
RUN composer install --no-dev --optimize-autoloader --no-interaction

# Fetch the RoadRunner binary for Laravel Octane (concurrent app server).
# Placed in the project root — where Octane's RoadRunner server discovers it.
RUN ./vendor/bin/rr get-binary --location /var/www/html \
&& chmod +x /var/www/html/rr
# The RoadRunner binary for Laravel Octane (concurrent app server), taken from
# the official image rather than downloaded during the build.
#
# `vendor/bin/rr get-binary` asks api.github.com for the release list, which
# makes every build depend on GitHub being reachable and on DNS answering at
# that moment. Five services in docker-compose.yml build from this Dockerfile,
# so a single `up --build` made that call several times over in parallel — enough
# for a home router's resolver to return NODATA and fail the install outright.
#
# The version is pinned to match `spiral/roadrunner` in composer.lock. Bump both
# together: Octane loads the PHP side from vendor and executes this binary, and
# they are meant to be the same release.
COPY --from=ghcr.io/roadrunner-server/roadrunner:2025.1.14 /usr/bin/rr /var/www/html/rr
RUN chmod +x /var/www/html/rr

# Install Node dependencies and build assets
RUN npm ci && npm run build
Expand Down
Loading
Loading