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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
305 changes: 298 additions & 7 deletions CHANGELOG.md

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,21 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d # dev ove
- New pages are React/Inertia (`backend/resources/js/Pages/...`); legacy Blade+Livewire pages still exist and are being ported — don't add new Blade pages.
- Config-driven CRUD: `ResourceTable` (list, fed by a synced collection) + `ResourceForm` (create/edit via Inertia `useForm`). Custom forms only when those don't fit.
- React escaping is the XSS defense — `dangerouslySetInnerHTML` is effectively banned.
- **Telling the user a write worked** — three mechanisms, pick by whether the page navigates:
- **Redirecting (the usual CRUD case) → flash.** `->with('success'|'error', …)` on the
redirect; `FlashMessages` in `AppLayout` renders it. The page needs no code. This is what
~86 controllers already do. It sits in the document flow, so it shifts the page down —
fine after a navigation, wrong under a rapid in-place interaction.
- **Staying on the page → `useToast()`** (`@openmes/ui`, provider already in `AppLayout`;
`Snackbar` is its native twin). Portaled, auto-dismisses, no layout shift. For actions
that write over `fetch`/`apiCall` — typically because the rows are a synced collection
and the screen updates itself.
**A toast does not survive an Inertia visit**: the visit remounts `AppLayout` and the
provider with it, so `router.post(…, { onSuccess: () => toast(…) })` silently shows
nothing. Navigate → flash. Toast → don't navigate.
- **Message belonging to one form or section → `InlineAlert`.** Stays where you put it.
- Field-level 422s need none of these: `ResourceForm` already prints each error under its
own field.

## Workflow

Expand Down
49 changes: 49 additions & 0 deletions backend/app/Http/Controllers/Concerns/StaysOnList.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

namespace App\Http\Controllers\Concerns;

use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

