PhoenixKitCatalogue

Catalogue module for PhoenixKit — product catalogue management with manufacturers, suppliers, categories, and items.

Designed for manufacturing companies (e.g. kitchen/furniture producers) that need to organize materials and components from multiple manufacturers and suppliers.

Features

Installation

Add to your parent app's mix.exs:

{:phoenix_kit_catalogue, "~> 0.1"}

Then:

mix deps.get

Development: During local development, you can use a path dependency instead: {:phoenix_kit_catalogue, path: "../phoenix_kit_catalogue"}

The module auto-discovers via beam scanning. Enable it in Admin > Modules.

Data Model

Manufacturer (1) ──< ManufacturerSupplier >── (1) Supplier
(many-to-many)
└──────────────────────────────┐
Catalogue (1) ──> Category (many) ──> Item (many)
(kind= ├── belongs_to Category (optional)
standard| ├── belongs_to Manufacturer (optional)
smart) │ └── has_many CatalogueRule (smart only)
└── references another Catalogue
with (value, unit, position)
├── position-ordered, soft-deletable
└── self-FK parent_uuid (V103) arbitrary-depth tree;
NULL means root; positions scoped per sibling group

All tables use UUIDv7 primary keys and are prefixed with phoenix_kit_cat_*.

Status & kind values

EntityStatuses
Catalogueactive, archived, deleted (plus kind: standard | smart)
Categoryactive, deleted
Itemactive, inactive, discontinued, deleted
Manufactureractive, inactive
Supplieractive, inactive
CatalogueRule(no status — rows are deleted directly when removed)

kind is an enum at the DB layer (CHECK (kind IN ('standard', 'smart'))). unit on rules is open-ended VARCHAR; v1 ships with "percent" and "flat" but consumers can introduce new units without a migration.

Soft-Delete System

First delete sets status to "deleted" (recoverable). Permanent delete removes from DB.

Cascade Behaviour

Downward on trash/permanent-delete:

Upward on restore:

All cascading operations run in database transactions.

API

The public API lives in PhoenixKitCatalogue.Catalogue. Every function has @doc documentation — use h/1 in IEx to explore.

Mutating functions return {:ok, struct} on success or {:error, reason} where reason is an atom from a fixed vocabulary (e.g. :would_create_cycle, :cross_catalogue, :not_siblings, :catalogue_not_found), a tagged tuple (e.g. {:referenced_by_smart_items, count}), or an Ecto.Changeset. Translate atoms to user-facing strings via PhoenixKitCatalogue.Errors.message/1 at the UI boundary — typically inside a LiveView's put_flash(:error, ...). Unknown atoms fall through to a diagnostic "Unexpected error: <inspect>".

Quick Reference

