diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml new file mode 100644 index 0000000..2d88974 --- /dev/null +++ b/.github/workflows/elixir.yml @@ -0,0 +1,46 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Elixir CI + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +permissions: + contents: read + +jobs: + build: + name: Build and test + runs-on: ubuntu-22.04 + + steps: + - uses: ikalnytskyi/action-setup-postgres@v7 # https://github.com/marketplace/actions/setup-postgresql-for-linux-macos-windows + + - run: psql postgresql://postgres:postgres@localhost:5432/postgres -c "SELECT 1" + - run: psql service=postgres -c "SELECT 1" + - run: psql -c "SELECT 1" + env: + PGSERVICE: postgres + + - uses: actions/checkout@v4 + - name: Set up Elixir + uses: erlef/setup-beam@61e01a43a562a89bfc54c7f9a378ff67b03e4a21 # v1.16.0 https://github.com/erlef/setup-beam/releases/tag/v1.16.0 + with: + elixir-version: "1.18.2" # [Required] Define the Elixir version + otp-version: "27.2" # [Required] Define the Erlang/OTP version + - name: Restore dependencies cache + uses: actions/cache@v3 + with: + path: deps + key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} + restore-keys: ${{ runner.os }}-mix- + - name: Install dependencies + run: mix deps.get + - name: Run tests + run: mix test --warnings-as-errors diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..5ba9039 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +elixir 1.18.2 +erlang 27.2.2 diff --git a/assets/css/app.css b/assets/css/app.css index 93764c6..b02f733 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -5,11 +5,6 @@ /* This file is for your main application CSS */ -.inline-container { - @apply h-full relative; - display: inline-block; -} - .inline-text-input { } @@ -27,11 +22,19 @@ @apply hover:bg-transparent !important; } +.inline-container { + @apply h-full relative; + display: inline-block; +} + .inline-container:hover + .link-to-inside-field > a > span { visibility: visible; } .link-to-inside-field { + position: absolute; + margin-top: 22px; + span { position: relative; left: -35px; @@ -43,6 +46,25 @@ } } +.link-to-inside-field.address-link { + span { + left: -135px; + } +} + +.revert-button { + position: absolute; + /* margin-top: 22px; */ + color: black; + @apply border-transparent bg-transparent; + @apply hover:bg-transparent !important; + + span { + position: relative; + left: -35px; + } +} + .breadcrumbs { @apply text-sm; @@ -64,6 +86,101 @@ input { @apply border-transparent hover:border-solid hover:border-zinc-300; + @apply border-b-brand; + } + } +} + +.product-category-table { + @apply text-left; + + td { + div { + width: 100%; } + + padding-left: 0; } } + +.product-name-field { + display: inline-block; + input { + field-sizing: content; + @apply border-transparent hover:border-solid hover:border-zinc-300; + @apply border-b-brand; + } +} + +.input-icon { + position: relative; +} + +.input-icon > i { + position: absolute; + display: block; + transform: translate(0, -40%); + top: 50%; + pointer-events: none; + width: 25px; + text-align: center; + font-style: normal; +} + +.input-icon input { + padding-left: 25px; + padding-right: 0; +} + +.cost-summary { + width: 200px; + float: right; + + div { + text-align: right; + + span { + float: left; + } + } +} + +.address-container { + display: inline-block; + width: 400px; +} + +.address div { + height: 1.5em; +} + +.address-container div select { + display: inline-block; + width: 200px; +} + +.fieldsetlike { + width: 500px; + overflow-x: scroll; + padding: 20px; + border: 1px solid lightgray; +} + +.fieldsetlikelabel { + position: relative; + top: 14px; /* change this how you need */ + left: 20px; /* change this how you need */ + background-color: white; + display: inline-block; +} + +.address-city-state-zip { + div { + display: inline-block; + } +} + +.empty-field { + @apply border-red-500 border-solid border-2; + box-sizing: border-box; +} diff --git a/assets/js/app.js b/assets/js/app.js index 955a23c..cbd2d13 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -31,6 +31,68 @@ const hooks = { ...live_select, }; +hooks.Sidebar = { + mounted() { + let menuOpen = false; + + let listeners = (a) => + a.addEventListener("click", () => { + if (!menuOpen) { + menuOpen = true; + document + .getElementById("off-canvas-menu") + .classList.add("translate-x-0"); + + document + .getElementById("off-canvas-menu") + .classList.remove("-translate-x-full"); + + document.getElementById("close-sidebar").classList.add("opacity-100"); + + document + .getElementById("close-sidebar") + .classList.remove("opacity-0"); + + document.getElementById("menu-backdrop").classList.add("opacity-100"); + + document + .getElementById("menu-backdrop") + .classList.remove("opacity-0"); + + document.getElementById("off-canvas").classList.add("z-50"); + + document.getElementById("off-canvas").style.display = "initial"; + } else { + menuOpen = false; + document + .getElementById("off-canvas-menu") + .classList.add("-translate-x-full"); + + document + .getElementById("off-canvas-menu") + .classList.remove("translate-x-0"); + + document.getElementById("close-sidebar").classList.add("opacity-0"); + + document + .getElementById("close-sidebar") + .classList.remove("opacity-100"); + + document.getElementById("menu-backdrop").classList.add("opacity-0"); + + document + .getElementById("menu-backdrop") + .classList.remove("opacity-100"); + document.getElementById("off-canvas").classList.remove("z-50"); + document.getElementById("off-canvas").style.display = "none"; + } + }); + + listeners(document.getElementById("open-sidebar")); + listeners(document.getElementById("close-sidebar")); + }, +}; + let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: { _csrf_token: csrfToken }, diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js index b2f3915..7094061 100644 --- a/assets/tailwind.config.js +++ b/assets/tailwind.config.js @@ -28,6 +28,35 @@ module.exports = { // Options: slate, gray, zinc, neutral, stone gray: colors.gray, + + // Logo based reds + mojo: { + 50: "#fdf4f3", + 100: "#fce7e4", + 200: "#fad4ce", + 300: "#f5b7ac", + 400: "#ee8c7b", + 500: "#e26651", + 600: "#bf432e", + 700: "#ad3b28", + 800: "#8f3425", + 900: "#783024", + 950: "#40160f", + }, + // Logo based greens + eagle: { + 50: "#f6f6f4", + 100: "#e5e6df", + 200: "#ccccbb", + 300: "#b7b7a0", + 400: "#9f9d80", + 500: "#918c6f", + 600: "#7f7860", + 700: "#6b6352", + 800: "#595247", + 900: "#4b453c", + 950: "#29251f", + }, }, }, }, diff --git a/config/config.exs b/config/config.exs index 6db21dc..b67249b 100644 --- a/config/config.exs +++ b/config/config.exs @@ -57,7 +57,14 @@ config :spark, config :therons_erp, ecto_repos: [TheronsErp.Repo], generators: [timestamp_type: :utc_datetime], - ash_domains: [TheronsErp.Sales, TheronsErp.Inventory, TheronsErp.Ledger, TheronsErp.Accounts] + ash_domains: [ + TheronsErp.Invoices, + TheronsErp.People, + TheronsErp.Sales, + TheronsErp.Inventory, + TheronsErp.Ledger, + TheronsErp.Accounts + ] # Configures the endpoint config :therons_erp, TheronsErpWeb.Endpoint, diff --git a/lib/therons_erp/inventory.ex b/lib/therons_erp/inventory.ex index cc35ce9..fecbd85 100644 --- a/lib/therons_erp/inventory.ex +++ b/lib/therons_erp/inventory.ex @@ -12,6 +12,9 @@ defmodule TheronsErp.Inventory do resource TheronsErp.Inventory.Product do define :create_product, args: [:name, :sales_price], action: :create define :update_product, args: [:name, :sales_price], action: :update + define :create_product_stub, action: :create_stub + define :get_saleable_products, action: :list_saleable + define :get_products, action: :list end end end diff --git a/lib/therons_erp/inventory/product.ex b/lib/therons_erp/inventory/product.ex index 1db0cfc..783329d 100644 --- a/lib/therons_erp/inventory/product.ex +++ b/lib/therons_erp/inventory/product.ex @@ -2,7 +2,8 @@ defmodule TheronsErp.Inventory.Product do use Ash.Resource, otp_app: :therons_erp, data_layer: AshPostgres.DataLayer, - domain: TheronsErp.Inventory + domain: TheronsErp.Inventory, + primary_read_warning?: false postgres do table "products" @@ -12,12 +13,30 @@ defmodule TheronsErp.Inventory.Product do actions do defaults [:read] + read :list do + primary? true + prepare build(sort: [identifier: :desc]) + end + + read :list_saleable do + filter expr(saleable == true) + prepare build(sort: [identifier: :desc]) + end + create :create do - accept [:name, :sales_price, :type, :category_id] + accept [:name, :sales_price, :type, :category_id, :saleable, :purchaseable, :cost] + end + + create :create_stub do + accept [] + + change fn changeset, context -> + Ash.Changeset.change_attribute(changeset, :name, "New Product") + end end update :update do - accept [:name, :sales_price, :type, :category_id] + accept [:name, :sales_price, :type, :category_id, :saleable, :purchaseable, :cost] end destroy :destroy do @@ -36,11 +55,20 @@ defmodule TheronsErp.Inventory.Product do end attribute :sales_price, :money + attribute :cost, :money attribute :type, TheronsErp.Inventory.Product.Types do default :goods end + attribute :saleable, :boolean do + default true + end + + attribute :purchaseable, :boolean do + default false + end + timestamps() end diff --git a/lib/therons_erp/invoices.ex b/lib/therons_erp/invoices.ex new file mode 100644 index 0000000..7e6779b --- /dev/null +++ b/lib/therons_erp/invoices.ex @@ -0,0 +1,9 @@ +defmodule TheronsErp.Invoices do + use Ash.Domain, + otp_app: :therons_erp + + resources do + resource TheronsErp.Invoices.Invoice + resource TheronsErp.Invoices.LineItem + end +end diff --git a/lib/therons_erp/invoices/invoice.ex b/lib/therons_erp/invoices/invoice.ex new file mode 100644 index 0000000..c8c1e20 --- /dev/null +++ b/lib/therons_erp/invoices/invoice.ex @@ -0,0 +1,72 @@ +defmodule TheronsErp.Invoices.Invoice do + use Ash.Resource, + otp_app: :therons_erp, + domain: TheronsErp.Invoices, + data_layer: AshPostgres.DataLayer, + extensions: [AshStateMachine] + + alias TheronsErp.Invoices.LineItem + + postgres do + table "invoices" + repo TheronsErp.Repo + end + + state_machine do + initial_states([:draft]) + default_initial_state(:draft) + + transitions do + transition(:send, from: [:draft], to: [:sent]) + end + end + + actions do + defaults [ + :read, + update: [:customer_id, :sales_order_id] + ] + + update :send do + change transition_state(:sent) + end + + create :create do + accept [:customer_id, :sales_order_id] + argument :sales_lines, {:array, :map} + + change fn changeset, context -> + Ash.Changeset.after_action(changeset, fn changeset, result -> + sales_lines = Ash.Changeset.get_argument(changeset, :sales_lines) + + for line <- sales_lines do + LineItem.create(%{ + price: line.sales_price, + quantity: line.quantity, + invoice_id: result.id, + product_id: line.product_id + }) + end + + {:ok, result} + end) + end + end + end + + attributes do + uuid_primary_key :id + + attribute :identifier, :integer do + generated? true + end + + timestamps() + end + + relationships do + belongs_to :customer, TheronsErp.People.Entity + belongs_to :sales_order, TheronsErp.Sales.SalesOrder + has_many :line_items, TheronsErp.Invoices.LineItem + end +end diff --git a/lib/therons_erp/invoices/line_item.ex b/lib/therons_erp/invoices/line_item.ex new file mode 100644 index 0000000..027fa72 --- /dev/null +++ b/lib/therons_erp/invoices/line_item.ex @@ -0,0 +1,41 @@ +defmodule TheronsErp.Invoices.LineItem do + use Ash.Resource, + otp_app: :therons_erp, + domain: TheronsErp.Invoices, + data_layer: AshPostgres.DataLayer + + postgres do + table "line_items" + repo TheronsErp.Repo + end + + code_interface do + define :create, args: [] + end + + actions do + defaults [:read] + + create :create do + accept [:price, :quantity, :invoice_id, :product_id] + end + end + + attributes do + uuid_primary_key :id + + attribute :price, :money + attribute :quantity, :integer + timestamps() + end + + relationships do + belongs_to :invoice, TheronsErp.Invoices.Invoice do + allow_nil? false + end + + belongs_to :product, TheronsErp.Inventory.Product do + allow_nil? false + end + end +end diff --git a/lib/therons_erp/people.ex b/lib/therons_erp/people.ex new file mode 100644 index 0000000..27d5066 --- /dev/null +++ b/lib/therons_erp/people.ex @@ -0,0 +1,13 @@ +defmodule TheronsErp.People do + use Ash.Domain, + otp_app: :therons_erp + + resources do + resource TheronsErp.People.Entity do + define :list_people, action: :read + end + + resource TheronsErp.People.Address do + end + end +end diff --git a/lib/therons_erp/people/address.ex b/lib/therons_erp/people/address.ex new file mode 100644 index 0000000..932d111 --- /dev/null +++ b/lib/therons_erp/people/address.ex @@ -0,0 +1,101 @@ +defmodule TheronsErp.People.Address do + def state_options do + [ + "AL", + "AK", + "AZ", + "AR", + "CA", + "CO", + "CT", + "DE", + "FL", + "GA", + "HI", + "ID", + "IL", + "IN", + "IA", + "KS", + "KY", + "LA", + "ME", + "MD", + "MA", + "MI", + "MN", + "MS", + "MO", + "MT", + "NE", + "NV", + "NH", + "NJ", + "NM", + "NY", + "NC", + "ND", + "OH", + "OK", + "OR", + "PA", + "RI", + "SC", + "SD", + "TN", + "TX", + "UT", + "VT", + "VA", + "WA", + "WV", + "WI", + "WY" + ] + end + + use Ash.Resource, + otp_app: :therons_erp, + domain: TheronsErp.People, + data_layer: AshPostgres.DataLayer + + postgres do + table "addresses" + repo TheronsErp.Repo + end + + actions do + defaults [:read] + + create :create do + primary? true + accept [:address, :address2, :city, :state, :zip_code, :phone, :entity_id] + end + + update :update do + primary? true + accept [:address, :address2, :city, :state, :zip_code, :phone, :entity_id] + end + + destroy :destroy do + primary? true + end + end + + attributes do + uuid_primary_key :id + + attribute :address, :string + attribute :address2, :string + attribute :city, :string + attribute :state, :string + attribute :zip_code, :string + attribute :phone, :string + + timestamps() + end + + relationships do + belongs_to :entity, TheronsErp.People.Entity + end +end diff --git a/lib/therons_erp/people/entity.ex b/lib/therons_erp/people/entity.ex new file mode 100644 index 0000000..9d2a470 --- /dev/null +++ b/lib/therons_erp/people/entity.ex @@ -0,0 +1,52 @@ +defmodule TheronsErp.People.Entity do + use Ash.Resource, + otp_app: :therons_erp, + domain: TheronsErp.People, + data_layer: AshPostgres.DataLayer + + postgres do + table "entities" + repo TheronsErp.Repo + end + + actions do + defaults [:read] + + create :create do + argument :addresses, {:array, :map} + accept [:name] + + change manage_relationship(:addresses, type: :create) + end + + update :update do + require_atomic? false + argument :addresses, {:array, :map} + accept [:name] + + change manage_relationship(:addresses, type: :direct_control) + end + + destroy :destroy do + primary? true + end + end + + attributes do + uuid_primary_key :id + + attribute :name, :string do + allow_nil? false + end + + timestamps() + end + + relationships do + has_many :addresses, TheronsErp.People.Address + + has_many :sales_orders, TheronsErp.Sales.SalesOrder do + destination_attribute :customer_id + end + end +end diff --git a/lib/therons_erp_web/sales.ex b/lib/therons_erp/sales.ex similarity index 100% rename from lib/therons_erp_web/sales.ex rename to lib/therons_erp/sales.ex diff --git a/lib/therons_erp/sales/sales_line.ex b/lib/therons_erp/sales/sales_line.ex new file mode 100644 index 0000000..ab1a35d --- /dev/null +++ b/lib/therons_erp/sales/sales_line.ex @@ -0,0 +1,79 @@ +defmodule TheronsErp.Sales.SalesLine do + use Ash.Resource, + otp_app: :therons_erp, + domain: TheronsErp.Sales, + data_layer: AshPostgres.DataLayer + + postgres do + table "sales_lines" + repo TheronsErp.Repo + end + + actions do + defaults [:destroy, :read] + + create :create do + primary? true + accept [:sales_price, :unit_price, :quantity, :product_id, :sales_order_id] + + # Set unit_price and sales_price based on product price and cost + change fn changeset, _ -> + # Load the product if it exists + if product_id = Ash.Changeset.get_attribute(changeset, :product_id) do + product = Ash.get!(TheronsErp.Inventory.Product, product_id) + + changeset + |> Ash.Changeset.change_attribute(:unit_price, product.cost) + |> Ash.Changeset.change_attribute(:sales_price, product.sales_price) + else + changeset + end + end + end + + update :update do + require_atomic? false + primary? true + accept [:sales_price, :unit_price, :quantity, :product_id, :total_price, :sales_order_id] + end + end + + attributes do + uuid_primary_key :id + + attribute :sales_price, :money + attribute :unit_price, :money + + attribute :quantity, :integer do + default 1 + end + + attribute :total_price, :money + + timestamps() + end + + relationships do + belongs_to :sales_order, TheronsErp.Sales.SalesOrder do + allow_nil? false + end + + belongs_to :product, TheronsErp.Inventory.Product do + allow_nil? false + end + end + + calculations do + calculate :calculated_total_price, :money, expr(sales_price * quantity) + + calculate :active_price, :money, expr(total_price || calculated_total_price) + + calculate :total_cost, :money, expr(unit_price * quantity) + + calculate :product_cost, :money, expr(product.cost) + calculate :product_price, :money, expr(product.price) + + # calculate :margin do + # end + end +end diff --git a/lib/therons_erp/sales/sales_order.ex b/lib/therons_erp/sales/sales_order.ex new file mode 100644 index 0000000..aa3e81d --- /dev/null +++ b/lib/therons_erp/sales/sales_order.ex @@ -0,0 +1,131 @@ +defmodule TheronsErp.Sales.SalesOrder do + use Ash.Resource, + otp_app: :therons_erp, + domain: TheronsErp.Sales, + data_layer: AshPostgres.DataLayer, + extensions: [AshStateMachine], + primary_read_warning?: false + + alias TheronsErp.Invoices.Invoice + + postgres do + table "sales_orders" + repo TheronsErp.Repo + end + + state_machine do + initial_states([:draft]) + default_initial_state(:draft) + + transitions do + transition(:ready, from: :draft, to: [:ready, :cancelled]) + transition(:invoice, from: :ready, to: [:invoiced]) + transition(:cancel, from: [:draft, :ready], to: :cancelled) + transition(:cancel_invoice, from: [:invoiced], to: :cancelled) + transition(:revive, from: [:cancelled, :ready], to: [:draft, :ready]) + transition(:complete, from: [:draft, :ready, :invoiced], to: :complete) + end + end + + actions do + update :invoice do + require_atomic? false + change transition_state(:invoiced) + + change fn changeset, result -> + Ash.Changeset.after_action(changeset, fn changeset, result -> + IO.inspect(result) + + Invoice + |> Ash.Changeset.for_create(:create, %{ + sales_order_id: result.id, + sales_lines: result.sales_lines + }) + |> Ash.create!() + + {:ok, result} + end) + end + end + + update :cancel_invoice do + # TODO cancel the invoice here + change transition_state(:cancelled) + end + + update :ready do + change transition_state(:ready) + end + + update :cancel do + change transition_state(:cancelled) + end + + update :revive do + change transition_state(:draft) + end + + update :complete do + change transition_state(:complete) + end + + read :read do + primary? true + prepare build(sort: [identifier: :desc]) + end + + destroy :destroy do + end + + create :create do + accept [:customer_id, :address_id] + primary? true + argument :sales_lines, {:array, :map} + + change manage_relationship(:sales_lines, type: :create), + where: [attribute_equals(:state, :draft)] + + # TODO validate address belongs to customer + end + + update :update do + accept [:customer_id, :address_id] + require_atomic? false + argument :sales_lines, {:array, :map} + + change manage_relationship(:sales_lines, type: :direct_control), + where: [attribute_equals(:state, :draft)] + + # TODO validate address belongs to customer + end + end + + attributes do + uuid_primary_key :id + + attribute :identifier, :integer do + generated? true + end + + timestamps() + end + + relationships do + has_many :sales_lines, TheronsErp.Sales.SalesLine do + destination_attribute :sales_order_id + sort id: :desc + end + + belongs_to :customer, TheronsErp.People.Entity + belongs_to :address, TheronsErp.People.Address + + has_one :invoice, TheronsErp.Invoices.Invoice do + destination_attribute :sales_order_id + end + end + + aggregates do + sum :total_price, [:sales_lines], :active_price + sum :total_cost, [:sales_lines], :total_cost + end +end diff --git a/lib/therons_erp_web/breadcrumbs.ex b/lib/therons_erp_web/breadcrumbs.ex index fa47a60..1080137 100644 --- a/lib/therons_erp_web/breadcrumbs.ex +++ b/lib/therons_erp_web/breadcrumbs.ex @@ -4,7 +4,6 @@ defmodule TheronsErpWeb.Breadcrumbs do def on_mount(:default, params, _session, socket) do socket = assign(socket, :breadcrumbs, decode_breadcrumbs(params["breadcrumbs"])) - socket = assign(socket, :wat, 3) {:cont, socket} end @@ -12,6 +11,10 @@ defmodule TheronsErpWeb.Breadcrumbs do [] end + def decode_breadcrumbs("") do + [] + end + def decode_breadcrumbs(breadcrumbs) do # base 64 code then JSON decode breadcrumbs @@ -77,7 +80,7 @@ defmodule TheronsErpWeb.Breadcrumbs do defp _navigate_back(_breadcrumbs, _from, args \\ nil) - defp _navigate_back([], from, args) do + defp _navigate_back([], from, _args) do which = case from do {"product_category", "new", _product_category_id} -> @@ -88,6 +91,9 @@ defmodule TheronsErpWeb.Breadcrumbs do {"products", "edit", product_id} -> ~p"/products/#{product_id}" + + {"people", entity_id} -> + ~p"/people/#{entity_id}" end {which, []} @@ -96,6 +102,13 @@ defmodule TheronsErpWeb.Breadcrumbs do defp _navigate_back([breadcrumb | breadcrumbs], _from, from_args) do which = case breadcrumb do + {"people", args, id} -> + if from_args do + ~p"/people/#{id}?#{[breadcrumbs: encode_breadcrumbs(breadcrumbs), args: args, from_args: from_args]}" + else + ~p"/people/#{id}?#{[breadcrumbs: encode_breadcrumbs(breadcrumbs), args: args]}" + end + {"products", "new", args} -> if from_args do ~p"/products/new?#{[breadcrumbs: encode_breadcrumbs(breadcrumbs), args: args, from_args: from_args]}" @@ -123,6 +136,13 @@ defmodule TheronsErpWeb.Breadcrumbs do else ~p"/product_categories/#{id}?#{[breadcrumbs: encode_breadcrumbs(breadcrumbs)]}" end + + {"sales_orders", id, params, _identifier} -> + if from_args do + ~p"/sales_orders/#{id}?#{[breadcrumbs: encode_breadcrumbs(breadcrumbs), from_args: from_args, args: params]}" + else + ~p"/sales_orders/#{id}?#{[breadcrumbs: encode_breadcrumbs(breadcrumbs), args: params]}" + end end {which, breadcrumbs} @@ -149,10 +169,26 @@ defmodule TheronsErpWeb.Breadcrumbs do ~p"/product_categories/#{id}?#{[breadcrumbs: append_and_encode(breadcrumbs, from)]}" end + def navigate_to_url(breadcrumbs, {"products", "new", line_id}, from) do + ~p"/products/new?#{[breadcrumbs: append_and_encode(breadcrumbs, from), line_id: line_id]}" + end + def navigate_to_url(breadcrumbs, {"products", id, _name}, from) do ~p"/products/#{id}?#{[breadcrumbs: append_and_encode(breadcrumbs, from)]}" end + def navigate_to_url(breadcrumbs, {"entities", id, _name}, from) do + ~p"/people/#{id}?#{[breadcrumbs: append_and_encode(breadcrumbs, from)]}" + end + + def navigate_to_url(breadcrumbs, {"addresses", "new", customer_id}, from) do + ~p"/people/#{customer_id}/new_address?#{[breadcrumbs: append_and_encode(breadcrumbs, from)]}" + end + + def navigate_to_url(breadcrumbs, {"invoices", id, _identifier}, from) do + ~p"/invoices/#{id}?#{[breadcrumbs: append_and_encode(breadcrumbs, from)]}" + end + defp append_and_encode(breadcrumbs, breadcrumb) do [breadcrumb | breadcrumbs] |> encode_breadcrumbs() @@ -162,6 +198,10 @@ defmodule TheronsErpWeb.Breadcrumbs do "Edit #{pid}" end + defp name_for_crumb({"products", "edit", pid, _}) do + "Edit #{pid}" + end + defp name_for_crumb({"products", _pid, identifier}) do "#{identifier}" end @@ -170,6 +210,10 @@ defmodule TheronsErpWeb.Breadcrumbs do "#{name}" end + defp name_for_crumb({"sales_orders", _sale_id, _params, serial_no}) do + "S#{serial_no}" + end + def stream_crumbs(list) when is_list(list) do _stream_crumbs(list) end diff --git a/lib/therons_erp_web/components/core_components.ex b/lib/therons_erp_web/components/core_components.ex index cd9dbea..4950b69 100644 --- a/lib/therons_erp_web/components/core_components.ex +++ b/lib/therons_erp_web/components/core_components.ex @@ -287,6 +287,8 @@ defmodule TheronsErpWeb.CoreComponents do attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2" attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs" + attr :inline_container, :boolean, default: false + attr :rest, :global, include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength multiple pattern placeholder readonly required rows size step) @@ -355,7 +357,7 @@ defmodule TheronsErpWeb.CoreComponents do id={@id} name={@name} class={[ - "mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6 min-h-[6rem]", + "mt-2 block w-full text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6 min-h-[6rem]", @errors == [] && "border-zinc-300 focus:border-zinc-400", @errors != [] && "border-rose-400 focus:border-rose-400" ]} @@ -369,7 +371,7 @@ defmodule TheronsErpWeb.CoreComponents do # All other inputs text, datetime-local, url, password, etc. are handled here... def input(assigns) do ~H""" -
+
<.label for={@id}>{@label} " group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold"} + > + <%= if @icon do %> + <.icon name={@icon} /> + <% end %> + {@text} + <%= if @todo && @todo > 0 do %> + + <% end %> + + """ + end + + def status_badge(assigns) do + case assigns.state do + :draft -> + ~H""" + Draft + """ + + :ready -> + ~H""" + Ready + """ + + :sent -> + ~H""" + Sent + """ + + :canceled -> + ~H""" + Canceled + """ + + _ -> + ~H""" + {assigns.state} + """ + end + end end diff --git a/lib/therons_erp_web/components/layouts/app.html.heex b/lib/therons_erp_web/components/layouts/app.html.heex index 98e3b24..6b82344 100644 --- a/lib/therons_erp_web/components/layouts/app.html.heex +++ b/lib/therons_erp_web/components/layouts/app.html.heex @@ -1,44 +1,266 @@
-
- -
- <.flash_group flash={@flash} /> - {@inner_content} + +
+
+
+ +
+ <.flash_group flash={@flash} /> + {@inner_content} +
+
diff --git a/lib/therons_erp_web/live/entity_live/form_component.ex b/lib/therons_erp_web/live/entity_live/form_component.ex new file mode 100644 index 0000000..b9d5908 --- /dev/null +++ b/lib/therons_erp_web/live/entity_live/form_component.ex @@ -0,0 +1,142 @@ +defmodule TheronsErpWeb.EntityLive.FormComponent do + # I don't think this is used because the new action creates + use TheronsErpWeb, :live_component + alias TheronsErpWeb.Breadcrumbs + + @impl true + def render(assigns) do + ~H""" +
+ <.header> + {@title} + <:subtitle> + + + <%= if @entity do %> + <.button + phx-click="delete" + phx-value-id={@entity.id} + phx-target={@myself} + data-confirm="Are you sure?" + class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded" + > + Delete + + <% end %> + + <.simple_form + for={@form} + id="entity-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save" + > + <.input field={@form[:name]} type="text" label="Name" /> + <.inputs_for :let={address} field={@form[:addresses]}> +
+ + Address {to_string(address.index)} + + + <.input field={address[:address]} type="text" label="Address" /> + <.input field={address[:address2]} type="text" label="Address2" /> + <.input field={address[:city]} type="text" label="City" /> + <.input + field={address[:state]} + type="select" + label="State" + options={TheronsErp.People.Address.state_options()} + /> + <.input field={address[:zip_code]} type="text" label="Zip Code" pattern="[0-9]{5}" /> + <.input field={address[:phone]} type="text" label="Phone" /> +
+ + + + + <:actions> + <.button phx-disable-with="Saving...">Save Entity + + +
+ """ + end + + @impl true + def update(assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> assign_form()} + end + + @impl true + def handle_event("validate", %{"entity" => entity_params}, socket) do + {:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, entity_params))} + end + + def handle_event("save", %{"entity" => entity_params}, socket) do + case AshPhoenix.Form.submit(socket.assigns.form, params: entity_params) do + {:ok, entity} -> + notify_parent({:saved, entity}) + + socket = + socket + |> put_flash(:info, "Entity #{socket.assigns.form.source.type}d successfully") + |> Breadcrumbs.navigate_back({"people", entity.id}, %{customer_id: entity.id}) + + {:noreply, socket} + + {:error, form} -> + {:noreply, assign(socket, form: form)} + end + end + + # Delete event + def handle_event("delete", %{"id" => id}, socket) do + # TODO validate ID + entity = Ash.get!(TheronsErp.People.Entity, id, actor: socket.assigns.current_user) + + case Ash.destroy(entity) do + :ok -> + {:noreply, + socket + |> put_flash(:info, "Entity deleted successfully.") + |> push_navigate(to: ~p"/people")} + + {:error, changeset} -> + {:noreply, assign(socket, changeset: changeset)} + end + end + + defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) + + defp assign_form(%{assigns: %{entity: entity}} = socket) do + form = + if entity do + AshPhoenix.Form.for_update(entity, :update, + as: "entity", + actor: socket.assigns.current_user + ) + else + AshPhoenix.Form.for_create(TheronsErp.People.Entity, :create, + as: "entity", + actor: socket.assigns.current_user + ) + end + + assign(socket, form: to_form(form)) + end +end diff --git a/lib/therons_erp_web/live/entity_live/index.ex b/lib/therons_erp_web/live/entity_live/index.ex new file mode 100644 index 0000000..253c192 --- /dev/null +++ b/lib/therons_erp_web/live/entity_live/index.ex @@ -0,0 +1,90 @@ +defmodule TheronsErpWeb.EntityLive.Index do + use TheronsErpWeb, :live_view + + @impl true + def render(assigns) do + ~H""" + <.header> + Listing Entities + <:actions> + <.link patch={~p"/people/new"}> + <.button>New Entity + + + + + <.table + id="entities" + rows={@streams.entities} + row_click={fn {_id, entity} -> JS.navigate(~p"/people/#{entity}") end} + > + <:col :let={{_id, entity}} label="Name">{entity.name} + + <:action :let={{_id, entity}}> +
+ <.link navigate={~p"/people/#{entity}"}>Show +
+ + <.link patch={~p"/people/#{entity}/edit"}>Edit + + + + <.modal + :if={@live_action in [:new, :edit]} + id="entity-modal" + show + on_cancel={JS.patch(~p"/people")} + > + <.live_component + module={TheronsErpWeb.EntityLive.FormComponent} + id={(@entity && @entity.id) || :new} + title={@page_title} + current_user={@current_user} + action={@live_action} + entity={@entity} + patch={~p"/people"} + breadcrumbs={@breadcrumbs} + /> + + """ + end + + @impl true + def mount(_params, _session, socket) do + {:ok, + socket + |> stream( + :entities, + Ash.read!(TheronsErp.People.Entity, actor: socket.assigns[:current_user]) + ) + |> assign_new(:current_user, fn -> nil end)} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :edit, %{"id" => id}) do + socket + |> assign(:page_title, "Edit Entity") + |> assign(:entity, Ash.get!(TheronsErp.People.Entity, id, actor: socket.assigns.current_user)) + end + + defp apply_action(socket, :new, _params) do + socket + |> assign(:page_title, "New Entity") + |> assign(:entity, nil) + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, "Listing Entities") + |> assign(:entity, nil) + end + + @impl true + def handle_info({TheronsErpWeb.EntityLive.FormComponent, {:saved, entity}}, socket) do + {:noreply, stream_insert(socket, :entities, entity)} + end +end diff --git a/lib/therons_erp_web/live/entity_live/new_address_form_component.ex b/lib/therons_erp_web/live/entity_live/new_address_form_component.ex new file mode 100644 index 0000000..4192a1f --- /dev/null +++ b/lib/therons_erp_web/live/entity_live/new_address_form_component.ex @@ -0,0 +1,91 @@ +defmodule TheronsErpWeb.EntityLive.NewAddressFormComponent do + use TheronsErpWeb, :live_component + alias TheronsErpWeb.Breadcrumbs + + @impl true + def render(assigns) do + ~H""" +
+ <.header> + New address for {@entity.name} + + <.simple_form + for={@form} + id="address-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save" + > + <.input field={@form[:address]} label="Address" required /> + <.input field={@form[:address2]} label="Address2" /> + <.input field={@form[:city]} label="City" required /> + <.input + field={@form[:state]} + type="select" + label="State" + options={TheronsErp.People.Address.state_options()} + /> + <.input field={@form[:zip_code]} label="Zip code" required /> + <.input field={@form[:phone]} label="Phone" required /> + + <:actions> + <.button phx-disable-with="Saving...">Save Address + + +
+ """ + end + + @impl true + def update(assigns, socket) do + {:ok, socket |> assign(assigns) |> assign(:address, nil) |> assign_form()} + end + + @impl true + def handle_event("validate", %{"address" => address_params}, socket) do + {:noreply, + assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, address_params))} + end + + def handle_event("save", %{"address" => address_params}, socket) do + case AshPhoenix.Form.submit(socket.assigns.form, params: address_params) do + {:ok, address} -> + notify_parent({:saved, address}) + + socket = + socket + |> put_flash(:info, "Address #{socket.assigns.form.source.type}d successfully") + |> Breadcrumbs.navigate_back({"people", address.entity_id}, %{ + address_id: address.id + }) + + {:noreply, socket} + + {:error, form} -> + {:noreply, assign(socket, form: form)} + end + end + + defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) + + defp assign_form(%{assigns: %{address: address, entity: entity}} = socket) do + form = + if address do + AshPhoenix.Form.for_update(address, :update, + as: "address", + actor: socket.assigns.current_user + ) + else + AshPhoenix.Form.for_create(TheronsErp.People.Address, :create, + as: "address", + actor: socket.assigns.current_user, + prepare_source: fn source -> + source + |> Ash.Changeset.change_attribute(:entity_id, entity.id) + end + ) + end + + assign(socket, form: to_form(form)) + end +end diff --git a/lib/therons_erp_web/live/entity_live/show.ex b/lib/therons_erp_web/live/entity_live/show.ex new file mode 100644 index 0000000..39e592e --- /dev/null +++ b/lib/therons_erp_web/live/entity_live/show.ex @@ -0,0 +1,144 @@ +defmodule TheronsErpWeb.EntityLive.Show do + use TheronsErpWeb, :live_view + + @impl true + def render(assigns) do + ~H""" + <.header> + Entity {@entity.name} + <:subtitle> + + <:actions> + <.link patch={~p"/people/#{@entity}/show/edit"} phx-click={JS.push_focus()}> + <.button>Edit entity + + + + +
+

