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..97b2a7e65 100644 --- a/.github/workflows/ember.yml +++ b/.github/workflows/ember.yml @@ -8,29 +8,33 @@ on: pull_request: branches: [ main ] +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..68833a4a8 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -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 Tests -- Will add tests back after phppest issue https://github.com/pestphp/pest/issues/920 is fixed - # run: composer test:unit + - name: Run Static Analysis + run: composer test:types + + - 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/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"}} > -