alias PhoenixKitCatalogue.Catalogue
# ── Catalogues ────────────────────────────────────────
Catalogue.list_catalogues() # excludes deleted
Catalogue.list_catalogues(status: "deleted") # only deleted
Catalogue.list_catalogues_by_name_prefix("Kit") # case-insensitive prefix match
Catalogue.list_catalogues_by_name_prefix("Kit", limit: 5, status: "archived")
Catalogue.create_catalogue(%{name: "Kitchen"})
Catalogue.update_catalogue(cat, %{name: "New Name"})
Catalogue.trash_catalogue(cat) # soft-delete (cascades down)
Catalogue.restore_catalogue(cat) # restore (cascades down)
Catalogue.permanently_delete_catalogue(cat) # hard-delete (cascades down)
# ── Categories ────────────────────────────────────────
Catalogue.list_categories_for_catalogue(cat_uuid) # excludes deleted
Catalogue.list_all_categories() # "Catalogue / Ancestor / Child" breadcrumb format
Catalogue.create_category(%{name: "Frames", catalogue_uuid: cat.uuid})
Catalogue.create_category(%{name: "Nested", catalogue_uuid: cat.uuid, parent_uuid: parent.uuid})
Catalogue.trash_category(category) # cascades through the whole subtree (V103)
Catalogue.restore_category(category) # restores deleted ancestors + subtree + items
Catalogue.permanently_delete_category(category) # hard-deletes subtree; cannot be undone
Catalogue.move_category_to_catalogue(category, target_uuid) # moves the whole subtree
Catalogue.next_category_position(cat_uuid) # root-level (parent_uuid: nil)
Catalogue.next_category_position(cat_uuid, parent_uuid) # scoped to a sibling group
# ── Nested categories (V103) ──────────────────────────
Catalogue.list_category_tree(cat_uuid)
# => [{%Category{}, 0}, {%Category{}, 1}, ...] # depth-first with depth
Catalogue.list_category_tree(cat_uuid, exclude_subtree_of: editing.uuid)
Catalogue.list_category_ancestors(child_uuid) # [root, ..., direct_parent]
Catalogue.move_category_under(child, parent.uuid) # reparent within the catalogue
Catalogue.move_category_under(child, nil) # promote to root
# => {:error, :would_create_cycle | :cross_catalogue | :parent_not_found}
Catalogue.swap_category_positions(a, b) # siblings only
# => {:error, :not_siblings} for non-siblings
# ── Items ─────────────────────────────────────────────
Catalogue.list_items() # all non-deleted, preloads all
Catalogue.list_items(status: "active", limit: 100) # with filters
Catalogue.list_items_for_category(cat_uuid) # excludes deleted
Catalogue.list_items_for_catalogue(cat_uuid) # excludes deleted
Catalogue.create_item(%{name: "Oak Panel", base_price: 25.50, sku: "OAK-18", catalogue_uuid: cat.uuid})
Catalogue.trash_item(item) # soft-delete
Catalogue.restore_item(item) # cascades up to category + catalogue
Catalogue.permanently_delete_item(item) # hard-delete
Catalogue.trash_items_in_category(cat_uuid) # bulk soft-delete
Catalogue.move_item_to_category(item, new_cat_uuid)
Catalogue.item_pricing(item)
# => %{
# base_price:, catalogue_markup:, item_markup:, markup_percentage:, sale_price:,
# catalogue_discount:, item_discount:, discount_percentage:, discount_amount:, final_price:
# }
# ── Smart catalogues ─────────────────────────────────
{:ok, services} = Catalogue.create_catalogue(%{name: "Services", kind: "smart"})
Catalogue.list_catalogues(kind: :smart)
{:ok, delivery} = Catalogue.create_item(%{
name: "Delivery",
catalogue_uuid: services.uuid,
default_value: 5, # fallback if a rule row has no value
default_unit: "percent" # fallback if a rule row has no unit
})
# Replace-all rules — one row per referenced catalogue
{:ok, rules} = Catalogue.put_catalogue_rules(delivery, [
%{referenced_catalogue_uuid: kitchen.uuid, value: 10, unit: "percent"},
%{referenced_catalogue_uuid: hardware.uuid, value: 20, unit: "flat"},
%{referenced_catalogue_uuid: plumbing.uuid} # inherits defaults: 5 percent
])
Catalogue.list_catalogue_rules(delivery)
Catalogue.catalogue_rule_map(delivery) # %{uuid => %CatalogueRule{}}
Catalogue.list_items_referencing_catalogue(kitchen.uuid)
Catalogue.catalogue_reference_count(kitchen.uuid)
# Resolve a single rule's effective {value, unit} (with item-default fallback)
CatalogueRule.effective(rule, delivery)
# Per-item overrides (nullable — `nil` inherits from catalogue, any value including 0 overrides)
Catalogue.create_item(%{
name: "Special Oak",
base_price: 100,
markup_percentage: 50, # override catalogue's markup
discount_percentage: 0, # explicit "no discount" even if catalogue has one
catalogue_uuid: cat.uuid
})
# Pure helpers on Item (no Repo hits — caller supplies the catalogue leg values)
Item.sale_price(item, catalogue.markup_percentage) # post-markup
Item.final_price(item, catalogue.markup_percentage, catalogue.discount_percentage) # post-discount
Item.discount_amount(item, catalogue.markup_percentage, catalogue.discount_percentage)
Item.effective_markup(item, catalogue.markup_percentage)
Item.effective_discount(item, catalogue.discount_percentage)
Catalogue.swap_category_positions(cat_a, cat_b) # atomic position swap
# ── Manufacturers ─────────────────────────────────────
Catalogue.list_manufacturers(status: "active")
Catalogue.create_manufacturer(%{name: "Blum", website: "https://blum.com"})
Catalogue.delete_manufacturer(m) # hard-delete
# ── Suppliers ─────────────────────────────────────────
Catalogue.list_suppliers(status: "active")
Catalogue.create_supplier(%{name: "Regional Distributors"})
Catalogue.delete_supplier(s) # hard-delete
# ── Manufacturer ↔ Supplier Links ─────────────────────
Catalogue.link_manufacturer_supplier(m_uuid, s_uuid)
Catalogue.unlink_manufacturer_supplier(m_uuid, s_uuid)
Catalogue.sync_manufacturer_suppliers(m_uuid, [s1_uuid, s2_uuid])
Catalogue.list_suppliers_for_manufacturer(m_uuid)
Catalogue.list_manufacturers_for_supplier(s_uuid)
# ── Search ────────────────────────────────────────────
Catalogue.search_items("oak") # global across all catalogues
Catalogue.search_items("oak", limit: 10)
Catalogue.search_items("oak", limit: 100, offset: 100) # paging
Catalogue.search_items("oak", catalogue_uuids: [a, b]) # only these catalogues
Catalogue.search_items("oak", category_uuids: [c1, c2]) # only these categories
Catalogue.search_items("oak", catalogue_uuids: [a], category_uuids: [c1]) # AND
Catalogue.search_items_in_catalogue(cat_uuid, "panel") # convenience wrapper
Catalogue.search_items_in_category(cat_uuid, "oak") # convenience wrapper
# Nested categories: scoping by a parent category also matches items
# in descendant categories (default since V103). Pass false to scope
# strictly to the given UUIDs.
Catalogue.search_items("oak", category_uuids: [root_uuid]) # matches descendants
Catalogue.search_items("oak", category_uuids: [root_uuid], include_descendants: false) # literal set only
# Unbounded total for paging / summaries (accepts the same scope filters)
Catalogue.count_search_items("oak")
Catalogue.count_search_items("oak", catalogue_uuids: [a, b])
Catalogue.count_search_items_in_catalogue(cat_uuid, "panel")
Catalogue.count_search_items_in_category(cat_uuid, "oak")
# Compose with the prefix lookup
uuids =
"Kit"
|> Catalogue.list_catalogues_by_name_prefix()
|> Enum.map(& &1.uuid)
Catalogue.search_items("oak", catalogue_uuids: uuids)
# ── Counts ────────────────────────────────────────────
Catalogue.item_count_for_catalogue(cat_uuid) # active items
Catalogue.category_count_for_catalogue(cat_uuid) # active categories
Catalogue.deleted_count_for_catalogue(cat_uuid) # deleted items + categories
Catalogue.deleted_catalogue_count()
# ── Multilang ─────────────────────────────────────────
Catalogue.get_translation(record, "ja")
Catalogue.set_translation(record, "ja", field_data, &Catalogue.update_catalogue/2)