Addresses

+
+ <%= for address <- @entity.addresses do %> +
+ +
+
+ <%!-- Address 1 --%> +
+ + <%!-- Address label --%> + Address + {address.address} + +
+ <%!-- Address 2 --%> +
+ + Address 2 + {address.address2} + +
+
+ <%!-- City --%> +
+ City + {address.city} +
+ <%!-- State --%> +
+ State + {address.state} +
+ <%!-- Zip Code --%> +
+ Zip Code + {address.zip_code} +
+
+ <%!-- Phone number --%> +
+ Phone Number + {address.phone} +
+
+
+
+ <% end %> + + <.back navigate={~p"/people"}>Back to entities + + <.modal + :if={@live_action == :edit} + id="entity-modal" + show + on_cancel={ + JS.navigate( + TheronsErpWeb.Breadcrumbs.get_previous_path( + @breadcrumbs, + {"people", @entity.id} + ) + ) + } + > + <.live_component + module={TheronsErpWeb.EntityLive.FormComponent} + id={@entity.id} + title={@page_title} + action={@live_action} + current_user={@current_user} + entity={@entity} + patch={~p"/people/#{@entity}"} + breadcrumbs={@breadcrumbs} + /> + + + <.modal + :if={@live_action == :new_address} + id="entity-modal" + show + on_cancel={ + JS.navigate( + TheronsErpWeb.Breadcrumbs.get_previous_path( + @breadcrumbs, + {"people", @entity.id} + ) + ) + } + > + <.live_component + module={TheronsErpWeb.EntityLive.NewAddressFormComponent} + id={@entity.id} + title={@page_title} + action={@live_action} + current_user={@current_user} + entity={@entity} + patch={~p"/people/#{@entity}"} + breadcrumbs={@breadcrumbs} + /> + + """ + end + + @impl true + def mount(_params, _session, socket) do + {:ok, socket} + end + + @impl true + def handle_params(%{"id" => id}, _, socket) do + {:noreply, + socket + |> assign(:page_title, page_title(socket.assigns.live_action)) + |> assign( + :entity, + Ash.get!(TheronsErp.People.Entity, id, + actor: socket.assigns.current_user, + load: [:addresses] + ) + )} + end + + defp page_title(:show), do: "Show Entity" + defp page_title(:edit), do: "Edit Entity" + defp page_title(:new_address), do: "New Address" +end diff --git a/lib/therons_erp_web/live/nav.ex b/lib/therons_erp_web/live/nav.ex new file mode 100644 index 0000000..89d4b12 --- /dev/null +++ b/lib/therons_erp_web/live/nav.ex @@ -0,0 +1,36 @@ +defmodule TheronsErpWeb.Nav do + use TheronsErpWeb, :live_view + + def on_mount(:default, _params, _session, socket) do + {:cont, + socket + |> attach_hook(:active_tab, :handle_params, &set_active_tab/3)} + end + + defp set_active_tab(_params, _url, socket) do + active_tab = + case {socket.view, socket.assigns.live_action} do + {so, _} + when so in [TheronsErpWeb.SalesOrderLive.Index, TheronsErpWeb.SalesOrderLive.Show] -> + :sales_orders + + {po, _} when po in [TheronsErpWeb.ProductLive.Index, TheronsErpWeb.ProductLive.Show] -> + :products + + {pp, _} when pp in [TheronsErpWeb.EntityLive.Index, TheronsErpWeb.EntityLive.Show] -> + :people + + {pc, _} + when pc in [ + TheronsErpWeb.ProductCategoryLive.Index, + TheronsErpWeb.ProductCategoryLive.Show + ] -> + :product_categories + + {_, _} -> + nil + end + + {:cont, assign(socket, :active_tab, active_tab)} + end +end diff --git a/lib/therons_erp_web/live/product_category_live/index.ex b/lib/therons_erp_web/live/product_category_live/index.ex index 0d274e4..f4cd3b0 100644 --- a/lib/therons_erp_web/live/product_category_live/index.ex +++ b/lib/therons_erp_web/live/product_category_live/index.ex @@ -92,7 +92,7 @@ defmodule TheronsErpWeb.ProductCategoryLive.Index do ) end - defp apply_action(socket, :new, params) do + defp apply_action(socket, :new, _params) do socket |> assign(:page_title, "New Product category") |> assign(:product_category, nil) diff --git a/lib/therons_erp_web/live/product_category_live/show.ex b/lib/therons_erp_web/live/product_category_live/show.ex index 905a177..9487b15 100644 --- a/lib/therons_erp_web/live/product_category_live/show.ex +++ b/lib/therons_erp_web/live/product_category_live/show.ex @@ -295,11 +295,11 @@ defmodule TheronsErpWeb.ProductCategoryLive.Show do {:noreply, socket |> put_flash(:error, "Cannot create a cycle in the product category tree")} else - raise e + reraise e, __STACKTRACE__ end _ -> - raise e + reraise e, __STACKTRACE__ end end end diff --git a/lib/therons_erp_web/live/product_live/form_component.ex b/lib/therons_erp_web/live/product_live/form_component.ex deleted file mode 100644 index 4604f2e..0000000 --- a/lib/therons_erp_web/live/product_live/form_component.ex +++ /dev/null @@ -1,151 +0,0 @@ -defmodule TheronsErpWeb.ProductLive.FormComponent do - use TheronsErpWeb, :live_component - alias TheronsErpWeb.Breadcrumbs - import TheronsErpWeb.Selects - alias TheronsErpWeb.ProductLive.Show - - @impl true - def render(assigns) do - ~H""" -
- <.header> - {@title} - <:subtitle>Use this form to manage product records in your database. - - - <.simple_form - for={@form} - id="product-form" - phx-target={@myself} - phx-change="validate" - phx-submit="save" - > - <.input field={@form[:name]} type="text" label="Name" /> - <.input field={@form[:sales_price]} type="text" label="Sales price" /><.input - field={@form[:type]} - type="text" - label="Type" - /> - <%!-- <.input - field={@form[:category_id]} - type="text" - label="Category" - /> --%> - - <.live_select - field={@form[:category_id]} - label="Category" - phx-target={@myself} - options={@initial_options} - update_min_len={0} - phx-focus="set-default" - > - <:option :let={opt}> - <.highlight matches={opt.matches} string={opt.label} value={opt.value} /> - - - - <:actions> - <.button phx-disable-with="Saving...">Save Product - - -
- """ - end - - @impl true - def handle_event("set-default", %{"id" => id}, socket) do - send_update(LiveSelect.Component, options: socket.assigns.categories, id: id) - - {:noreply, socket} - end - - def handle_event("live_select_change", %{"text" => text, "id" => live_select_id}, socket) do - matches = - Seqfuzz.matches(socket.assigns.categories, text, & &1.label, filter: true, sort: true) - - opts = - (matches - |> Enum.map(fn {categories, c} -> - %{value: categories.value, label: categories.label, matches: c.matches} - end) - |> Enum.take(5)) ++ additional_options() - - send_update(LiveSelect.Component, id: live_select_id, options: opts) - {:noreply, socket} - end - - @impl true - def update(assigns, socket) do - current_category_id = (assigns.product && assigns.product.category_id) || nil - - {:ok, - socket - |> assign(assigns) - |> assign(:categories, get_categories(current_category_id)) - |> assign(:initial_options, get_initial_options(current_category_id)) - |> assign_form()} - end - - @impl true - def handle_event("validate", %{"product" => product_params}, socket) do - if product_params["category_id"] == "create" do - # TODO add breadcrumbs - pid = if socket.assigns.product, do: socket.assigns.product.id, else: nil - - {:noreply, - socket - |> Breadcrumbs.navigate_to( - {"product_category", "new", pid}, - {"product", "new", product_params} - )} - else - {:noreply, - assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, product_params))} - end - end - - def handle_event("save", %{"product" => product_params}, socket) do - case AshPhoenix.Form.submit(socket.assigns.form, params: product_params) do - {:ok, product} -> - notify_parent({:saved, product |> Ash.load!(:category)}) - - socket = - socket - |> put_flash(:info, "Product #{socket.assigns.form.source.type}d successfully") - |> push_patch(to: socket.assigns.patch) - - {:noreply, socket} - - {:error, form} -> - {:noreply, assign(socket, form: form)} - end - end - - defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) - - defp assign_form(%{assigns: %{product: product, args: args, from_args: from_args}} = socket) do - form = - if product do - AshPhoenix.Form.for_update(product, :update, - as: "product", - actor: socket.assigns.current_user - ) - else - AshPhoenix.Form.for_create(TheronsErp.Inventory.Product, :create, - as: "product", - actor: socket.assigns.current_user - ) - end - - form = - case {args, from_args} do - {nil, nil} -> form - {_, nil} -> AshPhoenix.Form.validate(form, args) - {nil, _} -> AshPhoenix.Form.validate(form, from_args) - _ -> AshPhoenix.Form.validate(form, Map.merge(args, from_args)) - end - - assign(socket, form: to_form(form)) - end -end diff --git a/lib/therons_erp_web/live/product_live/index.ex b/lib/therons_erp_web/live/product_live/index.ex index eb53781..1e193c7 100644 --- a/lib/therons_erp_web/live/product_live/index.ex +++ b/lib/therons_erp_web/live/product_live/index.ex @@ -18,17 +18,24 @@ defmodule TheronsErpWeb.ProductLive.Index do rows={@streams.products} row_click={fn {_id, product} -> JS.navigate(~p"/products/#{product}") end} > + <:col :let={{_id, product}} label="Identifier">{product.identifier} <:col :let={{_id, product}} label="Name">{product.name} <:col :let={{_id, product}} label="Category"> {(product.category && product.category.full_name) || ""} + <:col :let={{_id, product}} label="Saleable"> + + + + <:col :let={{_id, product}} label="Purchaseable"> + + + <:action :let={{_id, product}}>
<.link navigate={~p"/products/#{product}"}>Show
- - <.link patch={~p"/products/#{product}/edit"}>Edit <:action :let={{id, product}}> @@ -40,37 +47,16 @@ defmodule TheronsErpWeb.ProductLive.Index do - - <.modal - :if={@live_action in [:new, :edit]} - id="product-modal" - show - on_cancel={JS.navigate(~p"/products")} - > - <.live_component - module={TheronsErpWeb.ProductLive.FormComponent} - id={(@product && @product.id) || :new} - title={@page_title} - current_user={@current_user} - action={@live_action} - breadcrumbs={@breadcrumbs} - product={@product} - args={@args} - from_args={@from_args} - patch={~p"/products"} - /> - """ end @impl true - def mount(params, _session, socket) do + def mount(_params, _session, socket) do {:ok, socket |> stream( :products, - Ash.read!(TheronsErp.Inventory.Product, actor: socket.assigns[:current_user]) - |> Ash.load!(:category) + TheronsErp.Inventory.get_products!(actor: socket.assigns[:current_user], load: [:category]) ) |> assign_new(:current_user, fn -> nil end)} end @@ -92,10 +78,16 @@ defmodule TheronsErpWeb.ProductLive.Index do ) end - defp apply_action(socket, :new, _params) do + defp apply_action(socket, :new, params) do + product = TheronsErp.Inventory.create_product_stub!() + socket |> assign(:page_title, "New Product") |> assign(:product, nil) + |> push_navigate( + to: + ~p"/products/#{product}?#{[breadcrumbs: params["breadcrumbs"], line_id: params["line_id"]]}" + ) end defp apply_action(socket, :index, _params) do diff --git a/lib/therons_erp_web/live/product_live/show.ex b/lib/therons_erp_web/live/product_live/show.ex index 0a06b67..b740b26 100644 --- a/lib/therons_erp_web/live/product_live/show.ex +++ b/lib/therons_erp_web/live/product_live/show.ex @@ -9,84 +9,81 @@ defmodule TheronsErpWeb.ProductLive.Show do ~H""" <.simple_form for={@form} id="product-inline-form" phx-change="validate" phx-submit="save"> <.header> - {@product.name} [{@product.identifier}] - <%= if @unsaved_changes do %> + + <.input field={@form[:name]} label="" data-1p-ignore /> + + <%= if @line_id not in ["", nil] do %> <.button phx-disable-with="Saving..." class="save-button"> - <.icon name="hero-check-circle" /> + <.icon name="hero-check-circle" /> Return to Sales Order + <% else %> + <%= if @unsaved_changes do %> + <.button phx-disable-with="Saving..." class="save-button"> + <.icon name="hero-check-circle" /> + + <% end %> <% end %> - <:subtitle> - - <:actions> - <.link patch={~p"/products/#{@product}/show/edit"} phx-click={JS.push_focus()}> - <.button>Edit product - - - - <%= if @live_action != :edit do %> -
- <.live_select - field={@form[:category_id]} - label="Category" - inline={true} - options={@initial_categories} - update_min_len={0} - phx-focus="set-default" - container_class="inline-container" - text_input_class="inline-text-input" - dropdown_class="inline-dropdown" - > - <:option :let={opt}> - <.highlight matches={opt.matches} string={opt.label} value={opt.value} /> - - <:inject_adjacent> - <%= if Phoenix.HTML.Form.input_value(@form, :category_id) do %> - - <.link navigate={ - TheronsErpWeb.Breadcrumbs.navigate_to_url( - @breadcrumbs, - {"product_category", Phoenix.HTML.Form.input_value(@form, :category_id), - get_category_name( - @categories, - Phoenix.HTML.Form.input_value(@form, :category_id) - )}, - {"products", @product.id, @product.name} - ) - }> - <.icon name="hero-arrow-right" /> - - - <% end %> - - -
- <% end %> + <:subtitle> + [{@product.identifier}] + - <.list> + <%!-- <.list> <:item title="Id">{@product.id} - + --%> + + <%= if @live_action != :edit do %> +
+ <.input field={@form[:saleable]} type="checkbox" label="Saleable" /> + + <.input field={@form[:purchaseable]} type="checkbox" label="Purchaseable" /> + <.input field={@form[:cost]} value={do_money(@form[:cost])} type="number" label="Cost" /> + + <.input + field={@form[:sales_price]} + value={do_money(@form[:sales_price])} + type="number" + label="Sales Price" + /> + + <.live_select + field={@form[:category_id]} + label="Category" + inline={true} + options={@initial_categories} + update_min_len={0} + phx-focus="set-default" + container_class="inline-container" + text_input_class="inline-text-input" + dropdown_class="inline-dropdown" + > + <:option :let={opt}> + <.highlight matches={opt.matches} string={opt.label} value={opt.value} /> + + <:inject_adjacent> + <%= if Phoenix.HTML.Form.input_value(@form, :category_id) not in ["create", nil] do %> + + <.link navigate={ + TheronsErpWeb.Breadcrumbs.navigate_to_url( + @breadcrumbs, + {"product_category", Phoenix.HTML.Form.input_value(@form, :category_id), + get_category_name( + @categories, + Phoenix.HTML.Form.input_value(@form, :category_id) + )}, + {"products", @product.id, @product.name} + ) + }> + <.icon name="hero-arrow-right" /> + + + <% end %> + + +
+ <% end %> <.back navigate={~p"/products"}>Back to products - <.modal - :if={@live_action == :edit} - id="product-modal" - show - on_cancel={JS.patch(~p"/products/#{@product}")} - > - <.live_component - module={TheronsErpWeb.ProductLive.FormComponent} - id={@product.id} - title={@page_title} - action={@live_action} - current_user={@current_user} - breadcrumbs={@breadcrumbs} - product={@product} - args={@args} - from_args={@from_args} - patch={~p"/products/#{@product}"} - /> - """ end @@ -115,6 +112,7 @@ defmodule TheronsErpWeb.ProductLive.Show do |> assign(:from_args, params["from_args"]) |> assign(:set_category, %{text: nil, value: nil}) |> assign(:unsaved_changes, false) + |> assign(:line_id, params["line_id"]) |> assign_form()} end @@ -194,7 +192,10 @@ defmodule TheronsErpWeb.ProductLive.Show do socket = socket |> put_flash(:info, "Product #{socket.assigns.form.source.type}d successfully") - |> Breadcrumbs.navigate_back({"products", "edit", product.id}) + |> Breadcrumbs.navigate_back({"products", "edit", product.id}, %{ + line_id: socket.assigns.line_id, + product_id: product.id + }) {:noreply, socket} @@ -245,4 +246,21 @@ defmodule TheronsErpWeb.ProductLive.Show do {:noreply, socket} end + + defp do_money(field) do + case field.value do + nil -> + "" + + "" -> + "" + + %Money{} = money -> + money.amount + |> Decimal.to_float() + + el -> + el + end + end end diff --git a/lib/therons_erp_web/live/sales_order_live/form_component.ex b/lib/therons_erp_web/live/sales_order_live/form_component.ex index 15d936f..249e883 100644 --- a/lib/therons_erp_web/live/sales_order_live/form_component.ex +++ b/lib/therons_erp_web/live/sales_order_live/form_component.ex @@ -21,31 +21,6 @@ defmodule TheronsErpWeb.SalesOrderLive.FormComponent do |> assign_form()} end - @impl true - def handle_event("validate", %{"sales_order" => sales_order_params}, socket) do - {:noreply, - assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, sales_order_params))} - end - - def handle_event("save", %{"sales_order" => sales_order_params}, socket) do - case AshPhoenix.Form.submit(socket.assigns.form, params: sales_order_params) do - {:ok, sales_order} -> - notify_parent({:saved, sales_order}) - - socket = - socket - |> put_flash(:info, "Sales order #{socket.assigns.form.source.type}d successfully") - |> push_patch(to: socket.assigns.patch) - - {:noreply, socket} - - {:error, form} -> - {:noreply, assign(socket, form: form)} - end - end - - defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) - defp assign_form(%{assigns: %{sales_order: sales_order}} = socket) do form = if sales_order do diff --git a/lib/therons_erp_web/live/sales_order_live/index.ex b/lib/therons_erp_web/live/sales_order_live/index.ex index 81f8968..1056c87 100644 --- a/lib/therons_erp_web/live/sales_order_live/index.ex +++ b/lib/therons_erp_web/live/sales_order_live/index.ex @@ -1,5 +1,6 @@ defmodule TheronsErpWeb.SalesOrderLive.Index do use TheronsErpWeb, :live_view + import TheronsErpWeb.Layouts @impl true def render(assigns) do @@ -18,7 +19,14 @@ defmodule TheronsErpWeb.SalesOrderLive.Index do rows={@streams.sales_orders} row_click={fn {_id, sales_order} -> JS.navigate(~p"/sales_orders/#{sales_order}") end} > - <:col :let={{_id, sales_order}} label="Id">{sales_order.id} + <:col :let={{_id, sales_order}} label="Id"> + {sales_order.identifier} + <.status_badge state={sales_order.state} /> + + + <:col :let={{_id, sales_order}} label="Customer"> + {if sales_order.customer, do: sales_order.customer.name, else: ""} + <:action :let={{_id, sales_order}}>
@@ -57,13 +65,18 @@ defmodule TheronsErpWeb.SalesOrderLive.Index do """ end + @ash_loads [:customer] + @impl true def mount(_params, _session, socket) do {:ok, socket |> stream( :sales_orders, - Ash.read!(TheronsErp.Sales.SalesOrder, actor: socket.assigns[:current_user]) + Ash.read!(TheronsErp.Sales.SalesOrder, + actor: socket.assigns[:current_user], + load: @ash_loads + ) ) |> assign_new(:current_user, fn -> nil end)} end @@ -78,7 +91,10 @@ defmodule TheronsErpWeb.SalesOrderLive.Index do |> assign(:page_title, "Edit Sales order") |> assign( :sales_order, - Ash.get!(TheronsErp.Sales.SalesOrder, id, actor: socket.assigns.current_user) + Ash.get!(TheronsErp.Sales.SalesOrder, id, + actor: socket.assigns.current_user, + load: @ash_loads + ) ) end @@ -88,7 +104,7 @@ defmodule TheronsErpWeb.SalesOrderLive.Index do socket |> assign(:page_title, "New Sales order") |> assign(:sales_order, nil) - |> push_redirect(to: ~p"/sales_orders/#{sales_order}") + |> push_navigate(to: ~p"/sales_orders/#{sales_order}") end defp apply_action(socket, :index, _params) do diff --git a/lib/therons_erp_web/live/sales_order_live/show.ex b/lib/therons_erp_web/live/sales_order_live/show.ex index ff8194f..f5e47db 100644 --- a/lib/therons_erp_web/live/sales_order_live/show.ex +++ b/lib/therons_erp_web/live/sales_order_live/show.ex @@ -1,12 +1,19 @@ defmodule TheronsErpWeb.SalesOrderLive.Show do + alias TheronsErpWeb.Breadcrumbs use TheronsErpWeb, :live_view + import TheronsErpWeb.Selects + import TheronsErpWeb.Layouts + + # TODO address should be selectable when customer is changed but not persisted + # TODO address should be reset when customer changed @impl true def render(assigns) do ~H""" <.simple_form for={@form} id="sales_order-form" phx-change="validate" phx-submit="save"> <.header> - Sales order {@sales_order.id} + Sales order {@sales_order.identifier} + <.status_badge state={@sales_order.state} /> <%= if @unsaved_changes do %> <.button phx-disable-with="Saving..." class="save-button"> <.icon name="hero-check-circle" /> @@ -18,68 +25,383 @@ defmodule TheronsErpWeb.SalesOrderLive.Show do <% end %> <% end %> - <:subtitle>This is a sales_order record from your database. <:actions> - <%!-- <.link patch={~p"/sales_orders/#{@sales_order}/show/edit"} phx-click={JS.push_focus()}> - <.button>Edit sales_order - --%> + <%= if @sales_order.state == :draft and not @unsaved_changes do %> + <.button phx-disable-with="Saving..." phx-click="set-ready"> + Ready + + <% end %> + <%= if @sales_order.state == :ready do %> + <.button phx-disable-with="Saving..." phx-click="set-draft"> + Return to draft + + <.button phx-disable-with="Saving..." phx-click="set-invoice"> + Generate invoice + + <% end %> + <:subtitle> - <.list> - <:item title="Id">{@sales_order.id} - + <%= if @sales_order.state == :invoiced do %> + <.link navigate={ + TheronsErpWeb.Breadcrumbs.navigate_to_url( + @breadcrumbs, + {"invoices", @sales_order.invoice.id, @sales_order.invoice.identifier}, + {"sales_orders", @sales_order.id, @params, @sales_order.identifier} + ) + }> + <.button>View invoice + + <% end %> + +
+