/**
* For a controller whose list page creates and edits in a drawer.
*
* A drawer posts from the list it is sitting on. Answering with the usual
* redirect to that same list is not a no-op: Inertia treats it as a visit, the
* page component remounts, and the user loses their search, their column
* filters, the page they were on and their scroll position — all of it client
* state in `DataTable`, none of it in the URL. So the drawer sends `stay` and
* the controller answers `back()` instead, leaving the page alone while the new
* or changed row live-syncs in on its own.
*
* `back()` is still a visit, which is what keeps the flash working: the message
* is read by `FlashMessages` in `AppLayout` exactly as it is after a redirect.
* Don't reach for `useToast()` here — a toast does not survive an Inertia visit
* (the provider remounts with the layout), which is the whole reason the two
* mechanisms are separate.
*
* The standalone `/create` and `/edit` pages post without `stay`, so they keep
* redirecting to the list the way they always have.
*/
trait StaysOnList
{
/**
* Finish a write: back to the list page if the caller asked to stay put,
* otherwise wherever the standalone form would have gone.
*
* return $this->saved($request, redirect()->route('admin.areas.index'),
* __('Area created successfully.'));
*
* `$onward` is a built response rather than a route name so this fits every
* shape already in use — `route()`, `to()`, and the `sectionRoute()` URLs a
* controller serving both sections has to build for itself.
*/
protected function saved(Request $request, RedirectResponse $onward, string $message): RedirectResponse
{
if ($request->boolean('stay')) {
return back()->with('success', $message);
}

return $onward->with('success', $message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@

namespace App\Http\Controllers\Web\Admin;

use App\Http\Controllers\Concerns\StaysOnList;
use App\Http\Controllers\Controller;
use App\Models\AnomalyReason;
use Illuminate\Http\Request;
use Inertia\Inertia;

class AnomalyReasonController extends Controller
{
use StaysOnList;

/**
* Display a listing of anomaly reasons. Rows live-sync via the
* `anomaly_reasons` shape; usage counts come as a prop.
Expand Down Expand Up @@ -49,8 +52,7 @@ public function store(Request $request)

AnomalyReason::create($validated);

return redirect()->route('admin.anomaly-reasons.index')
->with('success', 'Anomaly reason created successfully.');
return $this->saved($request, redirect()->route('admin.anomaly-reasons.index'), 'Anomaly reason created successfully.');
}

/**
Expand Down Expand Up @@ -80,8 +82,7 @@ public function update(Request $request, AnomalyReason $anomalyReason)

$anomalyReason->update($validated);

return redirect()->route('admin.anomaly-reasons.index')
->with('success', 'Anomaly reason updated successfully.');
return $this->saved($request, redirect()->route('admin.anomaly-reasons.index'), 'Anomaly reason updated successfully.');
}

/**
Expand Down
34 changes: 20 additions & 14 deletions backend/app/Http/Controllers/Web/Admin/AreaController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Http\Controllers\Web\Admin;

use App\Http\Controllers\Concerns\StaysOnList;
use App\Http\Controllers\Controller;
use App\Models\Area;
use App\Models\Site;
Expand All @@ -12,6 +13,8 @@

class AreaController extends Controller
{
use StaysOnList;

/**
* List areas (optionally scoped to a single site).
*
Expand All @@ -25,15 +28,17 @@ public function index(Request $request, ?Site $site = null)
return Inertia::render('admin/areas/Index', [
'counts' => $counts,
'siteNames' => $siteNames,
// Option lists for the list page's create/edit drawer. Optional, so the
// queries only run once someone opens it — most visits never do.
'sites' => Inertia::optional(fn () => $this->siteOptions()),
'customFields' => Inertia::optional(fn () => app(CustomFieldService::class)->clientConfig('area')),
]);
}

public function create(?Site $site = null)
{
$sites = \App\Models\Site::active()->orderBy('name')->get(['id', 'name']);

return Inertia::render('admin/areas/Create', [
'sites' => $sites,
'sites' => $this->siteOptions(),
'customFields' => app(CustomFieldService::class)->clientConfig('area'),
]);
}
Expand All @@ -56,13 +61,11 @@ public function store(Request $request, ?Site $site = null)

Area::create($validated);

if ($site && $site->exists) {
return redirect()->route('admin.sites.show', $site)
->with('success', 'Area created successfully.');
}
$onward = $site && $site->exists
? redirect()->route('admin.sites.show', $site)
: redirect()->route('admin.areas.index');

return redirect()->route('admin.areas.index')
->with('success', 'Area created successfully.');
return $this->saved($request, $onward, 'Area created successfully.');
}

public function show(Area $area)
Expand Down Expand Up @@ -91,11 +94,9 @@ public function show(Area $area)

public function edit(Area $area)
{
$sites = \App\Models\Site::active()->orderBy('name')->get(['id', 'name']);

return Inertia::render('admin/areas/Edit', [
'area' => $area->only('id', 'site_id', 'code', 'name', 'description', 'is_active', 'custom_fields'),
'sites' => $sites,
'sites' => $this->siteOptions(),
'customFields' => app(CustomFieldService::class)->clientConfig('area'),
]);
}
Expand All @@ -112,8 +113,7 @@ public function update(Request $request, Area $area)

$area->update($validated);

return redirect()->route('admin.areas.index')
->with('success', 'Area updated successfully.');
return $this->saved($request, redirect()->route('admin.areas.index'), 'Area updated successfully.');
}

public function destroy(Area $area)
Expand All @@ -139,6 +139,12 @@ public function toggleActive(Area $area)
->with('success', "Area {$status} successfully.");
}

/** Site dropdown for the create/edit form, wherever it's rendered. */
private function siteOptions()
{
return Site::active()->orderBy('name')->get(['id', 'name']);
}

private function validatePayload(Request $request, ?Area $area = null): array
{
$cf = app(CustomFieldService::class);
Expand Down
9 changes: 5 additions & 4 deletions backend/app/Http/Controllers/Web/Admin/CompanyController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@

namespace App\Http\Controllers\Web\Admin;

use App\Http\Controllers\Concerns\StaysOnList;
use App\Http\Controllers\Controller;
use App\Models\Company;
use Illuminate\Http\Request;
use Inertia\Inertia;

class CompanyController extends Controller
{
use StaysOnList;

/**
* Display a listing of companies.
*/
Expand Down Expand Up @@ -45,8 +48,7 @@ public function store(Request $request)

Company::create($validated);

return redirect()->route('admin.companies.index')
->with('success', 'Company created successfully.');
return $this->saved($request, redirect()->route('admin.companies.index'), 'Company created successfully.');
}

/**
Expand Down Expand Up @@ -79,8 +81,7 @@ public function update(Request $request, Company $company)

$company->update($validated);

return redirect()->route('admin.companies.index')
->with('success', 'Company updated successfully.');
return $this->saved($request, redirect()->route('admin.companies.index'), 'Company updated successfully.');
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Http\Controllers\Web\Admin;

use App\Http\Controllers\Concerns\StaysOnList;
use App\Http\Controllers\Controller;
use App\Http\Requests\Web\Admin\StoreCostSourceRequest;
use App\Http\Requests\Web\Admin\UpdateCostSourceRequest;
Expand All @@ -10,6 +11,8 @@

class CostSourceController extends Controller
{
use StaysOnList;

/**
* Display a listing of cost sources. Rows live-sync via the
* `cost_sources` shape; usage counts come as a prop.
Expand Down Expand Up @@ -40,8 +43,7 @@ public function store(StoreCostSourceRequest $request)
{
CostSource::create($request->validated());

return redirect()->route('admin.cost-sources.index')
->with('success', 'Cost source created successfully.');
return $this->saved($request, redirect()->route('admin.cost-sources.index'), 'Cost source created successfully.');
}

/**
Expand All @@ -61,8 +63,7 @@ public function update(UpdateCostSourceRequest $request, CostSource $costSource)
{
$costSource->update($request->validated());

return redirect()->route('admin.cost-sources.index')
->with('success', 'Cost source updated successfully.');
return $this->saved($request, redirect()->route('admin.cost-sources.index'), 'Cost source updated successfully.');
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Http\Controllers\Web\Admin;

use App\Http\Controllers\Concerns\StaysOnList;
use App\Http\Controllers\Controller;
use App\Http\Requests\CrewBreakWindowRequest;
use App\Models\Crew;
Expand All @@ -10,6 +11,8 @@

class CrewBreakWindowController extends Controller
{
use StaysOnList;

/**
* Display a listing of break windows. Rows live-sync via the
* `crew_break_windows` shape; crew names come as a prop (the shape only
Expand All @@ -19,6 +22,9 @@ public function index()
{
return Inertia::render('admin/crew-break-windows/Index', [
'crewNames' => Crew::orderBy('name')->pluck('name', 'id'),
// Option lists for the list page's create/edit drawer. Optional, so the
// queries only run once someone opens it — most visits never do.
'crews' => Inertia::optional(fn () => Crew::active()->orderBy('name')->get(['id', 'name'])),
]);
}

Expand All @@ -33,8 +39,7 @@ public function store(CrewBreakWindowRequest $request)
{
CrewBreakWindow::create($this->payload($request));

return redirect()->route('admin.crew-break-windows.index')
->with('success', __('Break window created successfully.'));
return $this->saved($request, redirect()->route('admin.crew-break-windows.index'), __('Break window created successfully.'));
}

public function edit(CrewBreakWindow $crewBreakWindow)
Expand All @@ -57,8 +62,7 @@ public function update(CrewBreakWindowRequest $request, CrewBreakWindow $crewBre
{
$crewBreakWindow->update($this->payload($request));

return redirect()->route('admin.crew-break-windows.index')
->with('success', __('Break window updated successfully.'));
return $this->saved($request, redirect()->route('admin.crew-break-windows.index'), __('Break window updated successfully.'));
}

public function destroy(CrewBreakWindow $crewBreakWindow)
Expand Down
29 changes: 24 additions & 5 deletions backend/app/Http/Controllers/Web/Admin/CrewController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@

namespace App\Http\Controllers\Web\Admin;

use App\Http\Controllers\Concerns\StaysOnList;
use App\Http\Controllers\Controller;
use App\Models\Crew;
use App\Models\Division;
use App\Models\Line;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;

class CrewController extends Controller
{
use StaysOnList;

/**
* Display a listing of crews.
*/
Expand All @@ -26,6 +30,18 @@ public function index(Request $request)
'counts' => $counts,
'divisionNames' => $divisionNames,
'leaderNames' => $leaderNames,
// Option lists for the list page's create/edit drawer. Optional, so the
// queries only run once someone opens it — most visits never do.
'divisions' => Inertia::optional(fn () => Division::active()->orderBy('name')->get(['id', 'name'])),
'users' => Inertia::optional(fn () => User::orderBy('name')->get(['id', 'name'])),
'lines' => Inertia::optional(fn () => Line::where('is_active', true)->orderBy('name')->get(['id', 'name'])),
// Line assignments are a pivot, so they're absent from the `crews`
// collection the list rows come from — the drawer can't read them off
// the record the way it reads every other field.
'crewLines' => Inertia::optional(fn () => DB::table('crew_line')
->get(['crew_id', 'line_id'])
->groupBy('crew_id')
->map(fn ($rows) => $rows->pluck('line_id')->all())),
]);
}

Expand Down Expand Up @@ -66,8 +82,7 @@ public function store(Request $request)
$crew = Crew::create(Arr::except($validated, 'line_ids'));
$crew->lines()->sync($request->input('line_ids', []));

return redirect()->route('admin.crews.index')
->with('success', 'Crew created successfully.');
return $this->saved($request, redirect()->route('admin.crews.index'), 'Crew created successfully.');
}

/**
Expand Down Expand Up @@ -109,10 +124,14 @@ public function update(Request $request, Crew $crew)
$validated['is_active'] = $request->boolean('is_active');

$crew->update(Arr::except($validated, 'line_ids'));
$crew->lines()->sync($request->input('line_ids', []));
// Only touch the pivot when the caller actually sent it. Defaulting to []
// here would let any partial update — a form that doesn't carry the field —
// silently detach every line the crew is assigned to.
if ($request->has('line_ids')) {
$crew->lines()->sync($request->input('line_ids', []));
}

return redirect()->route('admin.crews.index')
->with('success', 'Crew updated successfully.');
return $this->saved($request, redirect()->route('admin.crews.index'), 'Crew updated successfully.');
}

/**
Expand Down
Loading
Loading