Reusable Components

Import into any LiveView:

import PhoenixKitCatalogue.Web.Components

item_table/1

Data-driven item table with opt-in columns, actions, and card view:

<%!-- Minimal --%>
<.item_table items={@items} columns={[:name, :sku]} />
<%!-- Full featured with card view --%>
<.item_table
items={@items}
columns={[:name, :sku, :base_price, :price, :unit, :status]}
markup_percentage={@catalogue.markup_percentage}
edit_path={&Paths.item_edit/1}
on_delete="delete_item"
cards={true}
id="my-items"
/>

Available columns: :name, :sku, :base_price, :price (post-markup), :discount, :final_price (post-discount), :unit, :status, :category, :catalogue, :manufacturer. Pass markup_percentage={@cat.markup_percentage} when using :price or :final_price; pass discount_percentage={@cat.discount_percentage} when using :discount or :final_price.

Unknown columns render as "—" with a logger warning. Unloaded associations, nil values, and invalid markup types are handled gracefully — the component never crashes the page.

search_input/1

Search bar with debounce and clear button:

<.search_input query={@search_query} placeholder="Search..." />

view_mode_toggle/1

Global table/card toggle that syncs all tables sharing the same storage_key:

<.view_mode_toggle storage_key="my-items" />
<.item_table cards={true} show_toggle={false} storage_key="my-items" id="table-1" ... />
<.item_table cards={true} show_toggle={false} storage_key="my-items" id="table-2" ... />

scope_selector/1

Disclosure with catalogue/category checkbox lists for narrowing a search. Pairs with Catalogue.search_items/2's :catalogue_uuids / :category_uuids:

<.scope_selector
catalogues={@scope_catalogues}
categories={@scope_categories}
selected_catalogue_uuids={@selected_catalogue_uuids}
selected_category_uuids={@selected_category_uuids}
/>

Emits four events (names customizable via attrs): toggle_catalogue_scope, toggle_category_scope, clear_catalogue_scope, clear_category_scope. The LV owns the selected-UUIDs lists and feeds them into the search opts. Either catalogues or categories can be empty — the corresponding section is omitted.

catalogue_rules_picker/1

Smart-catalogue rule editor — one row per candidate catalogue with a checkbox, a numeric value input, and a unit dropdown. Pairs with Catalogue.put_catalogue_rules/3:

<.catalogue_rules_picker
catalogues={@candidate_catalogues}
rules={@working_rules}
item_default_value={@item_default_value}
/>