Customer

+ + <.live_select + field={@form[:customer_id]} + options={@default_customers} + inline={true} + update_min_len={0} + phx-focus="set-default-customers" + container_class="inline-container" + text_input_class="inline-text-input" + dropdown_class="inline-dropdown" + label="Customer" + > + <:option :let={opt}> + <.highlight matches={opt.matches} string={opt.label} value={opt.value} /> + + <:inject_adjacent> + <%= if Phoenix.HTML.Form.input_value(@form, :customer_id) do %> + + <.link navigate={ + TheronsErpWeb.Breadcrumbs.navigate_to_url( + @breadcrumbs, + {"entities", Phoenix.HTML.Form.input_value(@form, :customer_id), ""}, + {"sales_orders", @sales_order.id, @params, @sales_order.identifier} + ) + }> + <.icon name="hero-arrow-right" /> + + + <% end %> + + + + <%= if Phoenix.HTML.Form.input_value(@form, :customer_id) do %> +
+ <.input + field={@form[:address_id]} + type="select" + label="Address" + options={ + [{"Unselected", nil}] ++ + Enum.map(@addresses, &{&1.address, &1.id}) ++ + [{"Create new", "create"}] + } + /> +
+ + <.link navigate={ + TheronsErpWeb.Breadcrumbs.navigate_to_url( + @breadcrumbs, + {"entities", Phoenix.HTML.Form.input_value(@form, :customer_id), ""}, + {"sales_orders", @sales_order.id, @params, @sales_order.identifier} + ) + }> + <.icon name="hero-arrow-right" /> + + + <% end %> + + <%!-- Render address --%> + <%= if get_address(@form, @addresses) do %> +
+ +
+
+ <%!-- Address 1 --%> +
+ + <%!-- Address label --%> + Address + {get_address(@form, @addresses).address} + +
+ <%!-- Address 2 --%> +
+ + Address 2 + {get_address(@form, @addresses).address2} + +
+
+ <%!-- City --%> +
+ City + {get_address(@form, @addresses).city} +
+ <%!-- State --%> +
+ State + {get_address(@form, @addresses).state} +
+ <%!-- Zip Code --%> +
+ Zip Code + {get_address(@form, @addresses).zip_code} +
+
+ <%!-- Phone number --%> +
+ Phone Number + {get_address(@form, @addresses).phone} +
+
+
+
+ <% end %> +
+ + + - <.inputs_for :let={sales_line} field={@form[:sales_lines]}> - - - - - - - + <%= if @sales_order.state == :draft do %> + <.inputs_for :let={sales_line} field={@form[:sales_lines]}> + + + + + + + + + + + <% else %> + <%= for sales_line <- @sales_order.sales_lines do %> + + + + + + + + + <% end %> + <% end %>
Product Quantity Sales Price Unit PriceTotal PriceTotal Cost
- <.input field={sales_line[:quantity]} type="number" /> - - <.input - field={sales_line[:sales_price]} - value={do_money(sales_line[:sales_price])} - type="number" - /> - - <.input - field={sales_line[:unit_price]} - value={do_money(sales_line[:unit_price])} - type="number" - /> - - -
+ <.live_select + field={sales_line[:product_id]} + options={ + @default_products[Phoenix.HTML.Form.input_value(sales_line, :product_id)] || [] + } + inline={true} + update_min_len={0} + phx-focus="set-default" + container_class="inline-container" + text_input_class="inline-text-input" + dropdown_class="inline-dropdown" + label="" + > + <:option :let={opt}> + <.highlight matches={opt.matches} string={opt.label} value={opt.value} /> + + <:inject_adjacent> + <%= if Phoenix.HTML.Form.input_value(sales_line, :product_id) do %> + + <.link navigate={ + TheronsErpWeb.Breadcrumbs.navigate_to_url( + @breadcrumbs, + {"products", Phoenix.HTML.Form.input_value(sales_line, :product_id), + ""}, + {"sales_orders", @sales_order.id, @params, @sales_order.identifier} + ) + }> + <.icon name="hero-arrow-right" /> + + + <% end %> + + + + <.input field={sales_line[:quantity]} type="number" /> + + + $ +
+ <.input + field={sales_line[:sales_price]} + value={do_money(sales_line[:sales_price])} + type="number" + inline_container={true} + /> + <%= if compare_monies_neq(Phoenix.HTML.Form.input_value(sales_line, :sales_price), (if p = Phoenix.HTML.Form.input_value(sales_line, :product), do: p.sales_price, else: "")) do %> + <.button + phx-disable-with="Saving..." + class="revert-button" + name="revert" + value={"revert-price-#{sales_line.index}"} + > + <.icon name="hero-arrow-uturn-left" /> + + <% end %> +
+
+
+ + $ +
+ <.input + field={sales_line[:unit_price]} + value={do_money(sales_line[:unit_price])} + type="number" + inline_container={true} + /> + <%= if compare_monies_neq(Phoenix.HTML.Form.input_value(sales_line, :unit_price), (if p = Phoenix.HTML.Form.input_value(sales_line, :product), do: p.cost, else: "")) do %> + <.button + phx-disable-with="Saving..." + class="revert-button" + name="revert" + value={"revert-cost-#{sales_line.index}"} + > + <.icon name="hero-arrow-uturn-left" /> + + <% end %> +
+
+
+ + $ +
+ <.input + field={sales_line[:total_price]} + value={ + active_price_for_sales_line( + sales_line, + sales_line.index, + @total_price_changes + ) + } + type="number" + inline_container={true} + /> + <%= if (@total_price_changes[to_string(sales_line.index)] == true) || is_active_price_persisted?(sales_line, sales_line.index, @total_price_changes) do %> + <.button + phx-disable-with="Saving..." + class="revert-button" + name="revert" + value={"revert-total-price-#{sales_line.index}"} + > + <.icon name="hero-arrow-uturn-left" /> + + <% end %> +
+
+
+ + $ + <.input + field={sales_line[:total_cost]} + value={ + total_cost_for_sales_line(sales_line, sales_line.index, @total_cost_changes) + } + type="number" + inline_container={true} + /> + <%= if (@total_cost_changes[to_string(sales_line.index)] == true) || is_total_cost_persisted?(sales_line, sales_line.index, @total_cost_changes) do %> + <.button + phx-disable-with="Saving..." + class="revert-button" + name="revert" + value={"revert-total-cost-#{sales_line.index}"} + > + <.icon name="hero-arrow-uturn-left" /> + + <% end %> + + + +
+ <.link + navigate={ + TheronsErpWeb.Breadcrumbs.navigate_to_url( + @breadcrumbs, + {"products", sales_line.product.id, ""}, + {"sales_orders", @sales_order.id, @params, @sales_order.identifier} + ) + } + class="text-blue-600" + > + {sales_line.product.name} + + + {sales_line.quantity} + + $ {sales_line.sales_price.amount |> Decimal.to_float()} + + $ {sales_line.unit_price.amount |> Decimal.to_float()} + + $ {sales_line.active_price.amount |> Decimal.to_float()} + + $ {sales_line.total_cost.amount |> Decimal.to_float()} +
- + <%= if @sales_order.state == :draft do %> + + <% end %> + +
+
+ (Subtotal) $ {if @sales_order.total_price, + do: @sales_order.total_price.amount |> Decimal.to_float(), + else: 0} +
+
+ (Cost) $ {if @sales_order.total_cost, + do: @sales_order.total_cost.amount |> Decimal.to_float(), + else: 0} +
+
+
+ (Net) $ {Money.sub!( + @sales_order.total_price || Money.new(0, :USD), + @sales_order.total_cost || Money.new(0, :USD) + ).amount + |> Decimal.to_float()} +
+
<.back navigate={~p"/sales_orders"}>Back to sales_orders @@ -125,37 +447,259 @@ defmodule TheronsErpWeb.SalesOrderLive.Show do {:ok, socket} end + defp load_by_id(id, socket) do + Ash.get!(TheronsErp.Sales.SalesOrder, id, + actor: socket.assigns.current_user, + load: [ + :total_price, + :total_cost, + :address, + :invoice, + sales_lines: [:total_price, :product, :active_price, :calculated_total_price, :total_cost], + customer: [:addresses] + ] + ) + end + @impl true - def handle_params(%{"id" => id}, _, socket) do + def handle_params(%{"id" => id} = params, _, socket) do + sales_order = load_by_id(id, socket) + + default_products = + for line_item <- sales_order.sales_lines, into: %{} do + prod = line_item.product + + {prod.id, [%{value: prod.id, label: prod.name, matches: []}]} + end + {:noreply, socket |> assign(:page_title, page_title(socket.assigns.live_action)) |> assign( :sales_order, - Ash.get!(TheronsErp.Sales.SalesOrder, id, - actor: socket.assigns.current_user, - load: [:sales_lines] - ) + sales_order ) + |> assign(:args, params["args"]) + |> assign(:from_args, params["from_args"]) + |> assign(:params, params) + |> assign(:default_products, default_products) |> assign(:drop_sales, 0) + |> assign(:total_price_changes, %{}) + |> assign(:total_cost_changes, %{}) + |> assign(:set_customer, %{text: nil, value: nil}) + |> assign(:addresses, sales_order.customer.addresses) + |> assign(:default_customers, get_initial_customer_options(sales_order.customer_id)) |> assign_form()} end defp page_title(:show), do: "Show Sales order" defp page_title(:edit), do: "Edit Sales order" - @impl true - def handle_event("validate", %{"sales_order" => sales_order_params}, socket) do - form = AshPhoenix.Form.validate(socket.assigns.form, sales_order_params) - drop = length(sales_order_params["_drop_sales_lines"]) + defp record_total_price_change( + socket, + ["sales_order", "sales_lines", index, "total_price"] + ) do + changes = socket.assigns.total_price_changes + changes = put_in(changes[index], true) + + socket + |> assign(:total_price_changes, changes) + end + + defp record_total_price_change(socket, _) do + socket + end + + defp record_total_cost_change( + socket, + ["sales_order", "sales_lines", index, "total_cost"] + ) do + changes = socket.assigns.total_cost_changes + changes = put_in(changes[index], true) + + socket + |> assign(:total_cost_changes, changes) + end + + defp record_total_cost_change(socket, _) do + socket + end + + def handle_event( + "save", + %{"revert" => "revert-cost-" <> index, "sales_order" => params}, + socket + ) do + new_cost = + socket.assigns.form.source.data.sales_lines + |> Enum.at(String.to_integer(index)) + |> (&if(&1.product.cost, do: &1.product.cost.amount |> Decimal.to_string(), else: "")).() + + new_params = put_in(params, ["sales_lines", index, "unit_price"], new_cost) + + form = + AshPhoenix.Form.validate(socket.assigns.form, new_params) + + {:noreply, + socket + |> assign(:form, to_form(form)) + |> assign(:params, new_params) + |> assign(:unsaved_changes, form.source.changed?) + |> assign(:total_price_changes, Map.put(socket.assigns.total_price_changes, index, false))} + end + + def handle_event( + "save", + %{"revert" => "revert-price-" <> index, "sales_order" => params}, + socket + ) do + new_price = + socket.assigns.form.source.data.sales_lines + |> Enum.at(String.to_integer(index)) + |> (&if(&1.product.sales_price, + do: &1.product.sales_price.amount |> Decimal.to_string(), + else: "" + )).() + + new_params = put_in(params, ["sales_lines", index, "sales_price"], new_price) + + form = + AshPhoenix.Form.validate(socket.assigns.form, new_params) + + {:noreply, + socket + |> assign(:form, to_form(form)) + |> assign(:params, new_params) + |> assign(:unsaved_changes, form.source.changed?) + |> assign(:total_price_changes, Map.put(socket.assigns.total_price_changes, index, false))} + end + + def handle_event( + "save", + %{"revert" => "revert-total-price-" <> index, "sales_order" => params}, + socket + ) do + new_params = put_in(params, ["sales_lines", index, "total_price"], nil) + + form = + AshPhoenix.Form.validate(socket.assigns.form, new_params) + + {:noreply, + socket + |> assign(:form, to_form(form)) + |> assign(:params, new_params) + |> assign(:unsaved_changes, form.source.changed?) + |> assign(:total_price_changes, Map.put(socket.assigns.total_price_changes, index, false))} + end + + def handle_event( + "save", + %{"revert" => "revert-total-cost-" <> index, "sales_order" => params}, + socket + ) do + new_params = put_in(params, ["sales_lines", index, "total_cost"], nil) + + form = + AshPhoenix.Form.validate(socket.assigns.form, new_params) {:noreply, - assign(socket, form: form) - |> assign(:unsaved_changes, form.source.changed? || drop > 0) - |> assign(:drop_sales, drop)} + socket + |> assign(:form, to_form(form)) + |> assign(:params, new_params) + |> assign(:unsaved_changes, form.source.changed?) + |> assign(:total_cost_changes, Map.put(socket.assigns.total_cost_changes, index, false))} + end + + @impl true + def handle_event( + "validate", + %{"sales_order" => sales_order_params, "_target" => target} = params, + socket + ) do + sales_order_params = process_modifications(sales_order_params, socket) + + socket = + socket + |> record_total_price_change(params["_target"]) + |> record_total_cost_change(params["_target"]) + + output = + (sales_order_params["sales_lines"] || []) + |> Enum.map(fn {id, val} -> + {id, val["product_id"]} + end) + |> Enum.find(fn {_id, val} -> val == "create" end) + + has_create = + case output do + {id, "create"} -> id + _ -> nil + end + + if sales_order_params["address_id"] == "create" and + sales_order_params["customer_id"] not in [nil, "create"] and + target != ["sales_order", "customer_id"] do + {:noreply, + socket + |> Breadcrumbs.navigate_to( + {"addresses", "new", sales_order_params["customer_id"]}, + {"sales_orders", socket.assigns.sales_order.id, sales_order_params, + socket.assigns.sales_order.identifier} + )} + else + if has_create do + {:noreply, + socket + |> Breadcrumbs.navigate_to( + {"products", "new", has_create}, + {"sales_orders", socket.assigns.sales_order.id, sales_order_params, + socket.assigns.sales_order.identifier} + )} + else + if sales_order_params["customer_id"] == "create" do + sid = socket.assigns.sales_order.id + + {:noreply, + socket + |> Breadcrumbs.navigate_to( + {"entities", "new", sid}, + {"sales_orders", socket.assigns.sales_order.id, sales_order_params, + socket.assigns.sales_order.identifier} + )} + else + set_customer = + socket.assigns.default_customers + |> Enum.find(fn c -> c.value == sales_order_params["customer_id"] end) + |> case do + nil -> %{text: nil, value: nil} + c -> %{text: c.label, value: c.value} + end + + # If address_id not in customer addresses we want to reset the sales_order_params["address_id"] to nil + # Load the customer by sales_order_params["customer_id"] + customer = + Ash.get!(TheronsErp.People.Entity, sales_order_params["customer_id"], + load: [:addresses] + ) + + form = AshPhoenix.Form.validate(socket.assigns.form, sales_order_params) + drop = length(sales_order_params["_drop_sales_lines"] || []) + + {:noreply, + assign(socket, form: form) + |> assign(:unsaved_changes, form.source.changed? || drop > 0) + |> assign(:set_customer, set_customer) + |> assign(:addresses, customer.addresses) + |> assign(:params, sales_order_params) + |> assign(:drop_sales, drop)} + end + end + end end def handle_event("save", %{"sales_order" => sales_order_params}, socket) do + sales_order_params = process_modifications(sales_order_params, socket) + case AshPhoenix.Form.submit(socket.assigns.form, params: sales_order_params) do {:ok, sales_order} -> # notify_parent({:saved, sales_order}) @@ -165,12 +709,13 @@ defmodule TheronsErpWeb.SalesOrderLive.Show do |> put_flash(:info, "Sales order #{socket.assigns.form.source.type}d successfully") |> assign( :sales_order, - Ash.get!(TheronsErp.Sales.SalesOrder, socket.assigns.sales_order.id, - actor: socket.assigns.current_user, - load: [:sales_lines] - ) + load_by_id(socket.assigns.sales_order.id, socket) ) |> assign_form() + |> push_navigate( + to: + ~p"/sales_orders/#{sales_order.id}?#{[breadcrumbs: Breadcrumbs.encode_breadcrumbs(socket.assigns.breadcrumbs)]}" + ) # |> push_patch(to: socket.assigns.patch) @@ -181,7 +726,288 @@ defmodule TheronsErpWeb.SalesOrderLive.Show do end end - defp assign_form(%{assigns: %{sales_order: sales_order}} = socket) do + def handle_event("set-ready", _, socket) do + Ash.Changeset.for_update(socket.assigns.sales_order, :ready) |> Ash.update!() + {:noreply, socket |> assign(:sales_order, load_by_id(socket.assigns.sales_order.id, socket))} + end + + def handle_event("set-draft", _, socket) do + Ash.Changeset.for_update(socket.assigns.sales_order, :revive) |> Ash.update!() + {:noreply, socket |> assign(:sales_order, load_by_id(socket.assigns.sales_order.id, socket))} + end + + def handle_event("set-invoice", _, socket) do + Ash.Changeset.for_update(socket.assigns.sales_order, :invoice) |> Ash.update!() + {:noreply, socket |> assign(:sales_order, load_by_id(socket.assigns.sales_order.id, socket))} + end + + def handle_event( + "live_select_change", + %{"text" => text, "id" => "sales_order[sales_lines]" <> _ = id}, + socket + ) do + opts = + get_products("") + |> prepare_matches(text) + + send_update(LiveSelect.Component, id: id, options: opts) + + {:noreply, socket} + end + + def handle_event( + "live_select_change", + %{"text" => text, "id" => "sales_order_customer_id" <> _ = id}, + socket + ) do + opts = + get_customers("") + |> prepare_matches(text) + + send_update(LiveSelect.Component, id: id, options: opts) + + {:noreply, socket} + end + + @impl true + def handle_event( + "set-default-customers", + %{ + "id" => id + }, + socket + ) do + if cid = socket.assigns.from_args["customer_id"] do + opts = get_initial_customer_options(cid) + send_update(LiveSelect.Component, options: opts, id: id, value: cid) + else + text = + socket.assigns.set_customer.text || + (socket.assigns.sales_order.customer && socket.assigns.sales_order.customer.name) || "" + + value = socket.assigns.set_customer.value || socket.assigns.sales_order.customer_id + + opts = prepare_matches(socket.assigns.default_customers, text) + + send_update(LiveSelect.Component, options: opts, id: id, value: value) + end + + {:noreply, socket} + end + + @impl true + def handle_event( + "set-default", + %{"id" => id}, + socket + ) do + number = parse_select_id!(id) + + pid = socket.assigns.from_args["product_id"] + + if socket.assigns.from_args["product_id"] && + socket.assigns.from_args["line_id"] == to_string(number) do + opts = get_initial_product_options(pid) + + send_update(LiveSelect.Component, + options: opts, + id: id, + value: pid + ) + else + value = + socket.assigns.form + |> Phoenix.HTML.Form.input_value(:sales_lines) + |> Enum.at(String.to_integer(number)) + |> Phoenix.HTML.Form.input_value(:product_id) + + if value not in [nil, ""] do + opts = get_initial_product_options(value) + + send_update(LiveSelect.Component, + options: opts, + id: id, + value: value + ) + else + opts = get_initial_product_options(nil) + + send_update(LiveSelect.Component, + options: opts, + id: id, + value: nil + ) + end + end + + {:noreply, socket} + end + + defp get_address(form, addresses) do + id = Phoenix.HTML.Form.input_value(form, :address_id) + Enum.find(addresses, &(&1.id == id)) + end + + defp compare_monies_neq(a, b) do + case {a, b} do + {nil, nil} -> + false + + _ -> + not Money.equal?(a, b) + end + end + + def erase_total_price_changes(sales_order_params, price_changes) do + new_lines = + for {id, line} <- sales_order_params["sales_lines"], into: %{} do + if price_changes[id] != false do + {id, line} + else + new_line = put_in(line["total_price"], nil) + {id, new_line} + end + end + + put_in(sales_order_params["sales_lines"], new_lines) + end + + def erase_total_cost_changes(sales_order_params, cost_changes) do + new_lines = + for {id, line} <- sales_order_params["sales_lines"], into: %{} do + if cost_changes[id] != false do + {id, line} + else + new_line = put_in(line["total_cost"], nil) + {id, new_line} + end + end + + put_in(sales_order_params["sales_lines"], new_lines) + end + + def process_modifications(sales_order_params, socket) do + sales_order_params + |> erase_total_price_changes(socket.assigns.total_price_changes) + |> erase_total_cost_changes(socket.assigns.total_cost_changes) + end + + defp is_active_price_persisted?(sales_line, index, total_price_changes) do + if total_price_changes[to_string(index)] == false do + false + else + case sales_line.source.data do + # In case there's no data source (e.g., new line) + nil -> + false + + line_data -> + line_data.total_price != nil + end + end + end + + defp is_total_cost_persisted?(sales_line, index, total_cost_changes) do + if total_cost_changes[to_string(index)] == false do + false + else + case sales_line.source.data do + # In case there's no data source (e.g., new line) + nil -> + false + + line_data -> + line_data.total_cost != nil + end + end + end + + defp active_price_for_sales_line(sales_line, index, total_price_changes) do + data_total_price = + case Phoenix.HTML.Form.input_value(sales_line, :total_price) do + # In case there's no data source (e.g., new line) + nil -> nil + total_price -> total_price + end + + if (data_total_price && is_active_price_persisted?(sales_line, index, total_price_changes)) || + total_price_changes[to_string(index)] do + case data_total_price do + %Money{} -> + data_total_price.amount |> Decimal.to_string() + + _ -> + data_total_price + end + else + sales_price = Phoenix.HTML.Form.input_value(sales_line, :sales_price) + quantity = Phoenix.HTML.Form.input_value(sales_line, :quantity) + + case {sales_price, quantity} do + {nil, nil} -> + "" + + {_, nil} -> + "" + + {nil, _} -> + "" + + {_, _} -> + Ash.calculate!(TheronsErp.Sales.SalesLine, :calculated_total_price, + refs: %{sales_price: sales_price, quantity: quantity} + ) + |> Money.to_decimal() + |> Decimal.to_string() + end + end + end + + # TODO consider using Ash.calculate!/2 + defp total_cost_for_sales_line(sales_line, index, total_cost_changes) do + data_total_cost = + case Phoenix.HTML.Form.input_value(sales_line, :total_cost) do + # In case there's no data source (e.g., new line) + nil -> nil + total_cost -> total_cost + end + + if (data_total_cost && is_total_cost_persisted?(sales_line, index, total_cost_changes)) || + total_cost_changes[to_string(index)] do + case data_total_cost do + %Money{} -> + data_total_cost.amount |> Decimal.to_string() + + _ -> + data_total_cost + end + else + unit_price = Phoenix.HTML.Form.input_value(sales_line, :unit_price) + quantity = Phoenix.HTML.Form.input_value(sales_line, :quantity) + + case {unit_price, quantity} do + {nil, nil} -> + "" + + {_, nil} -> + "" + + {nil, _} -> + "" + + {_, _} -> + Ash.calculate!(TheronsErp.Sales.SalesLine, :total_cost, + refs: %{unit_price: unit_price, quantity: quantity} + ) + |> Money.to_decimal() + |> Decimal.to_string() + end + end + end + + defp assign_form( + %{assigns: %{sales_order: sales_order, args: args, from_args: from_args}} = socket + ) do form = if sales_order do AshPhoenix.Form.for_update(sales_order, :update, @@ -195,8 +1021,83 @@ defmodule TheronsErpWeb.SalesOrderLive.Show do ) end + form = + case {args, from_args} do + {nil, nil} -> + form + + {_, nil} -> + AshPhoenix.Form.validate(form, args) + + {nil, _} -> + new_args = + put_in( + from_args, + ["sales_lines", from_args["line_id"], "product_id"], + from_args["product_id"] + ) + + update_live_forms(new_args) + AshPhoenix.Form.validate(form, new_args) + + _ -> + cond do + from_args["line_id"] -> + new_args = + put_in( + Map.merge(args, from_args), + ["sales_lines", from_args["line_id"], "product_id"], + from_args["product_id"] + ) + + update_live_forms(new_args) + AshPhoenix.Form.validate(form, new_args) + + from_args["customer_id"] -> + new_args = + Map.merge(args, from_args) + + update_live_forms(new_args) + AshPhoenix.Form.validate(form, new_args) + + from_args["address_id"] -> + new_args = + Map.merge(args, from_args) + + update_live_forms(new_args) + AshPhoenix.Form.validate(form, new_args) + end + end + socket |> assign(form: to_form(form)) |> assign(:unsaved_changes, form.changed?) end + + defp update_live_forms(new_args) do + for {line_no, line} <- new_args["sales_lines"] do + pid = + line["product_id"] + + if pid not in [nil, ""] do + opts = get_initial_product_options(pid) + + id = + "sales_order[sales_lines][#{line_no}]_product_id_live_select_component" + + send_update(LiveSelect.Component, + options: opts, + id: id, + value: pid + ) + end + end + end + + defp parse_select_id!(id) do + [_, number] = + Regex.run(~r/sales_order\[sales_lines\]\[(\d+)\]_product_id_live_select_component/, id) + + number + end end diff --git a/lib/therons_erp_web/router.ex b/lib/therons_erp_web/router.ex index 01b6610..a6a770a 100644 --- a/lib/therons_erp_web/router.ex +++ b/lib/therons_erp_web/router.ex @@ -47,7 +47,11 @@ defmodule TheronsErpWeb.Router do sign_out_route AuthController ash_authentication_live_session :authentication_optional, - on_mount: [{TheronsErpWeb.LiveUserAuth, :live_user_optional}, TheronsErpWeb.Breadcrumbs] do + on_mount: [ + {TheronsErpWeb.LiveUserAuth, :live_user_optional}, + TheronsErpWeb.Breadcrumbs, + TheronsErpWeb.Nav + ] do live "/product_categories", ProductCategoryLive.Index, :index live "/product_categories/new", ProductCategoryLive.Index, :new live "/product_categories/:id/edit", ProductCategoryLive.Index, :edit @@ -57,17 +61,22 @@ defmodule TheronsErpWeb.Router do live "/products", ProductLive.Index, :index live "/products/new", ProductLive.Index, :new - live "/products/:id/edit", ProductLive.Index, :edit live "/products/:id", ProductLive.Show, :show - live "/products/:id/show/edit", ProductLive.Show, :edit live "/sales_orders", SalesOrderLive.Index, :index - live "/sales_orders/new", SalesOrderLive.Index, :new live "/sales_orders/:id/edit", SalesOrderLive.Index, :edit live "/sales_orders/:id", SalesOrderLive.Show, :show live "/sales_orders/:id/show/edit", SalesOrderLive.Show, :edit + + live "/people", EntityLive.Index, :index + live "/people/new", EntityLive.Index, :new + live "/people/:id/edit", EntityLive.Index, :edit + + live "/people/:id", EntityLive.Show, :show + live "/people/:id/new_address", EntityLive.Show, :new_address + live "/people/:id/show/edit", EntityLive.Show, :edit end # Remove these if you'd like to use your own authentication views diff --git a/lib/therons_erp_web/sales/sales_line.ex b/lib/therons_erp_web/sales/sales_line.ex deleted file mode 100644 index 15ed6b5..0000000 --- a/lib/therons_erp_web/sales/sales_line.ex +++ /dev/null @@ -1,39 +0,0 @@ -defmodule TheronsErp.Sales.SalesLine do - use Ash.Resource, - otp_app: :therons_erp, - domain: TheronsErp.Sales, - data_layer: AshPostgres.DataLayer - - postgres do - table "sales_lines" - repo TheronsErp.Repo - end - - actions do - defaults [:destroy, :read] - - create :create do - primary? true - accept [:sales_price, :unit_price, :quantity] - end - - update :update do - primary? true - accept [:sales_price, :unit_price, :quantity] - end - end - - attributes do - uuid_primary_key :id - - attribute :sales_price, :money - attribute :unit_price, :money - attribute :quantity, :integer - timestamps() - end - - relationships do - belongs_to :sales_order, TheronsErp.Sales.SalesOrder - belongs_to :product, TheronsErp.Inventory.Product - end -end diff --git a/lib/therons_erp_web/sales/sales_order.ex b/lib/therons_erp_web/sales/sales_order.ex deleted file mode 100644 index 07acc27..0000000 --- a/lib/therons_erp_web/sales/sales_order.ex +++ /dev/null @@ -1,43 +0,0 @@ -defmodule TheronsErp.Sales.SalesOrder do - use Ash.Resource, - otp_app: :therons_erp, - domain: TheronsErp.Sales, - data_layer: AshPostgres.DataLayer - - postgres do - table "sales_orders" - repo TheronsErp.Repo - end - - actions do - defaults [:read] - - destroy :destroy do - end - - create :create do - argument :sales_lines, {:array, :map} - - change manage_relationship(:sales_lines, type: :create) - end - - update :update do - require_atomic? false - argument :sales_lines, {:array, :map} - - change manage_relationship(:sales_lines, type: :direct_control) - end - end - - attributes do - uuid_primary_key :id - - timestamps() - end - - relationships do - has_many :sales_lines, TheronsErp.Sales.SalesLine do - destination_attribute :sales_order_id - end - end -end diff --git a/lib/therons_erp_web/selects.ex b/lib/therons_erp_web/selects.ex index 8368d7b..11ca157 100644 --- a/lib/therons_erp_web/selects.ex +++ b/lib/therons_erp_web/selects.ex @@ -1,24 +1,37 @@ defmodule TheronsErpWeb.Selects do + alias TheronsErp.People alias TheronsErp.Inventory - def prepare_matches(categories, text) do + def prepare_matches(items, text) do matches = - Seqfuzz.matches(categories, text, & &1.label, filter: true, sort: true) + Seqfuzz.matches(items, text, & &1.label, filter: true, sort: true) (matches - |> Enum.map(fn {categories, c} -> - %{value: categories.value, label: categories.label, matches: c.matches} + |> Enum.map(fn {items, c} -> + %{value: items.value, label: items.label, matches: c.matches} end) |> Enum.take(5)) ++ additional_options() end def get_categories(selected) do + _get_list(Inventory.get_categories!(), selected, & &1.full_name) + end + + def get_products(selected) do + _get_list(Inventory.get_saleable_products!(), selected, & &1.name) + end + + def get_customers(selected) do + _get_list(People.list_people!(), selected, & &1.name) + end + + defp _get_list(items, selected, mapper) do list = - Inventory.get_categories!() - |> Enum.map(fn cat -> + items + |> Enum.map(fn item -> %{ - value: to_string(cat.id), - label: cat.full_name, + value: to_string(item.id), + label: mapper.(item), matches: [] } end) @@ -43,16 +56,57 @@ defmodule TheronsErpWeb.Selects do ] end + def additional_product_options do + [ + %{ + value: :create, + label: "Create New", + matches: [] + } + ] + end + + def additional_customer_options do + [ + %{ + value: :create, + label: "Create New", + matches: [] + } + ] + end + def get_initial_options(selected) do - (get_categories(selected) ++ additional_options()) |> Enum.uniq() |> Enum.take(5) + (get_categories(selected) |> Enum.uniq() |> Enum.take(4)) ++ additional_options() + end + + def get_initial_customer_options(selected) do + (get_customers(selected) |> Enum.uniq() |> Enum.take(4)) ++ additional_customer_options() end + def get_initial_product_options(selected) do + (get_products(selected) + |> Enum.uniq() + |> Enum.take(4)) ++ additional_product_options() + end def get_category_name(categories, id) do found = categories |> Enum.find(&(to_string(&1.value) == to_string(id))) + if found do + found.label + else + nil + end + end + + def get_product_name(products, id) do + found = + products + |> Enum.find(&(to_string(&1.value) == to_string(id))) + found.label end end diff --git a/mix.exs b/mix.exs index 23e1a38..a784c5f 100644 --- a/mix.exs +++ b/mix.exs @@ -5,7 +5,7 @@ defmodule TheronsErp.MixProject do [ app: :therons_erp, version: "0.1.0", - elixir: "~> 1.14", + elixir: "~> 1.18.2", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, aliases: aliases(), @@ -76,7 +76,8 @@ defmodule TheronsErp.MixProject do {:live_select, "~> 1.0"}, {:seqfuzz, "~> 0.2.0"}, {:igniter, "~> 0.5", only: [:dev, :test]}, - {:petal_components, "~> 2.8"} + {:petal_components, "~> 2.8"}, + {:credo, "~> 1.7"} ] end diff --git a/mix.lock b/mix.lock index fa80c5b..3f52e1b 100644 --- a/mix.lock +++ b/mix.lock @@ -14,9 +14,11 @@ "assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"}, "bandit": {:hex, :bandit, "1.6.7", "42f30e37a1c89a2a12943c5dca76f731a2313e8a2e21c1a95dc8241893e922d1", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "551ba8ff5e4fc908cbeb8c9f0697775fb6813a96d9de5f7fe02e34e76fd7d184"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.2.0", "feab711974beba4cb348147170346fe097eea2e840db4e012a145e180ed4ab75", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "563e92a6c77d667b19c5f4ba17ab6d440a085696bdf4c68b9b0f5b30bc5422b8"}, + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "castore": {:hex, :castore, "1.0.11", "4bbd584741601eb658007339ea730b082cc61f3554cf2e8f39bf693a11b49073", [:mix], [], "hexpm", "e03990b4db988df56262852f20de0f659871c35154691427a5047f4967a16a62"}, "cldr_utils": {:hex, :cldr_utils, "2.28.2", "f500667164a9043369071e4f9dcef31f88b8589b2e2c07a1eb9f9fa53cb1dce9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "c506eb1a170ba7cdca59b304ba02a56795ed119856662f6b1a420af80ec42551"}, "comeonin": {:hex, :comeonin, "5.5.0", "364d00df52545c44a139bad919d7eacb55abf39e86565878e17cebb787977368", [:mix], [], "hexpm", "6287fc3ba0aad34883cbe3f7949fc1d1e738e5ccdce77165bc99490aa69f47fb"}, + "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "digital_token": {:hex, :digital_token, "1.0.0", "454a4444061943f7349a51ef74b7fb1ebd19e6a94f43ef711f7dae88c09347df", [:mix], [{:cldr_utils, "~> 2.17", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "8ed6f5a8c2fa7b07147b9963db506a1b4c7475d9afca6492136535b064c9e9e6"}, diff --git a/priv/repo/migrations/20250210171140_total_prices_on_sales_orders.exs b/priv/repo/migrations/20250210171140_total_prices_on_sales_orders.exs new file mode 100644 index 0000000..4cf973d --- /dev/null +++ b/priv/repo/migrations/20250210171140_total_prices_on_sales_orders.exs @@ -0,0 +1,25 @@ +defmodule TheronsErp.Repo.Migrations.TotalPricesOnSalesOrders do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:sales_lines) do + modify :product_id, :uuid, null: false + modify :sales_order_id, :uuid, null: false + add :total_price, :money_with_currency + end + end + + def down do + alter table(:sales_lines) do + remove :total_price + modify :sales_order_id, :uuid, null: true + modify :product_id, :uuid, null: true + end + end +end diff --git a/priv/repo/migrations/20250210184227_add_serial_no_to_sales_orders.exs b/priv/repo/migrations/20250210184227_add_serial_no_to_sales_orders.exs new file mode 100644 index 0000000..c39dd2b --- /dev/null +++ b/priv/repo/migrations/20250210184227_add_serial_no_to_sales_orders.exs @@ -0,0 +1,21 @@ +defmodule TheronsErp.Repo.Migrations.AddSerialNoToSalesOrders do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:sales_orders) do + add :identifier, :bigserial + end + end + + def down do + alter table(:sales_orders) do + remove :identifier + end + end +end diff --git a/priv/repo/migrations/20250210192335_states_for_sales_orders.exs b/priv/repo/migrations/20250210192335_states_for_sales_orders.exs new file mode 100644 index 0000000..c072140 --- /dev/null +++ b/priv/repo/migrations/20250210192335_states_for_sales_orders.exs @@ -0,0 +1,21 @@ +defmodule TheronsErp.Repo.Migrations.StatesForSalesOrders do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:sales_orders) do + add :state, :text, null: false, default: "draft" + end + end + + def down do + alter table(:sales_orders) do + remove :state + end + end +end diff --git a/priv/repo/migrations/20250210214928_people.exs b/priv/repo/migrations/20250210214928_people.exs new file mode 100644 index 0000000..96ffbaa --- /dev/null +++ b/priv/repo/migrations/20250210214928_people.exs @@ -0,0 +1,56 @@ +defmodule TheronsErp.Repo.Migrations.People do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + create table(:entities, primary_key: false) do + add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true + add :name, :text, null: false + + add :inserted_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :updated_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + end + + create table(:addresses, primary_key: false) do + add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true + add :address, :text + add :city, :text + add :state, :text + add :zip_code, :text + + add :inserted_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :updated_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :entity_id, + references(:entities, + column: :id, + name: "addresses_entity_id_fkey", + type: :uuid, + prefix: "public" + ) + end + end + + def down do + drop constraint(:addresses, "addresses_entity_id_fkey") + + drop table(:addresses) + + drop table(:entities) + end +end diff --git a/priv/repo/migrations/20250210215026_add_people_to_sales_orders.exs b/priv/repo/migrations/20250210215026_add_people_to_sales_orders.exs new file mode 100644 index 0000000..2a300cf --- /dev/null +++ b/priv/repo/migrations/20250210215026_add_people_to_sales_orders.exs @@ -0,0 +1,29 @@ +defmodule TheronsErp.Repo.Migrations.AddPeopleToSalesOrders do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:sales_orders) do + add :customer_id, + references(:entities, + column: :id, + name: "sales_orders_customer_id_fkey", + type: :uuid, + prefix: "public" + ) + end + end + + def down do + drop constraint(:sales_orders, "sales_orders_customer_id_fkey") + + alter table(:sales_orders) do + remove :customer_id + end + end +end diff --git a/priv/repo/migrations/20250210220244_add_address2.exs b/priv/repo/migrations/20250210220244_add_address2.exs new file mode 100644 index 0000000..39ae278 --- /dev/null +++ b/priv/repo/migrations/20250210220244_add_address2.exs @@ -0,0 +1,21 @@ +defmodule TheronsErp.Repo.Migrations.AddAddress2 do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:addresses) do + add :address2, :text + end + end + + def down do + alter table(:addresses) do + remove :address2 + end + end +end diff --git a/priv/repo/migrations/20250213040919_add_addresses_to_sales_orders.exs b/priv/repo/migrations/20250213040919_add_addresses_to_sales_orders.exs new file mode 100644 index 0000000..6e51981 --- /dev/null +++ b/priv/repo/migrations/20250213040919_add_addresses_to_sales_orders.exs @@ -0,0 +1,29 @@ +defmodule TheronsErp.Repo.Migrations.AddAddressesToSalesOrders do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:sales_orders) do + add :address_id, + references(:addresses, + column: :id, + name: "sales_orders_address_id_fkey", + type: :uuid, + prefix: "public" + ) + end + end + + def down do + drop constraint(:sales_orders, "sales_orders_address_id_fkey") + + alter table(:sales_orders) do + remove :address_id + end + end +end diff --git a/priv/repo/migrations/20250214041703_add_phone_number_to_address.exs b/priv/repo/migrations/20250214041703_add_phone_number_to_address.exs new file mode 100644 index 0000000..f2149ab --- /dev/null +++ b/priv/repo/migrations/20250214041703_add_phone_number_to_address.exs @@ -0,0 +1,21 @@ +defmodule TheronsErp.Repo.Migrations.AddPhoneNumberToAddress do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:addresses) do + add :phone, :text + end + end + + def down do + alter table(:addresses) do + remove :phone + end + end +end diff --git a/priv/repo/migrations/20250214173939_add_saleable_and_purchaseable_to_products.exs b/priv/repo/migrations/20250214173939_add_saleable_and_purchaseable_to_products.exs new file mode 100644 index 0000000..d0d4e01 --- /dev/null +++ b/priv/repo/migrations/20250214173939_add_saleable_and_purchaseable_to_products.exs @@ -0,0 +1,23 @@ +defmodule TheronsErp.Repo.Migrations.AddSaleableAndPurchaseableToProducts do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:products) do + add :saleable, :boolean, default: true + add :purchaseable, :boolean, default: false + end + end + + def down do + alter table(:products) do + remove :purchaseable + remove :saleable + end + end +end diff --git a/priv/repo/migrations/20250215165722_add_cost_to_products.exs b/priv/repo/migrations/20250215165722_add_cost_to_products.exs new file mode 100644 index 0000000..d825b18 --- /dev/null +++ b/priv/repo/migrations/20250215165722_add_cost_to_products.exs @@ -0,0 +1,21 @@ +defmodule TheronsErp.Repo.Migrations.AddCostToProducts do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:products) do + add :cost, :money_with_currency + end + end + + def down do + alter table(:products) do + remove :cost + end + end +end diff --git a/priv/repo/migrations/20250216164429_add_line_items_to_invoices.exs b/priv/repo/migrations/20250216164429_add_line_items_to_invoices.exs new file mode 100644 index 0000000..a36553f --- /dev/null +++ b/priv/repo/migrations/20250216164429_add_line_items_to_invoices.exs @@ -0,0 +1,93 @@ +defmodule TheronsErp.Repo.Migrations.AddLineItemsToInvoices do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:sales_lines) do + modify :quantity, :bigint, default: 1 + end + + create table(:line_items, primary_key: false) do + add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true + add :price, :money_with_currency + add :quantity, :bigint + + add :inserted_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :updated_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :invoice_id, :uuid + end + + create table(:invoices, primary_key: false) do + add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true + end + + alter table(:line_items) do + modify :invoice_id, + references(:invoices, + column: :id, + name: "line_items_invoice_id_fkey", + type: :uuid, + prefix: "public" + ) + end + + alter table(:invoices) do + add :identifier, :bigserial + + add :inserted_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :updated_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :customer_id, + references(:entities, + column: :id, + name: "invoices_customer_id_fkey", + type: :uuid, + prefix: "public" + ) + + add :state, :text, null: false, default: "draft" + end + end + + def down do + drop constraint(:invoices, "invoices_customer_id_fkey") + + alter table(:invoices) do + remove :state + remove :customer_id + remove :updated_at + remove :inserted_at + remove :identifier + end + + drop constraint(:line_items, "line_items_invoice_id_fkey") + + alter table(:line_items) do + modify :invoice_id, :uuid + end + + drop table(:invoices) + + drop table(:line_items) + + alter table(:sales_lines) do + modify :quantity, :bigint, default: nil + end + end +end diff --git a/priv/repo/migrations/20250216164600_add_product_to_line_item.exs b/priv/repo/migrations/20250216164600_add_product_to_line_item.exs new file mode 100644 index 0000000..92d7dee --- /dev/null +++ b/priv/repo/migrations/20250216164600_add_product_to_line_item.exs @@ -0,0 +1,33 @@ +defmodule TheronsErp.Repo.Migrations.AddProductToLineItem do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:line_items) do + modify :invoice_id, :uuid, null: false + + add :product_id, + references(:products, + column: :id, + name: "line_items_product_id_fkey", + type: :uuid, + prefix: "public" + ), + null: false + end + end + + def down do + drop constraint(:line_items, "line_items_product_id_fkey") + + alter table(:line_items) do + remove :product_id + modify :invoice_id, :uuid, null: true + end + end +end diff --git a/priv/repo/migrations/20250216165331_add_sales_order_to_invoice.exs b/priv/repo/migrations/20250216165331_add_sales_order_to_invoice.exs new file mode 100644 index 0000000..1bf3451 --- /dev/null +++ b/priv/repo/migrations/20250216165331_add_sales_order_to_invoice.exs @@ -0,0 +1,29 @@ +defmodule TheronsErp.Repo.Migrations.AddSalesOrderToInvoice do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:invoices) do + add :sales_order_id, + references(:sales_orders, + column: :id, + name: "invoices_sales_order_id_fkey", + type: :uuid, + prefix: "public" + ) + end + end + + def down do + drop constraint(:invoices, "invoices_sales_order_id_fkey") + + alter table(:invoices) do + remove :sales_order_id + end + end +end diff --git a/priv/resource_snapshots/repo/addresses/20250210214929.json b/priv/resource_snapshots/repo/addresses/20250210214929.json new file mode 100644 index 0000000..516036e --- /dev/null +++ b/priv/resource_snapshots/repo/addresses/20250210214929.json @@ -0,0 +1,118 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "address", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "city", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "state", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "zip_code", + "type": "text" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "addresses_entity_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "entities" + }, + "size": null, + "source": "entity_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": false, + "hash": "3D21B8882ED6A46DDCFC58B31608F4E737986837F4DB62F83FFB9E67BDCDC85A", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.TheronsErp.Repo", + "schema": null, + "table": "addresses" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/addresses/20250210220244.json b/priv/resource_snapshots/repo/addresses/20250210220244.json new file mode 100644 index 0000000..94ba399 --- /dev/null +++ b/priv/resource_snapshots/repo/addresses/20250210220244.json @@ -0,0 +1,128 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "address", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "address2", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "city", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "state", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "zip_code", + "type": "text" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "addresses_entity_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "entities" + }, + "size": null, + "source": "entity_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "77B2E8B507DE79AB658047771A614C3BF0B2AE02973F761C0FEA71AD208ECDCC", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.TheronsErp.Repo", + "schema": null, + "table": "addresses" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/addresses/20250214041703.json b/priv/resource_snapshots/repo/addresses/20250214041703.json new file mode 100644 index 0000000..c16c664 --- /dev/null +++ b/priv/resource_snapshots/repo/addresses/20250214041703.json @@ -0,0 +1,138 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "address", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "address2", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "city", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "state", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "zip_code", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "phone", + "type": "text" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "addresses_entity_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "entities" + }, + "size": null, + "source": "entity_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "1B145B96FFFBA0EFA920F95A79FC43C100F3F2FE5B1B7CFC27BF2F6FC641F249", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.TheronsErp.Repo", + "schema": null, + "table": "addresses" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/entities/20250210214929.json b/priv/resource_snapshots/repo/entities/20250210214929.json new file mode 100644 index 0000000..f154636 --- /dev/null +++ b/priv/resource_snapshots/repo/entities/20250210214929.json @@ -0,0 +1,59 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": false, + "hash": "86265667DE367BC57CECDDDF9F246B940D64A8947187DBA67436A6F0E695B98F", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.TheronsErp.Repo", + "schema": null, + "table": "entities" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/invoices/20250216164429.json b/priv/resource_snapshots/repo/invoices/20250216164429.json new file mode 100644 index 0000000..7ada1d4 --- /dev/null +++ b/priv/resource_snapshots/repo/invoices/20250216164429.json @@ -0,0 +1,98 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": true, + "primary_key?": false, + "references": null, + "size": null, + "source": "identifier", + "type": "bigint" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "invoices_customer_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "entities" + }, + "size": null, + "source": "customer_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "\"draft\"", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "state", + "type": "text" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "8557B4FF1D37A5787B61167BE00116638CF15DCDCD95EA338456F8CFBB78EF85", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.TheronsErp.Repo", + "schema": null, + "table": "invoices" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/invoices/20250216165331.json b/priv/resource_snapshots/repo/invoices/20250216165331.json new file mode 100644 index 0000000..d0ae4cd --- /dev/null +++ b/priv/resource_snapshots/repo/invoices/20250216165331.json @@ -0,0 +1,127 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": true, + "primary_key?": false, + "references": null, + "size": null, + "source": "identifier", + "type": "bigint" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "invoices_customer_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "entities" + }, + "size": null, + "source": "customer_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "invoices_sales_order_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "sales_orders" + }, + "size": null, + "source": "sales_order_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "\"draft\"", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "state", + "type": "text" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "E7336F732F424A985DFE0B6FB7F865BFA563256664C8FCB5016BB3EBB0BECB5B", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.TheronsErp.Repo", + "schema": null, + "table": "invoices" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/line_items/20250216164429.json b/priv/resource_snapshots/repo/line_items/20250216164429.json new file mode 100644 index 0000000..0274d9d --- /dev/null +++ b/priv/resource_snapshots/repo/line_items/20250216164429.json @@ -0,0 +1,98 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "price", + "type": "money_with_currency" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "quantity", + "type": "bigint" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "line_items_invoice_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "invoices" + }, + "size": null, + "source": "invoice_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": false, + "hash": "7B923DA42D5B6D7981FE6DA1DFD68408F766F419D20FAB879AF8EFA94E9E487B", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.TheronsErp.Repo", + "schema": null, + "table": "line_items" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/line_items/20250216164600.json b/priv/resource_snapshots/repo/line_items/20250216164600.json new file mode 100644 index 0000000..86d14bb --- /dev/null +++ b/priv/resource_snapshots/repo/line_items/20250216164600.json @@ -0,0 +1,127 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "price", + "type": "money_with_currency" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "quantity", + "type": "bigint" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "line_items_invoice_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "invoices" + }, + "size": null, + "source": "invoice_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "line_items_product_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "products" + }, + "size": null, + "source": "product_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": false, + "hash": "1A994869EE5887D16346A6EE9E895BA9ECA6CF90FF268AB54EBCD7DE054D01C5", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.TheronsErp.Repo", + "schema": null, + "table": "line_items" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/products/20250214173939.json b/priv/resource_snapshots/repo/products/20250214173939.json new file mode 100644 index 0000000..62667e7 --- /dev/null +++ b/priv/resource_snapshots/repo/products/20250214173939.json @@ -0,0 +1,138 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": true, + "primary_key?": false, + "references": null, + "size": null, + "source": "identifier", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "sales_price", + "type": "money_with_currency" + }, + { + "allow_nil?": true, + "default": "\"goods\"", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "type", + "type": "text" + }, + { + "allow_nil?": true, + "default": "true", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "saleable", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "false", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "purchaseable", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "products_category_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "product_categories" + }, + "size": null, + "source": "category_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "925D844AB184B6483A53E8552043B47C861D9CE1D45E879E8F9738EFEE6CB889", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.TheronsErp.Repo", + "schema": null, + "table": "products" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/products/20250215165722.json b/priv/resource_snapshots/repo/products/20250215165722.json new file mode 100644 index 0000000..0fbe899 --- /dev/null +++ b/priv/resource_snapshots/repo/products/20250215165722.json @@ -0,0 +1,148 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": true, + "primary_key?": false, + "references": null, + "size": null, + "source": "identifier", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "sales_price", + "type": "money_with_currency" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "cost", + "type": "money_with_currency" + }, + { + "allow_nil?": true, + "default": "\"goods\"", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "type", + "type": "text" + }, + { + "allow_nil?": true, + "default": "true", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "saleable", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "false", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "purchaseable", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "products_category_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "product_categories" + }, + "size": null, + "source": "category_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "F31886E3775556805B04D0302335203FAA06802A3E6F4E739C5EE603C6D43DEE", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.TheronsErp.Repo", + "schema": null, + "table": "products" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/sales_lines/20250210171140.json b/priv/resource_snapshots/repo/sales_lines/20250210171140.json new file mode 100644 index 0000000..add9914 --- /dev/null +++ b/priv/resource_snapshots/repo/sales_lines/20250210171140.json @@ -0,0 +1,147 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "sales_price", + "type": "money_with_currency" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "unit_price", + "type": "money_with_currency" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "quantity", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "total_price", + "type": "money_with_currency" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "sales_lines_sales_order_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "sales_orders" + }, + "size": null, + "source": "sales_order_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "sales_lines_product_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "products" + }, + "size": null, + "source": "product_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "E32866C9550F2867C2A49A869209523D057E8EE7B3B1679DC3D0B8E4CF2B8D2C", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.TheronsErp.Repo", + "schema": null, + "table": "sales_lines" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/sales_lines/20250216164429.json b/priv/resource_snapshots/repo/sales_lines/20250216164429.json new file mode 100644 index 0000000..460086c --- /dev/null +++ b/priv/resource_snapshots/repo/sales_lines/20250216164429.json @@ -0,0 +1,147 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "sales_price", + "type": "money_with_currency" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "unit_price", + "type": "money_with_currency" + }, + { + "allow_nil?": true, + "default": "1", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "quantity", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "total_price", + "type": "money_with_currency" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "sales_lines_sales_order_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "sales_orders" + }, + "size": null, + "source": "sales_order_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "sales_lines_product_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "products" + }, + "size": null, + "source": "product_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "730EA0DCCA6A1B7AE56232F80AF7C12D32F50CD2DD88F719AF9B491529CF0728", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.TheronsErp.Repo", + "schema": null, + "table": "sales_lines" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/sales_orders/20250210184227.json b/priv/resource_snapshots/repo/sales_orders/20250210184227.json new file mode 100644 index 0000000..a6a9708 --- /dev/null +++ b/priv/resource_snapshots/repo/sales_orders/20250210184227.json @@ -0,0 +1,59 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": true, + "primary_key?": false, + "references": null, + "size": null, + "source": "identifier", + "type": "bigint" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "2EE39FF720E03A809844B112863EB5FC6E9187FFDBFAA786C6DA2FF7D00750F4", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.TheronsErp.Repo", + "schema": null, + "table": "sales_orders" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/sales_orders/20250210192335.json b/priv/resource_snapshots/repo/sales_orders/20250210192335.json new file mode 100644 index 0000000..e9255fa --- /dev/null +++ b/priv/resource_snapshots/repo/sales_orders/20250210192335.json @@ -0,0 +1,69 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": true, + "primary_key?": false, + "references": null, + "size": null, + "source": "identifier", + "type": "bigint" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "\"draft\"", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "state", + "type": "text" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "D3C7713B02CE137604C629C9F4EC1466E6F4396AD90987A9F4DD3573BCC96211", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.TheronsErp.Repo", + "schema": null, + "table": "sales_orders" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/sales_orders/20250210215026.json b/priv/resource_snapshots/repo/sales_orders/20250210215026.json new file mode 100644 index 0000000..1d633af --- /dev/null +++ b/priv/resource_snapshots/repo/sales_orders/20250210215026.json @@ -0,0 +1,98 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": true, + "primary_key?": false, + "references": null, + "size": null, + "source": "identifier", + "type": "bigint" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "sales_orders_customer_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "entities" + }, + "size": null, + "source": "customer_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "\"draft\"", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "state", + "type": "text" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "32CA11C077C34B1CB24A55540C1DED3A9BDA876C23004A73315CFACAFAEBDB75", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.TheronsErp.Repo", + "schema": null, + "table": "sales_orders" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/sales_orders/20250213040919.json b/priv/resource_snapshots/repo/sales_orders/20250213040919.json new file mode 100644 index 0000000..fe257c9 --- /dev/null +++ b/priv/resource_snapshots/repo/sales_orders/20250213040919.json @@ -0,0 +1,127 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": true, + "primary_key?": false, + "references": null, + "size": null, + "source": "identifier", + "type": "bigint" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "sales_orders_customer_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "entities" + }, + "size": null, + "source": "customer_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "sales_orders_address_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "addresses" + }, + "size": null, + "source": "address_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "\"draft\"", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "state", + "type": "text" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "D3B5681CCC25F82D153DB35482A9F5B261886B3A66CD7E20A3338E9C0C89399B", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.TheronsErp.Repo", + "schema": null, + "table": "sales_orders" +} \ No newline at end of file diff --git a/test/breadcrumb_test.exs b/test/breadcrumb_test.exs index 83ca9c1..9b18fa8 100644 --- a/test/breadcrumb_test.exs +++ b/test/breadcrumb_test.exs @@ -1,14 +1,12 @@ -defmodule TheronsErpWeb.ProductCategoryLiveTest do +defmodule TheronsErpWeb.BreadcrumbTest do use TheronsErpWeb.ConnCase - import Phoenix.LiveViewTest - test "stream_crumbs" do out = [1, 2, 3] |> TheronsErpWeb.Breadcrumbs.stream_crumbs() |> Enum.to_list() - assert out = [[1, 2, 3], [2, 3], [3]] + assert ^out = [[1, 2, 3], [2, 3], [3]] end end diff --git a/test/therons_erp/inventory_test.exs b/test/therons_erp/inventory_test.exs index 14a747a..d303dd5 100644 --- a/test/therons_erp/inventory_test.exs +++ b/test/therons_erp/inventory_test.exs @@ -28,9 +28,8 @@ defmodule TheronsErp.InventoryTest do assert Ash.get!(ProductCategory, cat2.id).full_name == "Category 3 / Category 2" assert Ash.get!(ProductCategory, cat3.id).full_name == "Category 3" - assert_raise Ash.Error.Invalid, fn error -> - IO.inspect(error) - Inventory.change_parent!(cat3.id, cat2.id) + assert_raise Ash.Error.Invalid, fn -> + Inventory.change_parent!(cat3, cat2.id) end end end diff --git a/test/therons_erp/invoice_test.exs b/test/therons_erp/invoice_test.exs new file mode 100644 index 0000000..b6cec76 --- /dev/null +++ b/test/therons_erp/invoice_test.exs @@ -0,0 +1,37 @@ +defmodule TheronsErp.InvoiceTest do + use TheronsErp.DataCase + alias TheronsErp.Sales + alias TheronsErp.Inventory + alias TheronsErp.Sales.{SalesLine} + alias TheronsErp.Invoices.Invoice + + test "copying line items" do + sales_order = Sales.create_draft!() + + product = + Inventory.create_product!("Test Product", Money.new(100, :USD), %{cost: Money.new(50, :USD)}) + + sales_line = + SalesLine + |> Ash.Changeset.for_create(:create, %{ + sales_order_id: sales_order.id, + product_id: product.id, + quantity: 1 + }) + |> Ash.create!() + + invoice = + Invoice + |> Ash.Changeset.for_create(:create, %{ + sales_order_id: sales_order.id, + sales_lines: [sales_line] + }) + |> Ash.create!() + |> Ash.load!(:line_items) + + [line_item] = invoice.line_items + assert line_item.product_id == product.id + assert Money.equal?(line_item.price, sales_line.sales_price) + assert line_item.quantity == sales_line.quantity + end +end diff --git a/test/therons_erp_web/controllers/page_controller_test.exs b/test/therons_erp_web/controllers/page_controller_test.exs index 332cfc9..e31b984 100644 --- a/test/therons_erp_web/controllers/page_controller_test.exs +++ b/test/therons_erp_web/controllers/page_controller_test.exs @@ -3,6 +3,6 @@ defmodule TheronsErpWeb.PageControllerTest do test "GET /", %{conn: conn} do conn = get(conn, ~p"/") - assert html_response(conn, 200) =~ "Peace of mind from prototype to production" + assert html_response(conn, 200) =~ "TheronsErp" end end diff --git a/test/therons_erp_web/live/product_category_live_test.exs b/test/therons_erp_web/live/product_category_live_test.exs index 8dff03c..af930bd 100644 --- a/test/therons_erp_web/live/product_category_live_test.exs +++ b/test/therons_erp_web/live/product_category_live_test.exs @@ -1,121 +1,3 @@ defmodule TheronsErpWeb.ProductCategoryLiveTest do use TheronsErpWeb.ConnCase - - import Phoenix.LiveViewTest - import TheronsErp.InventoryFixtures - - @create_attrs %{name: "some name"} - @update_attrs %{name: "some updated name"} - @invalid_attrs %{name: nil} - - defp create_product_category(_) do - product_category = product_category_fixture() - %{product_category: product_category} - end - - describe "Index" do - setup [:create_product_category] - - test "lists all product_categories", %{conn: conn, product_category: product_category} do - {:ok, _index_live, html} = live(conn, ~p"/product_categories") - - assert html =~ "Listing Product categories" - assert html =~ product_category.name - end - - test "saves new product_category", %{conn: conn} do - {:ok, index_live, _html} = live(conn, ~p"/product_categories") - - assert index_live |> element("a", "New Product category") |> render_click() =~ - "New Product category" - - assert_patch(index_live, ~p"/product_categories/new") - - assert index_live - |> form("#product_category-form", product_category: @invalid_attrs) - |> render_change() =~ "can't be blank" - - assert index_live - |> form("#product_category-form", product_category: @create_attrs) - |> render_submit() - - assert_patch(index_live, ~p"/product_categories") - - html = render(index_live) - assert html =~ "Product category created successfully" - assert html =~ "some name" - end - - test "updates product_category in listing", %{conn: conn, product_category: product_category} do - {:ok, index_live, _html} = live(conn, ~p"/product_categories") - - assert index_live - |> element("#product_categories-#{product_category.id} a", "Edit") - |> render_click() =~ - "Edit Product category" - - assert_patch(index_live, ~p"/product_categories/#{product_category}/edit") - - assert index_live - |> form("#product_category-form", product_category: @invalid_attrs) - |> render_change() =~ "can't be blank" - - assert index_live - |> form("#product_category-form", product_category: @update_attrs) - |> render_submit() - - assert_patch(index_live, ~p"/product_categories") - - html = render(index_live) - assert html =~ "Product category updated successfully" - assert html =~ "some updated name" - end - - test "deletes product_category in listing", %{conn: conn, product_category: product_category} do - {:ok, index_live, _html} = live(conn, ~p"/product_categories") - - assert index_live - |> element("#product_categories-#{product_category.id} a", "Delete") - |> render_click() - - refute has_element?(index_live, "#product_categories-#{product_category.id}") - end - end - - describe "Show" do - setup [:create_product_category] - - test "displays product_category", %{conn: conn, product_category: product_category} do - {:ok, _show_live, html} = live(conn, ~p"/product_categories/#{product_category}") - - assert html =~ "Show Product category" - assert html =~ product_category.name - end - - test "updates product_category within modal", %{ - conn: conn, - product_category: product_category - } do - {:ok, show_live, _html} = live(conn, ~p"/product_categories/#{product_category}") - - assert show_live |> element("a", "Edit") |> render_click() =~ - "Edit Product category" - - assert_patch(show_live, ~p"/product_categories/#{product_category}/show/edit") - - assert show_live - |> form("#product_category-form", product_category: @invalid_attrs) - |> render_change() =~ "can't be blank" - - assert show_live - |> form("#product_category-form", product_category: @update_attrs) - |> render_submit() - - assert_patch(show_live, ~p"/product_categories/#{product_category}") - - html = render(show_live) - assert html =~ "Product category updated successfully" - assert html =~ "some updated name" - end - end end diff --git a/test/therons_erp_web/live/product_live_test.exs b/test/therons_erp_web/live/product_live_test.exs index 3d1313c..42fce11 100644 --- a/test/therons_erp_web/live/product_live_test.exs +++ b/test/therons_erp_web/live/product_live_test.exs @@ -1,113 +1,3 @@ defmodule TheronsErpWeb.ProductLiveTest do use TheronsErpWeb.ConnCase - - import Phoenix.LiveViewTest - import TheronsErp.InventoryFixtures - - @create_attrs %{name: "some name", tags: ["option1", "option2"]} - @update_attrs %{name: "some updated name", tags: ["option1"]} - @invalid_attrs %{name: nil, tags: []} - - defp create_product(_) do - product = product_fixture() - %{product: product} - end - - describe "Index" do - setup [:create_product] - - test "lists all products", %{conn: conn, product: product} do - {:ok, _index_live, html} = live(conn, ~p"/products") - - assert html =~ "Listing Products" - assert html =~ product.name - end - - test "saves new product", %{conn: conn} do - {:ok, index_live, _html} = live(conn, ~p"/products") - - assert index_live |> element("a", "New Product") |> render_click() =~ - "New Product" - - assert_patch(index_live, ~p"/products/new") - - assert index_live - |> form("#product-form", product: @invalid_attrs) - |> render_change() =~ "can't be blank" - - assert index_live - |> form("#product-form", product: @create_attrs) - |> render_submit() - - assert_patch(index_live, ~p"/products") - - html = render(index_live) - assert html =~ "Product created successfully" - assert html =~ "some name" - end - - test "updates product in listing", %{conn: conn, product: product} do - {:ok, index_live, _html} = live(conn, ~p"/products") - - assert index_live |> element("#products-#{product.id} a", "Edit") |> render_click() =~ - "Edit Product" - - assert_patch(index_live, ~p"/products/#{product}/edit") - - assert index_live - |> form("#product-form", product: @invalid_attrs) - |> render_change() =~ "can't be blank" - - assert index_live - |> form("#product-form", product: @update_attrs) - |> render_submit() - - assert_patch(index_live, ~p"/products") - - html = render(index_live) - assert html =~ "Product updated successfully" - assert html =~ "some updated name" - end - - test "deletes product in listing", %{conn: conn, product: product} do - {:ok, index_live, _html} = live(conn, ~p"/products") - - assert index_live |> element("#products-#{product.id} a", "Delete") |> render_click() - refute has_element?(index_live, "#products-#{product.id}") - end - end - - describe "Show" do - setup [:create_product] - - test "displays product", %{conn: conn, product: product} do - {:ok, _show_live, html} = live(conn, ~p"/products/#{product}") - - assert html =~ "Show Product" - assert html =~ product.name - end - - test "updates product within modal", %{conn: conn, product: product} do - {:ok, show_live, _html} = live(conn, ~p"/products/#{product}") - - assert show_live |> element("a", "Edit") |> render_click() =~ - "Edit Product" - - assert_patch(show_live, ~p"/products/#{product}/show/edit") - - assert show_live - |> form("#product-form", product: @invalid_attrs) - |> render_change() =~ "can't be blank" - - assert show_live - |> form("#product-form", product: @update_attrs) - |> render_submit() - - assert_patch(show_live, ~p"/products/#{product}") - - html = render(show_live) - assert html =~ "Product updated successfully" - assert html =~ "some updated name" - end - end end diff --git a/test/therons_erp_web/live/sales_orders_live.exs b/test/therons_erp_web/live/sales_orders_live.exs new file mode 100644 index 0000000..b4162ee --- /dev/null +++ b/test/therons_erp_web/live/sales_orders_live.exs @@ -0,0 +1,3 @@ +defmodule TheronsErpWeb.SalesOrdersLiveTest do + use TheronsErpWeb.ConnCase +end