diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 000000000..1e5580d43 --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,24 @@ +name: Create Release +on: + push: + tags: + - 'v*' + +jobs: + create: + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: ${{ github.ref_name }} + body_path: RELEASE.md + generate_release_notes: true + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ember.yml b/.github/workflows/ember.yml index 2f2c96606..d0cffbf40 100644 --- a/.github/workflows/ember.yml +++ b/.github/workflows/ember.yml @@ -6,31 +6,35 @@ on: tags: - 'v*' pull_request: - branches: [ main ] + branches: [ main, 'release/v*', 'dev-v*' ] + +env: + NODE_VERSION: 22.x jobs: build: runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [18.x] # Build on Node.js 18 - steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v2 + - name: Setup Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 with: - node-version: ${{ matrix.node-version }} + node-version: ${{ env.NODE_VERSION }} - name: Setup pnpm - uses: pnpm/action-setup@v2.0.1 + uses: pnpm/action-setup@v4 with: version: latest - name: Install Dependencies - run: pnpm install + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm run lint + + - name: Test + run: pnpm run test:ember - name: Build run: pnpm run build @@ -40,20 +44,20 @@ jobs: runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - - name: Setup Node.js 18.x - uses: actions/setup-node@v2 + - name: Setup Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 with: - node-version: 18.x + node-version: ${{ env.NODE_VERSION }} - name: Setup pnpm - uses: pnpm/action-setup@v2.0.1 + uses: pnpm/action-setup@v4 with: version: latest - name: Install Dependencies - run: pnpm install + run: pnpm install --frozen-lockfile - name: Build run: pnpm run build @@ -69,20 +73,20 @@ jobs: runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - - name: Setup Node.js 18.x - uses: actions/setup-node@v2 + - name: Setup Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 with: - node-version: 18.x + node-version: ${{ env.NODE_VERSION }} - name: Setup pnpm - uses: pnpm/action-setup@v2.0.1 + uses: pnpm/action-setup@v4 with: version: latest - name: Install Dependencies - run: pnpm install + run: pnpm install --frozen-lockfile - name: Build run: pnpm run build diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index badbe2fc0..ab039fd31 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -6,7 +6,7 @@ on: tags: - 'v*' pull_request: - branches: [ main ] + branches: [ main, 'release/v*', 'dev-v*' ] jobs: build: @@ -14,7 +14,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -34,8 +34,11 @@ jobs: - name: Install dependencies run: composer install --prefer-dist --no-progress - - name: Run Lint - run: composer lint + - name: Run PHP CS Fixer Dry Run + run: composer test:lint + + - name: Run Static Analysis + run: composer test:types - # - name: Run Tests -- Will add tests back after phppest issue https://github.com/pestphp/pest/issues/920 is fixed - # run: composer test:unit + - name: Run Tests + run: composer test:unit diff --git a/.gitignore b/.gitignore index 8c54a5a88..f168470b2 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,8 @@ composer.lock /server_vendor /server/vendor/ +/vendor +/.phpunit.cache composer.lock .phpunit.result.cache .php_cs.cache diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..2bd5a0a98 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/.template-lintrc.js b/.template-lintrc.js index 07d5ad82e..066b577ce 100644 --- a/.template-lintrc.js +++ b/.template-lintrc.js @@ -5,7 +5,6 @@ module.exports = { rules: { 'no-invalid-interactive': 'off', 'no-yield-only': 'off', - 'no-down-event-binding': 'off', 'table-groups': 'off', 'link-href-attributes': 'off', 'require-input-label': 'off', diff --git a/README.md b/README.md index 6719e3870..d506393be 100644 --- a/README.md +++ b/README.md @@ -13,35 +13,34 @@ This monorepo contains both the frontend and backend components of the Pallet extension for Fleetbase. The frontend is built using Ember.js and the backend is implemented in PHP. -* PHP 7.3.0 or above -* Ember.js v4.8 or above -* Ember CLI v4.8 or above -* Node.js v18 or above +* PHP 8.0 or above +* Ember.js v5.4 or above +* Ember CLI v5.4 or above +* Node.js v22 for CI builds ## Structure ``` ├── addon ├── app -├── assets ├── translations ├── config ├── node_modules ├── server │ ├── config -│ ├── data │ ├── migrations -│ ├── resources │ ├── src -│ ├── tests -│ └── vendor +│ └── tests +├── server_vendor ├── tests +├── extension.json ├── testem.js ├── index.js ├── package.json ├── phpstan.neon.dist ├── phpunit.xml.dist ├── pnpm-lock.yaml +├── pnpm-workspace.yaml ├── ember-cli-build.js ├── composer.json ├── CONTRIBUTING.md @@ -56,16 +55,238 @@ This monorepo contains both the frontend and backend components of the Pallet ex Install the PHP packages using Composer: ```bash -composer require fleetbase/core-api -composer require fleetbase/fleetops -composer require fleetbase/pallet +composer require fleetbase/pallet-api ``` ### Frontend Install the Ember.js Engine/Addon: ```bash -pnpm install @fleetbase/pallet +pnpm install @fleetbase/pallet-engine +``` + +## Consumable API + +Pallet exposes a versioned public API for end consumers alongside the console's internal one. It follows the same conventions as the FleetOps and Storefront APIs: requests authenticate with an organization API credential, records are addressed by public id, and responses never carry internal uuids. + +```text +https:///pallet/v1/... +``` + +### Authentication + +Every route requires an organization API credential, sent as a bearer token: + +```bash +curl https://api.fleetbase.io/pallet/v1/products \ + -H "Authorization: Bearer $FLEETBASE_API_KEY" +``` + +The middleware behind these routes is named `AuthenticateOnceWithBasicAuth`, but it reads +`Authorization: Bearer` — HTTP basic auth is not accepted. + +The credential resolves the company, and every listing, lookup and write is scoped to it. A record belonging to another company responds `404` rather than `403` — an id you cannot see is indistinguishable from one that does not exist. + +### Conventions + +* **Identifiers.** `id` is the public id (`product_a1b2c3`). Related records are both accepted and returned as public ids, so a consumer never handles a uuid. +* **Errors.** `{"error": "Product resource not found."}` with a conventional status code. +* **Deletes.** `{"id": "...", "object": "product", "deleted": true, "time": "..."}`. +* **Listings** accept `limit` (max 100, default 30), `offset` or `page`, `sort`, and per-column filters. +* **Object type.** Every resource carries an `object` field naming its type. + +### Endpoints + +| Method | Endpoint | Description | +| --- | --- | --- | +| `POST` | `pallet/v1/products` | Create a product | +| `GET` | `pallet/v1/products` | List products | +| `GET` | `pallet/v1/products/{id}` | Retrieve a product | +| `PUT` `PATCH` | `pallet/v1/products/{id}` | Update a product | +| `DELETE` | `pallet/v1/products/{id}` | Delete a product | +| `POST` | `pallet/v1/product-variants` | Create a variant against a product | +| `GET` | `pallet/v1/product-variants` | List variants | +| `GET` | `pallet/v1/product-variants/{id}` | Retrieve a variant | +| `PUT` `PATCH` | `pallet/v1/product-variants/{id}` | Update a variant | +| `DELETE` | `pallet/v1/product-variants/{id}` | Delete a variant | +| `POST` | `pallet/v1/suppliers` | Create a supplier | +| `GET` | `pallet/v1/suppliers` | List suppliers | +| `GET` | `pallet/v1/suppliers/{id}` | Retrieve a supplier | +| `PUT` `PATCH` | `pallet/v1/suppliers/{id}` | Update a supplier | +| `DELETE` | `pallet/v1/suppliers/{id}` | Delete a supplier | +| `POST` | `pallet/v1/warehouses` | Create a warehouse | +| `GET` | `pallet/v1/warehouses` | List warehouses | +| `GET` | `pallet/v1/warehouses/{id}` | Retrieve a warehouse | +| `PUT` `PATCH` | `pallet/v1/warehouses/{id}` | Update a warehouse | +| `DELETE` | `pallet/v1/warehouses/{id}` | Delete a warehouse | +| `POST` | `pallet/v1/warehouse-zones` | Create a zone in a warehouse | +| `GET` | `pallet/v1/warehouse-zones` | List zones | +| `GET` | `pallet/v1/warehouse-zones/{id}` | Retrieve a zone | +| `PUT` `PATCH` | `pallet/v1/warehouse-zones/{id}` | Update a zone | +| `DELETE` | `pallet/v1/warehouse-zones/{id}` | Delete a zone | +| `POST` | `pallet/v1/bin-locations` | Create a bin location | +| `GET` | `pallet/v1/bin-locations` | List bin locations | +| `GET` | `pallet/v1/bin-locations/{id}` | Retrieve a bin location | +| `PUT` `PATCH` | `pallet/v1/bin-locations/{id}` | Update a bin location | +| `DELETE` | `pallet/v1/bin-locations/{id}` | Delete a bin location | +| `GET` | `pallet/v1/inventory` | List stock records | +| `GET` | `pallet/v1/inventory/availability` | How much of a product can be committed | +| `GET` | `pallet/v1/inventory/{id}` | Retrieve a stock record | +| `POST` | `pallet/v1/stock-adjustments` | Adjust stock | +| `GET` | `pallet/v1/stock-adjustments` | List adjustments | +| `GET` | `pallet/v1/stock-adjustments/{id}` | Retrieve an adjustment | +| `POST` | `pallet/v1/purchase-orders` | Create an order with its lines | +| `GET` | `pallet/v1/purchase-orders` | List purchase orders | +| `GET` | `pallet/v1/purchase-orders/{id}` | Retrieve a purchase order | +| `PUT` `PATCH` | `pallet/v1/purchase-orders/{id}` | Update a purchase order | +| `DELETE` | `pallet/v1/purchase-orders/{id}` | Delete a purchase order | +| `POST` | `pallet/v1/purchase-orders/{id}/receive` | Receive stock against lines | +| `POST` | `pallet/v1/sales-orders` | Create an order with its lines | +| `GET` | `pallet/v1/sales-orders` | List sales orders | +| `GET` | `pallet/v1/sales-orders/{id}` | Retrieve a sales order | +| `PUT` `PATCH` | `pallet/v1/sales-orders/{id}` | Update a sales order | +| `DELETE` | `pallet/v1/sales-orders/{id}` | Delete a sales order | +| `POST` | `pallet/v1/sales-orders/{id}/fulfill` | Fulfill lines, deducting stock | +| `POST` | `pallet/v1/stock-transfers` | Create a transfer with its lines | +| `GET` | `pallet/v1/stock-transfers` | List transfers | +| `GET` | `pallet/v1/stock-transfers/{id}` | Retrieve a transfer | +| `DELETE` | `pallet/v1/stock-transfers/{id}` | Delete a transfer | +| `POST` | `pallet/v1/stock-transfers/{id}/approve` | Approve a pending transfer | +| `POST` | `pallet/v1/stock-transfers/{id}/ship` | Ship, deducting from the source | +| `POST` | `pallet/v1/stock-transfers/{id}/receive` | Receive, crediting the destination | +| `POST` | `pallet/v1/stock-transfers/{id}/cancel` | Cancel, restoring shipped stock | +| `GET` | `pallet/v1/batches` | List batches | +| `GET` | `pallet/v1/batches/{id}` | Retrieve a batch | +| `GET` | `pallet/v1/audits` | List audit entries | +| `GET` | `pallet/v1/audits/{id}` | Retrieve an audit entry | + +### What the API deliberately will not do + +Four resources refuse writes that would let a caller describe something that never happened: + +* **Stock levels** have no create, update or delete. Stock is a consequence of receipts, fulfilments, transfers and adjustments — move it through the operation that caused the movement. +* **Adjustments** cannot be updated or deleted. An adjustment records what happened; correcting one means making another, which is why the history shows both. +* **Batches** are read-only. A batch is produced by receiving stock. +* **Audit entries** are read-only. They are written by the system as operations happen. + +Transfers follow the same principle in a different form: there is no settable `status`. Use `approve`, `ship`, `receive` and `cancel`, so the record and the stock move together. + +### Checking availability + +The question an integrator usually has is whether a quantity can be committed, so the endpoint answers that directly rather than returning figures to compare: + +```bash +curl "https://api.fleetbase.io/pallet/v1/inventory/availability?product=product_a1b2c3&quantity=10" \ + -H "Authorization: Bearer $FLEETBASE_API_KEY" +``` + +```json +{ + "object": "inventory_availability", + "product": "product_a1b2c3", + "sku": "WIDGET-001", + "warehouse": null, + "requested_quantity": 10, + "available": true, + "available_quantity": 14, + "reserved_quantity": 6, + "quantity": 20, + "shortage_quantity": 0, + "out_of_stock": false, + "by_warehouse": [ + { + "warehouse": "warehouse_x1y2z3", + "warehouse_name": "Singapore Distribution Center", + "available_quantity": 14, + "reserved_quantity": 6, + "quantity": 20 + } + ] +} +``` + +Totals are aggregated across every warehouse holding the product and broken down per warehouse so a caller can choose where to source from. Pass `warehouse=` to narrow to one, or `sku=` in place of `product=` when integrating against your own catalogue. + +### Receiving against a purchase order + +An order and its lines are created together, then received by line: + +```bash +curl -X POST https://api.fleetbase.io/pallet/v1/purchase-orders \ + -H "Authorization: Bearer $FLEETBASE_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "supplier": "supplier_a1b2c3", + "warehouse": "warehouse_x1y2z3", + "items": [{ "product": "product_a1b2c3", "quantity": 20, "unit_price": 5.00 }] + }' +``` + +```bash +curl -X POST https://api.fleetbase.io/pallet/v1/purchase-orders/purchase_order_d4e5f6/receive \ + -H "Authorization: Bearer $FLEETBASE_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ "items": [{ "id": "poi_g7h8i9", "quantity_received": 12 }] }' +``` + +A receipt is capped at the line's outstanding quantity, so an over-receipt takes what is left rather than being rejected. The order reports `partial` until every line is complete. Fulfilling a sales order works the same way, except that insufficient stock rejects the whole request and leaves inventory untouched. + +## Storefront Inventory Integration + +Pallet is the canonical inventory authority for Storefront products. Storefront products and variants should be linked to Pallet products and variants by durable UUID fields, not by SKU alone: + +* `pallet_products.storefront_product_uuid` +* `pallet_product_variants.storefront_variant_uuid` + +SKU and barcode matching can be used for assisted lookup, but checkout enforcement should use the explicit Storefront link fields. + +### Internal API Contract + +All Storefront integration endpoints are scoped to the authenticated company under the protected internal API prefix: + +```text +pallet/int/v1/storefront/inventory/resolve +pallet/int/v1/storefront/inventory/availability +pallet/int/v1/storefront/inventory/availability-batch +pallet/int/v1/storefront/inventory/link +pallet/int/v1/storefront/inventory/unlink +pallet/int/v1/storefront/inventory/reserve +pallet/int/v1/storefront/inventory/reserve-batch +pallet/int/v1/storefront/inventory/reservations/context +pallet/int/v1/storefront/inventory/reservations/{id}/release +pallet/int/v1/storefront/inventory/reservations/{id}/commit +pallet/int/v1/storefront/inventory/reservations/release-batch +pallet/int/v1/storefront/inventory/reservations/commit-batch +pallet/int/v1/storefront/inventory/reservations/release-context +pallet/int/v1/storefront/inventory/reservations/commit-context +``` + +Storefront should call `availability` or `availability-batch` when cart quantities change, `reserve` or `reserve-batch` when checkout starts, `release` when a checkout expires or is cancelled, and `commit` when an order is captured or fulfilled. Batch and context release/commit endpoints are available for checkout lifecycles that store reservation UUIDs directly or only retain a Storefront checkout/cart/order context. Context release can release expired active reservations so abandoned checkout stock is returned; context commit only uses non-expired active reservations. Availability responses include a stable `inventory_summary` object with total, available, reserved, out-of-stock, low-stock, reorder, Pallet UUID, and Storefront UUID fields. + +Checkout reservations can include `storefront_checkout_uuid`, `storefront_cart_uuid`, `storefront_order_uuid`, `storefront_line_uuid`, and `storefront_reservation_key`. The reservation key is idempotent: retrying the same reservation key with the same quantity returns the existing active reservation, and retrying it with a different quantity replaces the reservation with one for the new quantity, releasing the previously held stock first. Either way a key holds at most one active reservation, so a retried or edited checkout line can never double-reserve stock. + +Product links can be created one product at a time: + +```json +{ + "pallet_product_uuid": "product_...", + "storefront_product_uuid": "..." +} +``` + +Variant links can be supplied individually with `pallet_variant_uuid` and `storefront_variant_uuid`, or in a batch: + +```json +{ + "pallet_product_uuid": "product_...", + "storefront_product_uuid": "...", + "variants": [ + { + "pallet_variant_uuid": "variant_...", + "storefront_variant_uuid": "..." + } + ] +} ``` ## Usage @@ -75,6 +296,7 @@ pnpm install @fleetbase/pallet 🧹 Keep a modern codebase with **PHP CS Fixer**: ```bash composer lint +composer lint:fix ``` ⚗️ Run static analysis using **PHPStan**: @@ -120,4 +342,4 @@ pnpm build See the Contributing Guide for details on how to contribute to this project. ## License -This project is licensed under the MIT License. \ No newline at end of file +This project is licensed under the AGPL-3.0-or-later License. diff --git a/RELEASE.md b/RELEASE.md index 0ab7eca7c..2f6bf53df 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,13 +1,27 @@ -> v0.0.2 ~ "RELEASE_NOTES_PLACEHOLDER — replace this line with the release title" +> v0.0.2 ~ "A working warehouse: stock-safe operations, a consumable API and a rebuilt console" --- ## Highlights -RELEASE_NOTES_PLACEHOLDER +v0.0.1 was a scaffold. v0.0.2 is the first release of Pallet that runs end to end: products, warehouses, stock, purchase and sales orders, and the operations in between, with the stock arithmetic guarded and the console rebuilt on the same patterns as the other Fleetbase extensions. -Describe what changed in this release. The first line above must name the version being -released, and both placeholder markers must be gone, or the release workflow refuses to -tag. +- **Consumable API.** A versioned public API for end consumers alongside the console's internal one, following the FleetOps and Storefront conventions: organization API credentials with bearer auth, records addressed by public id, no internal uuids in responses. It covers products and variants, warehouses, locations and stock, purchase orders and receiving, sales orders and fulfillment, adjustments, transfers, suppliers, batches and the audit trail. The README documents every endpoint, including what the API deliberately will not do. +- **Storefront inventory integration.** Pallet is the canonical inventory authority for Storefront products. Internal endpoints under `pallet/int/v1/storefront/inventory/` resolve, check availability (single and batch), link and unlink Storefront products and variants to Pallet products and variants by durable uuid fields, not by SKU alone. +- **Stock-safe mutations.** Every path that changes stock runs in a transaction with row locks: cancelling a transfer no longer loses stock and completing one no longer destroys it, cycle counts apply atomically, purchase-order receipts are locked and capped against over-receipt, reservations can no longer be released twice, and inventory creation and Storefront linking are transactional with uniqueness enforced. The stock movement ledger is exposed through the API and carries a running balance, and the full quantity set is served on every inventory listing. +- **Operations workflows.** Releasing a wave allocates its pick lists, picking moves stock, expired reservations are swept, and status guards stop invalid transitions. Cycle counts are blind, with an auditable reveal. Purchase orders, sales orders, stock transfers, waves and pick lists each open as a document, the pick list reads as the route a picker walks, and Receive Goods and Fulfill Order report the real reason a request was rejected, with the shortfall named by product. Purchase and sales orders get their own PO- and SO- number series, and partial receipts and fulfillments are audited. +- **Warehouses, zones and bin locations.** Warehouse is now a first-class model with its own table. Zones and bin locations are reachable from the warehouse, batches sort first-expired-first-out, and expiring stock is flagged on the panel header. +- **Dashboard, analytics and reports.** Thirteen metrics endpoints feed twenty-one widgets, the KPI tiles lead to the screens they summarise, unvalued stock is no longer valued at zero, and the low-stock screen agrees with the low-stock KPI. Pallet's tables are registered with the report builder, with a set of starter reports seeded. +- **Console rebuilt.** Every resource sits on the standard list, panel and overlay patterns, documented in `UI_CONVENTIONS.md`: expandable rows for pick lists and count sheets, a line item editor on orders, a rebuilt product form and details panel, pinned row-action columns, placeholder product images, and panels that fit the window. Sidebar navigation, the operations screens, widgets, facilities and inventory are translated. +- **Tenant scoping and validation.** Every tenant-owned listing is scoped to the authenticated company, console creates are validated instead of accepting empty records, unique columns are validated before MySQL answers with a stack trace, and the audit resource no longer leaks internal ids in its snapshots. +- **Audit trail.** The audit model is a WMS operational audit trail with an identifiable subject, a working diff and timestamps, and a deleted record reads as deleted rather than as a row of dashes. +- **Toolchain.** The addon runs on Ember 5.4 with ember-engines and Node 22. The server has a Pest 2 test harness on Testbench, static analysis, and php-cs-fixer, and CI runs lint, tests and build on pull requests into main and release branches. Merging a release branch tags and publishes the release automatically. + +--- +## Upgrading + +- Node 22 or later. The addon needs `@fleetbase/ember-core` ^0.3.22 and `@fleetbase/ember-ui` ^0.3.36. +- The server needs `fleetbase/core-api` and `fleetbase/fleetops-api`. Run migrations after updating: this release adds the `pallet_warehouses` table and new quantity, ledger and Storefront link columns. +- Storefront checkout enforcement should use the explicit `storefront_product_uuid` and `storefront_variant_uuid` link fields; SKU and barcode matching are for assisted lookup only. --- ## Need help? diff --git a/UI_CONVENTIONS.md b/UI_CONVENTIONS.md new file mode 100644 index 000000000..a0437da6d --- /dev/null +++ b/UI_CONVENTIONS.md @@ -0,0 +1,187 @@ +# Pallet UI conventions + +These rules are **derived from the reference modules**, not invented. Every one cites +the file it came from, so a disagreement can be settled by reading that file rather +than by argument. FleetOps is the most developed engine and is the primary reference; +ember-ui is the component library both consume. + +Paths below are relative to `packages/fleetops` or to the installed +`@fleetbase/ember-ui`. + +--- + +## 1. Never hand-roll what ember-ui provides + +Pallet has repeatedly built its own version of a component that already exists. +Before writing markup, check `@fleetbase/ember-ui/addon/components`. + +| Need | Use | Do not | +| ----------------------- | -------------------------------------------------------------------- | --------------------------------------- | +| A list screen | `Layout::Resource::Tabular` | `ContentPanel` wrapping a raw `` | +| Primary identity column | `Table::Cell::ResourceIdentity` | a bespoke `*-cell` component | +| An ID column | `click-to-copy` — the most-used cell in FleetOps (33 uses) | plain text | +| Status | `Table::Cell::Status` / `Badge` | a hand-styled span | +| Row actions | `Table::Cell::Dropdown` | inline buttons per row | +| A numeric column | `cell/count` (Pallet-local; ember-ui's base cell prints `-` for `0`) | `table/cell/base` | +| An enumeration input | `Select` with `@humanize={{true}}` | a free-text `Input` | + +`Table::Cell::ResourceIdentity` accepts `labelPath`, `mediaUrl`, `status`, `size` and +renders the avatar, label and status dot for you — see +`addon/components/cell/vehicle-identity.hbs`, which delegates to it. + +--- + +## 2. Density + +Fleetbase is compact. Measured across FleetOps' `details.hbs` and `form.hbs`: +`gap-2` (184 uses), `text-xs` (166), `text-[11px]` (104), `py-2`/`px-2`/`px-3`. + +- **Table cells**: `px-2 py-2` or `px-3 py-2`. **Not** `px-4 py-3` — Pallet's old + hand-rolled tables used that 74 times and rows came out roughly 1.5× too tall. +- **Field/grid gap**: `gap-2`. +- **Body text**: `text-xs`. Identity labels `text-sm leading-4`. +- **Identity avatars in cells**: `h-5 w-5` (`cell/vehicle-identity.hbs`). +- **Secondary badges in cells**: `text-[10px] leading-4`. +- Long cell text **truncates** (`min-w-0 truncate`) rather than wrapping to a second + line and doubling row height. + +--- + +## 3. The 11px label tier + +This is the single most visible thing Pallet was missing. FleetOps groups fields +under a small uppercase heading spanning the grid: + +```hbs +
+ Vehicle Identification +
+``` + +**But do not copy that recipe verbatim.** ember-ui already sets `.field-name` to +`text-[11px] tracking-wide uppercase font-semibold` (`addon/styles/layout/next.css`), +so a heading styled the same way is pixel-identical to the labels beneath it and +the grouping reads as noise. Measured in the console: heading and label came back +identical on every property. Use `pallet-field-group-heading` — heavier, darker, +ruled off — which is the tier FleetOps' recipe was reaching for. + +For the fields themselves prefer `field-info-container field-vertical-container +dashed-bottom`: label left, value right, one line each. That is what FleetOps' +`order/details/detail.hbs` uses, and it halves the height of a stacked list. + +A panel with more than about six fields should be grouped. An ungrouped wall of +label/value pairs is what makes a screen read as unfinished. + +--- + +## 4. Progressive disclosure + +FleetOps opens the **first** panel and collapses the rest: + +```hbs + + +``` + +`vehicle/details.hbs` has seven panels and opens two. Pallet opened every panel on +every screen, which is why its detail views scroll forever. Open what a reader needs +first; collapse the rest. + +--- + +## 5. Empty values + +Prefer hiding an optional field to printing a placeholder: + +```hbs +{{#if @resource.default_assignee.name}} +
…
+{{/if}} +``` + +Use `n-a` where a dash genuinely communicates "none recorded" — a column that must +keep its shape across rows. Do **not** stack placeholders (`Untitled product`, +`SKU not set`, `No description`, `Uncategorized`, `No supplier`) — five reassuring +strings make a broken record look merely incomplete, and they triple row height. + +`n-a` takes its **second argument as the fallback**. `{{n-a a b}}` means "a, else b" — +if `b` is itself nullable the result is empty, not a dash. Write `{{n-a (or a b)}}`. + +--- + +## 6. Structure of a form + +From `vehicle/form.hbs`: + +```hbs +
+ +
+
+ Group Name +
+ + + +
+
+
+``` + +Every input carries `disabled={{cannot-write @resource}}`. The grid is responsive +(`grid-cols-1 … lg:grid-cols-2`), never a fixed `grid-cols-2`. + +--- + +## 7. Component APIs that have already caught us + +Check the component's own source before assuming an argument name. + +- `Checkbox` reads `@value` and `@label`. It does **not** read `@checked`. +- `notifications.serverError(e)` reads `errors` off the object it is handed. Passing + `{ payload: { errors: [...] } }` silently yields the generic fallback message. +- It renders `errors[0]` only — put the detail in that one string. +- `@belongsTo` defaults to **async**, so the relation is a proxy that stays truthy + when its content is null. Read through Ember's `get`. +- A method called from a template as `(this.thing arg)` must be `@action`, or it + arrives unbound and `this` is undefined. +- A `{{! }}` comment ends at its first `}}`. If the comment quotes a helper, use + `{{!-- --}}`. +- `Overlay::Header` renders its title, status badge and createdAt **only when it + has no default block**. Buttons go in the `actions` named block; putting them + in the default block replaces the whole left section and the title vanishes. +- `MoneyInput` does not read `@disabled`. Its inner `` takes + `...attributes`, so write `disabled={{cannot-write @resource}}` — no `@`. +- `Select` reads `@disabled` **once, in its constructor**, and never updates it. +- `Layout::Resource::Panel` reads `@authSchema`, not `@authScheme`. +- `Layout::Resource::Tabular` has no `@canCreate`, `@canDelete`, + `@searchPlaceholder` or `@resource`. The New button renders only when + `@onPressNew` is passed — omitting it is how you hide it. +- Also silently ignored where Pallet passes them: `CountryName @showFlag`, + `FileUpload @for` / `@multiple`, `CoordinatesInput @onInit`, + `PhoneInput @autocomplete`. + +**Blocks are as easy to get wrong as arguments, and fail just as silently.** Audit +both. Three module-wide defects came from blocks alone: + +- `Overlay::Header` renders its left section — title, status, createdAt — only + when it has **no default block**. Buttons belong in `<:actions>`. +- `Layout::Resource::Tabular` has no `subheader` block. Its only block *replaces + the table*. Anything else goes above the component. +- `Layout::Section::Header`'s only `{{yield}}` sits inside the actions wormhole, + so a block passed to it renders where the buttons go, not under the title. Use + `@subtitle`, or leave it out — FleetOps resource lists carry no description. + +Every entry above came from reading the component's own source. An argument the +component never reads is worse than no argument: it reads as a working control. + +--- + +## 8. Styling + +Pallet carries ~1500 lines of `pallet-*` CSS. Do not add to it where an ember-ui +primitive or a utility class will do. Namespaced classes are acceptable for genuinely +novel surfaces; they are not acceptable as a parallel implementation of something the +design system already ships. + +Never redefine an ember-ui class name globally. diff --git a/addon/adapters/bin-location.js b/addon/adapters/bin-location.js new file mode 100644 index 000000000..5728057cb --- /dev/null +++ b/addon/adapters/bin-location.js @@ -0,0 +1 @@ +export { default } from './pallet'; diff --git a/addon/adapters/cycle-count-item.js b/addon/adapters/cycle-count-item.js new file mode 100644 index 000000000..5728057cb --- /dev/null +++ b/addon/adapters/cycle-count-item.js @@ -0,0 +1 @@ +export { default } from './pallet'; diff --git a/addon/adapters/cycle-count.js b/addon/adapters/cycle-count.js new file mode 100644 index 000000000..5728057cb --- /dev/null +++ b/addon/adapters/cycle-count.js @@ -0,0 +1 @@ +export { default } from './pallet'; diff --git a/addon/adapters/inventory-reservation.js b/addon/adapters/inventory-reservation.js new file mode 100644 index 000000000..5728057cb --- /dev/null +++ b/addon/adapters/inventory-reservation.js @@ -0,0 +1 @@ +export { default } from './pallet'; diff --git a/addon/adapters/pallet-product-variant.js b/addon/adapters/pallet-product-variant.js new file mode 100644 index 000000000..0fc98c4df --- /dev/null +++ b/addon/adapters/pallet-product-variant.js @@ -0,0 +1,7 @@ +import PalletAdapter from './pallet'; + +export default class PalletProductVariantAdapter extends PalletAdapter { + pathForType() { + return 'product-variants'; + } +} diff --git a/addon/adapters/pick-list-item.js b/addon/adapters/pick-list-item.js new file mode 100644 index 000000000..5728057cb --- /dev/null +++ b/addon/adapters/pick-list-item.js @@ -0,0 +1 @@ +export { default } from './pallet'; diff --git a/addon/adapters/pick-list.js b/addon/adapters/pick-list.js new file mode 100644 index 000000000..5728057cb --- /dev/null +++ b/addon/adapters/pick-list.js @@ -0,0 +1 @@ +export { default } from './pallet'; diff --git a/addon/adapters/purchase-order-item.js b/addon/adapters/purchase-order-item.js new file mode 100644 index 000000000..5728057cb --- /dev/null +++ b/addon/adapters/purchase-order-item.js @@ -0,0 +1 @@ +export { default } from './pallet'; diff --git a/addon/adapters/sales-order-item.js b/addon/adapters/sales-order-item.js new file mode 100644 index 000000000..5728057cb --- /dev/null +++ b/addon/adapters/sales-order-item.js @@ -0,0 +1 @@ +export { default } from './pallet'; diff --git a/addon/adapters/stock-transfer-item.js b/addon/adapters/stock-transfer-item.js new file mode 100644 index 000000000..5728057cb --- /dev/null +++ b/addon/adapters/stock-transfer-item.js @@ -0,0 +1 @@ +export { default } from './pallet'; diff --git a/addon/adapters/stock-transfer.js b/addon/adapters/stock-transfer.js new file mode 100644 index 000000000..5728057cb --- /dev/null +++ b/addon/adapters/stock-transfer.js @@ -0,0 +1 @@ +export { default } from './pallet'; diff --git a/addon/adapters/warehouse-zone.js b/addon/adapters/warehouse-zone.js new file mode 100644 index 000000000..5728057cb --- /dev/null +++ b/addon/adapters/warehouse-zone.js @@ -0,0 +1 @@ +export { default } from './pallet'; diff --git a/addon/adapters/wave.js b/addon/adapters/wave.js new file mode 100644 index 000000000..5728057cb --- /dev/null +++ b/addon/adapters/wave.js @@ -0,0 +1 @@ +export { default } from './pallet'; diff --git a/addon/components/admin/product-category.hbs b/addon/components/admin/product-category.hbs index 7819d8cb9..b12f6084b 100644 --- a/addon/components/admin/product-category.hbs +++ b/addon/components/admin/product-category.hbs @@ -1,15 +1,20 @@
-
+ {{#if this.isLoading}} +
+ +
+ {{/if}}
    {{#each this.categories as |category|}}
    {{category.name}}
    @@ -20,12 +25,16 @@
  • {{subCategory.name}}
{{/each}} {{/if}} + {{else}} + {{#unless this.isLoading}} +
  • No product categories yet.
  • + {{/unless}} {{/each}} diff --git a/addon/components/admin/product-category.js b/addon/components/admin/product-category.js index d5581a480..8b75af31d 100644 --- a/addon/components/admin/product-category.js +++ b/addon/components/admin/product-category.js @@ -13,8 +13,9 @@ export default class AdminProductCategoryComponent extends Component { @service hostRouter; @tracked categories = []; @tracked selectedCategory; - @tracked isLoading = false; @tracked buttonTitle = null; + @tracked isLoading = false; + @tracked isDeleting = false; constructor() { super(...arguments); @@ -22,6 +23,10 @@ export default class AdminProductCategoryComponent extends Component { this.fetchCategoryHierarchy(); } + getRecordUuid(record) { + return record?.uuid ?? record?.id; + } + @action async addCategory() { const category = this.store.createRecord('category', { for: 'pallet_product', @@ -39,13 +44,13 @@ export default class AdminProductCategoryComponent extends Component { file, { path: `uploads/${category.company_uuid}/product-category-icon/${dasherize(category.name ?? this.currentUser.companyId)}`, - subject_uuid: category.id, + subject_uuid: this.getRecordUuid(category), subject_type: `category`, type: `category_icon`, }, (uploadedFile) => { category.setProperties({ - icon_file_uuid: uploadedFile.id, + icon_file_uuid: this.getRecordUuid(uploadedFile), icon_url: uploadedFile.url, icon: uploadedFile, }); @@ -64,15 +69,23 @@ export default class AdminProductCategoryComponent extends Component { } @action async fetchCategoryHierarchy() { - const allCategories = await this.store.query('category', { - for: 'pallet_product', - with_subcategories: true, - }); + this.isLoading = true; - this.categories = allCategories.filter((category) => !category.parent); - this.categories.forEach((parentCategory) => { - parentCategory.subcategories = allCategories.filter((subcategory) => subcategory.parent?.id === parentCategory.id); - }); + try { + const allCategories = await this.store.query('category', { + for: 'pallet_product', + with_subcategories: true, + }); + + this.categories = allCategories.filter((category) => !category.parent); + this.categories.forEach((parentCategory) => { + parentCategory.subcategories = allCategories.filter((subcategory) => subcategory.parent?.id === parentCategory.id); + }); + } catch (error) { + this.notifications.serverError(error); + } finally { + this.isLoading = false; + } } @action async addSubCategory(parentCategory) { @@ -94,13 +107,13 @@ export default class AdminProductCategoryComponent extends Component { file, { path: `uploads/${category.company_uuid}/product-category-icon/${dasherize(category.name ?? this.currentUser.companyId)}`, - subject_uuid: category.id, + subject_uuid: this.getRecordUuid(category), subject_type: `category`, type: `category_icon`, }, (uploadedFile) => { category.setProperties({ - icon_file_uuid: uploadedFile.id, + icon_file_uuid: this.getRecordUuid(uploadedFile), icon_url: uploadedFile.url, icon: uploadedFile, }); @@ -115,25 +128,32 @@ export default class AdminProductCategoryComponent extends Component { this.notifications.success('New subcategory created.'); await this.fetchCategoryHierarchy(); } catch (error) { - this.notifications.error('Error creating subcategory.'); - console.error('Error creating subcategory:', error); + this.notifications.serverError(error); + modal.stopLoading(); } }, }); } - @action async deleteCategory(category) { - const confirmation = confirm(`Are you sure you want to delete the category "${category.name}"?`); + @action deleteCategory(category) { + return this.modalsManager.confirm({ + title: `Are you sure you want to delete the category "${category.name}"?`, + body: 'Products assigned to this category will no longer be categorized.', + confirm: async (modal) => { + modal.startLoading(); + this.isDeleting = true; - if (confirmation) { - try { - await category.destroyRecord(); - this.notifications.success('Category deleted successfully.'); - await this.fetchCategoryHierarchy(); - } catch (error) { - this.notifications.error('Error deleting category.'); - console.error('Error deleting category:', error); - } - } + try { + await category.destroyRecord(); + this.notifications.success('Category deleted successfully.'); + await this.fetchCategoryHierarchy(); + } catch (error) { + this.notifications.serverError(error); + modal.stopLoading(); + } finally { + this.isDeleting = false; + } + }, + }); } } diff --git a/addon/components/audit/diff.hbs b/addon/components/audit/diff.hbs new file mode 100644 index 000000000..3119f2bda --- /dev/null +++ b/addon/components/audit/diff.hbs @@ -0,0 +1,43 @@ +
    + {{#if this.changes.length}} +
    {{t "audit.diff.changed"}}
    +
    + + + + + + + + + {{#each this.changes key="key" as |change|}} + + + + + + {{/each}} + +
    {{t "audit.diff.field"}}{{t "audit.diff.before"}}{{t "audit.diff.after"}}
    {{smart-humanize change.key}}{{n-a change.before}}{{n-a change.after}}
    + {{/if}} + + {{#if this.meta.length}} +
    {{t "audit.diff.context"}}
    + + + {{#each this.meta key="key" as |entry|}} + + + + + {{/each}} + +
    {{smart-humanize entry.key}}{{n-a entry.value}}
    + {{/if}} + + {{#unless this.hasDetail}} + {{! An event that changed nothing and carried no context is a real case — a + transition logged for the record. Saying so beats an empty panel. }} +
    {{t "audit.diff.no-detail"}}
    + {{/unless}} + diff --git a/addon/components/audit/diff.js b/addon/components/audit/diff.js new file mode 100644 index 000000000..676cd28e6 --- /dev/null +++ b/addon/components/audit/diff.js @@ -0,0 +1,61 @@ +import Component from '@glimmer/component'; + +/** + * What actually changed in one audit event. + * + * SCREENS.md §G's must-never for this screen is showing before and after as a raw JSON + * blob in the row. A blob is technically complete and practically unreadable: the + * reader has to diff two objects by eye to find the one field that moved. + * + * So this pairs old and new by key and shows **only the keys that differ**. An audit + * row for a status change should read as one line, not as forty unchanged fields with + * one buried among them. + */ +export default class AuditDiffComponent extends Component { + get changes() { + const before = this.args.resource?.old_values ?? {}; + const after = this.args.resource?.new_values ?? {}; + const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort(); + + return keys.map((key) => ({ key, before: this.format(before[key]), after: this.format(after[key]) })).filter((change) => change.before !== change.after); + } + + /** + * Metadata is separate from the diff: it is context the event carried, not something + * that changed. Flattened to one line per entry so it reads as facts rather than as + * a nested structure. + */ + get meta() { + const meta = this.args.resource?.meta; + + if (!meta || typeof meta !== 'object') { + return []; + } + + return Object.keys(meta) + .sort() + .map((key) => ({ key, value: this.format(meta[key]) })); + } + + get hasDetail() { + return this.changes.length > 0 || this.meta.length > 0; + } + + /** + * Objects and arrays still have to be shown somehow; they are stringified rather + * than dumped over several lines, and only ever inside a single field's cell — the + * must-never is about the whole record arriving as a blob, not about a nested value + * being unrepresentable. + */ + format(value) { + if (value === null || value === undefined || value === '') { + return null; + } + + if (typeof value === 'object') { + return JSON.stringify(value); + } + + return String(value); + } +} diff --git a/addon/components/batch-form-panel.hbs b/addon/components/batch-form-panel.hbs index 9d275d44b..2c177c890 100644 --- a/addon/components/batch-form-panel.hbs +++ b/addon/components/batch-form-panel.hbs @@ -3,7 +3,7 @@ @position="right" @noBackdrop={{true}} @fullHeight={{true}} - @isResizeble={{or this.isResizable @isResizable}} + @isResizable={{or this.isResizable @isResizable}} @width={{or this.width @width "600px"}} > -
    + <:actions>
    +
    @@ -38,8 +40,7 @@ @triggerClass="form-select form-input" @infiniteScroll={{false}} @renderInPlace={{true}} - @onChange={{fn (mut this.batch.product)}} - @onChangeId={{fn (mut this.batch.product_uuid)}} + @onChange={{this.setProduct}} as |model| > {{model.name}} diff --git a/addon/components/batch-form-panel.js b/addon/components/batch-form-panel.js index a43f1fda6..a4ecfe5b9 100644 --- a/addon/components/batch-form-panel.js +++ b/addon/components/batch-form-panel.js @@ -2,6 +2,7 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; import contextComponentCallback from '@fleetbase/ember-core/utils/context-component-callback'; import applyContextComponentArguments from '@fleetbase/ember-core/utils/apply-context-component-arguments'; @@ -37,12 +38,6 @@ export default class BatchFormPanelComponent extends Component { */ @tracked context; - /** - * Indicates whether the component is in a loading state. - * @type {boolean} - */ - @tracked isLoading = false; - /** * Fuel Report status * @type {Array} @@ -58,6 +53,17 @@ export default class BatchFormPanelComponent extends Component { applyContextComponentArguments(this); } + getRecordUuid(record) { + return record?.uuid ?? record?.id; + } + + @action setProduct(product) { + this.batch.product = product; + this.batch.product_uuid = this.getRecordUuid(product); + this.batch.variant = null; + this.batch.variant_uuid = null; + } + /** * Sets the overlay context. * @@ -75,31 +81,21 @@ export default class BatchFormPanelComponent extends Component { * @action * @returns {Promise} */ - @action save() { + @task *saveTask() { const { batch } = this; this.loader.showLoader('.next-content-overlay-panel-container', { loadingMessage: 'Saving batch...', preserveTargetPosition: true }); - this.isLoading = true; - contextComponentCallback(this, 'onBeforeSave', batch); try { - return batch - .save() - .then((batch) => { - this.notifications.success(`Batch saved successfully.`); - contextComponentCallback(this, 'onAfterSave', batch); - }) - .catch((error) => { - this.notifications.serverError(error); - }) - .finally(() => { - this.loader.removeLoader('.next-content-overlay-panel-container '); - this.isLoading = false; - }); + const savedBatch = yield batch.save(); + this.notifications.success(`Batch saved successfully.`); + contextComponentCallback(this, 'onAfterSave', savedBatch); + return savedBatch; } catch (error) { + this.notifications.serverError(error); + } finally { this.loader.removeLoader('.next-content-overlay-panel-container '); - this.isLoading = false; } } diff --git a/addon/components/batch-panel.hbs b/addon/components/batch-panel.hbs index bb0031df7..8b4fc059b 100644 --- a/addon/components/batch-panel.hbs +++ b/addon/components/batch-panel.hbs @@ -7,8 +7,11 @@ @width={{or this.width @width "600px"}} > -