Emits four customizable events: toggle_catalogue_rule, set_catalogue_rule_value, set_catalogue_rule_unit, clear_catalogue_rules. The LV owns working_rules as a %{referenced_catalogue_uuid => %{value, unit}} map and calls put_catalogue_rules/3 on save. Rows with blank values show Inherit: N as placeholder when an item default is set. The per-row unit dropdown is self-contained — toggling a row on defaults its unit to "percent" and the item's default_unit does not cascade into rule rows.

item_picker/1

Combobox for picking a single item by searching across a scoped set of catalogues/categories. Server-side search, colocated keyboard-handling hook (arrow keys, enter, escape, home/end), debounced input. Pairs with the nested-category search: category scopes expand through descendants by default.

<.item_picker
id={"row-#{@row.id}-picker"}
category_uuids={[@category_uuid]}
selected_item={@row.item}
excluded_uuids={@used_uuids}
locale="en"
/>

The parent LV handles two messages:

def handle_info({:item_picker_select, id, %Item{} = item}, socket), do: ...
def handle_info({:item_picker_clear, id}, socket), do: ...

The dropdown is absolutely positioned with z-50; ancestor containers must not overflow: hidden.

search_results_summary/1 and empty_state/1

<%!-- Full result set loaded --%>
<.search_results_summary count={@total} query={@query} />
<%!-- Paged results — renders "Showing 100 of 237 results for …" --%>
<.search_results_summary count={@total} query={@query} loaded={length(@results)} />
<.empty_state message="No items yet." />

All component text (column headers, action labels, toggle tooltips, result counts) is localizable via PhoenixKit's Gettext backend.

All three resource types support a featured image through the shared PhoenixKitCatalogue.Attachments module. Catalogues and items additionally support an inline files dropzone; categories keep the lightweight featured-image-only treatment (they're a taxonomy node, so a full file grid per category is overkill). Each resource owns one folder in phoenix_kit_media_folders (named catalogue-<uuid> / catalogue-category-<uuid> / catalogue-item-<uuid>); data["files_folder_uuid"] on the resource points at it, and data["featured_image_uuid"] points at a phoenix_kit_files row. Folders are created lazily the first time the picker opens, so resources that never set a featured image don't materialize one.

The featured-image picker opens phoenix_kit's MediaSelectorModal scoped to the resource's folder (via a new scope_folder_id attr in phoenix_kit core) — browse and post-upload home folder are both constrained to that scope, so uploading the same file to two items creates a FolderLink rather than yanking the file between resources. See lib/phoenix_kit_catalogue/attachments.ex for the full behaviour (detach semantics for shared files, pending-folder rename on first save, upload-error messages, etc.).

There's no dedicated Catalogue.set_featured_image/2 context helper — programmatic callers use update_item/update_catalogue/update_category with %{data: %{"featured_image_uuid" => uuid, "files_folder_uuid" => folder_uuid}}.

Metadata (opt-in fields on items and catalogues)

Items and catalogues can opt into their own shared, code-defined lists of metadata fields, defined in PhoenixKitCatalogue.Metadata. Item definitions cover color, weight, dimensions, material, finish; catalogue definitions cover brand, collection, season, region, vendor reference. Values live on resource.data["meta"] as a flat %{key => string} map. The Metadata tab in the item and catalogue forms lets the user pick which fields to attach; legacy keys (defined in older code revisions but no longer listed) surface as "Legacy" rows with a remove-only action so stored data isn't silently lost. Categories stay metadata-free — they're lightweight taxonomy nodes.

Labels are translated via PhoenixKitWeb.Gettext. Adding / removing fields is a code edit to definitions/1; bump the resource-type clause you need.

Admin UI

The module registers admin tabs via PhoenixKit.Module:

PathView
/admin/catalogueCatalogue list with Active/Deleted tabs
/admin/catalogue/newNew catalogue form
/admin/catalogue/:uuidCatalogue detail with categories, items, status tabs
/admin/catalogue/:uuid/editEdit catalogue + permanent delete
/admin/catalogue/manufacturersManufacturer list
/admin/catalogue/suppliersSupplier list
/admin/catalogue/categories/:uuid/editEdit category + move + permanent delete
/admin/catalogue/items/:uuid/editEdit item + move

All forms support multilingual content when the Languages module is enabled.

Database & Migrations

This package contains no database migrations. All tables (phoenix_kit_cat_*) and migrations are managed by the parent phoenix_kit project. This module only defines Ecto schemas that map to those tables.

Tests

mix test

The test database must be created and migrated by the parent phoenix_kit project first.

120+ tests covering:

License

MIT