Builds locations, items, per-location stock, and a stock-movement audit
ledger (receive/withdraw/adjust) on top of the existing hand-rolled MVC
framework, gated behind the existing user login. Low-stock withdrawals/
adjustments queue an email alert. Adds a custom "positive_integer"
validation rule and a generic SMTP email driver (alongside the existing
Postmark one) to demonstrate the framework's documented extension points.
Removes the Product/Order rocket-shop demo and renumbers the surviving
migrations (users/profiles/jobs) since nothing had been deployed from the
single "Initial commit" yet.
Also fixes local dev environment issues hit while building this: Windows
reserves the original 8081/8081 Docker port mapping and 9090 in its dynamic
TCP exclusion range (now 9092/9091, documented in the readme), and
docker/start-container.sh had CRLF line endings from a Windows checkout that
broke its shebang inside the container (.gitattributes now forces *.sh to
LF).
Adds AGENTS.md/CLAUDE.md documenting environment gotchas and two real
framework bugs found along the way (Route::matches() not checking HTTP
method for parameterised paths, and required validation rejecting "0" due
to PHP's empty("0") === true) so they aren't re-discovered from scratch.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
master
| @@ -8,6 +8,18 @@ DB_DATABASE=pro-php-mvc | |||||
| DB_USERNAME=root | DB_USERNAME=root | ||||
| DB_PASSWORD= | DB_PASSWORD= | ||||
| EMAIL_TOKEN= | |||||
| EMAIL_DRIVER=smtp | |||||
| EMAIL_FROM_NAME= | EMAIL_FROM_NAME= | ||||
| EMAIL_FROM_EMAIL= | EMAIL_FROM_EMAIL= | ||||
| # smtp driver (default) - e.g. an internal relay/smart host | |||||
| EMAIL_SMTP_HOST= | |||||
| EMAIL_SMTP_PORT=25 | |||||
| EMAIL_SMTP_ENCRYPTION= | |||||
| EMAIL_SMTP_USERNAME= | |||||
| EMAIL_SMTP_PASSWORD= | |||||
| # postmark driver - only needed if EMAIL_DRIVER=postmark | |||||
| EMAIL_TOKEN= | |||||
| WAREHOUSE_ALERT_EMAIL= | |||||
| @@ -0,0 +1,3 @@ | |||||
| * text=auto eol=lf | |||||
| *.sh text eol=lf | |||||
| @@ -0,0 +1,154 @@ | |||||
| # Agent notes for this repo | |||||
| > Kept in sync with `CLAUDE.md` (same content, duplicated on purpose so both | |||||
| > Claude Code and other agent tooling pick it up automatically — update both | |||||
| > files together). | |||||
| Operational gotchas discovered while building the warehouse app on top of the | |||||
| hand-rolled framework in `framework/`. Read this before re-deriving any of it | |||||
| from scratch — most of it was expensive to find the first time. See | |||||
| `docs/framework.md` for how the framework itself works (routing, models, | |||||
| views, etc.) — this file is about the *environment* and *bugs*, not the | |||||
| framework's intended API. | |||||
| ## No local PHP/composer — everything runs through Docker | |||||
| There is no PHP or Composer on the host. Use: | |||||
| - `docker compose run --rm app <cmd>` for one-off commands (migrations, tests, scratch scripts) | |||||
| - `docker compose exec app <cmd>` against the running stack (`docker compose up -d`) | |||||
| `.env` is required and gitignored — copy it from `.env.example` before first run. | |||||
| `composer install` needs `--ignore-platform-reqs`. The committed `composer.lock` | |||||
| predates the container's PHP 8.2 image: `phpspec/prophecy` requires PHP `<8.1`, | |||||
| and `ext-zip` (needed by `dbrekelmans/bdi`/`php-webdriver`) isn't installed. | |||||
| This is a pre-existing gap, not something to "fix" by upgrading deps unless | |||||
| asked. | |||||
| ## Ports are a Windows-specific minefield | |||||
| Windows reserves TCP ports dynamically for Hyper-V/WSL2 NAT. A reserved port | |||||
| fails to bind with `ports are not available: ... forbidden by its access | |||||
| permissions` — this looks like "something else is using it" but isn't; it's | |||||
| an OS-level exclusion, not a process conflict. Check before picking a port: | |||||
| ``` | |||||
| netsh interface ipv4 show excludedportrange protocol=tcp | |||||
| ``` | |||||
| `8081`, `9090`, and others have been seen reserved on this machine already. | |||||
| Current mapping in `docker-compose.yml`: app → host `9092` (container `80`), | |||||
| phpLiteAdmin → host `9091` (container `8081`). The **container-side** ports | |||||
| must not change (`80` = nginx, `8081` = phpliteadmin's nginx vhost) — only | |||||
| remap the host side, and check the exclusion list first. | |||||
| ## `docker/start-container.sh` must stay LF | |||||
| A Windows checkout previously gave it CRLF line endings, which breaks its | |||||
| shebang inside the Linux container (`start-container: not found` in | |||||
| `docker logs pro-php-mvc-app`, crash-looping). `.gitattributes` now forces | |||||
| `*.sh` to LF on checkout. If this error reappears, run `file | |||||
| docker/start-container.sh` to confirm, then `sed -i 's/\r$//' | |||||
| docker/start-container.sh`. | |||||
| ## Stale root-owned cache files break the real app | |||||
| `docker compose run` (one-off containers) executes as root. The real `app` | |||||
| service's php-fpm workers run as `www-data`. If you compile views or write | |||||
| files via `docker compose run` and then the running app can't overwrite them | |||||
| (`Permission denied` in `AdvancedEngine::render()`, `storage/framework/views/`), | |||||
| it's stale root-owned `.php` cache files. Fix: delete them (gitignored, | |||||
| regenerable) and let `www-data` recreate them: | |||||
| ``` | |||||
| docker exec pro-php-mvc-app find /var/www/html/storage/framework/views -maxdepth 1 -name '*.php' -delete | |||||
| ``` | |||||
| ## Testing gotchas | |||||
| `phpunit.xml` registers `ServerExtension`, which **always** tries to | |||||
| auto-start `php command.php serve` before the suite (even for non-browser | |||||
| tests) and stop it after. This container has no `pcntl` extension, so both | |||||
| the start (`ServeCommand::handleSignals()` → `pcntl_async_signals()`) and the | |||||
| stop (`SIGTERM` constant) crash with a fatal error *after* the tests | |||||
| themselves have already run — the crash happens in teardown, so a `phpunit` | |||||
| run always exits non-zero even when everything passed. | |||||
| Workaround: bind a dummy TCP listener on the target `APP_HOST:APP_PORT` | |||||
| (check `docker exec <app> php -r "echo getenv('APP_HOST').':'.getenv('APP_PORT');"` | |||||
| — currently `0.0.0.0:80`) before running phpunit, so | |||||
| `ServerExtension::serverIsRunning()` sees it as already running and skips | |||||
| starting/stopping the real (broken) one: | |||||
| ``` | |||||
| docker compose run --rm app sh -c "php -r 'stream_socket_server(\"tcp://0.0.0.0:80\"); sleep(120);' & sleep 1 && vendor/bin/phpunit; kill %1 2>/dev/null; true" | |||||
| ``` | |||||
| `BrowserTest.php` (Symfony Panther/Firefox) will still error — there's no | |||||
| `geckodriver`/Firefox in this image. That's a pre-existing gap, not a | |||||
| regression to chase. | |||||
| `php command.php serve` is itself broken in this container for the same | |||||
| `pcntl` reason. Don't use it to "prove the app works" — hit a real running | |||||
| `docker compose up` stack over HTTP instead (curl, or a browser). | |||||
| ## Framework bugs found (work around at the app level — don't patch `framework/`) | |||||
| 1. **Router doesn't disambiguate by HTTP method for dynamic paths, and | |||||
| matches unanchored.** `Framework\Routing\Route::matches()` — once past the | |||||
| literal exact-match check, the regex built for a `{param}` route is | |||||
| checked with `preg_match_all` with no `^`/`$` anchors, and without | |||||
| checking `$method` at all. Consequences: | |||||
| - A shorter dynamic route (`/items/view/{item}`) matches as a *prefix* of | |||||
| any longer path (`/items/view/{item}/receive`) — whichever route is | |||||
| registered first wins, regardless of specificity or method. | |||||
| - Two routes on the *identical* dynamic path with different methods (GET | |||||
| show-form / POST submit) collide the same way — first-registered wins | |||||
| for both methods, so the POST silently runs the GET handler. | |||||
| **Rule of thumb:** every dynamic (`{param}`) route in `app/routes.php` | |||||
| needs a fully unique literal path. Never nest one dynamic route's path | |||||
| under another's, and never pair GET+POST on the same dynamic path. Static | |||||
| (no `{}`) paths are unaffected — they only ever use the exact-match | |||||
| branch. See the stock-action routes in `app/routes.php` (and the comment | |||||
| above them) for the working pattern: | |||||
| `/items/receive-form/{item}` (GET) vs `/items/receive/{item}` (POST), etc. | |||||
| 2. **PHP's `empty("0")` is `true`.** The framework's `required` rule | |||||
| (`RequiredRule::validate`) is `!empty($data[$field])`, so a legitimate | |||||
| `"0"` value fails `required`. Don't use `required` on a field where `0` is | |||||
| valid (e.g. "set stock to zero") — validate presence manually instead. See | |||||
| `AdjustStockController::handle()` for the pattern. | |||||
| 3. **No `ORDER BY` / aggregate support** in `Framework\Database\QueryBuilder`. | |||||
| Sort/sum in PHP after `->all()`. | |||||
| 4. **No unique constraints** in the migration DSL. Uniqueness (e.g. | |||||
| `items.sku`, `locations.code`) is not enforced anywhere — enforce at the | |||||
| app level if it starts to matter. | |||||
| Two drivers: `postmark` (API-based) and `smtp` (generic | |||||
| `Swift_SmtpTransport` — no new dependency, ships with `swiftmailer/swiftmailer` | |||||
| already). Switch via `EMAIL_DRIVER` in `.env`; `config/email.php` defaults to | |||||
| `smtp`. | |||||
| Emails are **always queued** (`app('queue')->push(...)`), never sent | |||||
| synchronously. Nothing processes the queue automatically — run | |||||
| `docker compose exec app php command.php queue:work` (blocks, polls every | |||||
| second) or jobs just sit in the `jobs` table forever. | |||||
| ## Domain model | |||||
| - `locations`, `items`, `stock` (one row per item+location, found-or-created | |||||
| via `Stock::forItemAtLocation()`), `stock_movements` (audit ledger: | |||||
| receive/withdraw/adjustment). | |||||
| - Auth gating is inline per-controller | |||||
| (`if (!session()->has('user_id')) { return redirect(...); }`) — the | |||||
| framework has no middleware concept; don't try to add one. | |||||
| - Low-stock alerts fire from `WithdrawStockController`/`AdjustStockController` | |||||
| after a stock change, when `item.reorder_level > 0 && total <= | |||||
| reorder_level`. Not de-duplicated — repeated withdrawals under threshold | |||||
| requeue repeated alerts. | |||||
| @@ -0,0 +1,154 @@ | |||||
| # Agent notes for this repo | |||||
| > Kept in sync with `AGENTS.md` (same content, duplicated on purpose so both | |||||
| > Claude Code and other agent tooling pick it up automatically — update both | |||||
| > files together). | |||||
| Operational gotchas discovered while building the warehouse app on top of the | |||||
| hand-rolled framework in `framework/`. Read this before re-deriving any of it | |||||
| from scratch — most of it was expensive to find the first time. See | |||||
| `docs/framework.md` for how the framework itself works (routing, models, | |||||
| views, etc.) — this file is about the *environment* and *bugs*, not the | |||||
| framework's intended API. | |||||
| ## No local PHP/composer — everything runs through Docker | |||||
| There is no PHP or Composer on the host. Use: | |||||
| - `docker compose run --rm app <cmd>` for one-off commands (migrations, tests, scratch scripts) | |||||
| - `docker compose exec app <cmd>` against the running stack (`docker compose up -d`) | |||||
| `.env` is required and gitignored — copy it from `.env.example` before first run. | |||||
| `composer install` needs `--ignore-platform-reqs`. The committed `composer.lock` | |||||
| predates the container's PHP 8.2 image: `phpspec/prophecy` requires PHP `<8.1`, | |||||
| and `ext-zip` (needed by `dbrekelmans/bdi`/`php-webdriver`) isn't installed. | |||||
| This is a pre-existing gap, not something to "fix" by upgrading deps unless | |||||
| asked. | |||||
| ## Ports are a Windows-specific minefield | |||||
| Windows reserves TCP ports dynamically for Hyper-V/WSL2 NAT. A reserved port | |||||
| fails to bind with `ports are not available: ... forbidden by its access | |||||
| permissions` — this looks like "something else is using it" but isn't; it's | |||||
| an OS-level exclusion, not a process conflict. Check before picking a port: | |||||
| ``` | |||||
| netsh interface ipv4 show excludedportrange protocol=tcp | |||||
| ``` | |||||
| `8081`, `9090`, and others have been seen reserved on this machine already. | |||||
| Current mapping in `docker-compose.yml`: app → host `9092` (container `80`), | |||||
| phpLiteAdmin → host `9091` (container `8081`). The **container-side** ports | |||||
| must not change (`80` = nginx, `8081` = phpliteadmin's nginx vhost) — only | |||||
| remap the host side, and check the exclusion list first. | |||||
| ## `docker/start-container.sh` must stay LF | |||||
| A Windows checkout previously gave it CRLF line endings, which breaks its | |||||
| shebang inside the Linux container (`start-container: not found` in | |||||
| `docker logs pro-php-mvc-app`, crash-looping). `.gitattributes` now forces | |||||
| `*.sh` to LF on checkout. If this error reappears, run `file | |||||
| docker/start-container.sh` to confirm, then `sed -i 's/\r$//' | |||||
| docker/start-container.sh`. | |||||
| ## Stale root-owned cache files break the real app | |||||
| `docker compose run` (one-off containers) executes as root. The real `app` | |||||
| service's php-fpm workers run as `www-data`. If you compile views or write | |||||
| files via `docker compose run` and then the running app can't overwrite them | |||||
| (`Permission denied` in `AdvancedEngine::render()`, `storage/framework/views/`), | |||||
| it's stale root-owned `.php` cache files. Fix: delete them (gitignored, | |||||
| regenerable) and let `www-data` recreate them: | |||||
| ``` | |||||
| docker exec pro-php-mvc-app find /var/www/html/storage/framework/views -maxdepth 1 -name '*.php' -delete | |||||
| ``` | |||||
| ## Testing gotchas | |||||
| `phpunit.xml` registers `ServerExtension`, which **always** tries to | |||||
| auto-start `php command.php serve` before the suite (even for non-browser | |||||
| tests) and stop it after. This container has no `pcntl` extension, so both | |||||
| the start (`ServeCommand::handleSignals()` → `pcntl_async_signals()`) and the | |||||
| stop (`SIGTERM` constant) crash with a fatal error *after* the tests | |||||
| themselves have already run — the crash happens in teardown, so a `phpunit` | |||||
| run always exits non-zero even when everything passed. | |||||
| Workaround: bind a dummy TCP listener on the target `APP_HOST:APP_PORT` | |||||
| (check `docker exec <app> php -r "echo getenv('APP_HOST').':'.getenv('APP_PORT');"` | |||||
| — currently `0.0.0.0:80`) before running phpunit, so | |||||
| `ServerExtension::serverIsRunning()` sees it as already running and skips | |||||
| starting/stopping the real (broken) one: | |||||
| ``` | |||||
| docker compose run --rm app sh -c "php -r 'stream_socket_server(\"tcp://0.0.0.0:80\"); sleep(120);' & sleep 1 && vendor/bin/phpunit; kill %1 2>/dev/null; true" | |||||
| ``` | |||||
| `BrowserTest.php` (Symfony Panther/Firefox) will still error — there's no | |||||
| `geckodriver`/Firefox in this image. That's a pre-existing gap, not a | |||||
| regression to chase. | |||||
| `php command.php serve` is itself broken in this container for the same | |||||
| `pcntl` reason. Don't use it to "prove the app works" — hit a real running | |||||
| `docker compose up` stack over HTTP instead (curl, or a browser). | |||||
| ## Framework bugs found (work around at the app level — don't patch `framework/`) | |||||
| 1. **Router doesn't disambiguate by HTTP method for dynamic paths, and | |||||
| matches unanchored.** `Framework\Routing\Route::matches()` — once past the | |||||
| literal exact-match check, the regex built for a `{param}` route is | |||||
| checked with `preg_match_all` with no `^`/`$` anchors, and without | |||||
| checking `$method` at all. Consequences: | |||||
| - A shorter dynamic route (`/items/view/{item}`) matches as a *prefix* of | |||||
| any longer path (`/items/view/{item}/receive`) — whichever route is | |||||
| registered first wins, regardless of specificity or method. | |||||
| - Two routes on the *identical* dynamic path with different methods (GET | |||||
| show-form / POST submit) collide the same way — first-registered wins | |||||
| for both methods, so the POST silently runs the GET handler. | |||||
| **Rule of thumb:** every dynamic (`{param}`) route in `app/routes.php` | |||||
| needs a fully unique literal path. Never nest one dynamic route's path | |||||
| under another's, and never pair GET+POST on the same dynamic path. Static | |||||
| (no `{}`) paths are unaffected — they only ever use the exact-match | |||||
| branch. See the stock-action routes in `app/routes.php` (and the comment | |||||
| above them) for the working pattern: | |||||
| `/items/receive-form/{item}` (GET) vs `/items/receive/{item}` (POST), etc. | |||||
| 2. **PHP's `empty("0")` is `true`.** The framework's `required` rule | |||||
| (`RequiredRule::validate`) is `!empty($data[$field])`, so a legitimate | |||||
| `"0"` value fails `required`. Don't use `required` on a field where `0` is | |||||
| valid (e.g. "set stock to zero") — validate presence manually instead. See | |||||
| `AdjustStockController::handle()` for the pattern. | |||||
| 3. **No `ORDER BY` / aggregate support** in `Framework\Database\QueryBuilder`. | |||||
| Sort/sum in PHP after `->all()`. | |||||
| 4. **No unique constraints** in the migration DSL. Uniqueness (e.g. | |||||
| `items.sku`, `locations.code`) is not enforced anywhere — enforce at the | |||||
| app level if it starts to matter. | |||||
| Two drivers: `postmark` (API-based) and `smtp` (generic | |||||
| `Swift_SmtpTransport` — no new dependency, ships with `swiftmailer/swiftmailer` | |||||
| already). Switch via `EMAIL_DRIVER` in `.env`; `config/email.php` defaults to | |||||
| `smtp`. | |||||
| Emails are **always queued** (`app('queue')->push(...)`), never sent | |||||
| synchronously. Nothing processes the queue automatically — run | |||||
| `docker compose exec app php command.php queue:work` (blocks, polls every | |||||
| second) or jobs just sit in the `jobs` table forever. | |||||
| ## Domain model | |||||
| - `locations`, `items`, `stock` (one row per item+location, found-or-created | |||||
| via `Stock::forItemAtLocation()`), `stock_movements` (audit ledger: | |||||
| receive/withdraw/adjustment). | |||||
| - Auth gating is inline per-controller | |||||
| (`if (!session()->has('user_id')) { return redirect(...); }`) — the | |||||
| framework has no middleware concept; don't try to add one. | |||||
| - Low-stock alerts fire from `WithdrawStockController`/`AdjustStockController` | |||||
| after a stock change, when `item.reorder_level > 0 && total <= | |||||
| reorder_level`. Not de-duplicated — repeated withdrawals under threshold | |||||
| requeue repeated alerts. | |||||
| @@ -0,0 +1,97 @@ | |||||
| <?php | |||||
| namespace App\Http\Controllers\Items; | |||||
| use App\Models\Item; | |||||
| use App\Models\Location; | |||||
| use App\Models\Stock; | |||||
| use App\Models\StockMovement; | |||||
| use Framework\Routing\Router; | |||||
| class AdjustStockController | |||||
| { | |||||
| public function handle(Router $router) | |||||
| { | |||||
| if (!session()->has('user_id')) { | |||||
| return redirect($router->route('show-register-form')); | |||||
| } | |||||
| secure(); | |||||
| $parameters = $router->current()->parameters(); | |||||
| $item = Item::find((int) $parameters['item']); | |||||
| if (!$item) { | |||||
| return redirect($router->route('show-items')); | |||||
| } | |||||
| $data = validate($_POST, [ | |||||
| 'location_id' => ['required'], | |||||
| // not ['required'] - PHP's empty("0") is true, which would reject | |||||
| // a legitimate "set this to zero" adjustment before it reaches | |||||
| // the ctype_digit check below (which does allow zero). | |||||
| 'quantity' => [], | |||||
| 'notes' => [], | |||||
| ], 'adjust_errors'); | |||||
| if (!isset($data['quantity']) || !ctype_digit((string) $data['quantity'])) { | |||||
| session()->put('adjust_errors', [ | |||||
| 'quantity' => ['quantity must be a whole number, 0 or greater'], | |||||
| ]); | |||||
| return redirect($router->route('show-adjust-stock-form', ['item' => $item->id])); | |||||
| } | |||||
| $location = Location::find((int) $data['location_id']); | |||||
| if (!$location) { | |||||
| return redirect($router->route('show-adjust-stock-form', ['item' => $item->id])); | |||||
| } | |||||
| $stock = Stock::forItemAtLocation($item->id, $location->id); | |||||
| $newQuantity = (int) $data['quantity']; | |||||
| $change = $newQuantity - $stock->quantity; | |||||
| $stock->quantity = $newQuantity; | |||||
| $stock->save(); | |||||
| $movement = new StockMovement(); | |||||
| $movement->item_id = $item->id; | |||||
| $movement->location_id = $location->id; | |||||
| $movement->type = 'adjustment'; | |||||
| $movement->quantity_change = $change; | |||||
| $movement->quantity_after = $newQuantity; | |||||
| $movement->notes = $data['notes'] ?? ''; | |||||
| $movement->user_id = session('user_id'); | |||||
| $movement->save(); | |||||
| $this->alertIfLowStock($item); | |||||
| return redirect($router->route('view-item', ['item' => $item->id])); | |||||
| } | |||||
| private function alertIfLowStock(Item $item): void | |||||
| { | |||||
| if ($item->reorder_level <= 0) { | |||||
| return; | |||||
| } | |||||
| $total = array_sum(array_map( | |||||
| fn($row) => $row->quantity, | |||||
| Stock::where('item_id', $item->id)->all(), | |||||
| )); | |||||
| if ($total > $item->reorder_level) { | |||||
| return; | |||||
| } | |||||
| app('queue')->push(function ($itemName, $total, $reorderLevel) { | |||||
| app('email') | |||||
| ->to(config('warehouse.alert_email')) | |||||
| ->subject("Low stock: {$itemName}") | |||||
| ->text("{$itemName} is at {$total} units, at or below its reorder level of {$reorderLevel}.") | |||||
| ->send(); | |||||
| }, $item->name, $total, $item->reorder_level); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,34 @@ | |||||
| <?php | |||||
| namespace App\Http\Controllers\Items; | |||||
| use App\Models\Item; | |||||
| use Framework\Routing\Router; | |||||
| class CreateItemController | |||||
| { | |||||
| public function handle(Router $router) | |||||
| { | |||||
| if (!session()->has('user_id')) { | |||||
| return redirect($router->route('show-register-form')); | |||||
| } | |||||
| secure(); | |||||
| $data = validate($_POST, [ | |||||
| 'sku' => ['required'], | |||||
| 'name' => ['required'], | |||||
| 'description' => [], | |||||
| 'reorder_level' => ['positive_integer'], | |||||
| ], 'create_item_errors'); | |||||
| $item = new Item(); | |||||
| $item->sku = $data['sku']; | |||||
| $item->name = $data['name']; | |||||
| $item->description = $data['description'] ?? ''; | |||||
| $item->reorder_level = !empty($data['reorder_level']) ? (int) $data['reorder_level'] : 0; | |||||
| $item->save(); | |||||
| return redirect($router->route('view-item', ['item' => $item->id])); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,57 @@ | |||||
| <?php | |||||
| namespace App\Http\Controllers\Items; | |||||
| use App\Models\Item; | |||||
| use App\Models\Location; | |||||
| use App\Models\Stock; | |||||
| use App\Models\StockMovement; | |||||
| use Framework\Routing\Router; | |||||
| class ReceiveStockController | |||||
| { | |||||
| public function handle(Router $router) | |||||
| { | |||||
| if (!session()->has('user_id')) { | |||||
| return redirect($router->route('show-register-form')); | |||||
| } | |||||
| secure(); | |||||
| $parameters = $router->current()->parameters(); | |||||
| $item = Item::find((int) $parameters['item']); | |||||
| if (!$item) { | |||||
| return redirect($router->route('show-items')); | |||||
| } | |||||
| $data = validate($_POST, [ | |||||
| 'location_id' => ['required'], | |||||
| 'quantity' => ['required', 'positive_integer'], | |||||
| 'notes' => [], | |||||
| ], 'receive_errors'); | |||||
| $location = Location::find((int) $data['location_id']); | |||||
| if (!$location) { | |||||
| return redirect($router->route('show-receive-stock-form', ['item' => $item->id])); | |||||
| } | |||||
| $stock = Stock::forItemAtLocation($item->id, $location->id); | |||||
| $stock->quantity = $stock->quantity + (int) $data['quantity']; | |||||
| $stock->save(); | |||||
| $movement = new StockMovement(); | |||||
| $movement->item_id = $item->id; | |||||
| $movement->location_id = $location->id; | |||||
| $movement->type = 'receive'; | |||||
| $movement->quantity_change = (int) $data['quantity']; | |||||
| $movement->quantity_after = $stock->quantity; | |||||
| $movement->notes = $data['notes'] ?? ''; | |||||
| $movement->user_id = session('user_id'); | |||||
| $movement->save(); | |||||
| return redirect($router->route('view-item', ['item' => $item->id])); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,41 @@ | |||||
| <?php | |||||
| namespace App\Http\Controllers\Items; | |||||
| use App\Models\Item; | |||||
| use App\Models\Location; | |||||
| use App\Models\Stock; | |||||
| use Framework\Routing\Router; | |||||
| class ShowAdjustStockFormController | |||||
| { | |||||
| public function handle(Router $router) | |||||
| { | |||||
| if (!session()->has('user_id')) { | |||||
| return redirect($router->route('show-register-form')); | |||||
| } | |||||
| $parameters = $router->current()->parameters(); | |||||
| $item = Item::find((int) $parameters['item']); | |||||
| if (!$item) { | |||||
| return redirect($router->route('show-items')); | |||||
| } | |||||
| $stock = Stock::where('item_id', $item->id)->all(); | |||||
| $quantitiesByLocation = []; | |||||
| foreach ($stock as $row) { | |||||
| $quantitiesByLocation[$row->location_id] = $row->quantity; | |||||
| } | |||||
| return view('items/adjust', [ | |||||
| 'item' => $item, | |||||
| 'locations' => Location::all(), | |||||
| 'quantitiesByLocation' => $quantitiesByLocation, | |||||
| 'adjustAction' => $router->route('adjust-stock', ['item' => $item->id]), | |||||
| 'csrf' => csrf(), | |||||
| ]); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,20 @@ | |||||
| <?php | |||||
| namespace App\Http\Controllers\Items; | |||||
| use Framework\Routing\Router; | |||||
| class ShowCreateItemFormController | |||||
| { | |||||
| public function handle(Router $router) | |||||
| { | |||||
| if (!session()->has('user_id')) { | |||||
| return redirect($router->route('show-register-form')); | |||||
| } | |||||
| return view('items/create', [ | |||||
| 'createAction' => $router->route('create-item'), | |||||
| 'csrf' => csrf(), | |||||
| ]); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,46 @@ | |||||
| <?php | |||||
| namespace App\Http\Controllers\Items; | |||||
| use App\Models\Item; | |||||
| use App\Models\Stock; | |||||
| use App\Models\StockMovement; | |||||
| use Framework\Routing\Router; | |||||
| class ShowItemController | |||||
| { | |||||
| public function handle(Router $router) | |||||
| { | |||||
| $parameters = $router->current()->parameters(); | |||||
| $item = Item::find((int) $parameters['item']); | |||||
| if (!$item) { | |||||
| return redirect($router->route('show-items')); | |||||
| } | |||||
| $stock = Stock::where('item_id', $item->id)->all(); | |||||
| $stock = array_filter($stock, fn($row) => $row->quantity > 0); | |||||
| foreach ($stock as $row) { | |||||
| $row->locationRoute = $router->route('view-location', ['location' => $row->location_id]); | |||||
| } | |||||
| $movements = StockMovement::where('item_id', $item->id)->all(); | |||||
| usort($movements, fn($a, $b) => $b->id <=> $a->id); | |||||
| $movements = array_slice($movements, 0, 20); | |||||
| return view('items/view', [ | |||||
| 'item' => $item, | |||||
| 'totalQuantity' => array_sum(array_map(fn($row) => $row->quantity, $stock)), | |||||
| 'stock' => $stock, | |||||
| 'movements' => $movements, | |||||
| 'receiveAction' => $router->route('show-receive-stock-form', ['item' => $item->id]), | |||||
| 'withdrawAction' => $router->route('show-withdraw-stock-form', ['item' => $item->id]), | |||||
| 'adjustAction' => $router->route('show-adjust-stock-form', ['item' => $item->id]), | |||||
| 'canManage' => session()->has('user_id'), | |||||
| ]); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,34 @@ | |||||
| <?php | |||||
| namespace App\Http\Controllers\Items; | |||||
| use App\Models\Item; | |||||
| use App\Models\Stock; | |||||
| use Framework\Routing\Router; | |||||
| class ShowItemsController | |||||
| { | |||||
| public function handle(Router $router) | |||||
| { | |||||
| $items = Item::all(); | |||||
| $stock = Stock::all(); | |||||
| $totalsByItem = []; | |||||
| foreach ($stock as $row) { | |||||
| $totalsByItem[$row->item_id] = ($totalsByItem[$row->item_id] ?? 0) + $row->quantity; | |||||
| } | |||||
| foreach ($items as $item) { | |||||
| $item->totalQuantity = $totalsByItem[$item->id] ?? 0; | |||||
| $item->isLowStock = $item->reorder_level > 0 && $item->totalQuantity <= $item->reorder_level; | |||||
| $item->route = $router->route('view-item', ['item' => $item->id]); | |||||
| } | |||||
| return view('items/index', [ | |||||
| 'items' => $items, | |||||
| 'createAction' => $router->route('show-create-item-form'), | |||||
| 'canManage' => session()->has('user_id'), | |||||
| ]); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,32 @@ | |||||
| <?php | |||||
| namespace App\Http\Controllers\Items; | |||||
| use App\Models\Item; | |||||
| use App\Models\Location; | |||||
| use Framework\Routing\Router; | |||||
| class ShowReceiveStockFormController | |||||
| { | |||||
| public function handle(Router $router) | |||||
| { | |||||
| if (!session()->has('user_id')) { | |||||
| return redirect($router->route('show-register-form')); | |||||
| } | |||||
| $parameters = $router->current()->parameters(); | |||||
| $item = Item::find((int) $parameters['item']); | |||||
| if (!$item) { | |||||
| return redirect($router->route('show-items')); | |||||
| } | |||||
| return view('items/receive', [ | |||||
| 'item' => $item, | |||||
| 'locations' => Location::all(), | |||||
| 'receiveAction' => $router->route('receive-stock', ['item' => $item->id]), | |||||
| 'csrf' => csrf(), | |||||
| ]); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,35 @@ | |||||
| <?php | |||||
| namespace App\Http\Controllers\Items; | |||||
| use App\Models\Item; | |||||
| use App\Models\Stock; | |||||
| use Framework\Routing\Router; | |||||
| class ShowWithdrawStockFormController | |||||
| { | |||||
| public function handle(Router $router) | |||||
| { | |||||
| if (!session()->has('user_id')) { | |||||
| return redirect($router->route('show-register-form')); | |||||
| } | |||||
| $parameters = $router->current()->parameters(); | |||||
| $item = Item::find((int) $parameters['item']); | |||||
| if (!$item) { | |||||
| return redirect($router->route('show-items')); | |||||
| } | |||||
| $stock = Stock::where('item_id', $item->id)->all(); | |||||
| $stock = array_filter($stock, fn($row) => $row->quantity > 0); | |||||
| return view('items/withdraw', [ | |||||
| 'item' => $item, | |||||
| 'stock' => $stock, | |||||
| 'withdrawAction' => $router->route('withdraw-stock', ['item' => $item->id]), | |||||
| 'csrf' => csrf(), | |||||
| ]); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,93 @@ | |||||
| <?php | |||||
| namespace App\Http\Controllers\Items; | |||||
| use App\Models\Item; | |||||
| use App\Models\Location; | |||||
| use App\Models\Stock; | |||||
| use App\Models\StockMovement; | |||||
| use Framework\Routing\Router; | |||||
| class WithdrawStockController | |||||
| { | |||||
| public function handle(Router $router) | |||||
| { | |||||
| if (!session()->has('user_id')) { | |||||
| return redirect($router->route('show-register-form')); | |||||
| } | |||||
| secure(); | |||||
| $parameters = $router->current()->parameters(); | |||||
| $item = Item::find((int) $parameters['item']); | |||||
| if (!$item) { | |||||
| return redirect($router->route('show-items')); | |||||
| } | |||||
| $data = validate($_POST, [ | |||||
| 'location_id' => ['required'], | |||||
| 'quantity' => ['required', 'positive_integer'], | |||||
| 'notes' => [], | |||||
| ], 'withdraw_errors'); | |||||
| $location = Location::find((int) $data['location_id']); | |||||
| if (!$location) { | |||||
| return redirect($router->route('show-withdraw-stock-form', ['item' => $item->id])); | |||||
| } | |||||
| $stock = Stock::forItemAtLocation($item->id, $location->id); | |||||
| $quantity = (int) $data['quantity']; | |||||
| if ($quantity > $stock->quantity) { | |||||
| session()->put('withdraw_errors', [ | |||||
| 'quantity' => ['Not enough stock at this location to withdraw that much'], | |||||
| ]); | |||||
| return redirect($router->route('show-withdraw-stock-form', ['item' => $item->id])); | |||||
| } | |||||
| $stock->quantity = $stock->quantity - $quantity; | |||||
| $stock->save(); | |||||
| $movement = new StockMovement(); | |||||
| $movement->item_id = $item->id; | |||||
| $movement->location_id = $location->id; | |||||
| $movement->type = 'withdraw'; | |||||
| $movement->quantity_change = -$quantity; | |||||
| $movement->quantity_after = $stock->quantity; | |||||
| $movement->notes = $data['notes'] ?? ''; | |||||
| $movement->user_id = session('user_id'); | |||||
| $movement->save(); | |||||
| $this->alertIfLowStock($item); | |||||
| return redirect($router->route('view-item', ['item' => $item->id])); | |||||
| } | |||||
| private function alertIfLowStock(Item $item): void | |||||
| { | |||||
| if ($item->reorder_level <= 0) { | |||||
| return; | |||||
| } | |||||
| $total = array_sum(array_map( | |||||
| fn($row) => $row->quantity, | |||||
| Stock::where('item_id', $item->id)->all(), | |||||
| )); | |||||
| if ($total > $item->reorder_level) { | |||||
| return; | |||||
| } | |||||
| app('queue')->push(function ($itemName, $total, $reorderLevel) { | |||||
| app('email') | |||||
| ->to(config('warehouse.alert_email')) | |||||
| ->subject("Low stock: {$itemName}") | |||||
| ->text("{$itemName} is at {$total} units, at or below its reorder level of {$reorderLevel}.") | |||||
| ->send(); | |||||
| }, $item->name, $total, $item->reorder_level); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,34 @@ | |||||
| <?php | |||||
| namespace App\Http\Controllers\Locations; | |||||
| use App\Models\Location; | |||||
| use Framework\Routing\Router; | |||||
| class CreateLocationController | |||||
| { | |||||
| public function handle(Router $router) | |||||
| { | |||||
| if (!session()->has('user_id')) { | |||||
| return redirect($router->route('show-register-form')); | |||||
| } | |||||
| secure(); | |||||
| $data = validate($_POST, [ | |||||
| 'code' => ['required'], | |||||
| 'name' => ['required'], | |||||
| 'description' => [], | |||||
| 'capacity' => ['positive_integer'], | |||||
| ], 'create_location_errors'); | |||||
| $location = new Location(); | |||||
| $location->code = $data['code']; | |||||
| $location->name = $data['name']; | |||||
| $location->description = $data['description'] ?? ''; | |||||
| $location->capacity = !empty($data['capacity']) ? (int) $data['capacity'] : null; | |||||
| $location->save(); | |||||
| return redirect($router->route('view-location', ['location' => $location->id])); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,20 @@ | |||||
| <?php | |||||
| namespace App\Http\Controllers\Locations; | |||||
| use Framework\Routing\Router; | |||||
| class ShowCreateLocationFormController | |||||
| { | |||||
| public function handle(Router $router) | |||||
| { | |||||
| if (!session()->has('user_id')) { | |||||
| return redirect($router->route('show-register-form')); | |||||
| } | |||||
| return view('locations/create', [ | |||||
| 'createAction' => $router->route('create-location'), | |||||
| 'csrf' => csrf(), | |||||
| ]); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,33 @@ | |||||
| <?php | |||||
| namespace App\Http\Controllers\Locations; | |||||
| use App\Models\Location; | |||||
| use App\Models\Stock; | |||||
| use Framework\Routing\Router; | |||||
| class ShowLocationController | |||||
| { | |||||
| public function handle(Router $router) | |||||
| { | |||||
| $parameters = $router->current()->parameters(); | |||||
| $location = Location::find((int) $parameters['location']); | |||||
| if (!$location) { | |||||
| return redirect($router->route('show-locations')); | |||||
| } | |||||
| $stock = Stock::where('location_id', $location->id)->all(); | |||||
| $stock = array_filter($stock, fn($row) => $row->quantity > 0); | |||||
| foreach ($stock as $row) { | |||||
| $row->itemRoute = $router->route('view-item', ['item' => $row->item_id]); | |||||
| } | |||||
| return view('locations/view', [ | |||||
| 'location' => $location, | |||||
| 'stock' => $stock, | |||||
| ]); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,33 @@ | |||||
| <?php | |||||
| namespace App\Http\Controllers\Locations; | |||||
| use App\Models\Location; | |||||
| use App\Models\Stock; | |||||
| use Framework\Routing\Router; | |||||
| class ShowLocationsController | |||||
| { | |||||
| public function handle(Router $router) | |||||
| { | |||||
| $locations = Location::all(); | |||||
| $stock = Stock::all(); | |||||
| $totalsByLocation = []; | |||||
| foreach ($stock as $row) { | |||||
| $totalsByLocation[$row->location_id] = ($totalsByLocation[$row->location_id] ?? 0) + $row->quantity; | |||||
| } | |||||
| foreach ($locations as $location) { | |||||
| $location->totalQuantity = $totalsByLocation[$location->id] ?? 0; | |||||
| $location->route = $router->route('view-location', ['location' => $location->id]); | |||||
| } | |||||
| return view('locations/index', [ | |||||
| 'locations' => $locations, | |||||
| 'createAction' => $router->route('show-create-location-form'), | |||||
| 'canManage' => session()->has('user_id'), | |||||
| ]); | |||||
| } | |||||
| } | |||||
| @@ -1,19 +0,0 @@ | |||||
| <?php | |||||
| namespace App\Http\Controllers\Products; | |||||
| use Framework\Routing\Router; | |||||
| class OrderProductController | |||||
| { | |||||
| public function handle(Router $router) | |||||
| { | |||||
| secure(); | |||||
| // use $data to create a database record... | |||||
| session()->put('ordered', true); | |||||
| return redirect($router->route('show-home-page')); | |||||
| } | |||||
| } | |||||
| @@ -1,22 +0,0 @@ | |||||
| <?php | |||||
| namespace App\Http\Controllers\Products; | |||||
| use App\Models\Product; | |||||
| use Framework\Routing\Router; | |||||
| class ShowProductController | |||||
| { | |||||
| public function handle(Router $router) | |||||
| { | |||||
| $parameters = $router->current()->parameters(); | |||||
| $product = Product::find((int) $parameters['product']); | |||||
| return view('products/view', [ | |||||
| 'product' => $product, | |||||
| 'orderAction' => $router->route('order-product', ['product' => $product->id]), | |||||
| 'csrf' => csrf(), | |||||
| ]); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,39 @@ | |||||
| <?php | |||||
| namespace App\Http\Controllers; | |||||
| use App\Models\Item; | |||||
| use App\Models\Location; | |||||
| use App\Models\Stock; | |||||
| use Framework\Routing\Router; | |||||
| class ShowDashboardController | |||||
| { | |||||
| public function handle(Router $router) | |||||
| { | |||||
| $items = Item::all(); | |||||
| $stock = Stock::all(); | |||||
| $totalsByItem = []; | |||||
| foreach ($stock as $row) { | |||||
| $totalsByItem[$row->item_id] = ($totalsByItem[$row->item_id] ?? 0) + $row->quantity; | |||||
| } | |||||
| $lowStockItems = array_values(array_filter($items, function ($item) use ($totalsByItem) { | |||||
| return $item->reorder_level > 0 && ($totalsByItem[$item->id] ?? 0) <= $item->reorder_level; | |||||
| })); | |||||
| foreach ($lowStockItems as $item) { | |||||
| $item->totalQuantity = $totalsByItem[$item->id] ?? 0; | |||||
| $item->route = $router->route('view-item', ['item' => $item->id]); | |||||
| } | |||||
| return view('dashboard', [ | |||||
| 'locationCount' => count(Location::all()), | |||||
| 'itemCount' => count($items), | |||||
| 'totalStock' => array_sum(array_map(fn($row) => $row->quantity, $stock)), | |||||
| 'lowStockItems' => $lowStockItems, | |||||
| ]); | |||||
| } | |||||
| } | |||||
| @@ -1,46 +0,0 @@ | |||||
| <?php | |||||
| namespace App\Http\Controllers; | |||||
| use App\Models\Product; | |||||
| use Framework\Routing\Router; | |||||
| class ShowHomePageController | |||||
| { | |||||
| public function handle(Router $router) | |||||
| { | |||||
| $cache = app('cache'); | |||||
| $products = Product::all(); | |||||
| $productsWithRoutes = array_map(function ($product) use ($router, $cache) { | |||||
| $key = "route-for-product-{$product->id}"; | |||||
| if (!$cache->has($key)) { | |||||
| $cache->put($key, $router->route('view-product', ['product' => $product->id])); | |||||
| } | |||||
| $product->route = $cache->get($key); | |||||
| return $product; | |||||
| }, $products); | |||||
| // app('queue')->push( | |||||
| // fn($name) => app('logging')->info("Hello {$name}"), | |||||
| // 'Chris', | |||||
| // ); | |||||
| // app('logging')->info('Send a task into the background'); | |||||
| // app('queue')->push( | |||||
| // fn($name) => app('email') | |||||
| // ->to('cgpitt@gmail.com') | |||||
| // ->text("Hello {$name}") | |||||
| // ->send(), | |||||
| // 'Chris', | |||||
| // ); | |||||
| return view('home', [ | |||||
| 'products' => $productsWithRoutes, | |||||
| ]); | |||||
| } | |||||
| } | |||||
| @@ -22,6 +22,6 @@ class LogInUserController | |||||
| session()->put('user_id', $user->id); | session()->put('user_id', $user->id); | ||||
| } | } | ||||
| return redirect($router->route('show-home-page')); | |||||
| return redirect($router->route('show-dashboard')); | |||||
| } | } | ||||
| } | } | ||||
| @@ -10,6 +10,6 @@ class LogOutUserController | |||||
| { | { | ||||
| session()->forget('user_id'); | session()->forget('user_id'); | ||||
| return redirect($router->route('show-home-page')); | |||||
| return redirect($router->route('show-dashboard')); | |||||
| } | } | ||||
| } | } | ||||
| @@ -24,11 +24,16 @@ class RegisterUserController | |||||
| $user->save(); | $user->save(); | ||||
| session()->put('registered', true); | session()->put('registered', true); | ||||
| session()->put('user_id', $user->id); | |||||
| app('queue')->push(function($user) { | |||||
| // send a mail to the user... | |||||
| }, $user); | |||||
| app('queue')->push(function ($name, $email) { | |||||
| app('email') | |||||
| ->to($email) | |||||
| ->subject('Welcome to the warehouse') | |||||
| ->text("Hi {$name}, your account is ready. You can now create locations and manage stock.") | |||||
| ->send(); | |||||
| }, $user->name, $user->email); | |||||
| return redirect($router->route('show-home-page')); | |||||
| return redirect($router->route('show-dashboard')); | |||||
| } | } | ||||
| } | } | ||||
| @@ -0,0 +1,20 @@ | |||||
| <?php | |||||
| namespace App\Models; | |||||
| use Framework\Database\Model; | |||||
| class Item extends Model | |||||
| { | |||||
| protected string $table = 'items'; | |||||
| public function stock(): mixed | |||||
| { | |||||
| return $this->hasMany(Stock::class, 'item_id'); | |||||
| } | |||||
| public function movements(): mixed | |||||
| { | |||||
| return $this->hasMany(StockMovement::class, 'item_id'); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,20 @@ | |||||
| <?php | |||||
| namespace App\Models; | |||||
| use Framework\Database\Model; | |||||
| class Location extends Model | |||||
| { | |||||
| protected string $table = 'locations'; | |||||
| public function stock(): mixed | |||||
| { | |||||
| return $this->hasMany(Stock::class, 'location_id'); | |||||
| } | |||||
| public function movements(): mixed | |||||
| { | |||||
| return $this->hasMany(StockMovement::class, 'location_id'); | |||||
| } | |||||
| } | |||||
| @@ -1,15 +0,0 @@ | |||||
| <?php | |||||
| namespace App\Models; | |||||
| use Framework\Database\Model; | |||||
| class Order extends Model | |||||
| { | |||||
| protected string $table = 'orders'; | |||||
| public function user(): mixed | |||||
| { | |||||
| return $this->belongsTo(User::class, 'user_id'); | |||||
| } | |||||
| } | |||||
| @@ -1,27 +0,0 @@ | |||||
| <?php | |||||
| namespace App\Models; | |||||
| use Framework\Database\Model; | |||||
| class Product extends Model | |||||
| { | |||||
| protected string $table = 'products'; | |||||
| public function getNameAttribute($value): string | |||||
| { | |||||
| return ucwords($value); | |||||
| } | |||||
| public function setDescriptionAttribute(string $value) | |||||
| { | |||||
| $limit = 50; | |||||
| $ending = '...'; | |||||
| if (mb_strwidth($value, 'UTF-8') <= $limit) { | |||||
| return $value; | |||||
| } | |||||
| return rtrim(mb_strimwidth($value, 0, $limit, '', 'UTF-8')) . $ending; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,36 @@ | |||||
| <?php | |||||
| namespace App\Models; | |||||
| use Framework\Database\Model; | |||||
| class Stock extends Model | |||||
| { | |||||
| protected string $table = 'stock'; | |||||
| public function item(): mixed | |||||
| { | |||||
| return $this->belongsTo(Item::class, 'item_id'); | |||||
| } | |||||
| public function location(): mixed | |||||
| { | |||||
| return $this->belongsTo(Location::class, 'location_id'); | |||||
| } | |||||
| public static function forItemAtLocation(int $itemId, int $locationId): static | |||||
| { | |||||
| $stock = static::where('item_id', $itemId)->where('location_id', $locationId)->first(); | |||||
| if ($stock) { | |||||
| return $stock; | |||||
| } | |||||
| $stock = new static(); | |||||
| $stock->item_id = $itemId; | |||||
| $stock->location_id = $locationId; | |||||
| $stock->quantity = 0; | |||||
| return $stock; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,25 @@ | |||||
| <?php | |||||
| namespace App\Models; | |||||
| use Framework\Database\Model; | |||||
| class StockMovement extends Model | |||||
| { | |||||
| protected string $table = 'stock_movements'; | |||||
| public function item(): mixed | |||||
| { | |||||
| return $this->belongsTo(Item::class, 'item_id'); | |||||
| } | |||||
| public function location(): mixed | |||||
| { | |||||
| return $this->belongsTo(Location::class, 'location_id'); | |||||
| } | |||||
| public function user(): mixed | |||||
| { | |||||
| return $this->belongsTo(User::class, 'user_id'); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,14 @@ | |||||
| <?php | |||||
| namespace App\Providers; | |||||
| use App\Validation\Rule\PositiveIntegerRule; | |||||
| use Framework\App; | |||||
| class ValidationExtensionsProvider | |||||
| { | |||||
| public function bind(App $app): void | |||||
| { | |||||
| $app->resolve('validator')->addRule('positive_integer', new PositiveIntegerRule()); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,22 @@ | |||||
| <?php | |||||
| namespace App\Validation\Rule; | |||||
| use Framework\Validation\Rule\Rule; | |||||
| class PositiveIntegerRule implements Rule | |||||
| { | |||||
| public function validate(array $data, string $field, array $params) | |||||
| { | |||||
| if (empty($data[$field])) { | |||||
| return true; | |||||
| } | |||||
| return ctype_digit((string) $data[$field]) && (int) $data[$field] > 0; | |||||
| } | |||||
| public function getMessage(array $data, string $field, array $params) | |||||
| { | |||||
| return "{$field} must be a positive whole number"; | |||||
| } | |||||
| } | |||||
| @@ -1,8 +1,20 @@ | |||||
| <?php | <?php | ||||
| use App\Http\Controllers\ShowHomePageController; | |||||
| use App\Http\Controllers\Products\OrderProductController; | |||||
| use App\Http\Controllers\Products\ShowProductController; | |||||
| use App\Http\Controllers\ShowDashboardController; | |||||
| use App\Http\Controllers\Locations\CreateLocationController; | |||||
| use App\Http\Controllers\Locations\ShowCreateLocationFormController; | |||||
| use App\Http\Controllers\Locations\ShowLocationController; | |||||
| use App\Http\Controllers\Locations\ShowLocationsController; | |||||
| use App\Http\Controllers\Items\AdjustStockController; | |||||
| use App\Http\Controllers\Items\CreateItemController; | |||||
| use App\Http\Controllers\Items\ReceiveStockController; | |||||
| use App\Http\Controllers\Items\ShowAdjustStockFormController; | |||||
| use App\Http\Controllers\Items\ShowCreateItemFormController; | |||||
| use App\Http\Controllers\Items\ShowItemController; | |||||
| use App\Http\Controllers\Items\ShowItemsController; | |||||
| use App\Http\Controllers\Items\ShowReceiveStockFormController; | |||||
| use App\Http\Controllers\Items\ShowWithdrawStockFormController; | |||||
| use App\Http\Controllers\Items\WithdrawStockController; | |||||
| use App\Http\Controllers\Users\LogInUserController; | use App\Http\Controllers\Users\LogInUserController; | ||||
| use App\Http\Controllers\Users\LogOutUserController; | use App\Http\Controllers\Users\LogOutUserController; | ||||
| use App\Http\Controllers\Users\RegisterUserController; | use App\Http\Controllers\Users\RegisterUserController; | ||||
| @@ -16,18 +28,96 @@ return function(Router $router) { | |||||
| $router->add( | $router->add( | ||||
| 'GET', '/', | 'GET', '/', | ||||
| [ShowHomePageController::class, 'handle'], | |||||
| )->name('show-home-page'); | |||||
| [ShowDashboardController::class, 'handle'], | |||||
| )->name('show-dashboard'); | |||||
| // locations... | |||||
| $router->add( | |||||
| 'GET', '/locations', | |||||
| [ShowLocationsController::class, 'handle'], | |||||
| )->name('show-locations'); | |||||
| $router->add( | |||||
| 'GET', '/locations/create', | |||||
| [ShowCreateLocationFormController::class, 'handle'], | |||||
| )->name('show-create-location-form'); | |||||
| $router->add( | |||||
| 'POST', '/locations/create', | |||||
| [CreateLocationController::class, 'handle'], | |||||
| )->name('create-location'); | |||||
| $router->add( | |||||
| 'GET', '/locations/view/{location}', | |||||
| [ShowLocationController::class, 'handle'], | |||||
| )->name('view-location'); | |||||
| // items... | |||||
| $router->add( | |||||
| 'GET', '/items', | |||||
| [ShowItemsController::class, 'handle'], | |||||
| )->name('show-items'); | |||||
| $router->add( | |||||
| 'GET', '/items/create', | |||||
| [ShowCreateItemFormController::class, 'handle'], | |||||
| )->name('show-create-item-form'); | |||||
| $router->add( | $router->add( | ||||
| 'GET', '/products/view/{product}', | |||||
| [ShowProductController::class, 'handle'], | |||||
| )->name('view-product'); | |||||
| 'POST', '/items/create', | |||||
| [CreateItemController::class, 'handle'], | |||||
| )->name('create-item'); | |||||
| $router->add( | $router->add( | ||||
| 'POST', '/products/order/{product}', | |||||
| [OrderProductController::class, 'handle'], | |||||
| )->name('order-product'); | |||||
| 'GET', '/items/view/{item}', | |||||
| [ShowItemController::class, 'handle'], | |||||
| )->name('view-item'); | |||||
| // stock movements... | |||||
| // | |||||
| // note: each of these six paths uses a distinct literal segment | |||||
| // (receive-form/receive, withdraw-form/withdraw, adjust-form/adjust). | |||||
| // Framework\Routing\Route::matches() does not check the HTTP method | |||||
| // once it falls through to its parameterised-path regex, and matches | |||||
| // that regex unanchored — so a shorter dynamic route (like view-item | |||||
| // above) would otherwise swallow any longer path that starts with the | |||||
| // same segments (e.g. "/items/view/{item}/receive"), regardless of | |||||
| // method. Giving every dynamic route here its own unique path segment | |||||
| // sidesteps that rather than patching the router. | |||||
| $router->add( | |||||
| 'GET', '/items/receive-form/{item}', | |||||
| [ShowReceiveStockFormController::class, 'handle'], | |||||
| )->name('show-receive-stock-form'); | |||||
| $router->add( | |||||
| 'POST', '/items/receive/{item}', | |||||
| [ReceiveStockController::class, 'handle'], | |||||
| )->name('receive-stock'); | |||||
| $router->add( | |||||
| 'GET', '/items/withdraw-form/{item}', | |||||
| [ShowWithdrawStockFormController::class, 'handle'], | |||||
| )->name('show-withdraw-stock-form'); | |||||
| $router->add( | |||||
| 'POST', '/items/withdraw/{item}', | |||||
| [WithdrawStockController::class, 'handle'], | |||||
| )->name('withdraw-stock'); | |||||
| $router->add( | |||||
| 'GET', '/items/adjust-form/{item}', | |||||
| [ShowAdjustStockFormController::class, 'handle'], | |||||
| )->name('show-adjust-stock-form'); | |||||
| $router->add( | |||||
| 'POST', '/items/adjust/{item}', | |||||
| [AdjustStockController::class, 'handle'], | |||||
| )->name('adjust-stock'); | |||||
| // users... | |||||
| $router->add( | $router->add( | ||||
| 'GET', '/register', | 'GET', '/register', | ||||
| @@ -1,6 +1,6 @@ | |||||
| { | { | ||||
| "name": "whoosh/website", | |||||
| "description": "A website that sells Whoosh rockets", | |||||
| "name": "warehouse/app", | |||||
| "description": "A warehouse management app: locations, items, and stock movements", | |||||
| "scripts": { | "scripts": { | ||||
| "serve": "php command.php serve", | "serve": "php command.php serve", | ||||
| "test": "vendor/bin/phpunit" | "test": "vendor/bin/phpunit" | ||||
| @@ -1,7 +1,19 @@ | |||||
| <?php | <?php | ||||
| return [ | return [ | ||||
| 'default' => 'postmark', | |||||
| 'default' => env('EMAIL_DRIVER', 'smtp'), | |||||
| 'smtp' => [ | |||||
| 'type' => 'smtp', | |||||
| 'host' => env('EMAIL_SMTP_HOST'), | |||||
| 'port' => env('EMAIL_SMTP_PORT', 25), | |||||
| 'encryption' => env('EMAIL_SMTP_ENCRYPTION'), | |||||
| 'username' => env('EMAIL_SMTP_USERNAME'), | |||||
| 'password' => env('EMAIL_SMTP_PASSWORD'), | |||||
| 'from' => [ | |||||
| 'name' => env('EMAIL_FROM_NAME'), | |||||
| 'email' => env('EMAIL_FROM_EMAIL'), | |||||
| ], | |||||
| ], | |||||
| 'postmark' => [ | 'postmark' => [ | ||||
| 'type' => 'postmark', | 'type' => 'postmark', | ||||
| 'token' => env('EMAIL_TOKEN'), | 'token' => env('EMAIL_TOKEN'), | ||||
| @@ -9,5 +21,5 @@ return [ | |||||
| 'name' => env('EMAIL_FROM_NAME'), | 'name' => env('EMAIL_FROM_NAME'), | ||||
| 'email' => env('EMAIL_FROM_EMAIL'), | 'email' => env('EMAIL_FROM_EMAIL'), | ||||
| ], | ], | ||||
| ] | |||||
| ], | |||||
| ]; | ]; | ||||
| @@ -14,4 +14,7 @@ return [ | |||||
| \Framework\Provider\SessionProvider::class, | \Framework\Provider\SessionProvider::class, | ||||
| \Framework\Provider\ValidationProvider::class, | \Framework\Provider\ValidationProvider::class, | ||||
| \Framework\Provider\ViewProvider::class, | \Framework\Provider\ViewProvider::class, | ||||
| // app-level providers... | |||||
| \App\Providers\ValidationExtensionsProvider::class, | |||||
| ]; | ]; | ||||
| @@ -0,0 +1,5 @@ | |||||
| <?php | |||||
| return [ | |||||
| 'alert_email' => env('WAREHOUSE_ALERT_EMAIL'), | |||||
| ]; | |||||
| @@ -1,18 +0,0 @@ | |||||
| <?php | |||||
| use Framework\Database\Connection\Connection; | |||||
| class CreateOrdersTable | |||||
| { | |||||
| public function migrate(Connection $connection) | |||||
| { | |||||
| $table = $connection->createTable('orders'); | |||||
| $table->id('id'); | |||||
| $table->int('quantity')->default(1); | |||||
| $table->float('price')->nullable(); | |||||
| $table->bool('is_confirmed')->default(false); | |||||
| $table->dateTime('ordered_at')->default('CURRENT_TIMESTAMP'); | |||||
| $table->text('notes'); | |||||
| $table->execute(); | |||||
| } | |||||
| } | |||||
| @@ -1,13 +0,0 @@ | |||||
| <?php | |||||
| use Framework\Database\Connection\Connection; | |||||
| class AddDeliveryInstructions | |||||
| { | |||||
| public function migrate(Connection $connection) | |||||
| { | |||||
| $table = $connection->alterTable('orders'); | |||||
| $table->text('delivery_instructions'); | |||||
| $table->execute(); | |||||
| } | |||||
| } | |||||
| @@ -1,13 +0,0 @@ | |||||
| <?php | |||||
| use Framework\Database\Connection\Connection; | |||||
| class ChangeQuantity | |||||
| { | |||||
| public function migrate(Connection $connection) | |||||
| { | |||||
| $table = $connection->alterTable('orders'); | |||||
| $table->int('quantity')->nullable()->alter(); | |||||
| $table->execute(); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,18 @@ | |||||
| <?php | |||||
| use Framework\Database\Connection\Connection; | |||||
| class CreateLocationsTable | |||||
| { | |||||
| public function migrate(Connection $connection) | |||||
| { | |||||
| $table = $connection->createTable('locations'); | |||||
| $table->id('id'); | |||||
| $table->string('code'); | |||||
| $table->string('name'); | |||||
| $table->text('description'); | |||||
| $table->int('capacity')->nullable(); | |||||
| $table->dateTime('created_at')->default('CURRENT_TIMESTAMP'); | |||||
| $table->execute(); | |||||
| } | |||||
| } | |||||
| @@ -1,13 +0,0 @@ | |||||
| <?php | |||||
| use Framework\Database\Connection\Connection; | |||||
| class DropPrice | |||||
| { | |||||
| public function migrate(Connection $connection) | |||||
| { | |||||
| $table = $connection->alterTable('orders'); | |||||
| $table->dropColumn('price'); | |||||
| $table->execute(); | |||||
| } | |||||
| } | |||||
| @@ -2,14 +2,17 @@ | |||||
| use Framework\Database\Connection\Connection; | use Framework\Database\Connection\Connection; | ||||
| class CreateProductsTable | |||||
| class CreateItemsTable | |||||
| { | { | ||||
| public function migrate(Connection $connection) | public function migrate(Connection $connection) | ||||
| { | { | ||||
| $table = $connection->createTable('products'); | |||||
| $table = $connection->createTable('items'); | |||||
| $table->id('id'); | $table->id('id'); | ||||
| $table->string('sku'); | |||||
| $table->string('name'); | $table->string('name'); | ||||
| $table->text('description'); | $table->text('description'); | ||||
| $table->int('reorder_level')->default(0); | |||||
| $table->dateTime('created_at')->default('CURRENT_TIMESTAMP'); | |||||
| $table->execute(); | $table->execute(); | ||||
| } | } | ||||
| } | } | ||||
| @@ -0,0 +1,16 @@ | |||||
| <?php | |||||
| use Framework\Database\Connection\Connection; | |||||
| class CreateStockTable | |||||
| { | |||||
| public function migrate(Connection $connection) | |||||
| { | |||||
| $table = $connection->createTable('stock'); | |||||
| $table->id('id'); | |||||
| $table->int('item_id'); | |||||
| $table->int('location_id'); | |||||
| $table->int('quantity')->default(0); | |||||
| $table->execute(); | |||||
| } | |||||
| } | |||||
| @@ -1,31 +0,0 @@ | |||||
| <?php | |||||
| use Framework\Database\Connection\Connection; | |||||
| class SeedProducts | |||||
| { | |||||
| public function migrate(Connection $connection) | |||||
| { | |||||
| $products = [ | |||||
| [ | |||||
| 'name' => 'Space Tour', | |||||
| 'description' => 'Take a trip on a rocket ship. Our tours are out of this world. Sign up now for a journey you won't soon forget.', | |||||
| ], | |||||
| [ | |||||
| 'name' => 'Large Rocket', | |||||
| 'description' => 'Need to bring some extra space-baggage? Everyone asking you to bring back a moon rock for them? This is the rocket you want...', | |||||
| ], | |||||
| [ | |||||
| 'name' => 'Small Rocket', | |||||
| 'description' => 'Space exploration is expensive. This rocket comes in under budget and atmosphere.', | |||||
| ], | |||||
| ]; | |||||
| foreach ($products as $product) { | |||||
| $connection | |||||
| ->query() | |||||
| ->from('products') | |||||
| ->insert(['name', 'description'], $product); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,21 @@ | |||||
| <?php | |||||
| use Framework\Database\Connection\Connection; | |||||
| class CreateStockMovementsTable | |||||
| { | |||||
| public function migrate(Connection $connection) | |||||
| { | |||||
| $table = $connection->createTable('stock_movements'); | |||||
| $table->id('id'); | |||||
| $table->int('item_id'); | |||||
| $table->int('location_id'); | |||||
| $table->string('type'); | |||||
| $table->int('quantity_change'); | |||||
| $table->int('quantity_after'); | |||||
| $table->text('notes'); | |||||
| $table->int('user_id'); | |||||
| $table->dateTime('created_at')->default('CURRENT_TIMESTAMP'); | |||||
| $table->execute(); | |||||
| } | |||||
| } | |||||
| @@ -1,13 +0,0 @@ | |||||
| <?php | |||||
| use Framework\Database\Connection\Connection; | |||||
| class AddUserId | |||||
| { | |||||
| public function migrate(Connection $connection) | |||||
| { | |||||
| $table = $connection->alterTable('orders'); | |||||
| $table->int('user_id'); | |||||
| $table->execute(); | |||||
| } | |||||
| } | |||||
| @@ -8,8 +8,8 @@ services: | |||||
| depends_on: | depends_on: | ||||
| - db | - db | ||||
| ports: | ports: | ||||
| - "8080:80" | |||||
| - "8081:8081" | |||||
| - "9092:80" | |||||
| - "9091:8081" | |||||
| volumes: | volumes: | ||||
| - ./:/var/www/html | - ./:/var/www/html | ||||
| - vendor_data:/var/www/html/vendor | - vendor_data:/var/www/html/vendor | ||||
| @@ -0,0 +1,103 @@ | |||||
| <?php | |||||
| namespace Framework\Email\Driver; | |||||
| use Framework\Email\Exception\CompositionException; | |||||
| use Swift_Mailer; | |||||
| use Swift_Message; | |||||
| use Swift_SmtpTransport; | |||||
| class SmtpDriver implements Driver | |||||
| { | |||||
| private array $config; | |||||
| private Swift_Mailer $mailer; | |||||
| private string $to; | |||||
| private string $subject; | |||||
| private string $text; | |||||
| private string $html; | |||||
| public function __construct(array $config) | |||||
| { | |||||
| $this->config = $config; | |||||
| } | |||||
| public function to(string $to): static | |||||
| { | |||||
| $this->to = $to; | |||||
| return $this; | |||||
| } | |||||
| public function subject(string $subject): static | |||||
| { | |||||
| $this->subject = $subject; | |||||
| return $this; | |||||
| } | |||||
| public function text(string $text): static | |||||
| { | |||||
| $this->text = $text; | |||||
| return $this; | |||||
| } | |||||
| public function html(string $html): static | |||||
| { | |||||
| $this->html = $html; | |||||
| return $this; | |||||
| } | |||||
| public function send(): void | |||||
| { | |||||
| if (!isset($this->to)) { | |||||
| throw new CompositionException('to required'); | |||||
| } | |||||
| if (!isset($this->text) && !isset($this->html)) { | |||||
| throw new CompositionException('text or email required'); | |||||
| } | |||||
| $fromName = $this->config['from']['name']; | |||||
| $fromEmail = $this->config['from']['email']; | |||||
| $subject = $this->subject ?? "Message from {$fromName}"; | |||||
| $message = (new Swift_Message($subject)) | |||||
| ->setFrom([$fromEmail => $fromName]) | |||||
| ->setTo([$this->to]); | |||||
| if (isset($this->text) && !isset($this->html)) { | |||||
| $message->setBody($this->text, 'text/plain'); | |||||
| } | |||||
| if (!isset($this->text) && isset($this->html)) { | |||||
| $message->setBody($this->html, 'text/html'); | |||||
| } | |||||
| if (isset($this->text, $this->html)) { | |||||
| $message | |||||
| ->setBody($this->html, 'text/html') | |||||
| ->addPart($this->text, 'text/plain'); | |||||
| } | |||||
| $this->mailer()->send($message); | |||||
| } | |||||
| private function mailer(): Swift_Mailer | |||||
| { | |||||
| if (!isset($this->mailer)) { | |||||
| $transport = new Swift_SmtpTransport( | |||||
| $this->config['host'], | |||||
| $this->config['port'], | |||||
| $this->config['encryption'] ?: null, | |||||
| ); | |||||
| if (!empty($this->config['username'])) { | |||||
| $transport->setUsername($this->config['username']); | |||||
| $transport->setPassword($this->config['password'] ?? ''); | |||||
| } | |||||
| $this->mailer = new Swift_Mailer($transport); | |||||
| } | |||||
| return $this->mailer; | |||||
| } | |||||
| } | |||||
| @@ -4,6 +4,7 @@ namespace Framework\Provider; | |||||
| use Framework\Email\Factory; | use Framework\Email\Factory; | ||||
| use Framework\Email\Driver\PostmarkDriver; | use Framework\Email\Driver\PostmarkDriver; | ||||
| use Framework\Email\Driver\SmtpDriver; | |||||
| use Framework\Support\DriverProvider; | use Framework\Support\DriverProvider; | ||||
| use Framework\Support\DriverFactory; | use Framework\Support\DriverFactory; | ||||
| @@ -25,6 +26,9 @@ class EmailProvider extends DriverProvider | |||||
| 'postmark' => function($config) { | 'postmark' => function($config) { | ||||
| return new PostmarkDriver($config); | return new PostmarkDriver($config); | ||||
| }, | }, | ||||
| 'smtp' => function($config) { | |||||
| return new SmtpDriver($config); | |||||
| }, | |||||
| ]; | ]; | ||||
| } | } | ||||
| } | } | ||||
| @@ -1,4 +1,6 @@ | |||||
| # Whoosh website | |||||
| # Warehouse | |||||
| A warehousing app: define storage locations, create items, then receive, withdraw, and adjust stock, with an audit trail and low-stock email alerts. | |||||
| To run the local development server, try: | To run the local development server, try: | ||||
| @@ -6,12 +8,6 @@ To run the local development server, try: | |||||
| export PHP_ENV=prod && php -S 127.0.0.1:8000 -t . | export PHP_ENV=prod && php -S 127.0.0.1:8000 -t . | ||||
| ``` | ``` | ||||
| To make requests with cURL, try: | |||||
| ``` | |||||
| curl -X DELETE http://127.0.0.1:8000/old-home | |||||
| ``` | |||||
| ## Running with Docker | ## Running with Docker | ||||
| ``` | ``` | ||||
| @@ -20,7 +16,7 @@ docker compose up -d --build | |||||
| This starts two services: | This starts two services: | ||||
| - **app** — nginx + php-fpm, served at http://localhost:8080 | |||||
| - **app** — nginx + php-fpm, served at http://localhost:9092 | |||||
| - **db** — MySQL 8, exposed on host port `33060` (only used if `DB_CONNECTION=mysql`) | - **db** — MySQL 8, exposed on host port `33060` (only used if `DB_CONNECTION=mysql`) | ||||
| The default database connection is **SQLite** (`database/database.sqlite`), configured via `DB_CONNECTION` in `.env` / `docker-compose.yml`. On first boot the `app` container automatically creates the SQLite file with the correct permissions and runs migrations. To switch to MySQL instead, set `DB_CONNECTION=mysql` in `docker-compose.yml`. | The default database connection is **SQLite** (`database/database.sqlite`), configured via `DB_CONNECTION` in `.env` / `docker-compose.yml`. On first boot the `app` container automatically creates the SQLite file with the correct permissions and runs migrations. To switch to MySQL instead, set `DB_CONNECTION=mysql` in `docker-compose.yml`. | ||||
| @@ -34,7 +30,9 @@ docker compose exec app php command.php migrate --fresh # drops all tables fir | |||||
| ### phpLiteAdmin | ### phpLiteAdmin | ||||
| A SQLite admin UI is available at http://localhost:8081, password `admin` (change it via the `PHPLITEADMIN_PASSWORD` environment variable in `docker-compose.yml`). It's a development-only tool — don't expose port 8081 outside your local machine. | |||||
| A SQLite admin UI is available at http://localhost:9091, password `admin` (change it via the `PHPLITEADMIN_PASSWORD` environment variable in `docker-compose.yml`). It's a development-only tool — don't expose this port outside your local machine. | |||||
| Note: host ports `9092`/`9091` map to the app's internal `80`/`8081` — the host side is deliberately different from the container side because Windows reserves `8081` and `9090` in its dynamic TCP port exclusion range (Hyper-V/WSL2), which blocks Docker from binding either. Check with `netsh interface ipv4 show excludedportrange protocol=tcp` if you hit a similar "ports are not available ... forbidden by its access permissions" error on another port. | |||||
| ## Framework guide | ## Framework guide | ||||
| @@ -0,0 +1,40 @@ | |||||
| @extends('layout') | |||||
| @includes('includes/large-feature') | |||||
| <div class="container mx-auto px-8 py-8 md:py-16"> | |||||
| <h1 class="text-3xl font-bold"> | |||||
| Warehouse overview | |||||
| </h1> | |||||
| <div class="flex flex-col md:flex-row md:space-x-4 my-8"> | |||||
| <div class="bg-gray-50 rounded-lg p-4 flex-1"> | |||||
| <div class="text-3xl font-bold">{{ $locationCount }}</div> | |||||
| <div class="text-gray-500">Locations</div> | |||||
| </div> | |||||
| <div class="bg-gray-50 rounded-lg p-4 flex-1"> | |||||
| <div class="text-3xl font-bold">{{ $itemCount }}</div> | |||||
| <div class="text-gray-500">Items</div> | |||||
| </div> | |||||
| <div class="bg-gray-50 rounded-lg p-4 flex-1"> | |||||
| <div class="text-3xl font-bold">{{ $totalStock }}</div> | |||||
| <div class="text-gray-500">Units in stock</div> | |||||
| </div> | |||||
| </div> | |||||
| <h2 class="text-2xl font-bold"> | |||||
| Low stock | |||||
| </h2> | |||||
| @if(count($lowStockItems) === 0) | |||||
| <p class="text-gray-500 my-4">Nothing is at or below its reorder level.</p> | |||||
| @endif | |||||
| @if(count($lowStockItems) > 0) | |||||
| <ul class="my-4 space-y-2"> | |||||
| @foreach($lowStockItems as $item) | |||||
| <li class="bg-red-50 rounded-lg p-4 flex justify-between items-center"> | |||||
| <a href="{{ $item->route }}" class="font-bold underline">{{ $item->name }}</a> | |||||
| <span class="text-red-500">{{ $item->totalQuantity }} left (reorder at {{ $item->reorder_level }})</span> | |||||
| </li> | |||||
| @endforeach | |||||
| </ul> | |||||
| @endif | |||||
| </div> | |||||
| @@ -1,22 +0,0 @@ | |||||
| @extends('layout') | |||||
| @includes('includes/large-feature') | |||||
| @foreach($products as $i => $product) | |||||
| <div class=" | |||||
| z-10 | |||||
| @if($i % 2 === 0) | |||||
| bg-gray-50 | |||||
| @endif | |||||
| "> | |||||
| <div class="container mx-auto px-8 py-8 md:py-16"> | |||||
| <h2 class="text-3xl font-bold"> | |||||
| {{ $product->name }} | |||||
| </h2> | |||||
| <p class="text-xl my-4"> | |||||
| {!! $product->description !!} | |||||
| </p> | |||||
| <a href="{{ $product->route }}" class="bg-indigo-500 rounded-lg p-2 text-white"> | |||||
| Order | |||||
| </a> | |||||
| </div> | |||||
| </div> | |||||
| @endforeach | |||||
| @@ -1,22 +1,24 @@ | |||||
| <div class="bg-gray-900 z-20"> | <div class="bg-gray-900 z-20"> | ||||
| <div class="container mx-auto px-8 py-8 md:py-16 flex flex-col md:flex-row items-center text-center md:text-left"> | <div class="container mx-auto px-8 py-8 md:py-16 flex flex-col md:flex-row items-center text-center md:text-left"> | ||||
| <div class="flex flex-shrink justify-end mb-8 md:-mb-20 pr-0 md:pr-16"> | |||||
| @includes('rocket') | |||||
| <div class="flex flex-shrink justify-end mb-8 md:-mb-4 pr-0 md:pr-16 text-6xl"> | |||||
| 📦 | |||||
| </div> | </div> | ||||
| <div class="flex flex-col justify-center flex-grow"> | <div class="flex flex-col justify-center flex-grow"> | ||||
| <a href="/" class="text-4xl text-white"> | <a href="/" class="text-4xl text-white"> | ||||
| Whoosh! | |||||
| Warehouse | |||||
| </a> | </a> | ||||
| <div class="text-2xl text-gray-300"> | <div class="text-2xl text-gray-300"> | ||||
| A place to buy rocket things | |||||
| Locations, items, and stock, all in one place | |||||
| </div> | </div> | ||||
| <ol class="text-white flex flex-row space-x-2"> | <ol class="text-white flex flex-row space-x-2"> | ||||
| <li><a class="underline" href="/">Home</a></li> | |||||
| <li><a class="underline" href="/">Dashboard</a></li> | |||||
| <li><a class="underline" href="/locations">Locations</a></li> | |||||
| <li><a class="underline" href="/items">Items</a></li> | |||||
| @if(session()->has('user_id')) | @if(session()->has('user_id')) | ||||
| <li><a class="underline" href="/log-out">Log out</a></li> | <li><a class="underline" href="/log-out">Log out</a></li> | ||||
| @endif | @endif | ||||
| @if(!session()->has('user_id')) | @if(!session()->has('user_id')) | ||||
| <li><a class="underline" href="/register">Register</a></li> | |||||
| <li><a class="underline" href="/register">Register / Log in</a></li> | |||||
| @endif | @endif | ||||
| </ol> | </ol> | ||||
| </div> | </div> | ||||
| @@ -1,19 +1,21 @@ | |||||
| <div class="bg-gray-900 z-20"> | <div class="bg-gray-900 z-20"> | ||||
| <div class="container mx-auto px-8 py-4 flex flex-col md:flex-row items-center text-center md:text-left"> | <div class="container mx-auto px-8 py-4 flex flex-col md:flex-row items-center text-center md:text-left"> | ||||
| <div class="flex flex-shrink items-center pr-0 md:pr-4 h-32"> | |||||
| @includes('rocket') | |||||
| <div class="flex flex-shrink items-center pr-0 md:pr-4 h-32 text-5xl"> | |||||
| 📦 | |||||
| </div> | </div> | ||||
| <div class="flex flex-col justify-center flex-grow"> | <div class="flex flex-col justify-center flex-grow"> | ||||
| <a href="/" class="text-4xl text-white"> | <a href="/" class="text-4xl text-white"> | ||||
| Whoosh! | |||||
| Warehouse | |||||
| </a> | </a> | ||||
| <ol class="text-white flex flex-row space-x-2"> | <ol class="text-white flex flex-row space-x-2"> | ||||
| <li><a class="underline" href="/">Home</a></li> | |||||
| <li><a class="underline" href="/">Dashboard</a></li> | |||||
| <li><a class="underline" href="/locations">Locations</a></li> | |||||
| <li><a class="underline" href="/items">Items</a></li> | |||||
| @if(session()->has('user_id')) | @if(session()->has('user_id')) | ||||
| <li><a class="underline" href="/log-out">Log out</a></li> | <li><a class="underline" href="/log-out">Log out</a></li> | ||||
| @endif | @endif | ||||
| @if(!session()->has('user_id')) | @if(!session()->has('user_id')) | ||||
| <li><a class="underline" href="/register">Register</a></li> | |||||
| <li><a class="underline" href="/register">Register / Log in</a></li> | |||||
| @endif | @endif | ||||
| </ol> | </ol> | ||||
| </div> | </div> | ||||
| @@ -0,0 +1,72 @@ | |||||
| @extends('layout') | |||||
| @includes('includes/small-feature') | |||||
| <div class="container mx-auto px-8 py-8 md:py-16"> | |||||
| <h1 class="text-3xl font-bold"> | |||||
| Adjust stock — {{ $item->name }} | |||||
| </h1> | |||||
| <p class="text-gray-500 my-4"> | |||||
| Use this after a physical count to correct the recorded quantity at a location. Enter the new total, not a delta. | |||||
| </p> | |||||
| <ul class="my-4 space-y-1 text-gray-500"> | |||||
| @foreach($locations as $location) | |||||
| <li>{{ $location->code }} — {{ $location->name }}: {{ $quantitiesByLocation[$location->id] ?? 0 }} currently recorded</li> | |||||
| @endforeach | |||||
| </ul> | |||||
| <form | |||||
| method="post" | |||||
| action="{{ $adjustAction }}" | |||||
| class="flex flex-col w-full space-y-4 max-w-xl my-4" | |||||
| > | |||||
| @if(session()->has('adjust_errors')) | |||||
| <ol class="list-disc text-red-500"> | |||||
| @foreach(session('adjust_errors') as $field => $errors) | |||||
| @foreach($errors as $error) | |||||
| <li>{{ $error }}</li> | |||||
| @endforeach | |||||
| @endforeach | |||||
| </ol> | |||||
| @endif | |||||
| <input type="hidden" name="csrf" value="{{ $csrf }}" /> | |||||
| <label for="location_id" class="flex flex-col w-full"> | |||||
| <span class="flex">Location:</span> | |||||
| <select | |||||
| id="location_id" | |||||
| name="location_id" | |||||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||||
| > | |||||
| @foreach($locations as $location) | |||||
| <option value="{{ $location->id }}">{{ $location->code }} — {{ $location->name }}</option> | |||||
| @endforeach | |||||
| </select> | |||||
| </label> | |||||
| <label for="quantity" class="flex flex-col w-full"> | |||||
| <span class="flex">New quantity:</span> | |||||
| <input | |||||
| id="quantity" | |||||
| name="quantity" | |||||
| type="number" | |||||
| min="0" | |||||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||||
| placeholder="0" | |||||
| /> | |||||
| </label> | |||||
| <label for="notes" class="flex flex-col w-full"> | |||||
| <span class="flex">Notes (optional):</span> | |||||
| <input | |||||
| id="notes" | |||||
| name="notes" | |||||
| type="text" | |||||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||||
| placeholder="Physical count on 2026-07-21" | |||||
| /> | |||||
| </label> | |||||
| <button | |||||
| type="submit" | |||||
| class="bg-indigo-500 rounded-lg p-2 text-white" | |||||
| > | |||||
| Save adjustment | |||||
| </button> | |||||
| </form> | |||||
| </div> | |||||
| @@ -0,0 +1,68 @@ | |||||
| @extends('layout') | |||||
| @includes('includes/small-feature') | |||||
| <div class="container mx-auto px-8 py-8 md:py-16"> | |||||
| <h1 class="text-3xl font-bold"> | |||||
| Add an item | |||||
| </h1> | |||||
| <form | |||||
| method="post" | |||||
| action="{{ $createAction }}" | |||||
| class="flex flex-col w-full space-y-4 max-w-xl my-4" | |||||
| > | |||||
| @if(session()->has('create_item_errors')) | |||||
| <ol class="list-disc text-red-500"> | |||||
| @foreach(session('create_item_errors') as $field => $errors) | |||||
| @foreach($errors as $error) | |||||
| <li>{{ $error }}</li> | |||||
| @endforeach | |||||
| @endforeach | |||||
| </ol> | |||||
| @endif | |||||
| <input type="hidden" name="csrf" value="{{ $csrf }}" /> | |||||
| <label for="sku" class="flex flex-col w-full"> | |||||
| <span class="flex">SKU:</span> | |||||
| <input | |||||
| id="sku" | |||||
| name="sku" | |||||
| type="text" | |||||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||||
| placeholder="WH-001" | |||||
| /> | |||||
| </label> | |||||
| <label for="name" class="flex flex-col w-full"> | |||||
| <span class="flex">Name:</span> | |||||
| <input | |||||
| id="name" | |||||
| name="name" | |||||
| type="text" | |||||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||||
| placeholder="Packing tape" | |||||
| /> | |||||
| </label> | |||||
| <label for="description" class="flex flex-col w-full"> | |||||
| <span class="flex">Description:</span> | |||||
| <input | |||||
| id="description" | |||||
| name="description" | |||||
| type="text" | |||||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||||
| /> | |||||
| </label> | |||||
| <label for="reorder_level" class="flex flex-col w-full"> | |||||
| <span class="flex">Reorder level (0 = no alert):</span> | |||||
| <input | |||||
| id="reorder_level" | |||||
| name="reorder_level" | |||||
| type="number" | |||||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||||
| placeholder="10" | |||||
| /> | |||||
| </label> | |||||
| <button | |||||
| type="submit" | |||||
| class="bg-indigo-500 rounded-lg p-2 text-white" | |||||
| > | |||||
| Add item | |||||
| </button> | |||||
| </form> | |||||
| </div> | |||||
| @@ -0,0 +1,35 @@ | |||||
| @extends('layout') | |||||
| @includes('includes/small-feature') | |||||
| <div class="container mx-auto px-8 py-8 md:py-16"> | |||||
| <div class="flex justify-between items-center"> | |||||
| <h1 class="text-3xl font-bold"> | |||||
| Items | |||||
| </h1> | |||||
| @if($canManage) | |||||
| <a href="{{ $createAction }}" class="bg-indigo-500 rounded-lg p-2 text-white"> | |||||
| Add item | |||||
| </a> | |||||
| @endif | |||||
| </div> | |||||
| @if(count($items) === 0) | |||||
| <p class="text-gray-500 my-4">No items yet.</p> | |||||
| @endif | |||||
| <ul class="my-4 space-y-2"> | |||||
| @foreach($items as $item) | |||||
| <li class=" | |||||
| rounded-lg p-4 flex justify-between items-center | |||||
| @if($item->isLowStock) bg-red-50 @endif | |||||
| @if(!$item->isLowStock) bg-gray-50 @endif | |||||
| "> | |||||
| <a href="{{ $item->route }}" class="font-bold underline"> | |||||
| {{ $item->sku }} — {{ $item->name }} | |||||
| </a> | |||||
| <span class="@if($item->isLowStock) text-red-500 @endif"> | |||||
| {{ $item->totalQuantity }} units | |||||
| </span> | |||||
| </li> | |||||
| @endforeach | |||||
| </ul> | |||||
| </div> | |||||
| @@ -0,0 +1,60 @@ | |||||
| @extends('layout') | |||||
| @includes('includes/small-feature') | |||||
| <div class="container mx-auto px-8 py-8 md:py-16"> | |||||
| <h1 class="text-3xl font-bold"> | |||||
| Receive stock — {{ $item->name }} | |||||
| </h1> | |||||
| <form | |||||
| method="post" | |||||
| action="{{ $receiveAction }}" | |||||
| class="flex flex-col w-full space-y-4 max-w-xl my-4" | |||||
| > | |||||
| @if(session()->has('receive_errors')) | |||||
| <ol class="list-disc text-red-500"> | |||||
| @foreach(session('receive_errors') as $field => $errors) | |||||
| @foreach($errors as $error) | |||||
| <li>{{ $error }}</li> | |||||
| @endforeach | |||||
| @endforeach | |||||
| </ol> | |||||
| @endif | |||||
| <input type="hidden" name="csrf" value="{{ $csrf }}" /> | |||||
| <label for="location_id" class="flex flex-col w-full"> | |||||
| <span class="flex">Location:</span> | |||||
| <select | |||||
| id="location_id" | |||||
| name="location_id" | |||||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||||
| > | |||||
| @foreach($locations as $location) | |||||
| <option value="{{ $location->id }}">{{ $location->code }} — {{ $location->name }}</option> | |||||
| @endforeach | |||||
| </select> | |||||
| </label> | |||||
| <label for="quantity" class="flex flex-col w-full"> | |||||
| <span class="flex">Quantity:</span> | |||||
| <input | |||||
| id="quantity" | |||||
| name="quantity" | |||||
| type="number" | |||||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||||
| placeholder="1" | |||||
| /> | |||||
| </label> | |||||
| <label for="notes" class="flex flex-col w-full"> | |||||
| <span class="flex">Notes (optional):</span> | |||||
| <input | |||||
| id="notes" | |||||
| name="notes" | |||||
| type="text" | |||||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||||
| /> | |||||
| </label> | |||||
| <button | |||||
| type="submit" | |||||
| class="bg-indigo-500 rounded-lg p-2 text-white" | |||||
| > | |||||
| Receive | |||||
| </button> | |||||
| </form> | |||||
| </div> | |||||
| @@ -0,0 +1,77 @@ | |||||
| @extends('layout') | |||||
| @includes('includes/small-feature') | |||||
| <div class="container mx-auto px-8 py-8 md:py-16"> | |||||
| <h1 class="text-3xl font-bold"> | |||||
| {{ $item->sku }} — {{ $item->name }} | |||||
| </h1> | |||||
| @if($item->description) | |||||
| <p class="text-xl my-4"> | |||||
| {{ $item->description }} | |||||
| </p> | |||||
| @endif | |||||
| <p class="text-gray-500"> | |||||
| {{ $totalQuantity }} units in stock | |||||
| @if($item->reorder_level > 0) | |||||
| (reorder at {{ $item->reorder_level }}) | |||||
| @endif | |||||
| </p> | |||||
| @if($canManage) | |||||
| <div class="flex space-x-4 my-4"> | |||||
| <a href="{{ $receiveAction }}" class="bg-indigo-500 rounded-lg p-2 text-white">Receive stock</a> | |||||
| <a href="{{ $withdrawAction }}" class="bg-indigo-500 rounded-lg p-2 text-white">Withdraw stock</a> | |||||
| <a href="{{ $adjustAction }}" class="bg-indigo-500 rounded-lg p-2 text-white">Adjust stock</a> | |||||
| </div> | |||||
| @endif | |||||
| <h2 class="text-2xl font-bold mt-8"> | |||||
| Stock by location | |||||
| </h2> | |||||
| @if(count($stock) === 0) | |||||
| <p class="text-gray-500 my-4">Not stored anywhere yet.</p> | |||||
| @endif | |||||
| <ul class="my-4 space-y-2"> | |||||
| @foreach($stock as $row) | |||||
| <li class="bg-gray-50 rounded-lg p-4 flex justify-between items-center"> | |||||
| <a href="{{ $row->locationRoute }}" class="font-bold underline">{{ $row->location->code }} — {{ $row->location->name }}</a> | |||||
| <span class="text-gray-500">{{ $row->quantity }} units</span> | |||||
| </li> | |||||
| @endforeach | |||||
| </ul> | |||||
| <h2 class="text-2xl font-bold mt-8"> | |||||
| Recent activity | |||||
| </h2> | |||||
| @if(count($movements) === 0) | |||||
| <p class="text-gray-500 my-4">No stock movements yet.</p> | |||||
| @endif | |||||
| <ul class="my-4 space-y-2"> | |||||
| @foreach($movements as $movement) | |||||
| <li class="bg-gray-50 rounded-lg p-2 flex justify-between items-center text-sm"> | |||||
| <span> | |||||
| {{ $movement->type }} — {{ $movement->location->code }} | |||||
| @if($movement->notes) | |||||
| — {{ $movement->notes }} | |||||
| @endif | |||||
| </span> | |||||
| <span class=" | |||||
| @if($movement->quantity_change < 0) | |||||
| text-red-500 | |||||
| @endif | |||||
| @if($movement->quantity_change >= 0) | |||||
| text-green-600 | |||||
| @endif | |||||
| "> | |||||
| @if($movement->quantity_change >= 0) | |||||
| + | |||||
| @endif | |||||
| {{ $movement->quantity_change }} | |||||
| </span> | |||||
| </li> | |||||
| @endforeach | |||||
| </ul> | |||||
| </div> | |||||
| @@ -0,0 +1,67 @@ | |||||
| @extends('layout') | |||||
| @includes('includes/small-feature') | |||||
| <div class="container mx-auto px-8 py-8 md:py-16"> | |||||
| <h1 class="text-3xl font-bold"> | |||||
| Withdraw stock — {{ $item->name }} | |||||
| </h1> | |||||
| @if(count($stock) === 0) | |||||
| <p class="text-gray-500 my-4">There's no stock anywhere to withdraw from.</p> | |||||
| @endif | |||||
| @if(count($stock) > 0) | |||||
| <form | |||||
| method="post" | |||||
| action="{{ $withdrawAction }}" | |||||
| class="flex flex-col w-full space-y-4 max-w-xl my-4" | |||||
| > | |||||
| @if(session()->has('withdraw_errors')) | |||||
| <ol class="list-disc text-red-500"> | |||||
| @foreach(session('withdraw_errors') as $field => $errors) | |||||
| @foreach($errors as $error) | |||||
| <li>{{ $error }}</li> | |||||
| @endforeach | |||||
| @endforeach | |||||
| </ol> | |||||
| @endif | |||||
| <input type="hidden" name="csrf" value="{{ $csrf }}" /> | |||||
| <label for="location_id" class="flex flex-col w-full"> | |||||
| <span class="flex">Location:</span> | |||||
| <select | |||||
| id="location_id" | |||||
| name="location_id" | |||||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||||
| > | |||||
| @foreach($stock as $row) | |||||
| <option value="{{ $row->location_id }}">{{ $row->location->code }} — {{ $row->location->name }} ({{ $row->quantity }} available)</option> | |||||
| @endforeach | |||||
| </select> | |||||
| </label> | |||||
| <label for="quantity" class="flex flex-col w-full"> | |||||
| <span class="flex">Quantity:</span> | |||||
| <input | |||||
| id="quantity" | |||||
| name="quantity" | |||||
| type="number" | |||||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||||
| placeholder="1" | |||||
| /> | |||||
| </label> | |||||
| <label for="notes" class="flex flex-col w-full"> | |||||
| <span class="flex">Notes (optional):</span> | |||||
| <input | |||||
| id="notes" | |||||
| name="notes" | |||||
| type="text" | |||||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||||
| /> | |||||
| </label> | |||||
| <button | |||||
| type="submit" | |||||
| class="bg-indigo-500 rounded-lg p-2 text-white" | |||||
| > | |||||
| Withdraw | |||||
| </button> | |||||
| </form> | |||||
| @endif | |||||
| </div> | |||||
| @@ -1,7 +1,7 @@ | |||||
| <!doctype html> | <!doctype html> | ||||
| <html lang="en"> | <html lang="en"> | ||||
| <head> | <head> | ||||
| <title>Whoosh!</title> | |||||
| <title>Warehouse</title> | |||||
| <link rel="preconnect" href="https://fonts.gstatic.com"> | <link rel="preconnect" href="https://fonts.gstatic.com"> | ||||
| <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@500;700&display=swap"> | <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@500;700&display=swap"> | ||||
| <link rel="stylesheet" href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css"> | <link rel="stylesheet" href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css"> | ||||
| @@ -0,0 +1,68 @@ | |||||
| @extends('layout') | |||||
| @includes('includes/small-feature') | |||||
| <div class="container mx-auto px-8 py-8 md:py-16"> | |||||
| <h1 class="text-3xl font-bold"> | |||||
| Add a location | |||||
| </h1> | |||||
| <form | |||||
| method="post" | |||||
| action="{{ $createAction }}" | |||||
| class="flex flex-col w-full space-y-4 max-w-xl my-4" | |||||
| > | |||||
| @if(session()->has('create_location_errors')) | |||||
| <ol class="list-disc text-red-500"> | |||||
| @foreach(session('create_location_errors') as $field => $errors) | |||||
| @foreach($errors as $error) | |||||
| <li>{{ $error }}</li> | |||||
| @endforeach | |||||
| @endforeach | |||||
| </ol> | |||||
| @endif | |||||
| <input type="hidden" name="csrf" value="{{ $csrf }}" /> | |||||
| <label for="code" class="flex flex-col w-full"> | |||||
| <span class="flex">Code:</span> | |||||
| <input | |||||
| id="code" | |||||
| name="code" | |||||
| type="text" | |||||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||||
| placeholder="A1-01" | |||||
| /> | |||||
| </label> | |||||
| <label for="name" class="flex flex-col w-full"> | |||||
| <span class="flex">Name:</span> | |||||
| <input | |||||
| id="name" | |||||
| name="name" | |||||
| type="text" | |||||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||||
| placeholder="Aisle A, Shelf 1" | |||||
| /> | |||||
| </label> | |||||
| <label for="description" class="flex flex-col w-full"> | |||||
| <span class="flex">Description:</span> | |||||
| <input | |||||
| id="description" | |||||
| name="description" | |||||
| type="text" | |||||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||||
| /> | |||||
| </label> | |||||
| <label for="capacity" class="flex flex-col w-full"> | |||||
| <span class="flex">Capacity (optional):</span> | |||||
| <input | |||||
| id="capacity" | |||||
| name="capacity" | |||||
| type="number" | |||||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||||
| placeholder="100" | |||||
| /> | |||||
| </label> | |||||
| <button | |||||
| type="submit" | |||||
| class="bg-indigo-500 rounded-lg p-2 text-white" | |||||
| > | |||||
| Add location | |||||
| </button> | |||||
| </form> | |||||
| </div> | |||||
| @@ -0,0 +1,35 @@ | |||||
| @extends('layout') | |||||
| @includes('includes/small-feature') | |||||
| <div class="container mx-auto px-8 py-8 md:py-16"> | |||||
| <div class="flex justify-between items-center"> | |||||
| <h1 class="text-3xl font-bold"> | |||||
| Locations | |||||
| </h1> | |||||
| @if($canManage) | |||||
| <a href="{{ $createAction }}" class="bg-indigo-500 rounded-lg p-2 text-white"> | |||||
| Add location | |||||
| </a> | |||||
| @endif | |||||
| </div> | |||||
| @if(count($locations) === 0) | |||||
| <p class="text-gray-500 my-4">No locations yet.</p> | |||||
| @endif | |||||
| <ul class="my-4 space-y-2"> | |||||
| @foreach($locations as $location) | |||||
| <li class="bg-gray-50 rounded-lg p-4 flex justify-between items-center"> | |||||
| <a href="{{ $location->route }}" class="font-bold underline"> | |||||
| {{ $location->code }} — {{ $location->name }} | |||||
| </a> | |||||
| <span class="text-gray-500"> | |||||
| {{ $location->totalQuantity }} | |||||
| @if($location->capacity) | |||||
| / {{ $location->capacity }} | |||||
| @endif | |||||
| units | |||||
| </span> | |||||
| </li> | |||||
| @endforeach | |||||
| </ul> | |||||
| </div> | |||||
| @@ -0,0 +1,34 @@ | |||||
| @extends('layout') | |||||
| @includes('includes/small-feature') | |||||
| <div class="container mx-auto px-8 py-8 md:py-16"> | |||||
| <h1 class="text-3xl font-bold"> | |||||
| {{ $location->code }} — {{ $location->name }} | |||||
| </h1> | |||||
| @if($location->description) | |||||
| <p class="text-xl my-4"> | |||||
| {{ $location->description }} | |||||
| </p> | |||||
| @endif | |||||
| @if($location->capacity) | |||||
| <p class="text-gray-500"> | |||||
| Capacity: {{ $location->capacity }} units | |||||
| </p> | |||||
| @endif | |||||
| <h2 class="text-2xl font-bold mt-8"> | |||||
| Stock held here | |||||
| </h2> | |||||
| @if(count($stock) === 0) | |||||
| <p class="text-gray-500 my-4">Nothing is stored here yet.</p> | |||||
| @endif | |||||
| <ul class="my-4 space-y-2"> | |||||
| @foreach($stock as $row) | |||||
| <li class="bg-gray-50 rounded-lg p-4 flex justify-between items-center"> | |||||
| <a href="{{ $row->itemRoute }}" class="font-bold underline">{{ $row->item->name }}</a> | |||||
| <span class="text-gray-500">{{ $row->quantity }} units</span> | |||||
| </li> | |||||
| @endforeach | |||||
| </ul> | |||||
| </div> | |||||
| @@ -1,45 +0,0 @@ | |||||
| @extends('layout') | |||||
| @includes('includes/large-feature') | |||||
| <div class="container mx-auto px-8 py-8 md:py-16"> | |||||
| <h1 class="text-3xl font-bold"> | |||||
| {{ $product->name }} | |||||
| </h1> | |||||
| <p class="text-xl my-4"> | |||||
| {!! $product->description !!} | |||||
| </p> | |||||
| <h2 class="text-2xl font-bold"> | |||||
| Order | |||||
| </h2> | |||||
| <form | |||||
| method="post" | |||||
| action="{{ $orderAction }}" | |||||
| class="flex flex-col w-full space-y-4 max-w-xl" | |||||
| > | |||||
| @if(session()->has('errors')) | |||||
| <ol class="list-disc text-red-500"> | |||||
| @foreach(session('errors') as $field => $errors) | |||||
| @foreach($errors as $error) | |||||
| <li>{{ $error }}</li> | |||||
| @endforeach | |||||
| @endforeach | |||||
| </ol> | |||||
| @endif | |||||
| <input type="hidden" name="csrf" value="{{ $csrf }}" /> | |||||
| <label for="quantity" class="flex flex-col w-full"> | |||||
| <span class="flex">Quantity:</span> | |||||
| <input | |||||
| id="quantity" | |||||
| name="quantity" | |||||
| type="number" | |||||
| class="bg-gray-50 rounded-lg p-2 text-gray-900" | |||||
| placeholder="1" | |||||
| /> | |||||
| </label> | |||||
| <button | |||||
| type="submit" | |||||
| class="bg-indigo-500 rounded-lg p-2 text-white" | |||||
| > | |||||
| Order | |||||
| </button> | |||||
| </form> | |||||
| </div> | |||||
| @@ -10,7 +10,7 @@ class RoutingTest extends TestCase | |||||
| $_SERVER['REQUEST_METHOD'] = 'GET'; | $_SERVER['REQUEST_METHOD'] = 'GET'; | ||||
| $_SERVER['REQUEST_URI'] = '/'; | $_SERVER['REQUEST_URI'] = '/'; | ||||
| $expected = 'Take a trip on a rocket ship'; | |||||
| $expected = 'Warehouse overview'; | |||||
| $this->assertStringContainsString($expected, app()->run()->content()); | $this->assertStringContainsString($expected, app()->run()->content()); | ||||
| } | } | ||||
| @@ -0,0 +1,97 @@ | |||||
| <?php | |||||
| use Framework\Testing\TestCase; | |||||
| use Framework\Testing\TestResponse; | |||||
| class WarehouseTest extends TestCase | |||||
| { | |||||
| public function testCreatingALocationRequiresLogin() | |||||
| { | |||||
| $_SERVER['REQUEST_METHOD'] = 'POST'; | |||||
| $_SERVER['REQUEST_URI'] = '/locations/create'; | |||||
| $_POST['csrf'] = csrf(); | |||||
| $_POST['code'] = 'A1'; | |||||
| $_POST['name'] = 'Aisle 1'; | |||||
| $response = new TestResponse(app()->run()); | |||||
| $this->assertTrue($response->isRedirecting()); | |||||
| $this->assertEquals('/register', $response->redirectingTo()); | |||||
| } | |||||
| public function testLoggedInUserCanCreateALocation() | |||||
| { | |||||
| session()->put('user_id', 1); | |||||
| $_SERVER['REQUEST_METHOD'] = 'POST'; | |||||
| $_SERVER['REQUEST_URI'] = '/locations/create'; | |||||
| $_POST['csrf'] = csrf(); | |||||
| $_POST['code'] = 'A1'; | |||||
| $_POST['name'] = 'Aisle 1'; | |||||
| $_POST['description'] = ''; | |||||
| $response = new TestResponse(app()->run()); | |||||
| $this->assertTrue($response->isRedirecting()); | |||||
| $_SERVER['REQUEST_METHOD'] = 'GET'; | |||||
| $_SERVER['REQUEST_URI'] = '/locations'; | |||||
| $content = app()->run()->content(); | |||||
| $this->assertStringContainsString('Aisle 1', $content); | |||||
| } | |||||
| public function testReceivingStockIncreasesTheItemsQuantity() | |||||
| { | |||||
| session()->put('user_id', 1); | |||||
| $_SERVER['REQUEST_METHOD'] = 'POST'; | |||||
| $_SERVER['REQUEST_URI'] = '/locations/create'; | |||||
| $_POST = [ | |||||
| 'csrf' => csrf(), | |||||
| 'code' => 'B1', | |||||
| 'name' => 'Bay 1', | |||||
| 'description' => '', | |||||
| ]; | |||||
| $locationResponse = new TestResponse(app()->run()); | |||||
| preg_match('#/locations/view/(\d+)#', $locationResponse->redirectingTo(), $locationMatch); | |||||
| $locationId = (int) $locationMatch[1]; | |||||
| $_SERVER['REQUEST_METHOD'] = 'POST'; | |||||
| $_SERVER['REQUEST_URI'] = '/items/create'; | |||||
| $_POST = [ | |||||
| 'csrf' => csrf(), | |||||
| 'sku' => 'SKU-1', | |||||
| 'name' => 'Widget', | |||||
| 'description' => '', | |||||
| 'reorder_level' => '', | |||||
| ]; | |||||
| $itemResponse = new TestResponse(app()->run()); | |||||
| preg_match('#/items/view/(\d+)#', $itemResponse->redirectingTo(), $itemMatch); | |||||
| $itemId = (int) $itemMatch[1]; | |||||
| $_SERVER['REQUEST_METHOD'] = 'POST'; | |||||
| $_SERVER['REQUEST_URI'] = "/items/receive/{$itemId}"; | |||||
| $_POST = [ | |||||
| 'csrf' => csrf(), | |||||
| 'location_id' => (string) $locationId, | |||||
| 'quantity' => '5', | |||||
| 'notes' => '', | |||||
| ]; | |||||
| $receiveResponse = new TestResponse(app()->run()); | |||||
| $this->assertTrue($receiveResponse->isRedirecting()); | |||||
| $_SERVER['REQUEST_METHOD'] = 'GET'; | |||||
| $_SERVER['REQUEST_URI'] = "/items/view/{$itemId}"; | |||||
| $content = app()->run()->content(); | |||||
| $this->assertStringContainsString('5 units in stock', $content); | |||||
| } | |||||
| } | |||||
Powered by TurnKey Linux.