Compare commits
	
		
			45 Commits
		
	
	
		
			0.8.5
			...
			334d841d57
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 334d841d57 | |||
| 1037f37be2 | |||
| 1796fb822f | |||
| 8ed64f9c87 | |||
| dd4a9f7119 | |||
| dbafaad500 | |||
| eb4ce07b5f | |||
| 2b7550a954 | |||
| abaccac9f0 | |||
| 2b81978adb | |||
| 17bfe1a987 | |||
| 81f21a02c4 | |||
| 9a17d4ad24 | |||
| 9835fe3f5e | |||
| 4dee8808f3 | |||
| 65c70ca398 | |||
| 550f6a6420 | |||
| 88c3f15fc8 | |||
| c33f15603b | |||
| 98775359af | |||
| e0e7b25bc4 | |||
| bdddf65685 | |||
| 6f50702b11 | |||
| 5f8d1a917f | |||
| 32801828fa | |||
| 6ed3312ea8 | |||
| b122253b9b | |||
| a68a16bf06 | |||
| 4b6d0952f8 | |||
| 0544b58ab6 | |||
| 6d26103784 | |||
| 0cae7c2940 | |||
| 1e645b5bb8 | |||
| bab2b26c13 | |||
| 8c95536ffd | |||
| d9251c7e4c | |||
| fe4e4f4f17 | |||
| e5e5449e8b | |||
| 355752598c | |||
| 03f8a2e8a7 | |||
| 071eb1b3c9 | |||
| 2987e4ff37 | |||
| ca81924ebe | |||
| 40e4f6fe0a | |||
| 213dcca973 | 
| @@ -17,7 +17,7 @@ steps: | ||||
|       - .mix | ||||
|  | ||||
| - name: test | ||||
|   image: elixir:1.14.1-alpine | ||||
|   image: elixir:1.14.4-alpine | ||||
|   environment: | ||||
|     TEST_DATABASE_URL: ecto://postgres:postgres@database/cannery_test | ||||
|     HOST: testing.example.tld | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| [ | ||||
|   import_deps: [:ecto, :phoenix], | ||||
|   inputs: ["*.{heex,ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{heex,ex,exs}"], | ||||
|   import_deps: [:ecto, :ecto_sql, :phoenix], | ||||
|   inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"], | ||||
|   subdirectories: ["priv/*/migrations"], | ||||
|   plugins: [Phoenix.LiveView.HTMLFormatter] | ||||
| ] | ||||
|   | ||||
| @@ -1,3 +1,3 @@ | ||||
| elixir 1.14.1-otp-25 | ||||
| erlang 25.1.2 | ||||
| nodejs 18.9.1 | ||||
| elixir 1.14.4-otp-25 | ||||
| erlang 25.3 | ||||
| nodejs 18.15.0 | ||||
|   | ||||
							
								
								
									
										49
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										49
									
								
								CHANGELOG.md
									
									
									
									
									
								
							| @@ -1,3 +1,38 @@ | ||||
| # v0.9.3 | ||||
| - Update dependencies | ||||
| - Add pack lot number to search | ||||
|  | ||||
| # v0.9.2 | ||||
| - Add lot number to packs | ||||
| - Don't show price paid and lot number columns when displaying packs if not used | ||||
| - Fix additional shotgun fields not being exportable | ||||
| - Fixes duplicate chamber size column for ammo types | ||||
| - Hide bullet type field when editing/creating shotgun ammo types | ||||
| - Fix ammo type creation not displaying all the necessary fields on first load | ||||
|  | ||||
| # v0.9.1 | ||||
| - Rename ammo type's "type" to "class" to avoid confusion | ||||
| - Rename "ammo type" to "type" to avoid confusion | ||||
| - Fixes type search | ||||
| - Fixes shot records table disappearing after selecting an empty ammo class | ||||
| - Code quality improvements | ||||
|  | ||||
| # v0.9.0 | ||||
| - Add length limits to all string fields | ||||
| - Add selectable ammo types | ||||
| - Improve onboarding experience slightly | ||||
| - Remove show used view from a container since it doesn't really make that much | ||||
|   sense | ||||
|  | ||||
| # v0.8.6 | ||||
| - Fix duplicate entries showing up | ||||
| - Show ammo packs under a type in a table by default | ||||
| - Only show historical ammo type information when displaying "Show used" in table | ||||
| - Only show historical ammo pack information when displaying "Show used" in table | ||||
| - Fix some values not being sorted in tables properly | ||||
| - Code quality improvements | ||||
| - Show link to ammo pack in ammo pack table while viewing ammo type | ||||
|  | ||||
| # v0.8.5 | ||||
| - Add link in readme to github mirror | ||||
| - Fix tables unable to sort on empty dates | ||||
| @@ -35,7 +70,7 @@ | ||||
| # v0.8.0 | ||||
| - Add search to catalog, ammo, container, tag and range index pages | ||||
| - Tweak urls for catalog, ammo, containers, tags and shot records | ||||
| - Fix bug with shot group chart not drawing lines between days correctly | ||||
| - Fix bug with shot record chart not drawing lines between days correctly | ||||
| - Improve cards across app (make them line up with each other) | ||||
| - Update translations and add spanish!!! (thank you Brea and Hannah!) | ||||
|  | ||||
| @@ -47,7 +82,7 @@ | ||||
| - Fix toggle button styling | ||||
| - Miscellanous code improvements | ||||
| - Improve container index table | ||||
| - Fix bug with ammo not updating after deleting shot group | ||||
| - Fix bug with ammo not updating after deleting shot record | ||||
| - Replace ammo "added on" with "purchased on" | ||||
| - Miscellaneous wording improvements | ||||
| - Update translations | ||||
| @@ -56,8 +91,8 @@ | ||||
| - Add shading to table component | ||||
| - Fix chart to sum by day | ||||
| - Fix whitespace when copying invite url | ||||
| - Make ammo type show page also display ammo groups as table | ||||
| - Make container show page also display ammo groups as table | ||||
| - Make ammo type show page also display packs as table | ||||
| - Make container show page also display packs as table | ||||
| - Display CPR for ammo packs | ||||
| - Add original count for ammo packs | ||||
| - Add ammo pack CPR and original count to json export | ||||
| @@ -81,7 +116,7 @@ | ||||
| - Add ammo type cloning | ||||
| - Add container cloning | ||||
| - Fix bug with moving ammo packs between containers | ||||
| - Add button to set rounds left to 0 when creating a shot group | ||||
| - Add button to set rounds left to 0 when creating a shot record | ||||
| - Update project dependencies | ||||
|  | ||||
| # v0.5.4 | ||||
| @@ -133,8 +168,8 @@ | ||||
| # v0.3.0 | ||||
| - Fix ammo type counts not showing when count is 0 | ||||
| - Add prompt to create first container before first ammo group | ||||
| - Edit and delete shot groups from ammo group show page | ||||
| - Use today's date when adding new shot groups | ||||
| - Edit and delete shot records from ammo group show page | ||||
| - Use today's date when adding new shot records | ||||
| - Create multiple ammo groups at one time | ||||
|  | ||||
| # v0.2.3 | ||||
|   | ||||
| @@ -17,8 +17,8 @@ If you're multilingual, this project can use your translations! Visit | ||||
|   functions as short as possible while keeping variable names descriptive! For | ||||
|   instance, use inline `do:` blocks for short functions and make your aliases as | ||||
|   short as possible without introducing ambiguity. | ||||
|   - I.e. since there's only one `AmmoGroup` in the app, please alias | ||||
|     `AmmoGroup.t()` instead of using `Cannery.Ammo.AmmoGroup.t()` | ||||
|   - I.e. since there's only one `Pack` in the app, please alias | ||||
|     `Pack.t()` instead of using `Cannery.Ammo.Pack.t()` | ||||
| - Use pipelines when possible. If only calling a single method, a pipeline isn't | ||||
|   strictly necessary but still encouraged for future modification. | ||||
| - Please add typespecs to your functions! Even your private functions may be | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| FROM elixir:1.14.1-alpine AS build | ||||
| FROM elixir:1.14.4-alpine AS build | ||||
|  | ||||
| # install build dependencies | ||||
| RUN apk add --no-cache build-base npm git python3 | ||||
|   | ||||
| @@ -13,8 +13,8 @@ The self-hosted firearm tracker website. | ||||
| # Features | ||||
|  | ||||
| - Create containers to store your ammunition, and tag them with custom tags | ||||
| - Add ammunition types to Cannery, and then ammunition groups to your containers | ||||
| - Stage groups of ammo for range day and record your ammo usage | ||||
| - Add ammunition types to Cannery, and then ammo packs to your containers | ||||
| - Stage ammo packs for range day and track your usage with shot records | ||||
| - Invitations via invite tokens or public registration | ||||
|  | ||||
| # Installation | ||||
|   | ||||
							
								
								
									
										3567
									
								
								assets/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3567
									
								
								assets/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -3,8 +3,8 @@ | ||||
|   "description": " ", | ||||
|   "license": "MIT", | ||||
|   "engines": { | ||||
|     "node": "v18.9.1", | ||||
|     "npm": "8.19.1" | ||||
|     "node": "v18.15.0", | ||||
|     "npm": "9.5.0" | ||||
|   }, | ||||
|   "scripts": { | ||||
|     "deploy": "NODE_ENV=production webpack --mode production", | ||||
| @@ -13,7 +13,7 @@ | ||||
|     "test": "standard" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@fortawesome/fontawesome-free": "^6.3.0", | ||||
|     "@fortawesome/fontawesome-free": "^6.4.0", | ||||
|     "chart.js": "^4.2.1", | ||||
|     "chartjs-adapter-date-fns": "^3.0.0", | ||||
|     "date-fns": "^2.29.3", | ||||
| @@ -23,27 +23,27 @@ | ||||
|     "topbar": "^2.0.1" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@babel/core": "^7.21.3", | ||||
|     "@babel/preset-env": "^7.20.2", | ||||
|     "@babel/core": "^7.21.4", | ||||
|     "@babel/preset-env": "^7.21.4", | ||||
|     "autoprefixer": "^10.4.14", | ||||
|     "babel-loader": "^9.1.2", | ||||
|     "copy-webpack-plugin": "^11.0.0", | ||||
|     "css-loader": "^6.7.3", | ||||
|     "css-minimizer-webpack-plugin": "^4.2.2", | ||||
|     "css-minimizer-webpack-plugin": "^5.0.0", | ||||
|     "file-loader": "^6.2.0", | ||||
|     "mini-css-extract-plugin": "^2.7.5", | ||||
|     "npm-check-updates": "^16.7.12", | ||||
|     "npm-check-updates": "^16.10.8", | ||||
|     "postcss": "^8.4.21", | ||||
|     "postcss-import": "^15.1.0", | ||||
|     "postcss-loader": "^7.1.0", | ||||
|     "postcss-preset-env": "^8.0.1", | ||||
|     "sass": "^1.59.3", | ||||
|     "sass-loader": "^13.2.1", | ||||
|     "postcss-loader": "^7.2.4", | ||||
|     "postcss-preset-env": "^8.3.1", | ||||
|     "sass": "^1.62.0", | ||||
|     "sass-loader": "^13.2.2", | ||||
|     "standard": "^17.0.0", | ||||
|     "tailwindcss": "^3.2.7", | ||||
|     "tailwindcss": "^3.3.1", | ||||
|     "terser-webpack-plugin": "^5.3.7", | ||||
|     "webpack": "^5.76.2", | ||||
|     "webpack": "^5.79.0", | ||||
|     "webpack-cli": "^5.0.1", | ||||
|     "webpack-dev-server": "^4.13.1" | ||||
|     "webpack-dev-server": "^4.13.2" | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -45,7 +45,7 @@ module.exports = (env, options) => { | ||||
|         { | ||||
|           test: /\.(woff(2)?|ttf|eot|svg|otf)(\?v=[0-9]\.[0-9]\.[0-9])?$/, | ||||
|           type: 'asset/resource', | ||||
|           generator: { filename: 'fonts/[name][ext]' } | ||||
|           generator: { filename: 'fonts/[name].[ext]' } | ||||
|         } | ||||
|       ] | ||||
|     }, | ||||
|   | ||||
| @@ -59,8 +59,7 @@ config :cannery, CanneryWeb.Endpoint, | ||||
|     patterns: [ | ||||
|       ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", | ||||
|       ~r"priv/gettext/.*(po)$", | ||||
|       ~r"lib/cannery_web/(live|views)/.*(ex)$", | ||||
|       ~r"lib/cannery_web/templates/.*(eex)$" | ||||
|       ~r"lib/cannery_web/*/.*(ex)$" | ||||
|     ] | ||||
|   ] | ||||
|  | ||||
|   | ||||
| @@ -12,7 +12,7 @@ if System.get_env("PHX_SERVER") && System.get_env("RELEASE_NAME") do | ||||
|   config :cannery, CanneryWeb.Endpoint, server: true | ||||
| end | ||||
|  | ||||
| config :cannery, CanneryWeb.ViewHelpers, shibao_mode: System.get_env("SHIBAO_MODE") == "true" | ||||
| config :cannery, CanneryWeb.HTMLHelpers, shibao_mode: System.get_env("SHIBAO_MODE") == "true" | ||||
|  | ||||
| # Set default locale | ||||
| config :gettext, :default_locale, System.get_env("LOCALE", "en_US") | ||||
|   | ||||
| @@ -374,8 +374,8 @@ defmodule Cannery.Accounts do | ||||
|   @doc """ | ||||
|   Deletes the signed token with the given context. | ||||
|   """ | ||||
|   @spec delete_session_token(token :: String.t()) :: :ok | ||||
|   def delete_session_token(token) do | ||||
|   @spec delete_user_session_token(token :: String.t()) :: :ok | ||||
|   def delete_user_session_token(token) do | ||||
|     UserToken.token_and_context_query(token, "session") |> Repo.delete_all() | ||||
|     :ok | ||||
|   end | ||||
|   | ||||
| @@ -3,14 +3,15 @@ defmodule Cannery.Email do | ||||
|   Emails that can be sent using Swoosh. | ||||
|  | ||||
|   You can find the base email templates at | ||||
|   `lib/cannery_web/templates/layout/email.html.heex` for html emails and | ||||
|   `lib/cannery_web/templates/layout/email.txt.heex` for text emails. | ||||
|   `lib/cannery_web/components/layouts/email_html.html.heex` for html emails and | ||||
|   `lib/cannery_web/components/layouts/email_text.txt.eex` for text emails. | ||||
|   """ | ||||
|  | ||||
|   use Phoenix.Swoosh, view: CanneryWeb.EmailView, layout: {CanneryWeb.LayoutView, :email} | ||||
|   import Swoosh.Email | ||||
|   import CanneryWeb.Gettext | ||||
|   import Phoenix.Template | ||||
|   alias Cannery.Accounts.User | ||||
|   alias CanneryWeb.EmailView | ||||
|   alias CanneryWeb.{EmailHTML, Layouts} | ||||
|  | ||||
|   @typedoc """ | ||||
|   Represents an HTML and text body email that can be sent | ||||
| @@ -28,21 +29,33 @@ defmodule Cannery.Email do | ||||
|   def generate_email("welcome", user, %{"url" => url}) do | ||||
|     user | ||||
|     |> base_email(dgettext("emails", "Confirm your Cannery account")) | ||||
|     |> render_body("confirm_email.html", %{user: user, url: url}) | ||||
|     |> text_body(EmailView.render("confirm_email.txt", %{user: user, url: url})) | ||||
|     |> html_email(:confirm_email_html, %{user: user, url: url}) | ||||
|     |> text_email(:confirm_email_text, %{user: user, url: url}) | ||||
|   end | ||||
|  | ||||
|   def generate_email("reset_password", user, %{"url" => url}) do | ||||
|     user | ||||
|     |> base_email(dgettext("emails", "Reset your Cannery password")) | ||||
|     |> render_body("reset_password.html", %{user: user, url: url}) | ||||
|     |> text_body(EmailView.render("reset_password.txt", %{user: user, url: url})) | ||||
|     |> html_email(:reset_password_html, %{user: user, url: url}) | ||||
|     |> text_email(:reset_password_text, %{user: user, url: url}) | ||||
|   end | ||||
|  | ||||
|   def generate_email("update_email", user, %{"url" => url}) do | ||||
|     user | ||||
|     |> base_email(dgettext("emails", "Update your Cannery email")) | ||||
|     |> render_body("update_email.html", %{user: user, url: url}) | ||||
|     |> text_body(EmailView.render("update_email.txt", %{user: user, url: url})) | ||||
|     |> html_email(:update_email_html, %{user: user, url: url}) | ||||
|     |> text_email(:update_email_text, %{user: user, url: url}) | ||||
|   end | ||||
|  | ||||
|   defp html_email(email, atom, assigns) do | ||||
|     heex = apply(EmailHTML, atom, [assigns]) | ||||
|     html = render_to_string(Layouts, "email_html", "html", email: email, inner_content: heex) | ||||
|     email |> html_body(html) | ||||
|   end | ||||
|  | ||||
|   defp text_email(email, atom, assigns) do | ||||
|     heex = apply(EmailHTML, atom, [assigns]) | ||||
|     text = render_to_string(Layouts, "email_text", "text", email: email, inner_content: heex) | ||||
|     email |> text_body(text) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -48,8 +48,9 @@ defmodule Cannery.Accounts.Invite do | ||||
|     %__MODULE__{} | ||||
|     |> change(token: token, created_by_id: user_id) | ||||
|     |> cast(attrs, [:name, :uses_left, :disabled_at]) | ||||
|     |> validate_required([:name, :token, :created_by_id]) | ||||
|     |> validate_length(:name, max: 255) | ||||
|     |> validate_number(:uses_left, greater_than_or_equal_to: 0) | ||||
|     |> validate_required([:name, :token, :created_by_id]) | ||||
|   end | ||||
|  | ||||
|   @doc false | ||||
| @@ -57,7 +58,8 @@ defmodule Cannery.Accounts.Invite do | ||||
|   def update_changeset(invite, attrs) do | ||||
|     invite | ||||
|     |> cast(attrs, [:name, :uses_left, :disabled_at]) | ||||
|     |> validate_required([:name]) | ||||
|     |> validate_length(:name, max: 255) | ||||
|     |> validate_number(:uses_left, greater_than_or_equal_to: 0) | ||||
|     |> validate_required([:name]) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -79,6 +79,7 @@ defmodule Cannery.Accounts.User do | ||||
|     %User{} | ||||
|     |> cast(attrs, [:email, :password, :locale]) | ||||
|     |> put_change(:invite_id, if(invite, do: invite.id)) | ||||
|     |> validate_length(:locale, max: 255) | ||||
|     |> validate_email() | ||||
|     |> validate_password(opts) | ||||
|   end | ||||
| @@ -209,6 +210,7 @@ defmodule Cannery.Accounts.User do | ||||
|   def locale_changeset(user_or_changeset, locale) do | ||||
|     user_or_changeset | ||||
|     |> cast(%{"locale" => locale}, [:locale]) | ||||
|     |> validate_length(:locale, max: 255) | ||||
|     |> validate_required(:locale) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -4,96 +4,143 @@ defmodule Cannery.ActivityLog do | ||||
|   """ | ||||
|  | ||||
|   import Ecto.Query, warn: false | ||||
|   alias Cannery.Ammo.{AmmoGroup, AmmoType} | ||||
|   alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Repo} | ||||
|   alias Ecto.Multi | ||||
|   alias Cannery.Ammo.{Pack, Type} | ||||
|   alias Cannery.{Accounts.User, ActivityLog.ShotRecord, Repo} | ||||
|   alias Ecto.{Multi, Queryable} | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the list of shot_groups. | ||||
|   Returns the list of shot_records. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> list_shot_groups(%User{id: 123}) | ||||
|       [%ShotGroup{}, ...] | ||||
|       iex> list_shot_records(:all, %User{id: 123}) | ||||
|       [%ShotRecord{}, ...] | ||||
|  | ||||
|       iex> list_shot_groups("cool", %User{id: 123}) | ||||
|       [%ShotGroup{notes: "My cool shot group"}, ...] | ||||
|       iex> list_shot_records("cool", :all, %User{id: 123}) | ||||
|       [%ShotRecord{notes: "My cool shot record"}, ...] | ||||
|  | ||||
|       iex> list_shot_records("cool", :rifle, %User{id: 123}) | ||||
|       [%ShotRecord{notes: "Shot some rifle rounds"}, ...] | ||||
|  | ||||
|   """ | ||||
|   @spec list_shot_groups(User.t()) :: [ShotGroup.t()] | ||||
|   @spec list_shot_groups(search :: nil | String.t(), User.t()) :: [ShotGroup.t()] | ||||
|   def list_shot_groups(search \\ nil, user) | ||||
|  | ||||
|   def list_shot_groups(search, %{id: user_id}) when search |> is_nil() or search == "", | ||||
|     do: Repo.all(from sg in ShotGroup, where: sg.user_id == ^user_id) | ||||
|  | ||||
|   def list_shot_groups(search, %{id: user_id}) when search |> is_binary() do | ||||
|     trimmed_search = String.trim(search) | ||||
|  | ||||
|     Repo.all( | ||||
|       from sg in ShotGroup, | ||||
|         left_join: ag in AmmoGroup, | ||||
|         on: sg.ammo_group_id == ag.id, | ||||
|         left_join: at in AmmoType, | ||||
|         on: ag.ammo_type_id == at.id, | ||||
|         where: sg.user_id == ^user_id, | ||||
|         where: | ||||
|           fragment( | ||||
|             "? @@ websearch_to_tsquery('english', ?)", | ||||
|             sg.search, | ||||
|             ^trimmed_search | ||||
|           ) or | ||||
|             fragment( | ||||
|               "? @@ websearch_to_tsquery('english', ?)", | ||||
|               ag.search, | ||||
|               ^trimmed_search | ||||
|             ) or | ||||
|             fragment( | ||||
|               "? @@ websearch_to_tsquery('english', ?)", | ||||
|               at.search, | ||||
|               ^trimmed_search | ||||
|             ), | ||||
|         order_by: { | ||||
|           :desc, | ||||
|           fragment( | ||||
|             "ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)", | ||||
|             sg.search, | ||||
|             ^trimmed_search | ||||
|           ) | ||||
|         } | ||||
|   @spec list_shot_records(Type.class() | :all, User.t()) :: [ShotRecord.t()] | ||||
|   @spec list_shot_records(search :: nil | String.t(), Type.class() | :all, User.t()) :: | ||||
|           [ShotRecord.t()] | ||||
|   def list_shot_records(search \\ nil, type, %{id: user_id}) do | ||||
|     from(sg in ShotRecord, | ||||
|       as: :sg, | ||||
|       left_join: ag in Pack, | ||||
|       as: :ag, | ||||
|       on: sg.pack_id == ag.id, | ||||
|       left_join: at in Type, | ||||
|       as: :at, | ||||
|       on: ag.type_id == at.id, | ||||
|       where: sg.user_id == ^user_id, | ||||
|       distinct: sg.id | ||||
|     ) | ||||
|     |> list_shot_records_search(search) | ||||
|     |> list_shot_records_filter_type(type) | ||||
|     |> Repo.all() | ||||
|   end | ||||
|  | ||||
|   @spec list_shot_groups_for_ammo_group(AmmoGroup.t(), User.t()) :: [ShotGroup.t()] | ||||
|   def list_shot_groups_for_ammo_group( | ||||
|         %AmmoGroup{id: ammo_group_id, user_id: user_id}, | ||||
|   @spec list_shot_records_search(Queryable.t(), search :: String.t() | nil) :: | ||||
|           Queryable.t() | ||||
|   defp list_shot_records_search(query, search) when search in ["", nil], do: query | ||||
|  | ||||
|   defp list_shot_records_search(query, search) when search |> is_binary() do | ||||
|     trimmed_search = String.trim(search) | ||||
|  | ||||
|     query | ||||
|     |> where( | ||||
|       [sg: sg, ag: ag, at: at], | ||||
|       fragment( | ||||
|         "? @@ websearch_to_tsquery('english', ?)", | ||||
|         sg.search, | ||||
|         ^trimmed_search | ||||
|       ) or | ||||
|         fragment( | ||||
|           "? @@ websearch_to_tsquery('english', ?)", | ||||
|           ag.search, | ||||
|           ^trimmed_search | ||||
|         ) or | ||||
|         fragment( | ||||
|           "? @@ websearch_to_tsquery('english', ?)", | ||||
|           at.search, | ||||
|           ^trimmed_search | ||||
|         ) | ||||
|     ) | ||||
|     |> order_by([sg: sg], { | ||||
|       :desc, | ||||
|       fragment( | ||||
|         "ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)", | ||||
|         sg.search, | ||||
|         ^trimmed_search | ||||
|       ) | ||||
|     }) | ||||
|   end | ||||
|  | ||||
|   @spec list_shot_records_filter_type(Queryable.t(), Type.class() | :all) :: | ||||
|           Queryable.t() | ||||
|   defp list_shot_records_filter_type(query, :rifle), | ||||
|     do: query |> where([at: at], at.class == :rifle) | ||||
|  | ||||
|   defp list_shot_records_filter_type(query, :pistol), | ||||
|     do: query |> where([at: at], at.class == :pistol) | ||||
|  | ||||
|   defp list_shot_records_filter_type(query, :shotgun), | ||||
|     do: query |> where([at: at], at.class == :shotgun) | ||||
|  | ||||
|   defp list_shot_records_filter_type(query, _all), do: query | ||||
|  | ||||
|   @doc """ | ||||
|   Returns a count of shot records. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_shot_record_count!(%User{id: 123}) | ||||
|       3 | ||||
|  | ||||
|   """ | ||||
|   @spec get_shot_record_count!(User.t()) :: integer() | ||||
|   def get_shot_record_count!(%User{id: user_id}) do | ||||
|     Repo.one( | ||||
|       from sg in ShotRecord, | ||||
|         where: sg.user_id == ^user_id, | ||||
|         select: count(sg.id), | ||||
|         distinct: true | ||||
|     ) || 0 | ||||
|   end | ||||
|  | ||||
|   @spec list_shot_records_for_pack(Pack.t(), User.t()) :: [ShotRecord.t()] | ||||
|   def list_shot_records_for_pack( | ||||
|         %Pack{id: pack_id, user_id: user_id}, | ||||
|         %User{id: user_id} | ||||
|       ) do | ||||
|     Repo.all( | ||||
|       from sg in ShotGroup, | ||||
|         where: sg.ammo_group_id == ^ammo_group_id, | ||||
|       from sg in ShotRecord, | ||||
|         where: sg.pack_id == ^pack_id, | ||||
|         where: sg.user_id == ^user_id | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets a single shot_group. | ||||
|   Gets a single shot_record. | ||||
|  | ||||
|   Raises `Ecto.NoResultsError` if the Shot group does not exist. | ||||
|   Raises `Ecto.NoResultsError` if the shot record does not exist. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_shot_group!(123, %User{id: 123}) | ||||
|       %ShotGroup{} | ||||
|       iex> get_shot_record!(123, %User{id: 123}) | ||||
|       %ShotRecord{} | ||||
|  | ||||
|       iex> get_shot_group!(456, %User{id: 123}) | ||||
|       iex> get_shot_record!(456, %User{id: 123}) | ||||
|       ** (Ecto.NoResultsError) | ||||
|  | ||||
|   """ | ||||
|   @spec get_shot_group!(ShotGroup.id(), User.t()) :: ShotGroup.t() | ||||
|   def get_shot_group!(id, %User{id: user_id}) do | ||||
|   @spec get_shot_record!(ShotRecord.id(), User.t()) :: ShotRecord.t() | ||||
|   def get_shot_record!(id, %User{id: user_id}) do | ||||
|     Repo.one!( | ||||
|       from sg in ShotGroup, | ||||
|       from sg in ShotRecord, | ||||
|         where: sg.id == ^id, | ||||
|         where: sg.user_id == ^user_id, | ||||
|         order_by: sg.date | ||||
| @@ -101,251 +148,249 @@ defmodule Cannery.ActivityLog do | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Creates a shot_group. | ||||
|   Creates a shot_record. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> create_shot_group(%{field: value}, %User{id: 123}) | ||||
|       {:ok, %ShotGroup{}} | ||||
|       iex> create_shot_record(%{field: value}, %User{id: 123}) | ||||
|       {:ok, %ShotRecord{}} | ||||
|  | ||||
|       iex> create_shot_group(%{field: bad_value}, %User{id: 123}) | ||||
|       iex> create_shot_record(%{field: bad_value}, %User{id: 123}) | ||||
|       {:error, %Ecto.Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec create_shot_group(attrs :: map(), User.t(), AmmoGroup.t()) :: | ||||
|           {:ok, ShotGroup.t()} | {:error, ShotGroup.changeset() | nil} | ||||
|   def create_shot_group(attrs, user, ammo_group) do | ||||
|   @spec create_shot_record(attrs :: map(), User.t(), Pack.t()) :: | ||||
|           {:ok, ShotRecord.t()} | {:error, ShotRecord.changeset() | nil} | ||||
|   def create_shot_record(attrs, user, pack) do | ||||
|     Multi.new() | ||||
|     |> Multi.insert( | ||||
|       :create_shot_group, | ||||
|       %ShotGroup{} |> ShotGroup.create_changeset(user, ammo_group, attrs) | ||||
|       :create_shot_record, | ||||
|       %ShotRecord{} |> ShotRecord.create_changeset(user, pack, attrs) | ||||
|     ) | ||||
|     |> Multi.run( | ||||
|       :ammo_group, | ||||
|       fn _repo, %{create_shot_group: %{ammo_group_id: ammo_group_id, user_id: user_id}} -> | ||||
|         ammo_group = | ||||
|       :pack, | ||||
|       fn _repo, %{create_shot_record: %{pack_id: pack_id, user_id: user_id}} -> | ||||
|         pack = | ||||
|           Repo.one( | ||||
|             from ag in AmmoGroup, | ||||
|               where: ag.id == ^ammo_group_id, | ||||
|             from ag in Pack, | ||||
|               where: ag.id == ^pack_id, | ||||
|               where: ag.user_id == ^user_id | ||||
|           ) | ||||
|  | ||||
|         {:ok, ammo_group} | ||||
|         {:ok, pack} | ||||
|       end | ||||
|     ) | ||||
|     |> Multi.update( | ||||
|       :update_ammo_group, | ||||
|       fn %{create_shot_group: %{count: shot_group_count}, ammo_group: %{count: ammo_group_count}} -> | ||||
|         ammo_group |> AmmoGroup.range_changeset(%{"count" => ammo_group_count - shot_group_count}) | ||||
|       :update_pack, | ||||
|       fn %{create_shot_record: %{count: shot_record_count}, pack: %{count: pack_count}} -> | ||||
|         pack |> Pack.range_changeset(%{"count" => pack_count - shot_record_count}) | ||||
|       end | ||||
|     ) | ||||
|     |> Repo.transaction() | ||||
|     |> case do | ||||
|       {:ok, %{create_shot_group: shot_group}} -> {:ok, shot_group} | ||||
|       {:error, :create_shot_group, changeset, _changes_so_far} -> {:error, changeset} | ||||
|       {:ok, %{create_shot_record: shot_record}} -> {:ok, shot_record} | ||||
|       {:error, :create_shot_record, changeset, _changes_so_far} -> {:error, changeset} | ||||
|       {:error, _other_transaction, _value, _changes_so_far} -> {:error, nil} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Updates a shot_group. | ||||
|   Updates a shot_record. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> update_shot_group(shot_group, %{field: new_value}, %User{id: 123}) | ||||
|       {:ok, %ShotGroup{}} | ||||
|       iex> update_shot_record(shot_record, %{field: new_value}, %User{id: 123}) | ||||
|       {:ok, %ShotRecord{}} | ||||
|  | ||||
|       iex> update_shot_group(shot_group, %{field: bad_value}, %User{id: 123}) | ||||
|       iex> update_shot_record(shot_record, %{field: bad_value}, %User{id: 123}) | ||||
|       {:error, %Ecto.Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec update_shot_group(ShotGroup.t(), attrs :: map(), User.t()) :: | ||||
|           {:ok, ShotGroup.t()} | {:error, ShotGroup.changeset() | nil} | ||||
|   def update_shot_group( | ||||
|         %ShotGroup{count: count, user_id: user_id} = shot_group, | ||||
|   @spec update_shot_record(ShotRecord.t(), attrs :: map(), User.t()) :: | ||||
|           {:ok, ShotRecord.t()} | {:error, ShotRecord.changeset() | nil} | ||||
|   def update_shot_record( | ||||
|         %ShotRecord{count: count, user_id: user_id} = shot_record, | ||||
|         attrs, | ||||
|         %User{id: user_id} = user | ||||
|       ) do | ||||
|     Multi.new() | ||||
|     |> Multi.update( | ||||
|       :update_shot_group, | ||||
|       shot_group |> ShotGroup.update_changeset(user, attrs) | ||||
|       :update_shot_record, | ||||
|       shot_record |> ShotRecord.update_changeset(user, attrs) | ||||
|     ) | ||||
|     |> Multi.run( | ||||
|       :ammo_group, | ||||
|       fn repo, %{update_shot_group: %{ammo_group_id: ammo_group_id, user_id: user_id}} -> | ||||
|         {:ok, | ||||
|          repo.one(from ag in AmmoGroup, where: ag.id == ^ammo_group_id and ag.user_id == ^user_id)} | ||||
|       :pack, | ||||
|       fn repo, %{update_shot_record: %{pack_id: pack_id, user_id: user_id}} -> | ||||
|         {:ok, repo.one(from ag in Pack, where: ag.id == ^pack_id and ag.user_id == ^user_id)} | ||||
|       end | ||||
|     ) | ||||
|     |> Multi.update( | ||||
|       :update_ammo_group, | ||||
|       :update_pack, | ||||
|       fn %{ | ||||
|            update_shot_group: %{count: new_count}, | ||||
|            ammo_group: %{count: ammo_group_count} = ammo_group | ||||
|            update_shot_record: %{count: new_count}, | ||||
|            pack: %{count: pack_count} = pack | ||||
|          } -> | ||||
|         shot_diff_to_add = new_count - count | ||||
|         new_ammo_group_count = ammo_group_count - shot_diff_to_add | ||||
|         ammo_group |> AmmoGroup.range_changeset(%{"count" => new_ammo_group_count}) | ||||
|         new_pack_count = pack_count - shot_diff_to_add | ||||
|         pack |> Pack.range_changeset(%{"count" => new_pack_count}) | ||||
|       end | ||||
|     ) | ||||
|     |> Repo.transaction() | ||||
|     |> case do | ||||
|       {:ok, %{update_shot_group: shot_group}} -> {:ok, shot_group} | ||||
|       {:error, :update_shot_group, changeset, _changes_so_far} -> {:error, changeset} | ||||
|       {:ok, %{update_shot_record: shot_record}} -> {:ok, shot_record} | ||||
|       {:error, :update_shot_record, changeset, _changes_so_far} -> {:error, changeset} | ||||
|       {:error, _other_transaction, _value, _changes_so_far} -> {:error, nil} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Deletes a shot_group. | ||||
|   Deletes a shot_record. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> delete_shot_group(shot_group, %User{id: 123}) | ||||
|       {:ok, %ShotGroup{}} | ||||
|       iex> delete_shot_record(shot_record, %User{id: 123}) | ||||
|       {:ok, %ShotRecord{}} | ||||
|  | ||||
|       iex> delete_shot_group(shot_group, %User{id: 123}) | ||||
|       iex> delete_shot_record(shot_record, %User{id: 123}) | ||||
|       {:error, %Ecto.Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec delete_shot_group(ShotGroup.t(), User.t()) :: | ||||
|           {:ok, ShotGroup.t()} | {:error, ShotGroup.changeset()} | ||||
|   def delete_shot_group( | ||||
|         %ShotGroup{user_id: user_id} = shot_group, | ||||
|   @spec delete_shot_record(ShotRecord.t(), User.t()) :: | ||||
|           {:ok, ShotRecord.t()} | {:error, ShotRecord.changeset()} | ||||
|   def delete_shot_record( | ||||
|         %ShotRecord{user_id: user_id} = shot_record, | ||||
|         %User{id: user_id} | ||||
|       ) do | ||||
|     Multi.new() | ||||
|     |> Multi.delete(:delete_shot_group, shot_group) | ||||
|     |> Multi.delete(:delete_shot_record, shot_record) | ||||
|     |> Multi.run( | ||||
|       :ammo_group, | ||||
|       fn repo, %{delete_shot_group: %{ammo_group_id: ammo_group_id, user_id: user_id}} -> | ||||
|         {:ok, | ||||
|          repo.one(from ag in AmmoGroup, where: ag.id == ^ammo_group_id and ag.user_id == ^user_id)} | ||||
|       :pack, | ||||
|       fn repo, %{delete_shot_record: %{pack_id: pack_id, user_id: user_id}} -> | ||||
|         {:ok, repo.one(from ag in Pack, where: ag.id == ^pack_id and ag.user_id == ^user_id)} | ||||
|       end | ||||
|     ) | ||||
|     |> Multi.update( | ||||
|       :update_ammo_group, | ||||
|       :update_pack, | ||||
|       fn %{ | ||||
|            delete_shot_group: %{count: count}, | ||||
|            ammo_group: %{count: ammo_group_count} = ammo_group | ||||
|            delete_shot_record: %{count: count}, | ||||
|            pack: %{count: pack_count} = pack | ||||
|          } -> | ||||
|         new_ammo_group_count = ammo_group_count + count | ||||
|         ammo_group |> AmmoGroup.range_changeset(%{"count" => new_ammo_group_count}) | ||||
|         new_pack_count = pack_count + count | ||||
|         pack |> Pack.range_changeset(%{"count" => new_pack_count}) | ||||
|       end | ||||
|     ) | ||||
|     |> Repo.transaction() | ||||
|     |> case do | ||||
|       {:ok, %{delete_shot_group: shot_group}} -> {:ok, shot_group} | ||||
|       {:error, :delete_shot_group, changeset, _changes_so_far} -> {:error, changeset} | ||||
|       {:ok, %{delete_shot_record: shot_record}} -> {:ok, shot_record} | ||||
|       {:error, :delete_shot_record, changeset, _changes_so_far} -> {:error, changeset} | ||||
|       {:error, _other_transaction, _value, _changes_so_far} -> {:error, nil} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the number of shot rounds for an ammo group | ||||
|   Returns the number of shot rounds for a pack | ||||
|   """ | ||||
|   @spec get_used_count(AmmoGroup.t(), User.t()) :: non_neg_integer() | ||||
|   def get_used_count(%AmmoGroup{id: ammo_group_id} = ammo_group, user) do | ||||
|     [ammo_group] | ||||
|   @spec get_used_count(Pack.t(), User.t()) :: non_neg_integer() | ||||
|   def get_used_count(%Pack{id: pack_id} = pack, user) do | ||||
|     [pack] | ||||
|     |> get_used_counts(user) | ||||
|     |> Map.get(ammo_group_id, 0) | ||||
|     |> Map.get(pack_id, 0) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the number of shot rounds for multiple ammo groups | ||||
|   Returns the number of shot rounds for multiple packs | ||||
|   """ | ||||
|   @spec get_used_counts([AmmoGroup.t()], User.t()) :: | ||||
|           %{optional(AmmoGroup.id()) => non_neg_integer()} | ||||
|   def get_used_counts(ammo_groups, %User{id: user_id}) do | ||||
|     ammo_group_ids = | ||||
|       ammo_groups | ||||
|       |> Enum.map(fn %{id: ammo_group_id} -> ammo_group_id end) | ||||
|   @spec get_used_counts([Pack.t()], User.t()) :: | ||||
|           %{optional(Pack.id()) => non_neg_integer()} | ||||
|   def get_used_counts(packs, %User{id: user_id}) do | ||||
|     pack_ids = | ||||
|       packs | ||||
|       |> Enum.map(fn %{id: pack_id} -> pack_id end) | ||||
|  | ||||
|     Repo.all( | ||||
|       from sg in ShotGroup, | ||||
|         where: sg.ammo_group_id in ^ammo_group_ids, | ||||
|       from sg in ShotRecord, | ||||
|         where: sg.pack_id in ^pack_ids, | ||||
|         where: sg.user_id == ^user_id, | ||||
|         group_by: sg.ammo_group_id, | ||||
|         select: {sg.ammo_group_id, sum(sg.count)} | ||||
|         group_by: sg.pack_id, | ||||
|         select: {sg.pack_id, sum(sg.count)} | ||||
|     ) | ||||
|     |> Map.new() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the last entered shot group date for an ammo group | ||||
|   Returns the last entered shot record date for a pack | ||||
|   """ | ||||
|   @spec get_last_used_date(AmmoGroup.t(), User.t()) :: Date.t() | nil | ||||
|   def get_last_used_date(%AmmoGroup{id: ammo_group_id} = ammo_group, user) do | ||||
|     [ammo_group] | ||||
|   @spec get_last_used_date(Pack.t(), User.t()) :: Date.t() | nil | ||||
|   def get_last_used_date(%Pack{id: pack_id} = pack, user) do | ||||
|     [pack] | ||||
|     |> get_last_used_dates(user) | ||||
|     |> Map.get(ammo_group_id) | ||||
|     |> Map.get(pack_id) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the last entered shot group date for an ammo group | ||||
|   Returns the last entered shot record date for a pack | ||||
|   """ | ||||
|   @spec get_last_used_dates([AmmoGroup.t()], User.t()) :: %{optional(AmmoGroup.id()) => Date.t()} | ||||
|   def get_last_used_dates(ammo_groups, %User{id: user_id}) do | ||||
|     ammo_group_ids = | ||||
|       ammo_groups | ||||
|       |> Enum.map(fn %AmmoGroup{id: ammo_group_id, user_id: ^user_id} -> ammo_group_id end) | ||||
|   @spec get_last_used_dates([Pack.t()], User.t()) :: %{optional(Pack.id()) => Date.t()} | ||||
|   def get_last_used_dates(packs, %User{id: user_id}) do | ||||
|     pack_ids = | ||||
|       packs | ||||
|       |> Enum.map(fn %Pack{id: pack_id, user_id: ^user_id} -> pack_id end) | ||||
|  | ||||
|     Repo.all( | ||||
|       from sg in ShotGroup, | ||||
|         where: sg.ammo_group_id in ^ammo_group_ids, | ||||
|       from sg in ShotRecord, | ||||
|         where: sg.pack_id in ^pack_ids, | ||||
|         where: sg.user_id == ^user_id, | ||||
|         group_by: sg.ammo_group_id, | ||||
|         select: {sg.ammo_group_id, max(sg.date)} | ||||
|         group_by: sg.pack_id, | ||||
|         select: {sg.pack_id, max(sg.date)} | ||||
|     ) | ||||
|     |> Map.new() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets the total number of rounds shot for an ammo type | ||||
|   Gets the total number of rounds shot for a type | ||||
|  | ||||
|   Raises `Ecto.NoResultsError` if the Ammo type does not exist. | ||||
|   Raises `Ecto.NoResultsError` if the type does not exist. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_used_count_for_ammo_type(123, %User{id: 123}) | ||||
|       iex> get_used_count_for_type(123, %User{id: 123}) | ||||
|       35 | ||||
|  | ||||
|       iex> get_used_count_for_ammo_type(456, %User{id: 123}) | ||||
|       iex> get_used_count_for_type(456, %User{id: 123}) | ||||
|       ** (Ecto.NoResultsError) | ||||
|  | ||||
|   """ | ||||
|   @spec get_used_count_for_ammo_type(AmmoType.t(), User.t()) :: non_neg_integer() | ||||
|   def get_used_count_for_ammo_type(%AmmoType{id: ammo_type_id} = ammo_type, user) do | ||||
|     [ammo_type] | ||||
|     |> get_used_count_for_ammo_types(user) | ||||
|     |> Map.get(ammo_type_id, 0) | ||||
|   @spec get_used_count_for_type(Type.t(), User.t()) :: non_neg_integer() | ||||
|   def get_used_count_for_type(%Type{id: type_id} = type, user) do | ||||
|     [type] | ||||
|     |> get_used_count_for_types(user) | ||||
|     |> Map.get(type_id, 0) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets the total number of rounds shot for multiple ammo types | ||||
|   Gets the total number of rounds shot for multiple types | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_used_count_for_ammo_types(123, %User{id: 123}) | ||||
|       iex> get_used_count_for_types(123, %User{id: 123}) | ||||
|       35 | ||||
|  | ||||
|   """ | ||||
|   @spec get_used_count_for_ammo_types([AmmoType.t()], User.t()) :: | ||||
|           %{optional(AmmoType.id()) => non_neg_integer()} | ||||
|   def get_used_count_for_ammo_types(ammo_types, %User{id: user_id}) do | ||||
|     ammo_type_ids = | ||||
|       ammo_types | ||||
|       |> Enum.map(fn %AmmoType{id: ammo_type_id, user_id: ^user_id} -> ammo_type_id end) | ||||
|   @spec get_used_count_for_types([Type.t()], User.t()) :: | ||||
|           %{optional(Type.id()) => non_neg_integer()} | ||||
|   def get_used_count_for_types(types, %User{id: user_id}) do | ||||
|     type_ids = | ||||
|       types | ||||
|       |> Enum.map(fn %Type{id: type_id, user_id: ^user_id} -> type_id end) | ||||
|  | ||||
|     Repo.all( | ||||
|       from ag in AmmoGroup, | ||||
|         left_join: sg in ShotGroup, | ||||
|         on: ag.id == sg.ammo_group_id, | ||||
|         where: ag.ammo_type_id in ^ammo_type_ids, | ||||
|       from ag in Pack, | ||||
|         left_join: sg in ShotRecord, | ||||
|         on: ag.id == sg.pack_id, | ||||
|         where: ag.type_id in ^type_ids, | ||||
|         where: not (sg.count |> is_nil()), | ||||
|         group_by: ag.ammo_type_id, | ||||
|         select: {ag.ammo_type_id, sum(sg.count)} | ||||
|         group_by: ag.type_id, | ||||
|         select: {ag.type_id, sum(sg.count)} | ||||
|     ) | ||||
|     |> Map.new() | ||||
|   end | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| defmodule Cannery.ActivityLog.ShotGroup do | ||||
| defmodule Cannery.ActivityLog.ShotRecord do | ||||
|   @moduledoc """ | ||||
|   A shot group records a group of ammo shot during a range trip | ||||
|   A shot record records a group of ammo shot during a range trip | ||||
|   """ | ||||
| 
 | ||||
|   use Ecto.Schema | ||||
|   import CanneryWeb.Gettext | ||||
|   import Ecto.Changeset | ||||
|   alias Cannery.{Accounts.User, Ammo, Ammo.AmmoGroup} | ||||
|   alias Cannery.{Accounts.User, Ammo, Ammo.Pack} | ||||
|   alias Ecto.{Changeset, UUID} | ||||
| 
 | ||||
|   @derive {Jason.Encoder, | ||||
| @@ -15,17 +15,17 @@ defmodule Cannery.ActivityLog.ShotGroup do | ||||
|              :count, | ||||
|              :date, | ||||
|              :notes, | ||||
|              :ammo_group_id | ||||
|              :pack_id | ||||
|            ]} | ||||
|   @primary_key {:id, :binary_id, autogenerate: true} | ||||
|   @foreign_key_type :binary_id | ||||
|   schema "shot_groups" do | ||||
|   schema "shot_records" do | ||||
|     field :count, :integer | ||||
|     field :date, :date | ||||
|     field :notes, :string | ||||
| 
 | ||||
|     field :user_id, :binary_id | ||||
|     field :ammo_group_id, :binary_id | ||||
|     field :pack_id, :binary_id | ||||
| 
 | ||||
|     timestamps() | ||||
|   end | ||||
| @@ -35,57 +35,57 @@ defmodule Cannery.ActivityLog.ShotGroup do | ||||
|           count: integer, | ||||
|           notes: String.t() | nil, | ||||
|           date: Date.t() | nil, | ||||
|           ammo_group_id: AmmoGroup.id(), | ||||
|           pack_id: Pack.id(), | ||||
|           user_id: User.id(), | ||||
|           inserted_at: NaiveDateTime.t(), | ||||
|           updated_at: NaiveDateTime.t() | ||||
|         } | ||||
|   @type new_shot_group :: %__MODULE__{} | ||||
|   @type new_shot_record :: %__MODULE__{} | ||||
|   @type id :: UUID.t() | ||||
|   @type changeset :: Changeset.t(t() | new_shot_group()) | ||||
|   @type changeset :: Changeset.t(t() | new_shot_record()) | ||||
| 
 | ||||
|   @doc false | ||||
|   @spec create_changeset( | ||||
|           new_shot_group(), | ||||
|           new_shot_record(), | ||||
|           User.t() | any(), | ||||
|           AmmoGroup.t() | any(), | ||||
|           Pack.t() | any(), | ||||
|           attrs :: map() | ||||
|         ) :: changeset() | ||||
|   def create_changeset( | ||||
|         shot_group, | ||||
|         shot_record, | ||||
|         %User{id: user_id}, | ||||
|         %AmmoGroup{id: ammo_group_id, user_id: user_id} = ammo_group, | ||||
|         %Pack{id: pack_id, user_id: user_id} = pack, | ||||
|         attrs | ||||
|       ) do | ||||
|     shot_group | ||||
|     shot_record | ||||
|     |> change(user_id: user_id) | ||||
|     |> change(ammo_group_id: ammo_group_id) | ||||
|     |> change(pack_id: pack_id) | ||||
|     |> cast(attrs, [:count, :notes, :date]) | ||||
|     |> validate_create_shot_group_count(ammo_group) | ||||
|     |> validate_required([:date, :ammo_group_id, :user_id]) | ||||
|     |> validate_length(:notes, max: 255) | ||||
|     |> validate_create_shot_record_count(pack) | ||||
|     |> validate_required([:date, :pack_id, :user_id]) | ||||
|   end | ||||
| 
 | ||||
|   def create_changeset(shot_group, _invalid_user, _invalid_ammo_group, attrs) do | ||||
|     shot_group | ||||
|   def create_changeset(shot_record, _invalid_user, _invalid_pack, attrs) do | ||||
|     shot_record | ||||
|     |> cast(attrs, [:count, :notes, :date]) | ||||
|     |> validate_required([:ammo_group_id, :user_id]) | ||||
|     |> validate_length(:notes, max: 255) | ||||
|     |> validate_required([:pack_id, :user_id]) | ||||
|     |> add_error(:invalid, dgettext("errors", "Please select a valid user and ammo pack")) | ||||
|   end | ||||
| 
 | ||||
|   defp validate_create_shot_group_count(changeset, %AmmoGroup{count: ammo_group_count}) do | ||||
|   defp validate_create_shot_record_count(changeset, %Pack{count: pack_count}) do | ||||
|     case changeset |> Changeset.get_field(:count) do | ||||
|       nil -> | ||||
|         changeset |> Changeset.add_error(:ammo_left, dgettext("errors", "can't be blank")) | ||||
| 
 | ||||
|       count when count > ammo_group_count -> | ||||
|       count when count > pack_count -> | ||||
|         changeset | ||||
|         |> Changeset.add_error(:ammo_left, dgettext("errors", "Ammo left must be at least 0")) | ||||
| 
 | ||||
|       count when count <= 0 -> | ||||
|         error = | ||||
|           dgettext("errors", "Ammo left can be at most %{count} rounds", | ||||
|             count: ammo_group_count - 1 | ||||
|           ) | ||||
|           dgettext("errors", "Ammo left can be at most %{count} rounds", count: pack_count - 1) | ||||
| 
 | ||||
|         changeset |> Changeset.add_error(:ammo_left, error) | ||||
| 
 | ||||
| @@ -95,28 +95,28 @@ defmodule Cannery.ActivityLog.ShotGroup do | ||||
|   end | ||||
| 
 | ||||
|   @doc false | ||||
|   @spec update_changeset(t() | new_shot_group(), User.t(), attrs :: map()) :: changeset() | ||||
|   def update_changeset(%__MODULE__{} = shot_group, user, attrs) do | ||||
|     shot_group | ||||
|   @spec update_changeset(t() | new_shot_record(), User.t(), attrs :: map()) :: changeset() | ||||
|   def update_changeset(%__MODULE__{} = shot_record, user, attrs) do | ||||
|     shot_record | ||||
|     |> cast(attrs, [:count, :notes, :date]) | ||||
|     |> validate_length(:notes, max: 255) | ||||
|     |> validate_number(:count, greater_than: 0) | ||||
|     |> validate_required([:count, :date]) | ||||
|     |> validate_update_shot_group_count(shot_group, user) | ||||
|     |> validate_update_shot_record_count(shot_record, user) | ||||
|   end | ||||
| 
 | ||||
|   defp validate_update_shot_group_count( | ||||
|   defp validate_update_shot_record_count( | ||||
|          changeset, | ||||
|          %__MODULE__{ammo_group_id: ammo_group_id, count: count}, | ||||
|          %__MODULE__{pack_id: pack_id, count: count}, | ||||
|          user | ||||
|        ) do | ||||
|     %{count: ammo_group_count} = Ammo.get_ammo_group!(ammo_group_id, user) | ||||
|     %{count: pack_count} = Ammo.get_pack!(pack_id, user) | ||||
| 
 | ||||
|     new_shot_group_count = changeset |> Changeset.get_field(:count) | ||||
|     shot_diff_to_add = new_shot_group_count - count | ||||
|     new_shot_record_count = changeset |> Changeset.get_field(:count) | ||||
|     shot_diff_to_add = new_shot_record_count - count | ||||
| 
 | ||||
|     if shot_diff_to_add > ammo_group_count do | ||||
|       error = | ||||
|         dgettext("errors", "Count can be at most %{count} shots", count: ammo_group_count + count) | ||||
|     if shot_diff_to_add > pack_count do | ||||
|       error = dgettext("errors", "Count can be at most %{count} shots", count: pack_count + count) | ||||
| 
 | ||||
|       changeset |> Changeset.add_error(:count, error) | ||||
|     else | ||||
							
								
								
									
										1102
									
								
								lib/cannery/ammo.ex
									
									
									
									
									
								
							
							
						
						
									
										1102
									
								
								lib/cannery/ammo.ex
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,4 +1,4 @@ | ||||
| defmodule Cannery.Ammo.AmmoGroup do | ||||
| defmodule Cannery.Ammo.Pack do | ||||
|   @moduledoc """ | ||||
|   A group of a certain ammunition type. | ||||
| 
 | ||||
| @@ -9,7 +9,7 @@ defmodule Cannery.Ammo.AmmoGroup do | ||||
|   use Ecto.Schema | ||||
|   import CanneryWeb.Gettext | ||||
|   import Ecto.Changeset | ||||
|   alias Cannery.Ammo.AmmoType | ||||
|   alias Cannery.Ammo.Type | ||||
|   alias Cannery.{Accounts.User, Containers, Containers.Container} | ||||
|   alias Ecto.{Changeset, UUID} | ||||
| 
 | ||||
| @@ -19,20 +19,22 @@ defmodule Cannery.Ammo.AmmoGroup do | ||||
|              :count, | ||||
|              :notes, | ||||
|              :price_paid, | ||||
|              :lot_number, | ||||
|              :staged, | ||||
|              :ammo_type_id, | ||||
|              :type_id, | ||||
|              :container_id | ||||
|            ]} | ||||
|   @primary_key {:id, :binary_id, autogenerate: true} | ||||
|   @foreign_key_type :binary_id | ||||
|   schema "ammo_groups" do | ||||
|   schema "packs" do | ||||
|     field :count, :integer | ||||
|     field :notes, :string | ||||
|     field :price_paid, :float | ||||
|     field :staged, :boolean, default: false | ||||
|     field :lot_number, :string | ||||
|     field :purchased_on, :date | ||||
| 
 | ||||
|     belongs_to :ammo_type, AmmoType | ||||
|     belongs_to :type, Type | ||||
|     field :container_id, :binary_id | ||||
|     field :user_id, :binary_id | ||||
| 
 | ||||
| @@ -45,60 +47,76 @@ defmodule Cannery.Ammo.AmmoGroup do | ||||
|           notes: String.t() | nil, | ||||
|           price_paid: float() | nil, | ||||
|           staged: boolean(), | ||||
|           lot_number: String.t() | nil, | ||||
|           purchased_on: Date.t(), | ||||
|           ammo_type: AmmoType.t() | nil, | ||||
|           ammo_type_id: AmmoType.id(), | ||||
|           type: Type.t() | nil, | ||||
|           type_id: Type.id(), | ||||
|           container_id: Container.id(), | ||||
|           user_id: User.id(), | ||||
|           inserted_at: NaiveDateTime.t(), | ||||
|           updated_at: NaiveDateTime.t() | ||||
|         } | ||||
|   @type new_ammo_group :: %__MODULE__{} | ||||
|   @type new_pack :: %__MODULE__{} | ||||
|   @type id :: UUID.t() | ||||
|   @type changeset :: Changeset.t(t() | new_ammo_group()) | ||||
|   @type changeset :: Changeset.t(t() | new_pack()) | ||||
| 
 | ||||
|   @doc false | ||||
|   @spec create_changeset( | ||||
|           new_ammo_group(), | ||||
|           AmmoType.t() | nil, | ||||
|           new_pack(), | ||||
|           Type.t() | nil, | ||||
|           Container.t() | nil, | ||||
|           User.t(), | ||||
|           attrs :: map() | ||||
|         ) :: changeset() | ||||
|   def create_changeset( | ||||
|         ammo_group, | ||||
|         %AmmoType{id: ammo_type_id}, | ||||
|         pack, | ||||
|         %Type{id: type_id}, | ||||
|         %Container{id: container_id, user_id: user_id}, | ||||
|         %User{id: user_id}, | ||||
|         attrs | ||||
|       ) | ||||
|       when is_binary(ammo_type_id) and is_binary(container_id) and is_binary(user_id) do | ||||
|     ammo_group | ||||
|     |> change(ammo_type_id: ammo_type_id) | ||||
|       when is_binary(type_id) and is_binary(container_id) and is_binary(user_id) do | ||||
|     pack | ||||
|     |> change(type_id: type_id) | ||||
|     |> change(user_id: user_id) | ||||
|     |> change(container_id: container_id) | ||||
|     |> cast(attrs, [:count, :price_paid, :notes, :staged, :purchased_on]) | ||||
|     |> cast(attrs, [:count, :price_paid, :notes, :staged, :purchased_on, :lot_number]) | ||||
|     |> validate_number(:count, greater_than: 0) | ||||
|     |> validate_required([:count, :staged, :purchased_on, :ammo_type_id, :container_id, :user_id]) | ||||
|     |> validate_number(:price_paid, greater_than_or_equal_to: 0) | ||||
|     |> validate_length(:lot_number, max: 255) | ||||
|     |> validate_required([:count, :staged, :purchased_on, :type_id, :container_id, :user_id]) | ||||
|   end | ||||
| 
 | ||||
|   @doc """ | ||||
|   Invalid changeset, used to prompt user to select ammo type and container | ||||
|   Invalid changeset, used to prompt user to select type and container | ||||
|   """ | ||||
|   def create_changeset(ammo_group, _invalid_ammo_type, _invalid_container, _invalid_user, attrs) do | ||||
|     ammo_group | ||||
|     |> cast(attrs, [:ammo_type_id, :container_id]) | ||||
|     |> validate_required([:ammo_type_id, :container_id]) | ||||
|     |> add_error(:invalid, dgettext("errors", "Please select an ammo type and container")) | ||||
|   def create_changeset(pack, _invalid_type, _invalid_container, _invalid_user, attrs) do | ||||
|     pack | ||||
|     |> cast(attrs, [:type_id, :container_id]) | ||||
|     |> validate_required([:type_id, :container_id]) | ||||
|     |> validate_number(:count, greater_than: 0) | ||||
|     |> validate_number(:price_paid, greater_than_or_equal_to: 0) | ||||
|     |> validate_length(:lot_number, max: 255) | ||||
|     |> add_error(:invalid, dgettext("errors", "Please select a type and container")) | ||||
|   end | ||||
| 
 | ||||
|   @doc false | ||||
|   @spec update_changeset(t() | new_ammo_group(), attrs :: map(), User.t()) :: changeset() | ||||
|   def update_changeset(ammo_group, attrs, user) do | ||||
|     ammo_group | ||||
|     |> cast(attrs, [:count, :price_paid, :notes, :staged, :purchased_on, :container_id]) | ||||
|   @spec update_changeset(t() | new_pack(), attrs :: map(), User.t()) :: changeset() | ||||
|   def update_changeset(pack, attrs, user) do | ||||
|     pack | ||||
|     |> cast(attrs, [ | ||||
|       :count, | ||||
|       :price_paid, | ||||
|       :notes, | ||||
|       :staged, | ||||
|       :purchased_on, | ||||
|       :lot_number, | ||||
|       :container_id | ||||
|     ]) | ||||
|     |> validate_number(:count, greater_than_or_equal_to: 0) | ||||
|     |> validate_number(:price_paid, greater_than_or_equal_to: 0) | ||||
|     |> validate_container_id(user) | ||||
|     |> validate_length(:lot_number, max: 255) | ||||
|     |> validate_required([:count, :staged, :purchased_on, :container_id]) | ||||
|   end | ||||
| 
 | ||||
| @@ -113,12 +131,12 @@ defmodule Cannery.Ammo.AmmoGroup do | ||||
|   end | ||||
| 
 | ||||
|   @doc """ | ||||
|   This range changeset is used when "using up" ammo groups, and allows for | ||||
|   This range changeset is used when "using up" packs, and allows for | ||||
|   updating the count to 0 | ||||
|   """ | ||||
|   @spec range_changeset(t() | new_ammo_group(), attrs :: map()) :: changeset() | ||||
|   def range_changeset(ammo_group, attrs) do | ||||
|     ammo_group | ||||
|   @spec range_changeset(t() | new_pack(), attrs :: map()) :: changeset() | ||||
|   def range_changeset(pack, attrs) do | ||||
|     pack | ||||
|     |> cast(attrs, [:count, :staged]) | ||||
|     |> validate_required([:count, :staged]) | ||||
|   end | ||||
| @@ -1,4 +1,4 @@ | ||||
| defmodule Cannery.Ammo.AmmoType do | ||||
| defmodule Cannery.Ammo.Type do | ||||
|   @moduledoc """ | ||||
|   An ammunition type. | ||||
| 
 | ||||
| @@ -8,65 +8,92 @@ defmodule Cannery.Ammo.AmmoType do | ||||
|   use Ecto.Schema | ||||
|   import Ecto.Changeset | ||||
|   alias Cannery.Accounts.User | ||||
|   alias Cannery.Ammo.AmmoGroup | ||||
|   alias Cannery.Ammo.Pack | ||||
|   alias Ecto.{Changeset, UUID} | ||||
| 
 | ||||
|   @derive {Jason.Encoder, | ||||
|            only: [ | ||||
|              :id, | ||||
|              :name, | ||||
|              :desc, | ||||
|              :class, | ||||
|              :bullet_type, | ||||
|              :bullet_core, | ||||
|              :cartridge, | ||||
|              :caliber, | ||||
|              :case_material, | ||||
|              :jacket_type, | ||||
|              :muzzle_velocity, | ||||
|              :powder_type, | ||||
|              :powder_grains_per_charge, | ||||
|              :grains, | ||||
|              :pressure, | ||||
|              :primer_type, | ||||
|              :firing_type, | ||||
|              :manufacturer, | ||||
|              :upc, | ||||
|              :tracer, | ||||
|              :incendiary, | ||||
|              :blank, | ||||
|              :corrosive, | ||||
|              :manufacturer, | ||||
|              :upc | ||||
|              :cartridge, | ||||
|              :jacket_type, | ||||
|              :powder_grains_per_charge, | ||||
|              :muzzle_velocity, | ||||
|              :wadding, | ||||
|              :shot_type, | ||||
|              :shot_material, | ||||
|              :shot_size, | ||||
|              :unfired_length, | ||||
|              :brass_height, | ||||
|              :chamber_size, | ||||
|              :load_grains, | ||||
|              :shot_charge_weight, | ||||
|              :dram_equivalent | ||||
|            ]} | ||||
|   @primary_key {:id, :binary_id, autogenerate: true} | ||||
|   @foreign_key_type :binary_id | ||||
|   schema "ammo_types" do | ||||
|   schema "types" do | ||||
|     field :name, :string | ||||
|     field :desc, :string | ||||
| 
 | ||||
|     # https://en.wikipedia.org/wiki/Bullet#Abbreviations | ||||
|     field :bullet_type, :string | ||||
|     field :class, Ecto.Enum, values: [:rifle, :shotgun, :pistol], default: :rifle | ||||
| 
 | ||||
|     # common fields | ||||
|     field :bullet_core, :string | ||||
|     field :cartridge, :string | ||||
|     # also gauge for shotguns | ||||
|     field :caliber, :string | ||||
|     field :case_material, :string | ||||
|     field :jacket_type, :string | ||||
|     field :muzzle_velocity, :integer | ||||
|     field :powder_type, :string | ||||
|     field :powder_grains_per_charge, :integer | ||||
|     field :grains, :integer | ||||
|     field :pressure, :string | ||||
|     field :primer_type, :string | ||||
|     field :firing_type, :string | ||||
|     field :manufacturer, :string | ||||
|     field :upc, :string | ||||
| 
 | ||||
|     field :tracer, :boolean, default: false | ||||
|     field :incendiary, :boolean, default: false | ||||
|     field :blank, :boolean, default: false | ||||
|     field :corrosive, :boolean, default: false | ||||
| 
 | ||||
|     field :manufacturer, :string | ||||
|     field :upc, :string | ||||
|     # rifle/pistol fields | ||||
|     # https://shootersreference.com/reloadingdata/bullet_abbreviations/ | ||||
|     field :bullet_type, :string | ||||
|     field :cartridge, :string | ||||
|     field :jacket_type, :string | ||||
|     field :powder_grains_per_charge, :integer | ||||
|     field :muzzle_velocity, :integer | ||||
| 
 | ||||
|     # shotgun fields | ||||
|     field :wadding, :string | ||||
|     field :shot_type, :string | ||||
|     field :shot_material, :string | ||||
|     field :shot_size, :string | ||||
|     field :unfired_length, :string | ||||
|     field :brass_height, :string | ||||
|     field :chamber_size, :string | ||||
|     field :load_grains, :integer | ||||
|     field :shot_charge_weight, :string | ||||
|     field :dram_equivalent, :string | ||||
| 
 | ||||
|     field :user_id, :binary_id | ||||
| 
 | ||||
|     has_many :ammo_groups, AmmoGroup | ||||
|     has_many :packs, Pack | ||||
| 
 | ||||
|     timestamps() | ||||
|   end | ||||
| @@ -75,74 +102,133 @@ defmodule Cannery.Ammo.AmmoType do | ||||
|           id: id(), | ||||
|           name: String.t(), | ||||
|           desc: String.t() | nil, | ||||
|           class: class(), | ||||
|           bullet_type: String.t() | nil, | ||||
|           bullet_core: String.t() | nil, | ||||
|           cartridge: String.t() | nil, | ||||
|           caliber: String.t() | nil, | ||||
|           case_material: String.t() | nil, | ||||
|           jacket_type: String.t() | nil, | ||||
|           muzzle_velocity: integer() | nil, | ||||
|           powder_type: String.t() | nil, | ||||
|           powder_grains_per_charge: integer() | nil, | ||||
|           grains: integer() | nil, | ||||
|           pressure: String.t() | nil, | ||||
|           primer_type: String.t() | nil, | ||||
|           firing_type: String.t() | nil, | ||||
|           manufacturer: String.t() | nil, | ||||
|           upc: String.t() | nil, | ||||
|           tracer: boolean(), | ||||
|           incendiary: boolean(), | ||||
|           blank: boolean(), | ||||
|           corrosive: boolean(), | ||||
|           manufacturer: String.t() | nil, | ||||
|           upc: String.t() | nil, | ||||
|           cartridge: String.t() | nil, | ||||
|           jacket_type: String.t() | nil, | ||||
|           powder_grains_per_charge: integer() | nil, | ||||
|           muzzle_velocity: integer() | nil, | ||||
|           wadding: String.t() | nil, | ||||
|           shot_type: String.t() | nil, | ||||
|           shot_material: String.t() | nil, | ||||
|           shot_size: String.t() | nil, | ||||
|           unfired_length: String.t() | nil, | ||||
|           brass_height: String.t() | nil, | ||||
|           chamber_size: String.t() | nil, | ||||
|           load_grains: integer() | nil, | ||||
|           shot_charge_weight: String.t() | nil, | ||||
|           dram_equivalent: String.t() | nil, | ||||
|           user_id: User.id(), | ||||
|           ammo_groups: [AmmoGroup.t()] | nil, | ||||
|           packs: [Pack.t()] | nil, | ||||
|           inserted_at: NaiveDateTime.t(), | ||||
|           updated_at: NaiveDateTime.t() | ||||
|         } | ||||
|   @type new_ammo_type :: %__MODULE__{} | ||||
|   @type new_type :: %__MODULE__{} | ||||
|   @type id :: UUID.t() | ||||
|   @type changeset :: Changeset.t(t() | new_ammo_type()) | ||||
|   @type changeset :: Changeset.t(t() | new_type()) | ||||
|   @type class :: :rifle | :shotgun | :pistol | nil | ||||
| 
 | ||||
|   @spec changeset_fields() :: [atom()] | ||||
|   defp changeset_fields, | ||||
|     do: [ | ||||
|       :name, | ||||
|       :desc, | ||||
|       :class, | ||||
|       :bullet_type, | ||||
|       :bullet_core, | ||||
|       :cartridge, | ||||
|       :caliber, | ||||
|       :case_material, | ||||
|       :jacket_type, | ||||
|       :muzzle_velocity, | ||||
|       :powder_type, | ||||
|       :powder_grains_per_charge, | ||||
|       :grains, | ||||
|       :pressure, | ||||
|       :primer_type, | ||||
|       :firing_type, | ||||
|       :manufacturer, | ||||
|       :upc, | ||||
|       :tracer, | ||||
|       :incendiary, | ||||
|       :blank, | ||||
|       :corrosive, | ||||
|       :cartridge, | ||||
|       :jacket_type, | ||||
|       :powder_grains_per_charge, | ||||
|       :muzzle_velocity, | ||||
|       :wadding, | ||||
|       :shot_type, | ||||
|       :shot_material, | ||||
|       :shot_size, | ||||
|       :unfired_length, | ||||
|       :brass_height, | ||||
|       :chamber_size, | ||||
|       :load_grains, | ||||
|       :shot_charge_weight, | ||||
|       :dram_equivalent | ||||
|     ] | ||||
| 
 | ||||
|   @spec string_fields() :: [atom()] | ||||
|   defp string_fields, | ||||
|     do: [ | ||||
|       :name, | ||||
|       :desc, | ||||
|       :bullet_type, | ||||
|       :bullet_core, | ||||
|       :caliber, | ||||
|       :case_material, | ||||
|       :powder_type, | ||||
|       :pressure, | ||||
|       :primer_type, | ||||
|       :firing_type, | ||||
|       :manufacturer, | ||||
|       :upc | ||||
|       :upc, | ||||
|       :cartridge, | ||||
|       :jacket_type, | ||||
|       :wadding, | ||||
|       :shot_type, | ||||
|       :shot_material, | ||||
|       :shot_size, | ||||
|       :unfired_length, | ||||
|       :brass_height, | ||||
|       :chamber_size, | ||||
|       :shot_charge_weight, | ||||
|       :dram_equivalent | ||||
|     ] | ||||
| 
 | ||||
|   @doc false | ||||
|   @spec create_changeset(new_ammo_type(), User.t(), attrs :: map()) :: changeset() | ||||
|   def create_changeset(ammo_type, %User{id: user_id}, attrs) do | ||||
|     ammo_type | ||||
|     |> change(user_id: user_id) | ||||
|     |> cast(attrs, changeset_fields()) | ||||
|   @spec create_changeset(new_type(), User.t(), attrs :: map()) :: changeset() | ||||
|   def create_changeset(type, %User{id: user_id}, attrs) do | ||||
|     changeset = | ||||
|       type | ||||
|       |> change(user_id: user_id) | ||||
|       |> cast(attrs, changeset_fields()) | ||||
| 
 | ||||
|     string_fields() | ||||
|     |> Enum.reduce(changeset, fn field, acc -> acc |> validate_length(field, max: 255) end) | ||||
|     |> validate_required([:name, :user_id]) | ||||
|   end | ||||
| 
 | ||||
|   @doc false | ||||
|   @spec update_changeset(t() | new_ammo_type(), attrs :: map()) :: changeset() | ||||
|   def update_changeset(ammo_type, attrs) do | ||||
|     ammo_type | ||||
|     |> cast(attrs, changeset_fields()) | ||||
|   @spec update_changeset(t() | new_type(), attrs :: map()) :: changeset() | ||||
|   def update_changeset(type, attrs) do | ||||
|     changeset = | ||||
|       type | ||||
|       |> cast(attrs, changeset_fields()) | ||||
| 
 | ||||
|     string_fields() | ||||
|     |> Enum.reduce(changeset, fn field, acc -> acc |> validate_length(field, max: 255) end) | ||||
|     |> validate_required(:name) | ||||
|   end | ||||
| end | ||||
| @@ -5,7 +5,7 @@ defmodule Cannery.Containers do | ||||
|  | ||||
|   import CanneryWeb.Gettext | ||||
|   import Ecto.Query, warn: false | ||||
|   alias Cannery.{Accounts.User, Ammo.AmmoGroup, Repo} | ||||
|   alias Cannery.{Accounts.User, Ammo.Pack, Repo} | ||||
|   alias Cannery.Containers.{Container, ContainerTag, Tag} | ||||
|   alias Ecto.Changeset | ||||
|  | ||||
| @@ -32,6 +32,7 @@ defmodule Cannery.Containers do | ||||
|       as: :t, | ||||
|       where: c.user_id == ^user_id, | ||||
|       order_by: c.name, | ||||
|       distinct: c.id, | ||||
|       preload: ^@container_preloads | ||||
|     ) | ||||
|     |> list_containers_search(search) | ||||
| @@ -91,7 +92,7 @@ defmodule Cannery.Containers do | ||||
|   @doc """ | ||||
|   Gets a single container. | ||||
|  | ||||
|   Raises `Ecto.NoResultsError` if the Container does not exist. | ||||
|   Raises `KeyError` if the Container does not exist. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
| @@ -99,18 +100,37 @@ defmodule Cannery.Containers do | ||||
|       %Container{} | ||||
|  | ||||
|       iex> get_container!(456, %User{id: 123}) | ||||
|       ** (Ecto.NoResultsError) | ||||
|       ** (KeyError) | ||||
|  | ||||
|   """ | ||||
|   @spec get_container!(Container.id(), User.t()) :: Container.t() | ||||
|   def get_container!(id, %User{id: user_id}) do | ||||
|     Repo.one!( | ||||
|   def get_container!(id, user) do | ||||
|     [id] | ||||
|     |> get_containers(user) | ||||
|     |> Map.fetch!(id) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets multiple containers. | ||||
|  | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_containers([123], %User{id: 123}) | ||||
|       %{123 => %Container{}} | ||||
|  | ||||
|   """ | ||||
|   @spec get_containers([Container.id()], User.t()) :: %{optional(Container.id()) => Container.t()} | ||||
|   def get_containers(ids, %User{id: user_id}) do | ||||
|     Repo.all( | ||||
|       from c in Container, | ||||
|         where: c.user_id == ^user_id, | ||||
|         where: c.id == ^id, | ||||
|         where: c.id in ^ids, | ||||
|         order_by: c.name, | ||||
|         preload: ^@container_preloads | ||||
|         preload: ^@container_preloads, | ||||
|         select: {c.id, c} | ||||
|     ) | ||||
|     |> Map.new() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
| @@ -183,7 +203,7 @@ defmodule Cannery.Containers do | ||||
|           {:ok, Container.t()} | {:error, Container.changeset()} | ||||
|   def delete_container(%Container{user_id: user_id} = container, %User{id: user_id}) do | ||||
|     Repo.one( | ||||
|       from ag in AmmoGroup, | ||||
|       from ag in Pack, | ||||
|         where: ag.container_id == ^container.id, | ||||
|         select: count(ag.id) | ||||
|     ) | ||||
| @@ -201,7 +221,7 @@ defmodule Cannery.Containers do | ||||
|  | ||||
|         container | ||||
|         |> Container.update_changeset(%{}) | ||||
|         |> Changeset.add_error(:ammo_groups, error) | ||||
|         |> Changeset.add_error(:packs, error) | ||||
|         |> Changeset.apply_action(:delete) | ||||
|     end | ||||
|   end | ||||
|   | ||||
| @@ -53,6 +53,8 @@ defmodule Cannery.Containers.Container do | ||||
|     container | ||||
|     |> change(user_id: user_id) | ||||
|     |> cast(attrs, [:name, :desc, :type, :location]) | ||||
|     |> validate_length(:name, max: 255) | ||||
|     |> validate_length(:type, max: 255) | ||||
|     |> validate_required([:name, :type, :user_id]) | ||||
|   end | ||||
|  | ||||
| @@ -61,6 +63,8 @@ defmodule Cannery.Containers.Container do | ||||
|   def update_changeset(container, attrs) do | ||||
|     container | ||||
|     |> cast(attrs, [:name, :desc, :type, :location]) | ||||
|     |> validate_length(:name, max: 255) | ||||
|     |> validate_length(:type, max: 255) | ||||
|     |> validate_required([:name, :type]) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -47,6 +47,9 @@ defmodule Cannery.Containers.Tag do | ||||
|     tag | ||||
|     |> change(user_id: user_id) | ||||
|     |> cast(attrs, [:name, :bg_color, :text_color]) | ||||
|     |> validate_length(:name, max: 255) | ||||
|     |> validate_length(:bg_color, max: 12) | ||||
|     |> validate_length(:text_color, max: 12) | ||||
|     |> validate_required([:name, :bg_color, :text_color, :user_id]) | ||||
|   end | ||||
|  | ||||
| @@ -55,6 +58,9 @@ defmodule Cannery.Containers.Tag do | ||||
|   def update_changeset(tag, attrs) do | ||||
|     tag | ||||
|     |> cast(attrs, [:name, :bg_color, :text_color]) | ||||
|     |> validate_length(:name, max: 255) | ||||
|     |> validate_length(:bg_color, max: 12) | ||||
|     |> validate_length(:text_color, max: 12) | ||||
|     |> validate_required([:name, :bg_color, :text_color]) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -14,17 +14,17 @@ defmodule Cannery.Logger do | ||||
|       |> Map.put(:stacktrace, Exception.format_stacktrace(stacktrace)) | ||||
|       |> pretty_encode() | ||||
|  | ||||
|     Logger.error(meta.reason, data: data) | ||||
|     Logger.error("#{meta.reason}: #{data}") | ||||
|   end | ||||
|  | ||||
|   def handle_event([:oban, :job, :start], measure, meta, _config) do | ||||
|     data = get_oban_job_data(meta, measure) |> pretty_encode() | ||||
|     Logger.info("Started oban job", data: data) | ||||
|     Logger.info("Started oban job: #{data}") | ||||
|   end | ||||
|  | ||||
|   def handle_event([:oban, :job, :stop], measure, meta, _config) do | ||||
|     data = get_oban_job_data(meta, measure) |> pretty_encode() | ||||
|     Logger.info("Finished oban job", data: data) | ||||
|     Logger.info("Finished oban job: #{data}") | ||||
|   end | ||||
|  | ||||
|   def handle_event([:oban, :job, unhandled_event], measure, meta, _config) do | ||||
| @@ -33,7 +33,7 @@ defmodule Cannery.Logger do | ||||
|       |> Map.put(:event, unhandled_event) | ||||
|       |> pretty_encode() | ||||
|  | ||||
|     Logger.warning("Unhandled oban job event", data: data) | ||||
|     Logger.warning("Unhandled oban job event: #{data}") | ||||
|   end | ||||
|  | ||||
|   def handle_event(unhandled_event, measure, meta, config) do | ||||
| @@ -45,7 +45,7 @@ defmodule Cannery.Logger do | ||||
|         config: config | ||||
|       }) | ||||
|  | ||||
|     Logger.warning("Unhandled telemetry event", data: data) | ||||
|     Logger.warning("Unhandled telemetry event: #{data}") | ||||
|   end | ||||
|  | ||||
|   defp get_oban_job_data(%{job: job}, measure) do | ||||
|   | ||||
| @@ -1,53 +1,61 @@ | ||||
| defmodule CanneryWeb do | ||||
|   @moduledoc """ | ||||
|   The entrypoint for defining your web interface, such | ||||
|   as controllers, views, channels and so on. | ||||
|   as controllers, components, channels, and so on. | ||||
|  | ||||
|   This can be used in your application as: | ||||
|  | ||||
|       use CanneryWeb, :controller | ||||
|       use CanneryWeb, :view | ||||
|       use CanneryWeb, :html | ||||
|  | ||||
|   The definitions below will be executed for every view, | ||||
|   controller, etc, so keep them short and clean, focused | ||||
|   The definitions below will be executed for every controller, | ||||
|   component, etc, so keep them short and clean, focused | ||||
|   on imports, uses and aliases. | ||||
|  | ||||
|   Do NOT define functions inside the quoted expressions | ||||
|   below. Instead, define any helper function in modules | ||||
|   and import those modules here. | ||||
|   below. Instead, define additional modules and import | ||||
|   those modules here. | ||||
|   """ | ||||
|  | ||||
|   def controller do | ||||
|     quote do | ||||
|       use Phoenix.Controller, namespace: CanneryWeb | ||||
|   def static_paths, do: ~w(css js fonts images favicon.ico robots.txt) | ||||
|  | ||||
|   def router do | ||||
|     quote do | ||||
|       use Phoenix.Router, helpers: false | ||||
|  | ||||
|       # Import common connection and controller functions to use in pipelines | ||||
|       import Plug.Conn | ||||
|       import CanneryWeb.Gettext | ||||
|       alias CanneryWeb.Router.Helpers, as: Routes | ||||
|       import Phoenix.Controller | ||||
|       import Phoenix.LiveView.Router | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def view do | ||||
|   def channel do | ||||
|     quote do | ||||
|       use Phoenix.View, | ||||
|         root: "lib/cannery_web/templates", | ||||
|         namespace: CanneryWeb | ||||
|       use Phoenix.Channel | ||||
|     end | ||||
|   end | ||||
|  | ||||
|       # Import convenience functions from controllers | ||||
|       import Phoenix.Controller, | ||||
|         only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] | ||||
|   def controller do | ||||
|     quote do | ||||
|       use Phoenix.Controller, | ||||
|         formats: [:html, :json], | ||||
|         layouts: [html: CanneryWeb.Layouts] | ||||
|  | ||||
|       # Include shared imports and aliases for views | ||||
|       unquote(view_helpers()) | ||||
|       # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse | ||||
|       import Plug.Conn | ||||
|       import CanneryWeb.Gettext | ||||
|  | ||||
|       unquote(verified_routes()) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def live_view do | ||||
|     quote do | ||||
|       use Phoenix.LiveView, layout: {CanneryWeb.LayoutView, :live} | ||||
|       use Phoenix.LiveView, | ||||
|         layout: {CanneryWeb.Layouts, :app} | ||||
|  | ||||
|       on_mount CanneryWeb.InitAssigns | ||||
|       unquote(view_helpers()) | ||||
|       unquote(html_helpers()) | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -55,49 +63,46 @@ defmodule CanneryWeb do | ||||
|     quote do | ||||
|       use Phoenix.LiveComponent | ||||
|  | ||||
|       unquote(view_helpers()) | ||||
|       unquote(html_helpers()) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def component do | ||||
|   def html do | ||||
|     quote do | ||||
|       use Phoenix.Component | ||||
|  | ||||
|       unquote(view_helpers()) | ||||
|       # Import convenience functions from controllers | ||||
|       import Phoenix.Controller, | ||||
|         only: [get_csrf_token: 0, view_module: 1, view_template: 1] | ||||
|  | ||||
|       # Include general helpers for rendering HTML | ||||
|       unquote(html_helpers()) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def router do | ||||
|   defp html_helpers do | ||||
|     quote do | ||||
|       use Phoenix.Router | ||||
|  | ||||
|       import Phoenix.{Controller, LiveView.Router} | ||||
|       # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse | ||||
|       import Plug.Conn | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def channel do | ||||
|     quote do | ||||
|       use Phoenix.Channel | ||||
|       # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse | ||||
|       import CanneryWeb.Gettext | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   defp view_helpers do | ||||
|     quote do | ||||
|       # Use all HTML functionality (forms, tags, etc) | ||||
|       # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse | ||||
|       use Phoenix.HTML | ||||
|  | ||||
|       # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) | ||||
|       # Import basic rendering functionality (render, render_layout, etc) | ||||
|       import CanneryWeb.{ErrorHelpers, Gettext, CoreComponents, ViewHelpers} | ||||
|       import Phoenix.{Component, View} | ||||
|       # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse | ||||
|       import Phoenix.Component | ||||
|       import CanneryWeb.{ErrorHelpers, Gettext, CoreComponents, HTMLHelpers} | ||||
|  | ||||
|       alias CanneryWeb.Endpoint | ||||
|       alias CanneryWeb.Router.Helpers, as: Routes | ||||
|       # Shortcut for generating JS commands | ||||
|       alias Phoenix.LiveView.JS | ||||
|  | ||||
|       # Routes generation with the ~p sigil | ||||
|       unquote(verified_routes()) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def verified_routes do | ||||
|     quote do | ||||
|       use Phoenix.VerifiedRoutes, | ||||
|         endpoint: CanneryWeb.Endpoint, | ||||
|         router: CanneryWeb.Router, | ||||
|         statics: CanneryWeb.static_paths() | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   | ||||
| @@ -1,10 +1,10 @@ | ||||
| defmodule CanneryWeb.Components.AddShotGroupComponent do | ||||
| defmodule CanneryWeb.Components.AddShotRecordComponent do | ||||
|   @moduledoc """ | ||||
|   Livecomponent that can create a ShotGroup | ||||
|   Livecomponent that can create a ShotRecord | ||||
|   """ | ||||
| 
 | ||||
|   use CanneryWeb, :live_component | ||||
|   alias Cannery.{Accounts.User, ActivityLog, ActivityLog.ShotGroup, Ammo.AmmoGroup} | ||||
|   alias Cannery.{Accounts.User, ActivityLog, ActivityLog.ShotRecord, Ammo.Pack} | ||||
|   alias Ecto.Changeset | ||||
|   alias Phoenix.LiveView.{JS, Socket} | ||||
| 
 | ||||
| @@ -12,15 +12,15 @@ defmodule CanneryWeb.Components.AddShotGroupComponent do | ||||
|   @spec update( | ||||
|           %{ | ||||
|             required(:current_user) => User.t(), | ||||
|             required(:ammo_group) => AmmoGroup.t(), | ||||
|             required(:pack) => Pack.t(), | ||||
|             optional(any()) => any() | ||||
|           }, | ||||
|           Socket.t() | ||||
|         ) :: {:ok, Socket.t()} | ||||
|   def update(%{ammo_group: ammo_group, current_user: current_user} = assigns, socket) do | ||||
|   def update(%{pack: pack, current_user: current_user} = assigns, socket) do | ||||
|     changeset = | ||||
|       %ShotGroup{date: Date.utc_today()} | ||||
|       |> ShotGroup.create_changeset(current_user, ammo_group, %{}) | ||||
|       %ShotRecord{date: Date.utc_today()} | ||||
|       |> ShotRecord.create_changeset(current_user, pack, %{}) | ||||
| 
 | ||||
|     {:ok, socket |> assign(assigns) |> assign(:changeset, changeset)} | ||||
|   end | ||||
| @@ -28,12 +28,12 @@ defmodule CanneryWeb.Components.AddShotGroupComponent do | ||||
|   @impl true | ||||
|   def handle_event( | ||||
|         "validate", | ||||
|         %{"shot_group" => shot_group_params}, | ||||
|         %{assigns: %{ammo_group: ammo_group, current_user: current_user}} = socket | ||||
|         %{"shot_record" => shot_record_params}, | ||||
|         %{assigns: %{pack: pack, current_user: current_user}} = socket | ||||
|       ) do | ||||
|     params = shot_group_params |> process_params(ammo_group) | ||||
|     params = shot_record_params |> process_params(pack) | ||||
| 
 | ||||
|     changeset = %ShotGroup{} |> ShotGroup.create_changeset(current_user, ammo_group, params) | ||||
|     changeset = %ShotRecord{} |> ShotRecord.create_changeset(current_user, pack, params) | ||||
| 
 | ||||
|     changeset = | ||||
|       case changeset |> Changeset.apply_action(:validate) do | ||||
| @@ -46,17 +46,17 @@ defmodule CanneryWeb.Components.AddShotGroupComponent do | ||||
| 
 | ||||
|   def handle_event( | ||||
|         "save", | ||||
|         %{"shot_group" => shot_group_params}, | ||||
|         %{"shot_record" => shot_record_params}, | ||||
|         %{ | ||||
|           assigns: %{ammo_group: ammo_group, current_user: current_user, return_to: return_to} | ||||
|           assigns: %{pack: pack, current_user: current_user, return_to: return_to} | ||||
|         } = socket | ||||
|       ) do | ||||
|     socket = | ||||
|       shot_group_params | ||||
|       |> process_params(ammo_group) | ||||
|       |> ActivityLog.create_shot_group(current_user, ammo_group) | ||||
|       shot_record_params | ||||
|       |> process_params(pack) | ||||
|       |> ActivityLog.create_shot_record(current_user, pack) | ||||
|       |> case do | ||||
|         {:ok, _shot_group} -> | ||||
|         {:ok, _shot_record} -> | ||||
|           prompt = dgettext("prompts", "Shots recorded successfully") | ||||
|           socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) | ||||
| 
 | ||||
| @@ -68,8 +68,8 @@ defmodule CanneryWeb.Components.AddShotGroupComponent do | ||||
|   end | ||||
| 
 | ||||
|   # calculate count from shots left | ||||
|   defp process_params(params, %AmmoGroup{count: count}) do | ||||
|     shot_group_count = | ||||
|   defp process_params(params, %Pack{count: count}) do | ||||
|     shot_record_count = | ||||
|       if params |> Map.get("ammo_left", "") == "" do | ||||
|         nil | ||||
|       else | ||||
| @@ -77,6 +77,6 @@ defmodule CanneryWeb.Components.AddShotGroupComponent do | ||||
|         count - new_count | ||||
|       end | ||||
| 
 | ||||
|     params |> Map.put("count", shot_group_count) | ||||
|     params |> Map.put("count", shot_record_count) | ||||
|   end | ||||
| end | ||||
| @@ -6,7 +6,7 @@ | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={@changeset} | ||||
|     id="shot-group-form" | ||||
|     id="shot-record-form" | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|     phx-target={@myself} | ||||
|     phx-change="validate" | ||||
| @@ -22,14 +22,14 @@ | ||||
|     <%= label(f, :ammo_left, gettext("Rounds left"), class: "title text-lg text-primary-600") %> | ||||
|     <%= number_input(f, :ammo_left, | ||||
|       min: 0, | ||||
|       max: @ammo_group.count - 1, | ||||
|       max: @pack.count - 1, | ||||
|       placeholder: gettext("Rounds left"), | ||||
|       class: "input input-primary" | ||||
|     ) %> | ||||
|     <button | ||||
|       type="button" | ||||
|       class="mx-2 my-1 text-sm btn btn-primary" | ||||
|       phx-click={JS.dispatch("cannery:set-zero", to: "#shot-group-form_ammo_left")} | ||||
|       phx-click={JS.dispatch("cannery:set-zero", to: "#shot-record-form_ammo_left")} | ||||
|     > | ||||
|       <%= gettext("Used up!") %> | ||||
|     </button> | ||||
| @@ -37,8 +37,9 @@ | ||||
| 
 | ||||
|     <%= label(f, :notes, gettext("Notes"), class: "title text-lg text-primary-600") %> | ||||
|     <%= textarea(f, :notes, | ||||
|       id: "add-shot-group-form-notes", | ||||
|       id: "add-shot-record-form-notes", | ||||
|       class: "input input-primary col-span-2", | ||||
|       maxlength: 255, | ||||
|       placeholder: gettext("Really great weather"), | ||||
|       phx_hook: "MaintainAttrs", | ||||
|       phx_update: "ignore" | ||||
| @@ -1,240 +0,0 @@ | ||||
| defmodule CanneryWeb.Components.AmmoGroupTableComponent do | ||||
|   @moduledoc """ | ||||
|   A component that displays a list of ammo groups | ||||
|   """ | ||||
|   use CanneryWeb, :live_component | ||||
|   alias Cannery.{Accounts.User, Ammo.AmmoGroup, ComparableDate} | ||||
|   alias Cannery.{ActivityLog, Ammo, Containers} | ||||
|   alias Ecto.UUID | ||||
|   alias Phoenix.LiveView.{Rendered, Socket} | ||||
|  | ||||
|   @impl true | ||||
|   @spec update( | ||||
|           %{ | ||||
|             required(:id) => UUID.t(), | ||||
|             required(:current_user) => User.t(), | ||||
|             required(:ammo_groups) => [AmmoGroup.t()], | ||||
|             optional(:ammo_type) => Rendered.t(), | ||||
|             optional(:range) => Rendered.t(), | ||||
|             optional(:container) => Rendered.t(), | ||||
|             optional(:actions) => Rendered.t(), | ||||
|             optional(any()) => any() | ||||
|           }, | ||||
|           Socket.t() | ||||
|         ) :: {:ok, Socket.t()} | ||||
|   def update(%{id: _id, ammo_groups: _ammo_group, current_user: _current_user} = assigns, socket) do | ||||
|     socket = | ||||
|       socket | ||||
|       |> assign(assigns) | ||||
|       |> assign_new(:ammo_type, fn -> [] end) | ||||
|       |> assign_new(:range, fn -> [] end) | ||||
|       |> assign_new(:container, fn -> [] end) | ||||
|       |> assign_new(:actions, fn -> [] end) | ||||
|       |> display_ammo_groups() | ||||
|  | ||||
|     {:ok, socket} | ||||
|   end | ||||
|  | ||||
|   defp display_ammo_groups( | ||||
|          %{ | ||||
|            assigns: %{ | ||||
|              ammo_groups: ammo_groups, | ||||
|              current_user: current_user, | ||||
|              ammo_type: ammo_type, | ||||
|              range: range, | ||||
|              container: container, | ||||
|              actions: actions | ||||
|            } | ||||
|          } = socket | ||||
|        ) do | ||||
|     columns = | ||||
|       if actions == [] do | ||||
|         [] | ||||
|       else | ||||
|         [%{label: gettext("Actions"), key: :actions, sortable: false}] | ||||
|       end | ||||
|  | ||||
|     columns = [ | ||||
|       %{label: gettext("Purchased on"), key: :purchased_on, type: ComparableDate}, | ||||
|       %{label: gettext("Last used on"), key: :used_up_on, type: ComparableDate} | columns | ||||
|     ] | ||||
|  | ||||
|     columns = | ||||
|       if container == [] do | ||||
|         columns | ||||
|       else | ||||
|         [%{label: gettext("Container"), key: :container} | columns] | ||||
|       end | ||||
|  | ||||
|     columns = | ||||
|       if range == [] do | ||||
|         columns | ||||
|       else | ||||
|         [%{label: gettext("Range"), key: :range} | columns] | ||||
|       end | ||||
|  | ||||
|     columns = [ | ||||
|       %{label: gettext("Count"), key: :count}, | ||||
|       %{label: gettext("Original Count"), key: :original_count}, | ||||
|       %{label: gettext("Price paid"), key: :price_paid}, | ||||
|       %{label: gettext("CPR"), key: :cpr}, | ||||
|       %{label: gettext("% left"), key: :remaining}, | ||||
|       %{label: gettext("Notes"), key: :notes} | ||||
|       | columns | ||||
|     ] | ||||
|  | ||||
|     columns = | ||||
|       if ammo_type == [] do | ||||
|         columns | ||||
|       else | ||||
|         [%{label: gettext("Ammo type"), key: :ammo_type} | columns] | ||||
|       end | ||||
|  | ||||
|     extra_data = %{ | ||||
|       current_user: current_user, | ||||
|       ammo_type: ammo_type, | ||||
|       columns: columns, | ||||
|       container: container, | ||||
|       original_counts: Ammo.get_original_counts(ammo_groups, current_user), | ||||
|       cprs: Ammo.get_cprs(ammo_groups, current_user), | ||||
|       last_used_dates: ActivityLog.get_last_used_dates(ammo_groups, current_user), | ||||
|       actions: actions, | ||||
|       range: range | ||||
|     } | ||||
|  | ||||
|     rows = | ||||
|       ammo_groups | ||||
|       |> Enum.map(fn ammo_group -> | ||||
|         ammo_group |> get_row_data_for_ammo_group(extra_data) | ||||
|       end) | ||||
|  | ||||
|     socket |> assign(columns: columns, rows: rows) | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def render(assigns) do | ||||
|     ~H""" | ||||
|     <div id={@id} class="w-full"> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.Components.TableComponent} | ||||
|         id={"table-#{@id}"} | ||||
|         columns={@columns} | ||||
|         rows={@rows} | ||||
|       /> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   @spec get_row_data_for_ammo_group(AmmoGroup.t(), additional_data :: map()) :: map() | ||||
|   defp get_row_data_for_ammo_group(ammo_group, %{columns: columns} = additional_data) do | ||||
|     columns | ||||
|     |> Map.new(fn %{key: key} -> | ||||
|       {key, get_value_for_key(key, ammo_group, additional_data)} | ||||
|     end) | ||||
|   end | ||||
|  | ||||
|   @spec get_value_for_key(atom(), AmmoGroup.t(), additional_data :: map()) :: | ||||
|           any() | {any(), Rendered.t()} | ||||
|   defp get_value_for_key( | ||||
|          :ammo_type, | ||||
|          %{ammo_type: %{name: ammo_type_name} = ammo_type}, | ||||
|          %{ammo_type: ammo_type_block} | ||||
|        ) do | ||||
|     assigns = %{ammo_type: ammo_type, ammo_type_block: ammo_type_block} | ||||
|  | ||||
|     {ammo_type_name, | ||||
|      ~H""" | ||||
|      <%= render_slot(@ammo_type_block, @ammo_type) %> | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:price_paid, %{price_paid: nil}, _additional_data), do: {"", nil} | ||||
|  | ||||
|   defp get_value_for_key(:price_paid, %{price_paid: price_paid}, _additional_data), | ||||
|     do: gettext("$%{amount}", amount: display_currency(price_paid)) | ||||
|  | ||||
|   defp get_value_for_key(:purchased_on, %{purchased_on: purchased_on} = assigns, _additional_data) do | ||||
|     {purchased_on, | ||||
|      ~H""" | ||||
|      <.date id={"#{@id}-purchased-on"} date={@purchased_on} /> | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:used_up_on, %{id: ammo_group_id}, %{last_used_dates: last_used_dates}) do | ||||
|     last_used_date = last_used_dates |> Map.get(ammo_group_id) | ||||
|     assigns = %{id: ammo_group_id, last_used_date: last_used_date} | ||||
|  | ||||
|     {last_used_date, | ||||
|      ~H""" | ||||
|      <%= if @last_used_date do %> | ||||
|        <.date id={"#{@id}-last-used-date"} date={@last_used_date} /> | ||||
|      <% else %> | ||||
|        <%= gettext("Never used") %> | ||||
|      <% end %> | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:range, %{staged: staged} = ammo_group, %{range: range}) do | ||||
|     assigns = %{range: range, ammo_group: ammo_group} | ||||
|  | ||||
|     {staged, | ||||
|      ~H""" | ||||
|      <%= render_slot(@range, @ammo_group) %> | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:remaining, ammo_group, %{current_user: current_user}), | ||||
|     do: | ||||
|       gettext("%{percentage}%", | ||||
|         percentage: ammo_group |> Ammo.get_percentage_remaining(current_user) | ||||
|       ) | ||||
|  | ||||
|   defp get_value_for_key(:actions, ammo_group, %{actions: actions}) do | ||||
|     assigns = %{actions: actions, ammo_group: ammo_group} | ||||
|  | ||||
|     ~H""" | ||||
|     <%= render_slot(@actions, @ammo_group) %> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:container, %{container: nil}, _additional_data), do: {nil, nil} | ||||
|  | ||||
|   defp get_value_for_key( | ||||
|          :container, | ||||
|          %{container_id: container_id} = ammo_group, | ||||
|          %{container: container, current_user: current_user} | ||||
|        ) do | ||||
|     assigns = %{ | ||||
|       container: | ||||
|         %{name: container_name} = container_id |> Containers.get_container!(current_user), | ||||
|       container_block: container, | ||||
|       ammo_group: ammo_group | ||||
|     } | ||||
|  | ||||
|     {container_name, | ||||
|      ~H""" | ||||
|      <%= render_slot(@container_block, {@ammo_group, @container}) %> | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:original_count, %{id: ammo_group_id}, %{ | ||||
|          original_counts: original_counts | ||||
|        }) do | ||||
|     Map.fetch!(original_counts, ammo_group_id) | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:cpr, %{price_paid: nil}, _additional_data), | ||||
|     do: gettext("No cost information") | ||||
|  | ||||
|   defp get_value_for_key(:cpr, %{id: ammo_group_id}, %{cprs: cprs}) do | ||||
|     gettext("$%{amount}", amount: display_currency(Map.fetch!(cprs, ammo_group_id))) | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:count, %{count: count}, _additional_data), | ||||
|     do: if(count == 0, do: gettext("Empty"), else: count) | ||||
|  | ||||
|   defp get_value_for_key(key, ammo_group, _additional_data), do: ammo_group |> Map.get(key) | ||||
|  | ||||
|   @spec display_currency(float()) :: String.t() | ||||
|   defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2) | ||||
| end | ||||
| @@ -1,252 +0,0 @@ | ||||
| defmodule CanneryWeb.Components.AmmoTypeTableComponent do | ||||
|   @moduledoc """ | ||||
|   A component that displays a list of ammo type | ||||
|   """ | ||||
|   use CanneryWeb, :live_component | ||||
|   alias Cannery.{Accounts.User, ActivityLog, Ammo, Ammo.AmmoType} | ||||
|   alias Ecto.UUID | ||||
|   alias Phoenix.LiveView.{Rendered, Socket} | ||||
|  | ||||
|   @impl true | ||||
|   @spec update( | ||||
|           %{ | ||||
|             required(:id) => UUID.t(), | ||||
|             required(:current_user) => User.t(), | ||||
|             optional(:show_used) => boolean(), | ||||
|             optional(:ammo_types) => [AmmoType.t()], | ||||
|             optional(:actions) => Rendered.t(), | ||||
|             optional(any()) => any() | ||||
|           }, | ||||
|           Socket.t() | ||||
|         ) :: {:ok, Socket.t()} | ||||
|   def update(%{id: _id, ammo_types: _ammo_types, current_user: _current_user} = assigns, socket) do | ||||
|     socket = | ||||
|       socket | ||||
|       |> assign(assigns) | ||||
|       |> assign_new(:show_used, fn -> false end) | ||||
|       |> assign_new(:actions, fn -> [] end) | ||||
|       |> display_ammo_types() | ||||
|  | ||||
|     {:ok, socket} | ||||
|   end | ||||
|  | ||||
|   defp display_ammo_types( | ||||
|          %{ | ||||
|            assigns: %{ | ||||
|              ammo_types: ammo_types, | ||||
|              current_user: current_user, | ||||
|              show_used: show_used, | ||||
|              actions: actions | ||||
|            } | ||||
|          } = socket | ||||
|        ) do | ||||
|     columns = | ||||
|       [ | ||||
|         %{label: gettext("Name"), key: :name, type: :name}, | ||||
|         %{label: gettext("Bullet type"), key: :bullet_type, type: :string}, | ||||
|         %{label: gettext("Bullet core"), key: :bullet_core, type: :string}, | ||||
|         %{label: gettext("Cartridge"), key: :cartridge, type: :string}, | ||||
|         %{label: gettext("Caliber"), key: :caliber, type: :string}, | ||||
|         %{label: gettext("Case material"), key: :case_material, type: :string}, | ||||
|         %{label: gettext("Jacket type"), key: :jacket_type, type: :string}, | ||||
|         %{label: gettext("Muzzle velocity"), key: :muzzle_velocity, type: :string}, | ||||
|         %{label: gettext("Powder type"), key: :powder_type, type: :string}, | ||||
|         %{ | ||||
|           label: gettext("Powder grains per charge"), | ||||
|           key: :powder_grains_per_charge, | ||||
|           type: :string | ||||
|         }, | ||||
|         %{label: gettext("Grains"), key: :grains, type: :string}, | ||||
|         %{label: gettext("Pressure"), key: :pressure, type: :string}, | ||||
|         %{label: gettext("Primer type"), key: :primer_type, type: :string}, | ||||
|         %{label: gettext("Firing type"), key: :firing_type, type: :string}, | ||||
|         %{label: gettext("Tracer"), key: :tracer, type: :boolean}, | ||||
|         %{label: gettext("Incendiary"), key: :incendiary, type: :boolean}, | ||||
|         %{label: gettext("Blank"), key: :blank, type: :boolean}, | ||||
|         %{label: gettext("Corrosive"), key: :corrosive, type: :boolean}, | ||||
|         %{label: gettext("Manufacturer"), key: :manufacturer, type: :string}, | ||||
|         %{label: gettext("UPC"), key: "upc", type: :string} | ||||
|       ] | ||||
|       |> Enum.filter(fn %{key: key, type: type} -> | ||||
|         # remove columns if all values match defaults | ||||
|         default_value = if type == :boolean, do: false, else: nil | ||||
|  | ||||
|         ammo_types | ||||
|         |> Enum.any?(fn ammo_type -> | ||||
|           not (ammo_type |> Map.get(key) == default_value) | ||||
|         end) | ||||
|       end) | ||||
|       |> Kernel.++([ | ||||
|         %{label: gettext("Rounds"), key: :round_count, type: :round_count} | ||||
|       ]) | ||||
|       |> Kernel.++( | ||||
|         if show_used do | ||||
|           [ | ||||
|             %{ | ||||
|               label: gettext("Used rounds"), | ||||
|               key: :used_round_count, | ||||
|               type: :used_round_count | ||||
|             }, | ||||
|             %{ | ||||
|               label: gettext("Total ever rounds"), | ||||
|               key: :historical_round_count, | ||||
|               type: :historical_round_count | ||||
|             } | ||||
|           ] | ||||
|         else | ||||
|           [] | ||||
|         end | ||||
|       ) | ||||
|       |> Kernel.++([%{label: gettext("Packs"), key: :ammo_count, type: :ammo_count}]) | ||||
|       |> Kernel.++( | ||||
|         if show_used do | ||||
|           [ | ||||
|             %{ | ||||
|               label: gettext("Used packs"), | ||||
|               key: :used_packs_count, | ||||
|               type: :used_packs_count | ||||
|             }, | ||||
|             %{ | ||||
|               label: gettext("Total ever packs"), | ||||
|               key: :historical_packs_count, | ||||
|               type: :historical_packs_count | ||||
|             } | ||||
|           ] | ||||
|         else | ||||
|           [] | ||||
|         end | ||||
|       ) | ||||
|       |> Kernel.++([ | ||||
|         %{label: gettext("Average CPR"), key: :avg_price_paid, type: :avg_price_paid}, | ||||
|         %{label: gettext("Actions"), key: "actions", type: :actions, sortable: false} | ||||
|       ]) | ||||
|  | ||||
|     round_counts = ammo_types |> Ammo.get_round_count_for_ammo_types(current_user) | ||||
|  | ||||
|     used_counts = | ||||
|       show_used && ammo_types |> ActivityLog.get_used_count_for_ammo_types(current_user) | ||||
|  | ||||
|     historical_round_counts = | ||||
|       show_used && ammo_types |> Ammo.get_historical_count_for_ammo_types(current_user) | ||||
|  | ||||
|     packs_count = ammo_types |> Ammo.get_ammo_groups_count_for_types(current_user) | ||||
|  | ||||
|     historical_packs_count = | ||||
|       show_used && ammo_types |> Ammo.get_ammo_groups_count_for_types(current_user, true) | ||||
|  | ||||
|     used_packs_count = | ||||
|       show_used && ammo_types |> Ammo.get_used_ammo_groups_count_for_types(current_user) | ||||
|  | ||||
|     average_costs = ammo_types |> Ammo.get_average_cost_for_ammo_types(current_user) | ||||
|  | ||||
|     extra_data = %{ | ||||
|       actions: actions, | ||||
|       current_user: current_user, | ||||
|       used_counts: used_counts, | ||||
|       round_counts: round_counts, | ||||
|       historical_round_counts: historical_round_counts, | ||||
|       packs_count: packs_count, | ||||
|       used_packs_count: used_packs_count, | ||||
|       historical_packs_count: historical_packs_count, | ||||
|       average_costs: average_costs | ||||
|     } | ||||
|  | ||||
|     rows = | ||||
|       ammo_types | ||||
|       |> Enum.map(fn ammo_type -> | ||||
|         ammo_type |> get_ammo_type_values(columns, extra_data) | ||||
|       end) | ||||
|  | ||||
|     socket |> assign(columns: columns, rows: rows) | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def render(assigns) do | ||||
|     ~H""" | ||||
|     <div id={@id} class="w-full"> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.Components.TableComponent} | ||||
|         id={"table-#{@id}"} | ||||
|         columns={@columns} | ||||
|         rows={@rows} | ||||
|       /> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   defp get_ammo_type_values(ammo_type, columns, extra_data) do | ||||
|     columns | ||||
|     |> Map.new(fn %{key: key, type: type} -> | ||||
|       {key, get_ammo_type_value(type, key, ammo_type, extra_data)} | ||||
|     end) | ||||
|   end | ||||
|  | ||||
|   defp get_ammo_type_value(:boolean, key, ammo_type, _other_data), | ||||
|     do: ammo_type |> Map.get(key) |> humanize() | ||||
|  | ||||
|   defp get_ammo_type_value(:round_count, _key, %{id: ammo_type_id}, %{round_counts: round_counts}), | ||||
|     do: Map.get(round_counts, ammo_type_id) | ||||
|  | ||||
|   defp get_ammo_type_value( | ||||
|          :historical_round_count, | ||||
|          _key, | ||||
|          %{id: ammo_type_id}, | ||||
|          %{historical_round_counts: historical_round_counts} | ||||
|        ), | ||||
|        do: Map.get(historical_round_counts, ammo_type_id) | ||||
|  | ||||
|   defp get_ammo_type_value(:used_round_count, _key, %{id: ammo_type_id}, %{ | ||||
|          used_counts: used_counts | ||||
|        }), | ||||
|        do: Map.get(used_counts, ammo_type_id) | ||||
|  | ||||
|   defp get_ammo_type_value( | ||||
|          :historical_packs_count, | ||||
|          _key, | ||||
|          %{id: ammo_type_id}, | ||||
|          %{historical_packs_count: historical_packs_count} | ||||
|        ), | ||||
|        do: Map.get(historical_packs_count, ammo_type_id) | ||||
|  | ||||
|   defp get_ammo_type_value(:used_packs_count, _key, %{id: ammo_type_id}, %{ | ||||
|          used_packs_count: used_packs_count | ||||
|        }), | ||||
|        do: Map.get(used_packs_count, ammo_type_id) | ||||
|  | ||||
|   defp get_ammo_type_value(:ammo_count, _key, %{id: ammo_type_id}, %{packs_count: packs_count}), | ||||
|     do: Map.get(packs_count, ammo_type_id) | ||||
|  | ||||
|   defp get_ammo_type_value(:avg_price_paid, _key, %{id: ammo_type_id}, %{ | ||||
|          average_costs: average_costs | ||||
|        }) do | ||||
|     case Map.get(average_costs, ammo_type_id) do | ||||
|       nil -> gettext("No cost information") | ||||
|       count -> gettext("$%{amount}", amount: display_currency(count)) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   defp get_ammo_type_value(:name, _key, ammo_type, _other_data) do | ||||
|     assigns = %{ammo_type: ammo_type} | ||||
|  | ||||
|     ~H""" | ||||
|     <.link navigate={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)} class="link"> | ||||
|       <%= @ammo_type.name %> | ||||
|     </.link> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   defp get_ammo_type_value(:actions, _key, ammo_type, %{actions: actions}) do | ||||
|     assigns = %{actions: actions, ammo_type: ammo_type} | ||||
|  | ||||
|     ~H""" | ||||
|     <%= render_slot(@actions, @ammo_type) %> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   defp get_ammo_type_value(nil, _key, _ammo_type, _other_data), do: nil | ||||
|  | ||||
|   defp get_ammo_type_value(_other, key, ammo_type, _other_data), do: ammo_type |> Map.get(key) | ||||
|  | ||||
|   @spec display_currency(float()) :: String.t() | ||||
|   defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2) | ||||
| end | ||||
| @@ -71,7 +71,7 @@ defmodule CanneryWeb.Components.ContainerTableComponent do | ||||
|       current_user: current_user, | ||||
|       tag_actions: tag_actions, | ||||
|       actions: actions, | ||||
|       pack_count: Ammo.get_ammo_groups_count_for_containers(containers, current_user), | ||||
|       pack_count: Ammo.get_packs_count_for_containers(containers, current_user), | ||||
|       round_count: Ammo.get_round_count_for_containers(containers, current_user) | ||||
|     } | ||||
|  | ||||
| @@ -109,14 +109,12 @@ defmodule CanneryWeb.Components.ContainerTableComponent do | ||||
|   end | ||||
|  | ||||
|   @spec get_value_for_key(atom(), Container.t(), extra_data :: map) :: any() | ||||
|   defp get_value_for_key(:name, %{id: id, name: container_name}, _extra_data) do | ||||
|     assigns = %{id: id, container_name: container_name} | ||||
|  | ||||
|   defp get_value_for_key(:name, %{name: container_name} = assigns, _extra_data) do | ||||
|     {container_name, | ||||
|      ~H""" | ||||
|      <div class="flex flex-wrap justify-center items-center"> | ||||
|        <.link navigate={Routes.container_show_path(Endpoint, :show, @id)} class="link"> | ||||
|          <%= @container_name %> | ||||
|        <.link navigate={~p"/container/#{@id}"} class="link"> | ||||
|          <%= @name %> | ||||
|        </.link> | ||||
|      </div> | ||||
|      """} | ||||
|   | ||||
| @@ -3,11 +3,11 @@ defmodule CanneryWeb.CoreComponents do | ||||
|   Provides core UI components. | ||||
|   """ | ||||
|   use Phoenix.Component | ||||
|   import CanneryWeb.{Gettext, ViewHelpers} | ||||
|   use CanneryWeb, :verified_routes | ||||
|   import CanneryWeb.{Gettext, HTMLHelpers} | ||||
|   alias Cannery.{Accounts, Accounts.Invite, Accounts.User} | ||||
|   alias Cannery.{Ammo, Ammo.AmmoGroup} | ||||
|   alias Cannery.{Containers, Containers.Container, Containers.Tag} | ||||
|   alias CanneryWeb.{Endpoint, HomeLive} | ||||
|   alias Cannery.{Ammo, Ammo.Pack} | ||||
|   alias Cannery.{Containers.Container, Containers.Tag} | ||||
|   alias CanneryWeb.Router.Helpers, as: Routes | ||||
|   alias Phoenix.LiveView.{JS, Rendered} | ||||
|  | ||||
| @@ -29,13 +29,13 @@ defmodule CanneryWeb.CoreComponents do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       <.modal return_to={Routes.<%= schema.singular %>_index_path(Endpoint, :index)}> | ||||
|       <.modal return_to={~p"/\#{<%= schema.plural %>}"}> | ||||
|         <.live_component | ||||
|           module={<%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.FormComponent} | ||||
|           id={@<%= schema.singular %>.id || :new} | ||||
|           title={@page_title} | ||||
|           action={@live_action} | ||||
|           return_to={Routes.<%= schema.singular %>_index_path(Endpoint, :index)} | ||||
|           return_to={~p"/\#{<%= schema.singular %>}"} | ||||
|           <%= schema.singular %>: @<%= schema.singular %> | ||||
|         /> | ||||
|       </.modal> | ||||
| @@ -86,15 +86,15 @@ defmodule CanneryWeb.CoreComponents do | ||||
|  | ||||
|   def simple_tag_card(assigns) | ||||
|  | ||||
|   attr :ammo_group, AmmoGroup, required: true | ||||
|   attr :pack, Pack, required: true | ||||
|   attr :current_user, User, required: true | ||||
|   attr :original_count, :integer, default: nil | ||||
|   attr :cpr, :integer, default: nil | ||||
|   attr :last_used_date, Date, default: nil | ||||
|   attr :show_container, :boolean, default: false | ||||
|   attr :container, Container, default: nil | ||||
|   slot(:inner_block) | ||||
|  | ||||
|   def ammo_group_card(assigns) | ||||
|   def pack_card(assigns) | ||||
|  | ||||
|   @spec display_currency(float()) :: String.t() | ||||
|   defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2) | ||||
|   | ||||
| @@ -1,71 +0,0 @@ | ||||
| <div | ||||
|   id={"ammo_group-#{@ammo_group.id}"} | ||||
|   class="mx-4 my-2 px-8 py-4 | ||||
|     flex flex-col justify-center items-center | ||||
|     border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
|     transition-all duration-300 ease-in-out" | ||||
| > | ||||
|   <.link navigate={Routes.ammo_group_show_path(Endpoint, :show, @ammo_group)} class="mb-2 link"> | ||||
|     <h1 class="title text-xl title-primary-500"> | ||||
|       <%= @ammo_group.ammo_type.name %> | ||||
|     </h1> | ||||
|   </.link> | ||||
|  | ||||
|   <div class="flex flex-col justify-center items-center"> | ||||
|     <span class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Count:") %> | ||||
|       <%= if @ammo_group.count == 0, do: gettext("Empty"), else: @ammo_group.count %> | ||||
|     </span> | ||||
|  | ||||
|     <span | ||||
|       :if={@original_count && @original_count != @ammo_group.count} | ||||
|       class="rounded-lg title text-lg" | ||||
|     > | ||||
|       <%= gettext("Original Count:") %> | ||||
|       <%= @original_count %> | ||||
|     </span> | ||||
|  | ||||
|     <span :if={@ammo_group.notes} class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Notes:") %> | ||||
|       <%= @ammo_group.notes %> | ||||
|     </span> | ||||
|  | ||||
|     <span :if={@ammo_group.purchased_on} class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Purchased on:") %> | ||||
|       <.date id={"#{@ammo_group.id}-purchased-on"} date={@ammo_group.purchased_on} /> | ||||
|     </span> | ||||
|  | ||||
|     <span :if={@last_used_date} class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Last used on:") %> | ||||
|       <.date id={"#{@ammo_group.id}-last-used-on"} date={@last_used_date} /> | ||||
|     </span> | ||||
|  | ||||
|     <span :if={@ammo_group.price_paid} class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Price paid:") %> | ||||
|       <%= gettext("$%{amount}", amount: display_currency(@ammo_group.price_paid)) %> | ||||
|     </span> | ||||
|  | ||||
|     <span :if={@cpr} class="rounded-lg title text-lg"> | ||||
|       <%= gettext("CPR:") %> | ||||
|       <%= gettext("$%{amount}", amount: display_currency(@cpr)) %> | ||||
|     </span> | ||||
|  | ||||
|     <span | ||||
|       :if={@show_container && Containers.get_container!(@ammo_group.container_id, @current_user)} | ||||
|       class="rounded-lg title text-lg" | ||||
|     > | ||||
|       <%= gettext("Container:") %> | ||||
|  | ||||
|       <.link | ||||
|         navigate={Routes.container_show_path(Endpoint, :show, @ammo_group.container_id)} | ||||
|         class="link" | ||||
|       > | ||||
|         <%= Containers.get_container!(@ammo_group.container_id, @current_user).name %> | ||||
|       </.link> | ||||
|     </span> | ||||
|   </div> | ||||
|  | ||||
|   <div :if={@inner_block} class="mt-4 flex space-x-4 justify-center items-center"> | ||||
|     <%= render_slot(@inner_block) %> | ||||
|   </div> | ||||
| </div> | ||||
| @@ -5,7 +5,7 @@ | ||||
|     border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
|     transition-all duration-300 ease-in-out" | ||||
| > | ||||
|   <.link navigate={Routes.container_show_path(Endpoint, :show, @container)} class="link"> | ||||
|   <.link navigate={~p"/container/#{@container}"} class="link"> | ||||
|     <h1 class="px-4 py-2 rounded-lg title text-xl"> | ||||
|       <%= @container.name %> | ||||
|     </h1> | ||||
| @@ -27,10 +27,10 @@ | ||||
|       <%= @container.location %> | ||||
|     </span> | ||||
|  | ||||
|     <%= if @container |> Ammo.get_ammo_groups_count_for_container!(@current_user) != 0 do %> | ||||
|     <%= if @container |> Ammo.get_packs_count_for_container!(@current_user) != 0 do %> | ||||
|       <span class="rounded-lg title text-lg"> | ||||
|         <%= gettext("Packs:") %> | ||||
|         <%= @container |> Ammo.get_ammo_groups_count_for_container!(@current_user) %> | ||||
|         <%= @container |> Ammo.get_packs_count_for_container!(@current_user) %> | ||||
|       </span> | ||||
|  | ||||
|       <span class="rounded-lg title text-lg"> | ||||
|   | ||||
| @@ -23,7 +23,7 @@ | ||||
|   <% end %> | ||||
|  | ||||
|   <.qr_code | ||||
|     content={Routes.user_registration_url(Endpoint, :new, invite: @invite.token)} | ||||
|     content={url(CanneryWeb.Endpoint, ~p"/users/register?invite=#{@invite.token}")} | ||||
|     filename={@invite.name} | ||||
|   /> | ||||
|  | ||||
| @@ -36,7 +36,7 @@ | ||||
|       id={"code-#{@invite.id}"} | ||||
|       class="mx-2 my-1 text-xs px-4 py-2 rounded-lg text-center break-all text-gray-100 bg-primary-800" | ||||
|       phx-no-format | ||||
|     ><%= Routes.user_registration_url(Endpoint, :new, invite: @invite.token) %></code> | ||||
|     ><%= url(CanneryWeb.Endpoint, ~p"/users/register?invite=#{@invite.token}") %></code> | ||||
|     <%= if @code_actions, do: render_slot(@code_actions) %> | ||||
|   </div> | ||||
|  | ||||
|   | ||||
| @@ -6,6 +6,7 @@ | ||||
|     p-8 flex flex-col justify-center items-center cursor-auto" | ||||
|   style="background-color: rgba(0,0,0,0.4);" | ||||
|   phx-remove={hide_modal()} | ||||
|   aria-label={gettext("Close modal")} | ||||
| > | ||||
|   <span class="hidden"></span> | ||||
| </.link> | ||||
| @@ -31,6 +32,7 @@ | ||||
|         text-gray-500 hover:text-gray-800 | ||||
|         transition-all duration-500 ease-in-out" | ||||
|       phx-remove={hide_modal()} | ||||
|       aria-label={gettext("Close modal")} | ||||
|     > | ||||
|       <i class="fa-fw fa-lg fas fa-times"></i> | ||||
|     </.link> | ||||
|   | ||||
| @@ -0,0 +1,67 @@ | ||||
| <div | ||||
|   id={"pack-#{@pack.id}"} | ||||
|   class="mx-4 my-2 px-8 py-4 | ||||
|     flex flex-col justify-center items-center | ||||
|     border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
|     transition-all duration-300 ease-in-out" | ||||
| > | ||||
|   <.link navigate={~p"/ammo/show/#{@pack}"} class="mb-2 link"> | ||||
|     <h1 class="title text-xl title-primary-500"> | ||||
|       <%= @pack.type.name %> | ||||
|     </h1> | ||||
|   </.link> | ||||
|  | ||||
|   <div class="flex flex-col justify-center items-center"> | ||||
|     <span class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Count:") %> | ||||
|       <%= if @pack.count == 0, do: gettext("Empty"), else: @pack.count %> | ||||
|     </span> | ||||
|  | ||||
|     <span :if={@original_count && @original_count != @pack.count} class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Original Count:") %> | ||||
|       <%= @original_count %> | ||||
|     </span> | ||||
|  | ||||
|     <span :if={@pack.notes} class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Notes:") %> | ||||
|       <%= @pack.notes %> | ||||
|     </span> | ||||
|  | ||||
|     <span :if={@pack.purchased_on} class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Purchased on:") %> | ||||
|       <.date id={"#{@pack.id}-purchased-on"} date={@pack.purchased_on} /> | ||||
|     </span> | ||||
|  | ||||
|     <span :if={@last_used_date} class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Last used on:") %> | ||||
|       <.date id={"#{@pack.id}-last-used-on"} date={@last_used_date} /> | ||||
|     </span> | ||||
|  | ||||
|     <span :if={@pack.price_paid} class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Price paid:") %> | ||||
|       <%= gettext("$%{amount}", amount: display_currency(@pack.price_paid)) %> | ||||
|     </span> | ||||
|  | ||||
|     <span :if={@cpr} class="rounded-lg title text-lg"> | ||||
|       <%= gettext("CPR:") %> | ||||
|       <%= gettext("$%{amount}", amount: display_currency(@cpr)) %> | ||||
|     </span> | ||||
|  | ||||
|     <span :if={@pack.lot_number} class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Lot number:") %> | ||||
|       <%= @pack.lot_number %> | ||||
|     </span> | ||||
|  | ||||
|     <span :if={@container} class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Container:") %> | ||||
|  | ||||
|       <.link navigate={~p"/container/#{@container}"} class="link"> | ||||
|         <%= @container.name %> | ||||
|       </.link> | ||||
|     </span> | ||||
|   </div> | ||||
|  | ||||
|   <div :if={@inner_block} class="mt-4 flex space-x-4 justify-center items-center"> | ||||
|     <%= render_slot(@inner_block) %> | ||||
|   </div> | ||||
| </div> | ||||
| @@ -1,4 +1,4 @@ | ||||
| <label for={@id || @action} class="inline-flex relative items-center cursor-pointer"> | ||||
| <label for={@id || @action} class="relative inline-flex items-center cursor-pointer"> | ||||
|   <input | ||||
|     id={@id || @action} | ||||
|     type="checkbox" | ||||
| @@ -23,7 +23,7 @@ | ||||
|   </div> | ||||
|   <span | ||||
|     id={"#{@id || @action}-label"} | ||||
|     class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300" | ||||
|     class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300 whitespace-nowrap" | ||||
|   > | ||||
|     <%= render_slot(@inner_block) %> | ||||
|   </span> | ||||
|   | ||||
| @@ -1,12 +1,9 @@ | ||||
| <nav role="navigation" class="mb-8 px-8 py-4 w-full bg-primary-500"> | ||||
|   <div class="flex flex-col sm:flex-row justify-between items-center"> | ||||
|     <div class="mb-4 sm:mb-0 sm:mr-8 flex flex-row justify-start items-center space-x-2"> | ||||
|       <.link | ||||
|         navigate={Routes.live_path(Endpoint, HomeLive)} | ||||
|         class="inline mx-2 my-1 leading-5 text-xl text-white" | ||||
|       > | ||||
|       <.link navigate={~p"/"} class="inline mx-2 my-1 leading-5 text-xl text-white"> | ||||
|         <img | ||||
|           src={Routes.static_path(Endpoint, "/images/cannery.svg")} | ||||
|           src={~p"/images/cannery.svg"} | ||||
|           alt={gettext("Cannery logo")} | ||||
|           class="inline-block h-8 mx-1" | ||||
|         /> | ||||
| @@ -27,64 +24,43 @@ | ||||
|       text-lg text-white text-ellipsis"> | ||||
|       <%= if @current_user do %> | ||||
|         <li class="mx-2 my-1"> | ||||
|           <.link | ||||
|             navigate={Routes.tag_index_path(Endpoint, :index)} | ||||
|             class="text-white hover:underline" | ||||
|           > | ||||
|           <.link navigate={~p"/tags"} class="text-white hover:underline"> | ||||
|             <%= gettext("Tags") %> | ||||
|           </.link> | ||||
|         </li> | ||||
|         <li class="mx-2 my-1"> | ||||
|           <.link | ||||
|             navigate={Routes.container_index_path(Endpoint, :index)} | ||||
|             class="text-white hover:underline" | ||||
|           > | ||||
|           <.link navigate={~p"/containers"} class="text-white hover:underline"> | ||||
|             <%= gettext("Containers") %> | ||||
|           </.link> | ||||
|         </li> | ||||
|         <li class="mx-2 my-1"> | ||||
|           <.link | ||||
|             navigate={Routes.ammo_type_index_path(Endpoint, :index)} | ||||
|             class="text-white hover:underline" | ||||
|           > | ||||
|           <.link navigate={~p"/catalog"} class="text-white hover:underline"> | ||||
|             <%= gettext("Catalog") %> | ||||
|           </.link> | ||||
|         </li> | ||||
|         <li class="mx-2 my-1"> | ||||
|           <.link | ||||
|             navigate={Routes.ammo_group_index_path(Endpoint, :index)} | ||||
|             class="text-white hover:underline" | ||||
|           > | ||||
|           <.link navigate={~p"/ammo"} class="text-white hover:underline"> | ||||
|             <%= gettext("Ammo") %> | ||||
|           </.link> | ||||
|         </li> | ||||
|         <li class="mx-2 my-1"> | ||||
|           <.link | ||||
|             navigate={Routes.range_index_path(Endpoint, :index)} | ||||
|             class="text-white hover:underline" | ||||
|           > | ||||
|           <.link navigate={~p"/range"} class="text-white hover:underline"> | ||||
|             <%= gettext("Range") %> | ||||
|           </.link> | ||||
|         </li> | ||||
|         <li :if={@current_user |> Accounts.is_already_admin?()} class="mx-2 my-1"> | ||||
|           <.link | ||||
|             navigate={Routes.invite_index_path(Endpoint, :index)} | ||||
|             class="text-white hover:underline" | ||||
|           > | ||||
|           <.link navigate={~p"/invites"} class="text-white hover:underline"> | ||||
|             <%= gettext("Invites") %> | ||||
|           </.link> | ||||
|         </li> | ||||
|         <li class="mx-2 my-1"> | ||||
|           <.link | ||||
|             href={Routes.user_settings_path(Endpoint, :edit)} | ||||
|             class="text-white hover:underline truncate" | ||||
|           > | ||||
|           <.link href={~p"/users/settings"} class="text-white hover:underline truncate"> | ||||
|             <%= @current_user.email %> | ||||
|           </.link> | ||||
|         </li> | ||||
|         <li class="mx-2 my-1"> | ||||
|           <.link | ||||
|             href={Routes.user_session_path(Endpoint, :delete)} | ||||
|             href={~p"/users/log_out"} | ||||
|             method="delete" | ||||
|             data-confirm={dgettext("prompts", "Are you sure you want to log out?")} | ||||
|             aria-label={gettext("Log out")} | ||||
| @@ -100,7 +76,7 @@ | ||||
|           class="mx-2 my-1" | ||||
|         > | ||||
|           <.link | ||||
|             navigate={Routes.live_dashboard_path(Endpoint, :home)} | ||||
|             navigate={~p"/dashboard"} | ||||
|             class="text-white hover:underline" | ||||
|             aria-label={gettext("Live Dashboard")} | ||||
|           > | ||||
| @@ -109,18 +85,12 @@ | ||||
|         </li> | ||||
|       <% else %> | ||||
|         <li :if={Accounts.allow_registration?()} class="mx-2 my-1"> | ||||
|           <.link | ||||
|             href={Routes.user_registration_path(Endpoint, :new)} | ||||
|             class="text-white hover:underline truncate" | ||||
|           > | ||||
|           <.link href={~p"/users/register"} class="text-white hover:underline truncate"> | ||||
|             <%= dgettext("actions", "Register") %> | ||||
|           </.link> | ||||
|         </li> | ||||
|         <li class="mx-2 my-1"> | ||||
|           <.link | ||||
|             href={Routes.user_session_path(Endpoint, :new)} | ||||
|             class="text-white hover:underline truncate" | ||||
|           > | ||||
|           <.link href={~p"/users/log_in"} class="text-white hover:underline truncate"> | ||||
|             <%= dgettext("actions", "Log in") %> | ||||
|           </.link> | ||||
|         </li> | ||||
|   | ||||
							
								
								
									
										10
									
								
								lib/cannery_web/components/email_html.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								lib/cannery_web/components/email_html.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| defmodule CanneryWeb.EmailHTML do | ||||
|   @moduledoc """ | ||||
|   Renders email templates | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :html | ||||
|  | ||||
|   embed_templates "email_html/*.html", suffix: "_html" | ||||
|   embed_templates "email_html/*.txt", suffix: "_text" | ||||
| end | ||||
| @@ -9,4 +9,4 @@ | ||||
| 
 | ||||
| <%= dgettext("emails", | ||||
|   "If you didn't create an account at %{url}, please ignore this.", | ||||
|   url: Routes.live_url(Endpoint, HomeLive)) %> | ||||
|   url: ~p"/") %> | ||||
| @@ -7,4 +7,4 @@ | ||||
| 
 | ||||
| <%= dgettext("emails", | ||||
|   "If you didn't request this change from %{url}, please ignore this.", | ||||
|   url: Routes.live_url(Endpoint, HomeLive)) %> | ||||
|   url: ~p"/") %> | ||||
| @@ -7,4 +7,4 @@ | ||||
| 
 | ||||
| <%= dgettext("emails", | ||||
|   "If you didn't request this change from %{url}, please ignore this.", | ||||
|   url: Routes.live_url(Endpoint, HomeLive)) %> | ||||
|   url: ~p"/") %> | ||||
							
								
								
									
										17
									
								
								lib/cannery_web/components/layouts.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								lib/cannery_web/components/layouts.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| defmodule CanneryWeb.Layouts do | ||||
|   @moduledoc """ | ||||
|   The root layouts for the entire application | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :html | ||||
|  | ||||
|   embed_templates "layouts/*" | ||||
|  | ||||
|   def get_title(%{assigns: %{title: title}}) when title not in [nil, ""] do | ||||
|     gettext("Cannery | %{title}", title: title) | ||||
|   end | ||||
|  | ||||
|   def get_title(_conn) do | ||||
|     gettext("Cannery") | ||||
|   end | ||||
| end | ||||
| @@ -3,11 +3,11 @@ | ||||
|     <.topbar current_user={assigns[:current_user]} /> | ||||
| 
 | ||||
|     <div class="mx-8 my-2 flex flex-col space-y-4 text-center"> | ||||
|       <p :if={get_flash(@conn, :info)} class="alert alert-info" role="alert"> | ||||
|         <%= get_flash(@conn, :info) %> | ||||
|       <p :if={@flash["info"]} class="alert alert-info" role="alert"> | ||||
|         <%= @flash["info"] %> | ||||
|       </p> | ||||
|       <p :if={get_flash(@conn, :error)} class="alert alert-danger" role="alert"> | ||||
|         <%= get_flash(@conn, :error) %> | ||||
|       <p :if={@flash["error"]} class="alert alert-danger" role="alert"> | ||||
|         <%= @flash["error"] %> | ||||
|       </p> | ||||
|     </div> | ||||
|   </header> | ||||
| @@ -9,7 +9,7 @@ | ||||
| 
 | ||||
|     <hr style="margin: 2em auto; border-width: 1px; border-color: rgb(212, 212, 216); width: 100%; max-width: 42rem;" /> | ||||
| 
 | ||||
|     <a style="color: rgb(31, 31, 31);" href={Routes.live_url(Endpoint, HomeLive)}> | ||||
|     <a style="color: rgb(31, 31, 31);" href={~p"/"}> | ||||
|       <%= dgettext( | ||||
|         "emails", | ||||
|         "This email was sent from Cannery, the self-hosted firearm tracker website." | ||||
| @@ -8,4 +8,4 @@ | ||||
| 
 | ||||
| <%= dgettext("emails", | ||||
|   "This email was sent from Cannery at %{url}, the self-hosted firearm tracker website.", | ||||
|   url: Routes.live_url(Endpoint, HomeLive)) %> | ||||
|   url: ~p"/") %> | ||||
| @@ -5,21 +5,12 @@ | ||||
|     <meta http-equiv="X-UA-Compatible" content="IE=edge" /> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||||
|     <%= csrf_meta_tag() %> | ||||
|     <link | ||||
|       rel="shortcut icon" | ||||
|       type="image/jpg" | ||||
|       href={Routes.static_path(@conn, "/images/cannery.svg")} | ||||
|     /> | ||||
|     <link rel="shortcut icon" type="image/jpg" href={~p"/images/cannery.svg"} /> | ||||
|     <.live_title suffix={" | #{gettext("Cannery")}"}> | ||||
|       <%= assigns[:page_title] || gettext("Cannery") %> | ||||
|     </.live_title> | ||||
|     <link phx-track-static rel="stylesheet" href={Routes.static_path(@conn, "/css/app.css")} /> | ||||
|     <script | ||||
|       defer | ||||
|       phx-track-static | ||||
|       type="text/javascript" | ||||
|       src={Routes.static_path(@conn, "/js/app.js")} | ||||
|     > | ||||
|     <link phx-track-static rel="stylesheet" href={~p"/css/app.css"} /> | ||||
|     <script defer phx-track-static type="text/javascript" src={~p"/js/app.js"}> | ||||
|     </script> | ||||
|   </head> | ||||
| 
 | ||||
| @@ -1,11 +1,10 @@ | ||||
| defmodule CanneryWeb.Components.MoveAmmoGroupComponent do | ||||
| defmodule CanneryWeb.Components.MovePackComponent do | ||||
|   @moduledoc """ | ||||
|   Livecomponent that can move an ammo group to another container | ||||
|   Livecomponent that can move a pack to another container | ||||
|   """ | ||||
| 
 | ||||
|   use CanneryWeb, :live_component | ||||
|   alias Cannery.{Accounts.User, Ammo, Ammo.AmmoGroup, Containers, Containers.Container} | ||||
|   alias CanneryWeb.Endpoint | ||||
|   alias Cannery.{Accounts.User, Ammo, Ammo.Pack, Containers, Containers.Container} | ||||
|   alias Ecto.Changeset | ||||
|   alias Phoenix.LiveView.Socket | ||||
| 
 | ||||
| @@ -13,17 +12,16 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do | ||||
|   @spec update( | ||||
|           %{ | ||||
|             required(:current_user) => User.t(), | ||||
|             required(:ammo_group) => AmmoGroup.t(), | ||||
|             required(:pack) => Pack.t(), | ||||
|             optional(any()) => any() | ||||
|           }, | ||||
|           Socket.t() | ||||
|         ) :: {:ok, Socket.t()} | ||||
|   def update( | ||||
|         %{ammo_group: %{container_id: container_id} = ammo_group, current_user: current_user} = | ||||
|           assigns, | ||||
|         %{pack: %{container_id: container_id} = pack, current_user: current_user} = assigns, | ||||
|         socket | ||||
|       ) do | ||||
|     changeset = ammo_group |> AmmoGroup.update_changeset(%{}, current_user) | ||||
|     changeset = pack |> Pack.update_changeset(%{}, current_user) | ||||
| 
 | ||||
|     containers = | ||||
|       Containers.list_containers(current_user) | ||||
| @@ -41,16 +39,15 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do | ||||
|   def handle_event( | ||||
|         "move", | ||||
|         %{"container_id" => container_id}, | ||||
|         %{assigns: %{ammo_group: ammo_group, current_user: current_user, return_to: return_to}} = | ||||
|           socket | ||||
|         %{assigns: %{pack: pack, current_user: current_user, return_to: return_to}} = socket | ||||
|       ) do | ||||
|     %{name: container_name} = Containers.get_container!(container_id, current_user) | ||||
| 
 | ||||
|     socket = | ||||
|       ammo_group | ||||
|       |> Ammo.update_ammo_group(%{"container_id" => container_id}, current_user) | ||||
|       pack | ||||
|       |> Ammo.update_pack(%{"container_id" => container_id}, current_user) | ||||
|       |> case do | ||||
|         {:ok, _ammo_group} -> | ||||
|         {:ok, _pack} -> | ||||
|           prompt = dgettext("prompts", "Ammo moved to %{name} successfully", name: container_name) | ||||
|           socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) | ||||
| 
 | ||||
| @@ -86,13 +83,13 @@ defmodule CanneryWeb.Components.MoveAmmoGroupComponent do | ||||
|           <%= display_emoji("😔") %> | ||||
|         </h2> | ||||
| 
 | ||||
|         <.link navigate={Routes.container_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|         <.link navigate={~p"/containers/new"} class="btn btn-primary"> | ||||
|           <%= dgettext("actions", "Add another container!") %> | ||||
|         </.link> | ||||
|       <% else %> | ||||
|         <.live_component | ||||
|           module={CanneryWeb.Components.TableComponent} | ||||
|           id="move_ammo_group_table" | ||||
|           id="move_pack_table" | ||||
|           columns={@columns} | ||||
|           rows={@rows} | ||||
|         /> | ||||
							
								
								
									
										269
									
								
								lib/cannery_web/components/pack_table_component.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										269
									
								
								lib/cannery_web/components/pack_table_component.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,269 @@ | ||||
| defmodule CanneryWeb.Components.PackTableComponent do | ||||
|   @moduledoc """ | ||||
|   A component that displays a list of packs | ||||
|   """ | ||||
|   use CanneryWeb, :live_component | ||||
|   alias Cannery.{Accounts.User, Ammo.Pack, ComparableDate} | ||||
|   alias Cannery.{ActivityLog, Ammo, Containers} | ||||
|   alias CanneryWeb.Components.TableComponent | ||||
|   alias Ecto.UUID | ||||
|   alias Phoenix.LiveView.{Rendered, Socket} | ||||
|  | ||||
|   @impl true | ||||
|   @spec update( | ||||
|           %{ | ||||
|             required(:id) => UUID.t(), | ||||
|             required(:current_user) => User.t(), | ||||
|             required(:packs) => [Pack.t()], | ||||
|             required(:show_used) => boolean(), | ||||
|             optional(:type) => Rendered.t(), | ||||
|             optional(:range) => Rendered.t(), | ||||
|             optional(:container) => Rendered.t(), | ||||
|             optional(:actions) => Rendered.t(), | ||||
|             optional(any()) => any() | ||||
|           }, | ||||
|           Socket.t() | ||||
|         ) :: {:ok, Socket.t()} | ||||
|   def update( | ||||
|         %{id: _id, packs: _pack, current_user: _current_user, show_used: _show_used} = assigns, | ||||
|         socket | ||||
|       ) do | ||||
|     socket = | ||||
|       socket | ||||
|       |> assign(assigns) | ||||
|       |> assign_new(:type, fn -> [] end) | ||||
|       |> assign_new(:range, fn -> [] end) | ||||
|       |> assign_new(:container, fn -> [] end) | ||||
|       |> assign_new(:actions, fn -> [] end) | ||||
|       |> display_packs() | ||||
|  | ||||
|     {:ok, socket} | ||||
|   end | ||||
|  | ||||
|   defp display_packs( | ||||
|          %{ | ||||
|            assigns: %{ | ||||
|              packs: packs, | ||||
|              current_user: current_user, | ||||
|              type: type, | ||||
|              range: range, | ||||
|              container: container, | ||||
|              actions: actions, | ||||
|              show_used: show_used | ||||
|            } | ||||
|          } = socket | ||||
|        ) do | ||||
|     lot_number_used = packs |> Enum.any?(fn %{lot_number: lot_number} -> !!lot_number end) | ||||
|     price_paid_used = packs |> Enum.any?(fn %{price_paid: price_paid} -> !!price_paid end) | ||||
|  | ||||
|     columns = | ||||
|       [] | ||||
|       |> TableComponent.maybe_compose_columns( | ||||
|         %{label: gettext("Actions"), key: :actions, sortable: false}, | ||||
|         actions != [] | ||||
|       ) | ||||
|       |> TableComponent.maybe_compose_columns(%{ | ||||
|         label: gettext("Last used on"), | ||||
|         key: :used_up_on, | ||||
|         type: ComparableDate | ||||
|       }) | ||||
|       |> TableComponent.maybe_compose_columns(%{ | ||||
|         label: gettext("Purchased on"), | ||||
|         key: :purchased_on, | ||||
|         type: ComparableDate | ||||
|       }) | ||||
|       |> TableComponent.maybe_compose_columns( | ||||
|         %{label: gettext("Container"), key: :container}, | ||||
|         container != [] | ||||
|       ) | ||||
|       |> TableComponent.maybe_compose_columns( | ||||
|         %{label: gettext("Range"), key: :range}, | ||||
|         range != [] | ||||
|       ) | ||||
|       |> TableComponent.maybe_compose_columns( | ||||
|         %{label: gettext("Lot number"), key: :lot_number}, | ||||
|         lot_number_used | ||||
|       ) | ||||
|       |> TableComponent.maybe_compose_columns( | ||||
|         %{label: gettext("CPR"), key: :cpr}, | ||||
|         price_paid_used | ||||
|       ) | ||||
|       |> TableComponent.maybe_compose_columns( | ||||
|         %{label: gettext("Price paid"), key: :price_paid}, | ||||
|         price_paid_used | ||||
|       ) | ||||
|       |> TableComponent.maybe_compose_columns( | ||||
|         %{label: gettext("% left"), key: :remaining}, | ||||
|         show_used | ||||
|       ) | ||||
|       |> TableComponent.maybe_compose_columns( | ||||
|         %{label: gettext("Original Count"), key: :original_count}, | ||||
|         show_used | ||||
|       ) | ||||
|       |> TableComponent.maybe_compose_columns(%{ | ||||
|         label: if(show_used, do: gettext("Current Count"), else: gettext("Count")), | ||||
|         key: :count | ||||
|       }) | ||||
|       |> TableComponent.maybe_compose_columns( | ||||
|         %{label: gettext("Type"), key: :type}, | ||||
|         type != [] | ||||
|       ) | ||||
|  | ||||
|     containers = | ||||
|       packs | ||||
|       |> Enum.map(fn %{container_id: container_id} -> container_id end) | ||||
|       |> Containers.get_containers(current_user) | ||||
|  | ||||
|     extra_data = %{ | ||||
|       current_user: current_user, | ||||
|       type: type, | ||||
|       columns: columns, | ||||
|       container: container, | ||||
|       containers: containers, | ||||
|       original_counts: Ammo.get_original_counts(packs, current_user), | ||||
|       cprs: Ammo.get_cprs(packs, current_user), | ||||
|       last_used_dates: ActivityLog.get_last_used_dates(packs, current_user), | ||||
|       percentages_remaining: Ammo.get_percentages_remaining(packs, current_user), | ||||
|       actions: actions, | ||||
|       range: range | ||||
|     } | ||||
|  | ||||
|     rows = | ||||
|       packs | ||||
|       |> Enum.map(fn pack -> | ||||
|         pack |> get_row_data_for_pack(extra_data) | ||||
|       end) | ||||
|  | ||||
|     socket |> assign(columns: columns, rows: rows) | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def render(assigns) do | ||||
|     ~H""" | ||||
|     <div id={@id} class="w-full"> | ||||
|       <.live_component module={TableComponent} id={"table-#{@id}"} columns={@columns} rows={@rows} /> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   @spec get_row_data_for_pack(Pack.t(), additional_data :: map()) :: map() | ||||
|   defp get_row_data_for_pack(pack, %{columns: columns} = additional_data) do | ||||
|     columns | ||||
|     |> Map.new(fn %{key: key} -> | ||||
|       {key, get_value_for_key(key, pack, additional_data)} | ||||
|     end) | ||||
|   end | ||||
|  | ||||
|   @spec get_value_for_key(atom(), Pack.t(), additional_data :: map()) :: | ||||
|           any() | {any(), Rendered.t()} | ||||
|   defp get_value_for_key( | ||||
|          :type, | ||||
|          %{type: %{name: type_name} = type}, | ||||
|          %{type: type_block} | ||||
|        ) do | ||||
|     assigns = %{type: type, type_block: type_block} | ||||
|  | ||||
|     {type_name, | ||||
|      ~H""" | ||||
|      <%= render_slot(@type_block, @type) %> | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:price_paid, %{price_paid: nil}, _additional_data), | ||||
|     do: {0, gettext("No cost information")} | ||||
|  | ||||
|   defp get_value_for_key(:price_paid, %{price_paid: price_paid}, _additional_data), | ||||
|     do: {price_paid, gettext("$%{amount}", amount: display_currency(price_paid))} | ||||
|  | ||||
|   defp get_value_for_key(:purchased_on, %{purchased_on: purchased_on} = assigns, _additional_data) do | ||||
|     {purchased_on, | ||||
|      ~H""" | ||||
|      <.date id={"#{@id}-purchased-on"} date={@purchased_on} /> | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:used_up_on, %{id: pack_id}, %{last_used_dates: last_used_dates}) do | ||||
|     last_used_date = last_used_dates |> Map.get(pack_id) | ||||
|     assigns = %{id: pack_id, last_used_date: last_used_date} | ||||
|  | ||||
|     {last_used_date, | ||||
|      ~H""" | ||||
|      <%= if @last_used_date do %> | ||||
|        <.date id={"#{@id}-last-used-date"} date={@last_used_date} /> | ||||
|      <% else %> | ||||
|        <%= gettext("Never used") %> | ||||
|      <% end %> | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:range, %{staged: staged} = pack, %{range: range}) do | ||||
|     assigns = %{range: range, pack: pack} | ||||
|  | ||||
|     {staged, | ||||
|      ~H""" | ||||
|      <%= render_slot(@range, @pack) %> | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key( | ||||
|          :remaining, | ||||
|          %{id: pack_id}, | ||||
|          %{percentages_remaining: percentages_remaining} | ||||
|        ) do | ||||
|     percentage = Map.fetch!(percentages_remaining, pack_id) | ||||
|     {percentage, gettext("%{percentage}%", percentage: percentage)} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:actions, pack, %{actions: actions}) do | ||||
|     assigns = %{actions: actions, pack: pack} | ||||
|  | ||||
|     ~H""" | ||||
|     <%= render_slot(@actions, @pack) %> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:container, %{container: nil}, _additional_data), do: {nil, nil} | ||||
|  | ||||
|   defp get_value_for_key( | ||||
|          :container, | ||||
|          %{container_id: container_id} = pack, | ||||
|          %{container: container_block, containers: containers} | ||||
|        ) do | ||||
|     container = %{name: container_name} = Map.fetch!(containers, container_id) | ||||
|  | ||||
|     assigns = %{ | ||||
|       container: container, | ||||
|       container_block: container_block, | ||||
|       pack: pack | ||||
|     } | ||||
|  | ||||
|     {container_name, | ||||
|      ~H""" | ||||
|      <%= render_slot(@container_block, {@pack, @container}) %> | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key( | ||||
|          :original_count, | ||||
|          %{id: pack_id}, | ||||
|          %{original_counts: original_counts} | ||||
|        ) do | ||||
|     Map.fetch!(original_counts, pack_id) | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:cpr, %{price_paid: nil}, _additional_data), | ||||
|     do: {0, gettext("No cost information")} | ||||
|  | ||||
|   defp get_value_for_key(:cpr, %{id: pack_id}, %{cprs: cprs}) do | ||||
|     amount = Map.fetch!(cprs, pack_id) | ||||
|     {amount, gettext("$%{amount}", amount: display_currency(amount))} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:count, %{count: count}, _additional_data), | ||||
|     do: if(count == 0, do: {0, gettext("Empty")}, else: count) | ||||
|  | ||||
|   defp get_value_for_key(key, pack, _additional_data), do: pack |> Map.get(key) | ||||
|  | ||||
|   @spec display_currency(float()) :: String.t() | ||||
|   defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2) | ||||
| end | ||||
| @@ -1,9 +1,9 @@ | ||||
| defmodule CanneryWeb.Components.ShotGroupTableComponent do | ||||
| defmodule CanneryWeb.Components.ShotRecordTableComponent do | ||||
|   @moduledoc """ | ||||
|   A component that displays a list of shot groups | ||||
|   A component that displays a list of shot records | ||||
|   """ | ||||
|   use CanneryWeb, :live_component | ||||
|   alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Ammo, ComparableDate} | ||||
|   alias Cannery.{Accounts.User, ActivityLog.ShotRecord, Ammo, ComparableDate} | ||||
|   alias Ecto.UUID | ||||
|   alias Phoenix.LiveView.{Rendered, Socket} | ||||
| 
 | ||||
| @@ -12,26 +12,29 @@ defmodule CanneryWeb.Components.ShotGroupTableComponent do | ||||
|           %{ | ||||
|             required(:id) => UUID.t(), | ||||
|             required(:current_user) => User.t(), | ||||
|             optional(:shot_groups) => [ShotGroup.t()], | ||||
|             optional(:shot_records) => [ShotRecord.t()], | ||||
|             optional(:actions) => Rendered.t(), | ||||
|             optional(any()) => any() | ||||
|           }, | ||||
|           Socket.t() | ||||
|         ) :: {:ok, Socket.t()} | ||||
|   def update(%{id: _id, shot_groups: _shot_groups, current_user: _current_user} = assigns, socket) do | ||||
|   def update( | ||||
|         %{id: _id, shot_records: _shot_records, current_user: _current_user} = assigns, | ||||
|         socket | ||||
|       ) do | ||||
|     socket = | ||||
|       socket | ||||
|       |> assign(assigns) | ||||
|       |> assign_new(:actions, fn -> [] end) | ||||
|       |> display_shot_groups() | ||||
|       |> display_shot_records() | ||||
| 
 | ||||
|     {:ok, socket} | ||||
|   end | ||||
| 
 | ||||
|   defp display_shot_groups( | ||||
|   defp display_shot_records( | ||||
|          %{ | ||||
|            assigns: %{ | ||||
|              shot_groups: shot_groups, | ||||
|              shot_records: shot_records, | ||||
|              current_user: current_user, | ||||
|              actions: actions | ||||
|            } | ||||
| @@ -45,17 +48,17 @@ defmodule CanneryWeb.Components.ShotGroupTableComponent do | ||||
|       %{label: gettext("Actions"), key: :actions, sortable: false} | ||||
|     ] | ||||
| 
 | ||||
|     ammo_groups = | ||||
|       shot_groups | ||||
|       |> Enum.map(fn %{ammo_group_id: ammo_group_id} -> ammo_group_id end) | ||||
|       |> Ammo.get_ammo_groups(current_user) | ||||
|     packs = | ||||
|       shot_records | ||||
|       |> Enum.map(fn %{pack_id: pack_id} -> pack_id end) | ||||
|       |> Ammo.get_packs(current_user) | ||||
| 
 | ||||
|     extra_data = %{current_user: current_user, actions: actions, ammo_groups: ammo_groups} | ||||
|     extra_data = %{current_user: current_user, actions: actions, packs: packs} | ||||
| 
 | ||||
|     rows = | ||||
|       shot_groups | ||||
|       |> Enum.map(fn shot_group -> | ||||
|         shot_group |> get_row_data_for_shot_group(columns, extra_data) | ||||
|       shot_records | ||||
|       |> Enum.map(fn shot_record -> | ||||
|         shot_record |> get_row_data_for_shot_record(columns, extra_data) | ||||
|       end) | ||||
| 
 | ||||
|     socket | ||||
| @@ -81,22 +84,22 @@ defmodule CanneryWeb.Components.ShotGroupTableComponent do | ||||
|     """ | ||||
|   end | ||||
| 
 | ||||
|   @spec get_row_data_for_shot_group(ShotGroup.t(), columns :: [map()], extra_data :: map()) :: | ||||
|   @spec get_row_data_for_shot_record(ShotRecord.t(), columns :: [map()], extra_data :: map()) :: | ||||
|           map() | ||||
|   defp get_row_data_for_shot_group(shot_group, columns, extra_data) do | ||||
|   defp get_row_data_for_shot_record(shot_record, columns, extra_data) do | ||||
|     columns | ||||
|     |> Map.new(fn %{key: key} -> | ||||
|       {key, get_row_value(key, shot_group, extra_data)} | ||||
|       {key, get_row_value(key, shot_record, extra_data)} | ||||
|     end) | ||||
|   end | ||||
| 
 | ||||
|   defp get_row_value(:name, %{ammo_group_id: ammo_group_id}, %{ammo_groups: ammo_groups}) do | ||||
|     assigns = %{ammo_group: ammo_group = Map.fetch!(ammo_groups, ammo_group_id)} | ||||
|   defp get_row_value(:name, %{pack_id: pack_id}, %{packs: packs}) do | ||||
|     assigns = %{pack: pack = Map.fetch!(packs, pack_id)} | ||||
| 
 | ||||
|     {ammo_group.ammo_type.name, | ||||
|     {pack.type.name, | ||||
|      ~H""" | ||||
|      <.link navigate={Routes.ammo_group_show_path(Endpoint, :show, @ammo_group)} class="link"> | ||||
|        <%= @ammo_group.ammo_type.name %> | ||||
|      <.link navigate={~p"/ammo/show/#{@pack}"} class="link"> | ||||
|        <%= @pack.type.name %> | ||||
|      </.link> | ||||
|      """} | ||||
|   end | ||||
| @@ -108,13 +111,13 @@ defmodule CanneryWeb.Components.ShotGroupTableComponent do | ||||
|      """} | ||||
|   end | ||||
| 
 | ||||
|   defp get_row_value(:actions, shot_group, %{actions: actions}) do | ||||
|     assigns = %{actions: actions, shot_group: shot_group} | ||||
|   defp get_row_value(:actions, shot_record, %{actions: actions}) do | ||||
|     assigns = %{actions: actions, shot_record: shot_record} | ||||
| 
 | ||||
|     ~H""" | ||||
|     <%= render_slot(@actions, @shot_group) %> | ||||
|     <%= render_slot(@actions, @shot_record) %> | ||||
|     """ | ||||
|   end | ||||
| 
 | ||||
|   defp get_row_value(key, shot_group, _extra_data), do: shot_group |> Map.get(key) | ||||
|   defp get_row_value(key, shot_record, _extra_data), do: shot_record |> Map.get(key) | ||||
| end | ||||
| @@ -135,4 +135,25 @@ defmodule CanneryWeb.Components.TableComponent do | ||||
|       sort_mode | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Conditionally composes elements into the columns list, supports maps and | ||||
|   lists. Works tail to front in order for efficiency | ||||
|  | ||||
|       iex> [] | ||||
|       ...> |> maybe_compose_columns(%{label: "Column 3"}, true) | ||||
|       ...> |> maybe_compose_columns(%{label: "Column 2"}, false) | ||||
|       ...> |> maybe_compose_columns(%{label: "Column 1"}) | ||||
|       [%{label: "Column 1"}, %{label: "Column 3"}] | ||||
|  | ||||
|   """ | ||||
|   @spec maybe_compose_columns(list(), element_to_add :: list() | map()) :: list() | ||||
|   @spec maybe_compose_columns(list(), element_to_add :: list() | map(), boolean()) :: list() | ||||
|   def maybe_compose_columns(columns, element_or_elements, add? \\ true) | ||||
|  | ||||
|   def maybe_compose_columns(columns, elements, true) when is_list(elements), | ||||
|     do: Enum.concat(elements, columns) | ||||
|  | ||||
|   def maybe_compose_columns(columns, element, true) when is_map(element), do: [element | columns] | ||||
|   def maybe_compose_columns(columns, _element_or_elements, false), do: columns | ||||
| end | ||||
|   | ||||
							
								
								
									
										287
									
								
								lib/cannery_web/components/type_table_component.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										287
									
								
								lib/cannery_web/components/type_table_component.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,287 @@ | ||||
| defmodule CanneryWeb.Components.TypeTableComponent do | ||||
|   @moduledoc """ | ||||
|   A component that displays a list of types | ||||
|   """ | ||||
|   use CanneryWeb, :live_component | ||||
|   alias Cannery.{Accounts.User, ActivityLog, Ammo, Ammo.Type} | ||||
|   alias CanneryWeb.Components.TableComponent | ||||
|   alias Ecto.UUID | ||||
|   alias Phoenix.LiveView.{Rendered, Socket} | ||||
|  | ||||
|   @impl true | ||||
|   @spec update( | ||||
|           %{ | ||||
|             required(:id) => UUID.t(), | ||||
|             required(:current_user) => User.t(), | ||||
|             optional(:class) => Type.class() | nil, | ||||
|             optional(:show_used) => boolean(), | ||||
|             optional(:types) => [Type.t()], | ||||
|             optional(:actions) => Rendered.t(), | ||||
|             optional(any()) => any() | ||||
|           }, | ||||
|           Socket.t() | ||||
|         ) :: {:ok, Socket.t()} | ||||
|   def update(%{id: _id, types: _types, current_user: _current_user} = assigns, socket) do | ||||
|     socket = | ||||
|       socket | ||||
|       |> assign(assigns) | ||||
|       |> assign_new(:show_used, fn -> false end) | ||||
|       |> assign_new(:class, fn -> :all end) | ||||
|       |> assign_new(:actions, fn -> [] end) | ||||
|       |> display_types() | ||||
|  | ||||
|     {:ok, socket} | ||||
|   end | ||||
|  | ||||
|   defp display_types( | ||||
|          %{ | ||||
|            assigns: %{ | ||||
|              types: types, | ||||
|              current_user: current_user, | ||||
|              show_used: show_used, | ||||
|              class: class, | ||||
|              actions: actions | ||||
|            } | ||||
|          } = socket | ||||
|        ) do | ||||
|     filtered_columns = | ||||
|       [ | ||||
|         %{label: gettext("Cartridge"), key: :cartridge, type: :string}, | ||||
|         %{ | ||||
|           label: if(class == :shotgun, do: gettext("Gauge"), else: gettext("Caliber")), | ||||
|           key: :caliber, | ||||
|           type: :string | ||||
|         }, | ||||
|         %{label: gettext("Unfired shell length"), key: :unfired_length, type: :string}, | ||||
|         %{label: gettext("Brass height"), key: :brass_height, type: :string}, | ||||
|         %{label: gettext("Chamber size"), key: :chamber_size, type: :string}, | ||||
|         %{label: gettext("Grains"), key: :grains, type: :string}, | ||||
|         %{label: gettext("Bullet type"), key: :bullet_type, type: :string}, | ||||
|         %{ | ||||
|           label: if(class == :shotgun, do: gettext("Slug core"), else: gettext("Bullet core")), | ||||
|           key: :bullet_core, | ||||
|           type: :string | ||||
|         }, | ||||
|         %{label: gettext("Jacket type"), key: :jacket_type, type: :string}, | ||||
|         %{label: gettext("Case material"), key: :case_material, type: :string}, | ||||
|         %{label: gettext("Wadding"), key: :wadding, type: :string}, | ||||
|         %{label: gettext("Shot type"), key: :shot_type, type: :string}, | ||||
|         %{label: gettext("Shot material"), key: :shot_material, type: :string}, | ||||
|         %{label: gettext("Shot size"), key: :shot_size, type: :string}, | ||||
|         %{label: gettext("Load grains"), key: :load_grains, type: :string}, | ||||
|         %{label: gettext("Shot charge weight"), key: :shot_charge_weight, type: :string}, | ||||
|         %{label: gettext("Powder type"), key: :powder_type, type: :string}, | ||||
|         %{ | ||||
|           label: gettext("Powder grains per charge"), | ||||
|           key: :powder_grains_per_charge, | ||||
|           type: :string | ||||
|         }, | ||||
|         %{label: gettext("Pressure"), key: :pressure, type: :string}, | ||||
|         %{label: gettext("Dram equivalent"), key: :dram_equivalent, type: :string}, | ||||
|         %{label: gettext("Muzzle velocity"), key: :muzzle_velocity, type: :string}, | ||||
|         %{label: gettext("Primer type"), key: :primer_type, type: :string}, | ||||
|         %{label: gettext("Firing type"), key: :firing_type, type: :string}, | ||||
|         %{label: gettext("Tracer"), key: :tracer, type: :atom}, | ||||
|         %{label: gettext("Incendiary"), key: :incendiary, type: :atom}, | ||||
|         %{label: gettext("Blank"), key: :blank, type: :atom}, | ||||
|         %{label: gettext("Corrosive"), key: :corrosive, type: :atom}, | ||||
|         %{label: gettext("Manufacturer"), key: :manufacturer, type: :string} | ||||
|       ] | ||||
|       |> Enum.filter(fn %{key: key, type: type} -> | ||||
|         # remove columns if all values match defaults | ||||
|         default_value = if type == :atom, do: false, else: nil | ||||
|  | ||||
|         types | ||||
|         |> Enum.any?(fn type -> Map.get(type, key, default_value) != default_value end) | ||||
|       end) | ||||
|  | ||||
|     columns = | ||||
|       [%{label: gettext("Actions"), key: "actions", type: :actions, sortable: false}] | ||||
|       |> TableComponent.maybe_compose_columns(%{ | ||||
|         label: gettext("Average CPR"), | ||||
|         key: :avg_price_paid, | ||||
|         type: :avg_price_paid | ||||
|       }) | ||||
|       |> TableComponent.maybe_compose_columns( | ||||
|         %{ | ||||
|           label: gettext("Total ever packs"), | ||||
|           key: :historical_pack_count, | ||||
|           type: :historical_pack_count | ||||
|         }, | ||||
|         show_used | ||||
|       ) | ||||
|       |> TableComponent.maybe_compose_columns( | ||||
|         %{ | ||||
|           label: gettext("Used packs"), | ||||
|           key: :used_pack_count, | ||||
|           type: :used_pack_count | ||||
|         }, | ||||
|         show_used | ||||
|       ) | ||||
|       |> TableComponent.maybe_compose_columns(%{ | ||||
|         label: gettext("Packs"), | ||||
|         key: :ammo_count, | ||||
|         type: :ammo_count | ||||
|       }) | ||||
|       |> TableComponent.maybe_compose_columns( | ||||
|         %{ | ||||
|           label: gettext("Total ever rounds"), | ||||
|           key: :historical_round_count, | ||||
|           type: :historical_round_count | ||||
|         }, | ||||
|         show_used | ||||
|       ) | ||||
|       |> TableComponent.maybe_compose_columns( | ||||
|         %{ | ||||
|           label: gettext("Used rounds"), | ||||
|           key: :used_round_count, | ||||
|           type: :used_round_count | ||||
|         }, | ||||
|         show_used | ||||
|       ) | ||||
|       |> TableComponent.maybe_compose_columns(%{ | ||||
|         label: gettext("Rounds"), | ||||
|         key: :round_count, | ||||
|         type: :round_count | ||||
|       }) | ||||
|       |> TableComponent.maybe_compose_columns(filtered_columns) | ||||
|       |> TableComponent.maybe_compose_columns( | ||||
|         %{label: gettext("Class"), key: :class, type: :atom}, | ||||
|         class in [:all, nil] | ||||
|       ) | ||||
|       |> TableComponent.maybe_compose_columns(%{label: gettext("Name"), key: :name, type: :name}) | ||||
|  | ||||
|     round_counts = types |> Ammo.get_round_count_for_types(current_user) | ||||
|     packs_count = types |> Ammo.get_packs_count_for_types(current_user) | ||||
|     average_costs = types |> Ammo.get_average_cost_for_types(current_user) | ||||
|  | ||||
|     [used_counts, historical_round_counts, historical_pack_counts, used_pack_counts] = | ||||
|       if show_used do | ||||
|         [ | ||||
|           types |> ActivityLog.get_used_count_for_types(current_user), | ||||
|           types |> Ammo.get_historical_count_for_types(current_user), | ||||
|           types |> Ammo.get_packs_count_for_types(current_user, true), | ||||
|           types |> Ammo.get_used_packs_count_for_types(current_user) | ||||
|         ] | ||||
|       else | ||||
|         [nil, nil, nil, nil] | ||||
|       end | ||||
|  | ||||
|     extra_data = %{ | ||||
|       actions: actions, | ||||
|       current_user: current_user, | ||||
|       used_counts: used_counts, | ||||
|       round_counts: round_counts, | ||||
|       historical_round_counts: historical_round_counts, | ||||
|       packs_count: packs_count, | ||||
|       used_pack_counts: used_pack_counts, | ||||
|       historical_pack_counts: historical_pack_counts, | ||||
|       average_costs: average_costs | ||||
|     } | ||||
|  | ||||
|     rows = | ||||
|       types | ||||
|       |> Enum.map(fn type -> | ||||
|         type |> get_type_values(columns, extra_data) | ||||
|       end) | ||||
|  | ||||
|     socket |> assign(columns: columns, rows: rows) | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def render(assigns) do | ||||
|     ~H""" | ||||
|     <div id={@id} class="w-full"> | ||||
|       <.live_component module={TableComponent} id={"table-#{@id}"} columns={@columns} rows={@rows} /> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   defp get_type_values(type, columns, extra_data) do | ||||
|     columns | ||||
|     |> Map.new(fn %{key: key, type: column_type} -> | ||||
|       {key, get_type_value(column_type, key, type, extra_data)} | ||||
|     end) | ||||
|   end | ||||
|  | ||||
|   defp get_type_value(:atom, key, type, _other_data), | ||||
|     do: type |> Map.get(key) |> humanize() | ||||
|  | ||||
|   defp get_type_value(:round_count, _key, %{id: type_id}, %{round_counts: round_counts}), | ||||
|     do: Map.get(round_counts, type_id, 0) | ||||
|  | ||||
|   defp get_type_value( | ||||
|          :historical_round_count, | ||||
|          _key, | ||||
|          %{id: type_id}, | ||||
|          %{historical_round_counts: historical_round_counts} | ||||
|        ) do | ||||
|     Map.get(historical_round_counts, type_id, 0) | ||||
|   end | ||||
|  | ||||
|   defp get_type_value( | ||||
|          :used_round_count, | ||||
|          _key, | ||||
|          %{id: type_id}, | ||||
|          %{used_counts: used_counts} | ||||
|        ) do | ||||
|     Map.get(used_counts, type_id, 0) | ||||
|   end | ||||
|  | ||||
|   defp get_type_value( | ||||
|          :historical_pack_count, | ||||
|          _key, | ||||
|          %{id: type_id}, | ||||
|          %{historical_pack_counts: historical_pack_counts} | ||||
|        ) do | ||||
|     Map.get(historical_pack_counts, type_id, 0) | ||||
|   end | ||||
|  | ||||
|   defp get_type_value( | ||||
|          :used_pack_count, | ||||
|          _key, | ||||
|          %{id: type_id}, | ||||
|          %{used_pack_counts: used_pack_counts} | ||||
|        ) do | ||||
|     Map.get(used_pack_counts, type_id, 0) | ||||
|   end | ||||
|  | ||||
|   defp get_type_value(:ammo_count, _key, %{id: type_id}, %{packs_count: packs_count}), | ||||
|     do: Map.get(packs_count, type_id) | ||||
|  | ||||
|   defp get_type_value( | ||||
|          :avg_price_paid, | ||||
|          _key, | ||||
|          %{id: type_id}, | ||||
|          %{average_costs: average_costs} | ||||
|        ) do | ||||
|     case Map.get(average_costs, type_id) do | ||||
|       nil -> {0, gettext("No cost information")} | ||||
|       count -> {count, gettext("$%{amount}", amount: display_currency(count))} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   defp get_type_value(:name, _key, %{name: type_name} = assigns, _other_data) do | ||||
|     {type_name, | ||||
|      ~H""" | ||||
|      <.link navigate={~p"/type/#{@id}"} class="link"> | ||||
|        <%= @name %> | ||||
|      </.link> | ||||
|      """} | ||||
|   end | ||||
|  | ||||
|   defp get_type_value(:actions, _key, type, %{actions: actions}) do | ||||
|     assigns = %{actions: actions, type: type} | ||||
|  | ||||
|     ~H""" | ||||
|     <%= render_slot(@actions, @type) %> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   defp get_type_value(nil, _key, _type, _other_data), do: nil | ||||
|  | ||||
|   defp get_type_value(_other, key, type, _other_data), do: type |> Map.get(key) | ||||
|  | ||||
|   @spec display_currency(float()) :: String.t() | ||||
|   defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2) | ||||
| end | ||||
| @@ -6,7 +6,8 @@ defmodule CanneryWeb.EmailController do | ||||
|   use CanneryWeb, :controller | ||||
|   alias Cannery.Accounts.User | ||||
|  | ||||
|   plug :put_layout, {CanneryWeb.LayoutView, :email} | ||||
|   plug :put_root_layout, html: {CanneryWeb.Layouts, :email_html} | ||||
|   plug :put_layout, false | ||||
|  | ||||
|   @sample_assigns %{ | ||||
|     email: %{subject: "Example subject"}, | ||||
| @@ -18,6 +19,6 @@ defmodule CanneryWeb.EmailController do | ||||
|   Debug route used to preview emails | ||||
|   """ | ||||
|   def preview(conn, %{"id" => template}) do | ||||
|     render(conn, "#{template |> to_string()}.html", @sample_assigns) | ||||
|     render(conn, String.to_existing_atom(template), @sample_assigns) | ||||
|   end | ||||
| end | ||||
|   | ||||
							
								
								
									
										16
									
								
								lib/cannery_web/controllers/error_html.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								lib/cannery_web/controllers/error_html.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| defmodule CanneryWeb.ErrorHTML do | ||||
|   use CanneryWeb, :html | ||||
|  | ||||
|   embed_templates "error_html/*" | ||||
|  | ||||
|   def render(template, _assigns) do | ||||
|     error_string = | ||||
|       case template do | ||||
|         "404.html" -> dgettext("errors", "Not found") | ||||
|         "401.html" -> dgettext("errors", "Unauthorized") | ||||
|         _other_path -> dgettext("errors", "Internal server error") | ||||
|       end | ||||
|  | ||||
|     error(%{error_string: error_string}) | ||||
|   end | ||||
| end | ||||
| @@ -24,10 +24,7 @@ | ||||
| 
 | ||||
|         <hr class="w-full hr" /> | ||||
| 
 | ||||
|         <.link | ||||
|           href={Routes.live_path(Endpoint, HomeLive)} | ||||
|           class="link title text-primary-600 text-lg" | ||||
|         > | ||||
|         <.link href={~p"/"} class="link title text-primary-600 text-lg"> | ||||
|           <%= dgettext("errors", "Go back home") %> | ||||
|         </.link> | ||||
|       </div> | ||||
							
								
								
									
										14
									
								
								lib/cannery_web/controllers/error_json.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								lib/cannery_web/controllers/error_json.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| defmodule CanneryWeb.ErrorJSON do | ||||
|   import CanneryWeb.Gettext | ||||
|  | ||||
|   def render(template, _assigns) do | ||||
|     error_string = | ||||
|       case template do | ||||
|         "404.json" -> dgettext("errors", "Not found") | ||||
|         "401.json" -> dgettext("errors", "Unauthorized") | ||||
|         _other_path -> dgettext("errors", "Internal server error") | ||||
|       end | ||||
|  | ||||
|     %{errors: %{detail: error_string}} | ||||
|   end | ||||
| end | ||||
| @@ -3,74 +3,72 @@ defmodule CanneryWeb.ExportController do | ||||
|   alias Cannery.{ActivityLog, Ammo, Containers} | ||||
|  | ||||
|   def export(%{assigns: %{current_user: current_user}} = conn, %{"mode" => "json"}) do | ||||
|     ammo_types = Ammo.list_ammo_types(current_user) | ||||
|     used_counts = ammo_types |> ActivityLog.get_used_count_for_ammo_types(current_user) | ||||
|     round_counts = ammo_types |> Ammo.get_round_count_for_ammo_types(current_user) | ||||
|     ammo_group_counts = ammo_types |> Ammo.get_ammo_groups_count_for_types(current_user) | ||||
|     types = Ammo.list_types(current_user, :all) | ||||
|     used_counts = types |> ActivityLog.get_used_count_for_types(current_user) | ||||
|     round_counts = types |> Ammo.get_round_count_for_types(current_user) | ||||
|     pack_counts = types |> Ammo.get_packs_count_for_types(current_user) | ||||
|  | ||||
|     total_ammo_group_counts = | ||||
|       ammo_types |> Ammo.get_ammo_groups_count_for_types(current_user, true) | ||||
|     total_pack_counts = types |> Ammo.get_packs_count_for_types(current_user, true) | ||||
|  | ||||
|     average_costs = ammo_types |> Ammo.get_average_cost_for_ammo_types(current_user) | ||||
|     average_costs = types |> Ammo.get_average_cost_for_types(current_user) | ||||
|  | ||||
|     ammo_types = | ||||
|       ammo_types | ||||
|       |> Enum.map(fn %{id: ammo_type_id} = ammo_type -> | ||||
|         ammo_type | ||||
|     types = | ||||
|       types | ||||
|       |> Enum.map(fn %{id: type_id} = type -> | ||||
|         type | ||||
|         |> Jason.encode!() | ||||
|         |> Jason.decode!() | ||||
|         |> Map.merge(%{ | ||||
|           "average_cost" => Map.get(average_costs, ammo_type_id), | ||||
|           "round_count" => Map.get(round_counts, ammo_type_id, 0), | ||||
|           "used_count" => Map.get(used_counts, ammo_type_id, 0), | ||||
|           "ammo_group_count" => Map.get(ammo_group_counts, ammo_type_id, 0), | ||||
|           "total_ammo_group_count" => Map.get(total_ammo_group_counts, ammo_type_id, 0) | ||||
|           "average_cost" => Map.get(average_costs, type_id), | ||||
|           "round_count" => Map.get(round_counts, type_id, 0), | ||||
|           "used_count" => Map.get(used_counts, type_id, 0), | ||||
|           "pack_count" => Map.get(pack_counts, type_id, 0), | ||||
|           "total_pack_count" => Map.get(total_pack_counts, type_id, 0) | ||||
|         }) | ||||
|       end) | ||||
|  | ||||
|     ammo_groups = Ammo.list_ammo_groups(nil, true, current_user) | ||||
|     used_counts = ammo_groups |> ActivityLog.get_used_counts(current_user) | ||||
|     original_counts = ammo_groups |> Ammo.get_original_counts(current_user) | ||||
|     cprs = ammo_groups |> Ammo.get_cprs(current_user) | ||||
|     packs = Ammo.list_packs(nil, :all, current_user, true) | ||||
|     used_counts = packs |> ActivityLog.get_used_counts(current_user) | ||||
|     original_counts = packs |> Ammo.get_original_counts(current_user) | ||||
|     cprs = packs |> Ammo.get_cprs(current_user) | ||||
|     percentages_remaining = packs |> Ammo.get_percentages_remaining(current_user) | ||||
|  | ||||
|     ammo_groups = | ||||
|       ammo_groups | ||||
|       |> Enum.map(fn %{id: ammo_group_id} = ammo_group -> | ||||
|         percentage_remaining = ammo_group |> Ammo.get_percentage_remaining(current_user) | ||||
|  | ||||
|         ammo_group | ||||
|     packs = | ||||
|       packs | ||||
|       |> Enum.map(fn %{id: pack_id} = pack -> | ||||
|         pack | ||||
|         |> Jason.encode!() | ||||
|         |> Jason.decode!() | ||||
|         |> Map.merge(%{ | ||||
|           "used_count" => Map.get(used_counts, ammo_group_id), | ||||
|           "percentage_remaining" => percentage_remaining, | ||||
|           "original_count" => Map.get(original_counts, ammo_group_id), | ||||
|           "cpr" => Map.get(cprs, ammo_group_id) | ||||
|           "used_count" => Map.get(used_counts, pack_id), | ||||
|           "percentage_remaining" => Map.fetch!(percentages_remaining, pack_id), | ||||
|           "original_count" => Map.get(original_counts, pack_id), | ||||
|           "cpr" => Map.get(cprs, pack_id) | ||||
|         }) | ||||
|       end) | ||||
|  | ||||
|     shot_groups = ActivityLog.list_shot_groups(current_user) | ||||
|     shot_records = ActivityLog.list_shot_records(:all, current_user) | ||||
|  | ||||
|     containers = | ||||
|       Containers.list_containers(current_user) | ||||
|       |> Enum.map(fn container -> | ||||
|         ammo_group_count = container |> Ammo.get_ammo_groups_count_for_container!(current_user) | ||||
|         pack_count = container |> Ammo.get_packs_count_for_container!(current_user) | ||||
|         round_count = container |> Ammo.get_round_count_for_container!(current_user) | ||||
|  | ||||
|         container | ||||
|         |> Jason.encode!() | ||||
|         |> Jason.decode!() | ||||
|         |> Map.merge(%{ | ||||
|           "ammo_group_count" => ammo_group_count, | ||||
|           "pack_count" => pack_count, | ||||
|           "round_count" => round_count | ||||
|         }) | ||||
|       end) | ||||
|  | ||||
|     json(conn, %{ | ||||
|       user: current_user, | ||||
|       ammo_types: ammo_types, | ||||
|       ammo_groups: ammo_groups, | ||||
|       shot_groups: shot_groups, | ||||
|       types: types, | ||||
|       packs: packs, | ||||
|       shot_records: shot_records, | ||||
|       containers: containers | ||||
|     }) | ||||
|   end | ||||
|   | ||||
| @@ -1,11 +0,0 @@ | ||||
| defmodule CanneryWeb.HomeController do | ||||
|   @moduledoc """ | ||||
|   Controller for home page | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :controller | ||||
|  | ||||
|   def index(conn, _params) do | ||||
|     render(conn, "index.html") | ||||
|   end | ||||
| end | ||||
							
								
								
									
										5
									
								
								lib/cannery_web/controllers/home_html.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								lib/cannery_web/controllers/home_html.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| defmodule CanneryWeb.HomeHTML do | ||||
|   use CanneryWeb, :html | ||||
|  | ||||
|   embed_templates "home_html/*" | ||||
| end | ||||
| @@ -3,12 +3,11 @@ defmodule CanneryWeb.UserAuth do | ||||
|   Functions for user session and authentication | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :verified_routes | ||||
|   import Plug.Conn | ||||
|   import Phoenix.Controller | ||||
|   import CanneryWeb.Gettext | ||||
|   alias Cannery.{Accounts, Accounts.User} | ||||
|   alias CanneryWeb.HomeLive | ||||
|   alias CanneryWeb.Router.Helpers, as: Routes | ||||
|  | ||||
|   # Make the remember me cookie valid for 60 days. | ||||
|   # If you want bump or reduce this value, also change | ||||
| @@ -39,7 +38,7 @@ defmodule CanneryWeb.UserAuth do | ||||
|       dgettext("errors", "You must confirm your account and log in to access this page.") | ||||
|     ) | ||||
|     |> maybe_store_return_to() | ||||
|     |> redirect(to: Routes.user_session_path(conn, :new)) | ||||
|     |> redirect(to: ~p"/users/log_in") | ||||
|     |> halt() | ||||
|   end | ||||
|  | ||||
| @@ -49,8 +48,7 @@ defmodule CanneryWeb.UserAuth do | ||||
|  | ||||
|     conn | ||||
|     |> renew_session() | ||||
|     |> put_session(:user_token, token) | ||||
|     |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}") | ||||
|     |> put_token_in_session(token) | ||||
|     |> maybe_write_remember_me_cookie(token, params) | ||||
|     |> redirect(to: user_return_to || signed_in_path(conn)) | ||||
|   end | ||||
| @@ -96,7 +94,7 @@ defmodule CanneryWeb.UserAuth do | ||||
|   """ | ||||
|   def log_out_user(conn) do | ||||
|     user_token = get_session(conn, :user_token) | ||||
|     user_token && Accounts.delete_session_token(user_token) | ||||
|     user_token && Accounts.delete_user_session_token(user_token) | ||||
|  | ||||
|     if live_socket_id = get_session(conn, :live_socket_id) do | ||||
|       CanneryWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) | ||||
| @@ -105,7 +103,7 @@ defmodule CanneryWeb.UserAuth do | ||||
|     conn | ||||
|     |> renew_session() | ||||
|     |> delete_resp_cookie(@remember_me_cookie) | ||||
|     |> redirect(to: "/") | ||||
|     |> redirect(to: ~p"/") | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
| @@ -119,19 +117,110 @@ defmodule CanneryWeb.UserAuth do | ||||
|   end | ||||
|  | ||||
|   defp ensure_user_token(conn) do | ||||
|     if user_token = get_session(conn, :user_token) do | ||||
|       {user_token, conn} | ||||
|     if token = get_session(conn, :user_token) do | ||||
|       {token, conn} | ||||
|     else | ||||
|       conn = fetch_cookies(conn, signed: [@remember_me_cookie]) | ||||
|  | ||||
|       if user_token = conn.cookies[@remember_me_cookie] do | ||||
|         {user_token, put_session(conn, :user_token, user_token)} | ||||
|       if token = conn.cookies[@remember_me_cookie] do | ||||
|         {token, put_token_in_session(conn, token)} | ||||
|       else | ||||
|         {nil, conn} | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Handles mounting and authenticating the current_user in LiveViews. | ||||
|  | ||||
|   ## `on_mount` arguments | ||||
|  | ||||
|     * `:mount_current_user` - Assigns current_user | ||||
|       to socket assigns based on user_token, or nil if | ||||
|       there's no user_token or no matching user. | ||||
|  | ||||
|     * `:ensure_authenticated` - Authenticates the user from the session, | ||||
|       and assigns the current_user to socket assigns based | ||||
|       on user_token. | ||||
|       Redirects to login page if there's no logged user. | ||||
|  | ||||
|     * `:redirect_if_user_is_authenticated` - Authenticates the user from the session. | ||||
|       Redirects to signed_in_path if there's a logged user. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|   Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate | ||||
|   the current_user: | ||||
|  | ||||
|       defmodule CanneryWeb.PageLive do | ||||
|         use CanneryWeb, :live_view | ||||
|  | ||||
|         on_mount {CanneryWeb.UserAuth, :mount_current_user} | ||||
|         ... | ||||
|       end | ||||
|  | ||||
|   Or use the `live_session` of your router to invoke the on_mount callback: | ||||
|  | ||||
|       live_session :authenticated, on_mount: [{CanneryWeb.UserAuth, :ensure_authenticated}] do | ||||
|         live "/profile", ProfileLive, :index | ||||
|       end | ||||
|   """ | ||||
|   def on_mount(:mount_current_user, _params, session, socket) do | ||||
|     {:cont, mount_current_user(session, socket)} | ||||
|   end | ||||
|  | ||||
|   def on_mount(:ensure_authenticated, _params, session, socket) do | ||||
|     socket = mount_current_user(session, socket) | ||||
|  | ||||
|     if socket.assigns.current_user do | ||||
|       {:cont, socket} | ||||
|     else | ||||
|       error_flash = dgettext("errors", "You must log in to access this page.") | ||||
|  | ||||
|       socket = | ||||
|         socket | ||||
|         |> Phoenix.LiveView.put_flash(:error, error_flash) | ||||
|         |> Phoenix.LiveView.redirect(to: ~p"/users/log_in") | ||||
|  | ||||
|       {:halt, socket} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def on_mount(:ensure_admin, _params, session, socket) do | ||||
|     socket = mount_current_user(session, socket) | ||||
|  | ||||
|     if socket.assigns.current_user && socket.assigns.current_user.role == :admin do | ||||
|       {:cont, socket} | ||||
|     else | ||||
|       error_flash = dgettext("errors", "You must log in as an administrator to access this page.") | ||||
|  | ||||
|       socket = | ||||
|         socket | ||||
|         |> Phoenix.LiveView.put_flash(:error, error_flash) | ||||
|         |> Phoenix.LiveView.redirect(to: ~p"/users/log_in") | ||||
|  | ||||
|       {:halt, socket} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do | ||||
|     socket = mount_current_user(session, socket) | ||||
|  | ||||
|     if socket.assigns.current_user do | ||||
|       {:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))} | ||||
|     else | ||||
|       {:cont, socket} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   defp mount_current_user(session, socket) do | ||||
|     Phoenix.Component.assign_new(socket, :current_user, fn -> | ||||
|       if user_token = session["user_token"] do | ||||
|         Accounts.get_user_by_session_token(user_token) | ||||
|       end | ||||
|     end) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Used for routes that require the user to not be authenticated. | ||||
|   """ | ||||
| @@ -161,7 +250,7 @@ defmodule CanneryWeb.UserAuth do | ||||
|         dgettext("errors", "You must confirm your account and log in to access this page.") | ||||
|       ) | ||||
|       |> maybe_store_return_to() | ||||
|       |> redirect(to: Routes.user_session_path(conn, :new)) | ||||
|       |> redirect(to: ~p"/users/log_in") | ||||
|       |> halt() | ||||
|     end | ||||
|   end | ||||
| @@ -176,16 +265,34 @@ defmodule CanneryWeb.UserAuth do | ||||
|       conn | ||||
|       |> put_flash(:error, dgettext("errors", "You are not authorized to view this page.")) | ||||
|       |> maybe_store_return_to() | ||||
|       |> redirect(to: Routes.live_path(conn, HomeLive)) | ||||
|       |> redirect(to: ~p"/") | ||||
|       |> halt() | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def put_user_locale(%{assigns: %{current_user: %{locale: locale}}} = conn, _opts) do | ||||
|     default = Application.fetch_env!(:gettext, :default_locale) | ||||
|     Gettext.put_locale(locale || default) | ||||
|     conn |> put_session(:locale, locale || default) | ||||
|   end | ||||
|  | ||||
|   def put_user_locale(conn, _opts) do | ||||
|     default = Application.fetch_env!(:gettext, :default_locale) | ||||
|     Gettext.put_locale(default) | ||||
|     conn |> put_session(:locale, default) | ||||
|   end | ||||
|  | ||||
|   defp put_token_in_session(conn, token) do | ||||
|     conn | ||||
|     |> put_session(:user_token, token) | ||||
|     |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}") | ||||
|   end | ||||
|  | ||||
|   defp maybe_store_return_to(%{method: "GET"} = conn) do | ||||
|     put_session(conn, :user_return_to, current_path(conn)) | ||||
|   end | ||||
|  | ||||
|   defp maybe_store_return_to(conn), do: conn | ||||
|  | ||||
|   defp signed_in_path(_conn), do: "/" | ||||
|   defp signed_in_path(_conn), do: ~p"/" | ||||
| end | ||||
|   | ||||
| @@ -5,14 +5,14 @@ defmodule CanneryWeb.UserConfirmationController do | ||||
|   alias Cannery.Accounts | ||||
|  | ||||
|   def new(conn, _params) do | ||||
|     render(conn, "new.html", page_title: gettext("Confirm your account")) | ||||
|     render(conn, :new, page_title: gettext("Confirm your account")) | ||||
|   end | ||||
|  | ||||
|   def create(conn, %{"user" => %{"email" => email}}) do | ||||
|     if user = Accounts.get_user_by_email(email) do | ||||
|       Accounts.deliver_user_confirmation_instructions( | ||||
|         user, | ||||
|         &Routes.user_confirmation_url(conn, :confirm, &1) | ||||
|         fn token -> url(CanneryWeb.Endpoint, ~p"/users/confirm/#{token}") end | ||||
|       ) | ||||
|     end | ||||
|  | ||||
| @@ -22,11 +22,10 @@ defmodule CanneryWeb.UserConfirmationController do | ||||
|       :info, | ||||
|       dgettext( | ||||
|         "prompts", | ||||
|         "If your email is in our system and it has not been confirmed yet, " <> | ||||
|           "you will receive an email with instructions shortly." | ||||
|         "If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly." | ||||
|       ) | ||||
|     ) | ||||
|     |> redirect(to: "/") | ||||
|     |> redirect(to: ~p"/") | ||||
|   end | ||||
|  | ||||
|   # Do not log in the user after confirmation to avoid a | ||||
| @@ -36,7 +35,7 @@ defmodule CanneryWeb.UserConfirmationController do | ||||
|       {:ok, %{email: email}} -> | ||||
|         conn | ||||
|         |> put_flash(:info, dgettext("prompts", "%{email} confirmed successfully.", email: email)) | ||||
|         |> redirect(to: "/") | ||||
|         |> redirect(to: ~p"/") | ||||
|  | ||||
|       :error -> | ||||
|         # If there is a current user and the account was already confirmed, | ||||
| @@ -45,7 +44,7 @@ defmodule CanneryWeb.UserConfirmationController do | ||||
|         # a warning message. | ||||
|         case conn.assigns do | ||||
|           %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> | ||||
|             redirect(conn, to: "/") | ||||
|             redirect(conn, to: ~p"/") | ||||
|  | ||||
|           %{} -> | ||||
|             conn | ||||
| @@ -53,7 +52,7 @@ defmodule CanneryWeb.UserConfirmationController do | ||||
|               :error, | ||||
|               dgettext("errors", "User confirmation link is invalid or it has expired.") | ||||
|             ) | ||||
|             |> redirect(to: "/") | ||||
|             |> redirect(to: ~p"/") | ||||
|         end | ||||
|     end | ||||
|   end | ||||
|   | ||||
							
								
								
									
										6
									
								
								lib/cannery_web/controllers/user_confirmation_html.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								lib/cannery_web/controllers/user_confirmation_html.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| defmodule CanneryWeb.UserConfirmationHTML do | ||||
|   use CanneryWeb, :html | ||||
|   alias Cannery.Accounts | ||||
|  | ||||
|   embed_templates "user_confirmation_html/*" | ||||
| end | ||||
| @@ -7,7 +7,7 @@ | ||||
|     :let={f} | ||||
|     for={%{}} | ||||
|     as={:user} | ||||
|     action={Routes.user_confirmation_path(@conn, :create)} | ||||
|     action={~p"/users/confirm"} | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|   > | ||||
|     <%= label(f, :email, gettext("Email"), class: "title text-lg text-primary-600") %> | ||||
| @@ -21,14 +21,10 @@ | ||||
|   <hr class="hr" /> | ||||
| 
 | ||||
|   <div class="flex flex-row justify-center items-center space-x-4"> | ||||
|     <.link | ||||
|       :if={Accounts.allow_registration?()} | ||||
|       href={Routes.user_registration_path(@conn, :new)} | ||||
|       class="btn btn-primary" | ||||
|     > | ||||
|     <.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Register") %> | ||||
|     </.link> | ||||
|     <.link href={Routes.user_session_path(@conn, :new)} class="btn btn-primary"> | ||||
|     <.link href={~p"/users/log_in"} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Log in") %> | ||||
|     </.link> | ||||
|   </div> | ||||
| @@ -2,7 +2,6 @@ defmodule CanneryWeb.UserRegistrationController do | ||||
|   use CanneryWeb, :controller | ||||
|   import CanneryWeb.Gettext | ||||
|   alias Cannery.{Accounts, Accounts.Invites} | ||||
|   alias CanneryWeb.{Endpoint, HomeLive} | ||||
|   alias Ecto.Changeset | ||||
|  | ||||
|   def new(conn, %{"invite" => invite_token}) do | ||||
| @@ -11,7 +10,7 @@ defmodule CanneryWeb.UserRegistrationController do | ||||
|     else | ||||
|       conn | ||||
|       |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired")) | ||||
|       |> redirect(to: Routes.live_path(Endpoint, HomeLive)) | ||||
|       |> redirect(to: ~p"/") | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -21,13 +20,13 @@ defmodule CanneryWeb.UserRegistrationController do | ||||
|     else | ||||
|       conn | ||||
|       |> put_flash(:error, dgettext("errors", "Sorry, public registration is disabled")) | ||||
|       |> redirect(to: Routes.live_path(Endpoint, HomeLive)) | ||||
|       |> redirect(to: ~p"/") | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   # renders new user registration page | ||||
|   defp render_new(conn, invite_token \\ nil) do | ||||
|     render(conn, "new.html", | ||||
|     render(conn, :new, | ||||
|       changeset: Accounts.change_user_registration(), | ||||
|       invite_token: invite_token, | ||||
|       page_title: gettext("Register") | ||||
| @@ -40,7 +39,7 @@ defmodule CanneryWeb.UserRegistrationController do | ||||
|     else | ||||
|       conn | ||||
|       |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired")) | ||||
|       |> redirect(to: Routes.live_path(Endpoint, HomeLive)) | ||||
|       |> redirect(to: ~p"/") | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -50,7 +49,7 @@ defmodule CanneryWeb.UserRegistrationController do | ||||
|     else | ||||
|       conn | ||||
|       |> put_flash(:error, dgettext("errors", "Sorry, public registration is disabled")) | ||||
|       |> redirect(to: Routes.live_path(Endpoint, HomeLive)) | ||||
|       |> redirect(to: ~p"/") | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -59,17 +58,17 @@ defmodule CanneryWeb.UserRegistrationController do | ||||
|       {:ok, user} -> | ||||
|         Accounts.deliver_user_confirmation_instructions( | ||||
|           user, | ||||
|           &Routes.user_confirmation_url(conn, :confirm, &1) | ||||
|           fn token -> url(CanneryWeb.Endpoint, ~p"/users/confirm/#{token}") end | ||||
|         ) | ||||
|  | ||||
|         conn | ||||
|         |> put_flash(:info, dgettext("prompts", "Please check your email to verify your account")) | ||||
|         |> redirect(to: Routes.user_session_path(Endpoint, :new)) | ||||
|         |> redirect(to: ~p"/users/log_in") | ||||
|  | ||||
|       {:error, :invalid_token} -> | ||||
|         conn | ||||
|         |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired")) | ||||
|         |> redirect(to: Routes.live_path(Endpoint, HomeLive)) | ||||
|         |> redirect(to: ~p"/") | ||||
|  | ||||
|       {:error, %Changeset{} = changeset} -> | ||||
|         conn |> render("new.html", changeset: changeset, invite_token: invite_token) | ||||
|   | ||||
							
								
								
									
										5
									
								
								lib/cannery_web/controllers/user_registration_html.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								lib/cannery_web/controllers/user_registration_html.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| defmodule CanneryWeb.UserRegistrationHTML do | ||||
|   use CanneryWeb, :html | ||||
|  | ||||
|   embed_templates "user_registration_html/*" | ||||
| end | ||||
| @@ -6,7 +6,7 @@ | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={@changeset} | ||||
|     action={Routes.user_registration_path(@conn, :create)} | ||||
|     action={~p"/users/register"} | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|   > | ||||
|     <p :if={@changeset.action && not @changeset.valid?()} class="alert alert-danger col-span-3"> | ||||
| @@ -40,10 +40,10 @@ | ||||
|   <hr class="hr" /> | ||||
| 
 | ||||
|   <div class="flex flex-row justify-center items-center space-x-4"> | ||||
|     <.link href={Routes.user_session_path(@conn, :new)} class="btn btn-primary"> | ||||
|     <.link href={~p"/users/log_in"} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Log in") %> | ||||
|     </.link> | ||||
|     <.link href={Routes.user_reset_password_path(@conn, :new)} class="btn btn-primary"> | ||||
|     <.link href={~p"/users/reset_password"} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Forgot your password?") %> | ||||
|     </.link> | ||||
|   </div> | ||||
| @@ -6,14 +6,14 @@ defmodule CanneryWeb.UserResetPasswordController do | ||||
|   plug :get_user_by_reset_password_token when action in [:edit, :update] | ||||
|  | ||||
|   def new(conn, _params) do | ||||
|     render(conn, "new.html", page_title: gettext("Forgot your password?")) | ||||
|     render(conn, :new, page_title: gettext("Forgot your password?")) | ||||
|   end | ||||
|  | ||||
|   def create(conn, %{"user" => %{"email" => email}}) do | ||||
|     if user = Accounts.get_user_by_email(email) do | ||||
|       Accounts.deliver_user_reset_password_instructions( | ||||
|         user, | ||||
|         &Routes.user_reset_password_url(conn, :edit, &1) | ||||
|         fn token -> url(CanneryWeb.Endpoint, ~p"/users/reset_password/#{token}") end | ||||
|       ) | ||||
|     end | ||||
|  | ||||
| @@ -23,15 +23,14 @@ defmodule CanneryWeb.UserResetPasswordController do | ||||
|       :info, | ||||
|       dgettext( | ||||
|         "prompts", | ||||
|         "If your email is in our system, you will receive instructions to " <> | ||||
|           "reset your password shortly." | ||||
|         "If your email is in our system, you will receive instructions to reset your password shortly." | ||||
|       ) | ||||
|     ) | ||||
|     |> redirect(to: "/") | ||||
|     |> redirect(to: ~p"/") | ||||
|   end | ||||
|  | ||||
|   def edit(conn, _params) do | ||||
|     render(conn, "edit.html", | ||||
|     render(conn, :edit, | ||||
|       changeset: Accounts.change_user_password(conn.assigns.user), | ||||
|       page_title: gettext("Reset your password") | ||||
|     ) | ||||
| @@ -44,10 +43,10 @@ defmodule CanneryWeb.UserResetPasswordController do | ||||
|       {:ok, _} -> | ||||
|         conn | ||||
|         |> put_flash(:info, dgettext("prompts", "Password reset successfully.")) | ||||
|         |> redirect(to: Routes.user_session_path(conn, :new)) | ||||
|         |> redirect(to: ~p"/users/log_in") | ||||
|  | ||||
|       {:error, changeset} -> | ||||
|         render(conn, "edit.html", changeset: changeset) | ||||
|         render(conn, :edit, changeset: changeset) | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -62,7 +61,7 @@ defmodule CanneryWeb.UserResetPasswordController do | ||||
|         :error, | ||||
|         dgettext("errors", "Reset password link is invalid or it has expired.") | ||||
|       ) | ||||
|       |> redirect(to: "/") | ||||
|       |> redirect(to: ~p"/") | ||||
|       |> halt() | ||||
|     end | ||||
|   end | ||||
|   | ||||
							
								
								
									
										6
									
								
								lib/cannery_web/controllers/user_reset_password_html.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								lib/cannery_web/controllers/user_reset_password_html.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| defmodule CanneryWeb.UserResetPasswordHTML do | ||||
|   use CanneryWeb, :html | ||||
|   alias Cannery.Accounts | ||||
|  | ||||
|   embed_templates "user_reset_password_html/*" | ||||
| end | ||||
| @@ -6,7 +6,7 @@ | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={@changeset} | ||||
|     action={Routes.user_reset_password_path(@conn, :update, @token)} | ||||
|     action={~p"/users/reset_password/#{@token}"} | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|   > | ||||
|     <p :if={@changeset.action && not @changeset.valid?()} class="alert alert-danger col-span-3"> | ||||
| @@ -34,14 +34,10 @@ | ||||
|   <hr class="hr" /> | ||||
| 
 | ||||
|   <div class="flex flex-row justify-center items-center space-x-4"> | ||||
|     <.link | ||||
|       :if={Accounts.allow_registration?()} | ||||
|       href={Routes.user_registration_path(@conn, :new)} | ||||
|       class="btn btn-primary" | ||||
|     > | ||||
|     <.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Register") %> | ||||
|     </.link> | ||||
|     <.link href={Routes.user_session_path(@conn, :new)} class="btn btn-primary"> | ||||
|     <.link href={~p"/users/log_in"} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Log in") %> | ||||
|     </.link> | ||||
|   </div> | ||||
| @@ -7,7 +7,7 @@ | ||||
|     :let={f} | ||||
|     for={%{}} | ||||
|     as={:user} | ||||
|     action={Routes.user_reset_password_path(@conn, :create)} | ||||
|     action={~p"/users/reset_password"} | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|   > | ||||
|     <%= label(f, :email, gettext("Email"), class: "title text-lg text-primary-600") %> | ||||
| @@ -21,14 +21,10 @@ | ||||
|   <hr class="hr" /> | ||||
| 
 | ||||
|   <div class="flex flex-row justify-center items-center space-x-4"> | ||||
|     <.link | ||||
|       :if={Accounts.allow_registration?()} | ||||
|       href={Routes.user_registration_path(@conn, :new)} | ||||
|       class="btn btn-primary" | ||||
|     > | ||||
|     <.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Register") %> | ||||
|     </.link> | ||||
|     <.link href={Routes.user_session_path(@conn, :new)} class="btn btn-primary"> | ||||
|     <.link href={~p"/users/log_in"} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Log in") %> | ||||
|     </.link> | ||||
|   </div> | ||||
| @@ -5,7 +5,7 @@ defmodule CanneryWeb.UserSessionController do | ||||
|   alias CanneryWeb.UserAuth | ||||
|  | ||||
|   def new(conn, _params) do | ||||
|     render(conn, "new.html", error_message: nil, page_title: gettext("Log in")) | ||||
|     render(conn, :new, error_message: nil, page_title: gettext("Log in")) | ||||
|   end | ||||
|  | ||||
|   def create(conn, %{"user" => user_params}) do | ||||
| @@ -14,7 +14,7 @@ defmodule CanneryWeb.UserSessionController do | ||||
|     if user = Accounts.get_user_by_email_and_password(email, password) do | ||||
|       UserAuth.log_in_user(conn, user, user_params) | ||||
|     else | ||||
|       render(conn, "new.html", error_message: dgettext("errors", "Invalid email or password")) | ||||
|       render(conn, :new, error_message: dgettext("errors", "Invalid email or password")) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   | ||||
							
								
								
									
										6
									
								
								lib/cannery_web/controllers/user_session_html.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								lib/cannery_web/controllers/user_session_html.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| defmodule CanneryWeb.UserSessionHTML do | ||||
|   use CanneryWeb, :html | ||||
|   alias Cannery.Accounts | ||||
|  | ||||
|   embed_templates "user_session_html/*" | ||||
| end | ||||
| @@ -6,8 +6,8 @@ | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={@conn} | ||||
|     action={Routes.user_session_path(@conn, :create)} | ||||
|     as="user" | ||||
|     action={~p"/users/log_in"} | ||||
|     as={:user} | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|   > | ||||
|     <p :if={@error_message} class="alert alert-danger col-span-3"> | ||||
| @@ -31,14 +31,10 @@ | ||||
|   <hr class="hr" /> | ||||
| 
 | ||||
|   <div class="flex flex-row justify-center items-center space-x-4"> | ||||
|     <.link | ||||
|       :if={Accounts.allow_registration?()} | ||||
|       href={Routes.user_registration_path(@conn, :new)} | ||||
|       class="btn btn-primary" | ||||
|     > | ||||
|     <.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Register") %> | ||||
|     </.link> | ||||
|     <.link href={Routes.user_reset_password_path(@conn, :new)} class="btn btn-primary"> | ||||
|     <.link href={~p"/users/reset_password"} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Forgot your password?") %> | ||||
|     </.link> | ||||
|   </div> | ||||
| @@ -2,12 +2,12 @@ defmodule CanneryWeb.UserSettingsController do | ||||
|   use CanneryWeb, :controller | ||||
|   import CanneryWeb.Gettext | ||||
|   alias Cannery.Accounts | ||||
|   alias CanneryWeb.{HomeLive, UserAuth} | ||||
|   alias CanneryWeb.UserAuth | ||||
|  | ||||
|   plug :assign_email_and_password_changesets | ||||
|  | ||||
|   def edit(conn, _params) do | ||||
|     render(conn, "edit.html", page_title: gettext("Settings")) | ||||
|     render(conn, :edit, page_title: gettext("Settings")) | ||||
|   end | ||||
|  | ||||
|   def update(%{assigns: %{current_user: user}} = conn, %{ | ||||
| @@ -20,7 +20,7 @@ defmodule CanneryWeb.UserSettingsController do | ||||
|         Accounts.deliver_update_email_instructions( | ||||
|           applied_user, | ||||
|           user.email, | ||||
|           &Routes.user_settings_url(conn, :confirm_email, &1) | ||||
|           fn token -> url(CanneryWeb.Endpoint, ~p"/users/settings/confirm_email/#{token}") end | ||||
|         ) | ||||
|  | ||||
|         conn | ||||
| @@ -31,10 +31,10 @@ defmodule CanneryWeb.UserSettingsController do | ||||
|             "A link to confirm your email change has been sent to the new address." | ||||
|           ) | ||||
|         ) | ||||
|         |> redirect(to: Routes.user_settings_path(conn, :edit)) | ||||
|         |> redirect(to: ~p"/users/settings") | ||||
|  | ||||
|       {:error, changeset} -> | ||||
|         conn |> render("edit.html", email_changeset: changeset) | ||||
|         conn |> render(:edit, email_changeset: changeset) | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -47,11 +47,11 @@ defmodule CanneryWeb.UserSettingsController do | ||||
|       {:ok, user} -> | ||||
|         conn | ||||
|         |> put_flash(:info, dgettext("prompts", "Password updated successfully.")) | ||||
|         |> put_session(:user_return_to, Routes.user_settings_path(conn, :edit)) | ||||
|         |> put_session(:user_return_to, ~p"/users/settings") | ||||
|         |> UserAuth.log_in_user(user) | ||||
|  | ||||
|       {:error, changeset} -> | ||||
|         conn |> render("edit.html", password_changeset: changeset) | ||||
|         conn |> render(:edit, password_changeset: changeset) | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -63,10 +63,10 @@ defmodule CanneryWeb.UserSettingsController do | ||||
|       {:ok, _user} -> | ||||
|         conn | ||||
|         |> put_flash(:info, dgettext("prompts", "Language updated successfully.")) | ||||
|         |> redirect(to: Routes.user_settings_path(conn, :edit)) | ||||
|         |> redirect(to: ~p"/users/settings") | ||||
|  | ||||
|       {:error, changeset} -> | ||||
|         conn |> render("edit.html", locale_changeset: changeset) | ||||
|         conn |> render(:edit, locale_changeset: changeset) | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -75,7 +75,7 @@ defmodule CanneryWeb.UserSettingsController do | ||||
|       :ok -> | ||||
|         conn | ||||
|         |> put_flash(:info, dgettext("prompts", "Email changed successfully.")) | ||||
|         |> redirect(to: Routes.user_settings_path(conn, :edit)) | ||||
|         |> redirect(to: ~p"/users/settings") | ||||
|  | ||||
|       :error -> | ||||
|         conn | ||||
| @@ -83,7 +83,7 @@ defmodule CanneryWeb.UserSettingsController do | ||||
|           :error, | ||||
|           dgettext("errors", "Email change link is invalid or it has expired.") | ||||
|         ) | ||||
|         |> redirect(to: Routes.user_settings_path(conn, :edit)) | ||||
|         |> redirect(to: ~p"/users/settings") | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -93,11 +93,11 @@ defmodule CanneryWeb.UserSettingsController do | ||||
|  | ||||
|       conn | ||||
|       |> put_flash(:error, dgettext("prompts", "Your account has been deleted")) | ||||
|       |> redirect(to: Routes.live_path(conn, HomeLive)) | ||||
|       |> redirect(to: ~p"/") | ||||
|     else | ||||
|       conn | ||||
|       |> put_flash(:error, dgettext("errors", "Unable to delete user")) | ||||
|       |> redirect(to: Routes.user_settings_path(conn, :edit)) | ||||
|       |> redirect(to: ~p"/users/settings") | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   | ||||
							
								
								
									
										5
									
								
								lib/cannery_web/controllers/user_settings_html.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								lib/cannery_web/controllers/user_settings_html.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| defmodule CanneryWeb.UserSettingsHTML do | ||||
|   use CanneryWeb, :html | ||||
|  | ||||
|   embed_templates "user_settings_html/*" | ||||
| end | ||||
| @@ -8,7 +8,7 @@ | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={@email_changeset} | ||||
|     action={Routes.user_settings_path(@conn, :update)} | ||||
|     action={~p"/users/settings"} | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|   > | ||||
|     <h3 class="title text-primary-600 text-lg text-center col-span-3"> | ||||
| @@ -50,7 +50,7 @@ | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={@password_changeset} | ||||
|     action={Routes.user_settings_path(@conn, :update)} | ||||
|     action={~p"/users/settings"} | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|   > | ||||
|     <h3 class="title text-primary-600 text-lg text-center col-span-3"> | ||||
| @@ -104,7 +104,7 @@ | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={@locale_changeset} | ||||
|     action={Routes.user_settings_path(@conn, :update)} | ||||
|     action={~p"/users/settings"} | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|   > | ||||
|     <%= label(f, :locale, dgettext("actions", "Change Language"), | ||||
| @@ -142,17 +142,13 @@ | ||||
|   <hr class="hr" /> | ||||
| 
 | ||||
|   <div class="flex justify-center items-center"> | ||||
|     <.link | ||||
|       href={Routes.export_path(@conn, :export, :json)} | ||||
|       class="mx-4 my-2 btn btn-primary" | ||||
|       target="_blank" | ||||
|     > | ||||
|     <.link href={~p"/export/json"} class="mx-4 my-2 btn btn-primary" target="_blank"> | ||||
|       <%= dgettext("actions", "Export Data as JSON") %> | ||||
|     </.link> | ||||
| 
 | ||||
|     <.link | ||||
|       href={Routes.user_settings_path(@conn, :delete, @current_user)} | ||||
|       method={:delete} | ||||
|       href={~p"/users/settings/#{@current_user}"} | ||||
|       method="delete" | ||||
|       class="mx-4 my-2 btn btn-alert" | ||||
|       data-confirm={dgettext("prompts", "Are you sure you want to delete your account?")} | ||||
|     > | ||||
| @@ -20,7 +20,7 @@ defmodule CanneryWeb.Endpoint do | ||||
|     at: "/", | ||||
|     from: :cannery, | ||||
|     gzip: false, | ||||
|     only: ~w(css fonts images js favicon.ico robots.txt) | ||||
|     only: CanneryWeb.static_paths() | ||||
|  | ||||
|   # Code reloading can be explicitly enabled under the | ||||
|   # :code_reloader configuration of your endpoint. | ||||
|   | ||||
| @@ -1,8 +1,6 @@ | ||||
| defmodule CanneryWeb.ViewHelpers do | ||||
| defmodule CanneryWeb.HTMLHelpers do | ||||
|   @moduledoc """ | ||||
|   Contains common helpers that can be used in liveviews and regular views. These | ||||
|   are automatically imported into any Phoenix View using `use CanneryWeb, | ||||
|   :view` | ||||
|   Contains common helpers that are used for rendering | ||||
|   """ | ||||
| 
 | ||||
|   use Phoenix.Component | ||||
| @@ -1,221 +0,0 @@ | ||||
| <div class="flex flex-col space-y-8 justify-center items-center"> | ||||
|   <h1 class="title text-2xl title-primary-500"> | ||||
|     <%= gettext("Ammo") %> | ||||
|   </h1> | ||||
|  | ||||
|   <h2 | ||||
|     :if={@ammo_groups |> Enum.empty?() and @search |> is_nil()} | ||||
|     class="title text-xl text-primary-600" | ||||
|   > | ||||
|     <%= gettext("No Ammo") %> | ||||
|     <%= display_emoji("😔") %> | ||||
|   </h2> | ||||
|  | ||||
|   <%= cond do %> | ||||
|     <% @containers_count == 0 -> %> | ||||
|       <div class="flex justify-center items-center"> | ||||
|         <h2 class="m-2 title text-md text-primary-600"> | ||||
|           <%= dgettext("prompts", "You'll need to") %> | ||||
|         </h2> | ||||
|  | ||||
|         <.link navigate={Routes.container_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|           <%= dgettext("actions", "add a container first") %> | ||||
|         </.link> | ||||
|       </div> | ||||
|     <% @ammo_types_count == 0 -> %> | ||||
|       <div class="flex justify-center items-center"> | ||||
|         <h2 class="m-2 title text-md text-primary-600"> | ||||
|           <%= dgettext("prompts", "You'll need to") %> | ||||
|         </h2> | ||||
|  | ||||
|         <.link navigate={Routes.ammo_type_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|           <%= dgettext("actions", "add an ammo type first") %> | ||||
|         </.link> | ||||
|       </div> | ||||
|     <% @ammo_groups |> Enum.empty?() and @search |> is_nil() -> %> | ||||
|       <.link patch={Routes.ammo_group_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|         <%= dgettext("actions", "Add your first box!") %> | ||||
|       </.link> | ||||
|     <% true -> %> | ||||
|       <.link patch={Routes.ammo_group_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|         <%= dgettext("actions", "Add Ammo") %> | ||||
|       </.link> | ||||
|   <% end %> | ||||
|  | ||||
|   <div class="w-full flex flex-col sm:flex-row justify-center items-center space-y-4 sm:space-y-0 sm:space-x-4 max-w-xl"> | ||||
|     <.form | ||||
|       :let={f} | ||||
|       for={%{}} | ||||
|       as={:search} | ||||
|       phx-change="search" | ||||
|       phx-submit="search" | ||||
|       class="grow self-stretch flex flex-col items-stretch" | ||||
|     > | ||||
|       <%= text_input(f, :search_term, | ||||
|         class: "input input-primary", | ||||
|         value: @search, | ||||
|         role: "search", | ||||
|         phx_debounce: 300, | ||||
|         placeholder: gettext("Search ammo") | ||||
|       ) %> | ||||
|     </.form> | ||||
|  | ||||
|     <.toggle_button action="toggle_show_used" value={@show_used}> | ||||
|       <span class="title text-lg text-primary-600"> | ||||
|         <%= gettext("Show used") %> | ||||
|       </span> | ||||
|     </.toggle_button> | ||||
|   </div> | ||||
|  | ||||
|   <%= if @ammo_groups |> Enum.empty?() do %> | ||||
|     <h2 class="title text-xl text-primary-600"> | ||||
|       <%= gettext("No Ammo") %> | ||||
|       <%= display_emoji("😔") %> | ||||
|     </h2> | ||||
|   <% else %> | ||||
|     <.live_component | ||||
|       module={CanneryWeb.Components.AmmoGroupTableComponent} | ||||
|       id="ammo-group-index-table" | ||||
|       ammo_groups={@ammo_groups} | ||||
|       current_user={@current_user} | ||||
|     > | ||||
|       <:ammo_type :let={%{name: ammo_type_name} = ammo_type}> | ||||
|         <.link navigate={Routes.ammo_type_show_path(Endpoint, :show, ammo_type)} class="link"> | ||||
|           <%= ammo_type_name %> | ||||
|         </.link> | ||||
|       </:ammo_type> | ||||
|       <:range :let={ammo_group}> | ||||
|         <div class="min-w-20 py-2 px-4 h-full flex flew-wrap justify-center items-center"> | ||||
|           <button | ||||
|             type="button" | ||||
|             class="mx-2 my-1 text-sm btn btn-primary" | ||||
|             phx-click="toggle_staged" | ||||
|             phx-value-ammo_group_id={ammo_group.id} | ||||
|           > | ||||
|             <%= if ammo_group.staged, | ||||
|               do: dgettext("actions", "Unstage"), | ||||
|               else: dgettext("actions", "Stage") %> | ||||
|           </button> | ||||
|  | ||||
|           <.link | ||||
|             patch={Routes.ammo_group_index_path(Endpoint, :add_shot_group, ammo_group)} | ||||
|             class="mx-2 my-1 text-sm btn btn-primary" | ||||
|           > | ||||
|             <%= dgettext("actions", "Record shots") %> | ||||
|           </.link> | ||||
|         </div> | ||||
|       </:range> | ||||
|       <:container :let={{ammo_group, %{name: container_name} = container}}> | ||||
|         <div class="min-w-20 py-2 px-4 h-full flex flew-wrap justify-center items-center"> | ||||
|           <.link | ||||
|             navigate={Routes.container_show_path(Endpoint, :show, container)} | ||||
|             class="mx-2 my-1 link" | ||||
|           > | ||||
|             <%= container_name %> | ||||
|           </.link> | ||||
|  | ||||
|           <.link | ||||
|             patch={Routes.ammo_group_index_path(Endpoint, :move, ammo_group)} | ||||
|             class="mx-2 my-1 text-sm btn btn-primary" | ||||
|           > | ||||
|             <%= dgettext("actions", "Move ammo") %> | ||||
|           </.link> | ||||
|         </div> | ||||
|       </:container> | ||||
|       <:actions :let={%{count: ammo_group_count} = ammo_group}> | ||||
|         <div class="py-2 px-4 h-full space-x-4 flex justify-center items-center"> | ||||
|           <.link | ||||
|             navigate={Routes.ammo_group_show_path(Endpoint, :show, ammo_group)} | ||||
|             class="text-primary-600 link" | ||||
|             aria-label={ | ||||
|               dgettext("actions", "View ammo group of %{ammo_group_count} bullets", | ||||
|                 ammo_group_count: ammo_group_count | ||||
|               ) | ||||
|             } | ||||
|           > | ||||
|             <i class="fa-fw fa-lg fas fa-eye"></i> | ||||
|           </.link> | ||||
|  | ||||
|           <.link | ||||
|             patch={Routes.ammo_group_index_path(Endpoint, :edit, ammo_group)} | ||||
|             class="text-primary-600 link" | ||||
|             aria-label={ | ||||
|               dgettext("actions", "Edit ammo group of %{ammo_group_count} bullets", | ||||
|                 ammo_group_count: ammo_group_count | ||||
|               ) | ||||
|             } | ||||
|           > | ||||
|             <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|           </.link> | ||||
|  | ||||
|           <.link | ||||
|             patch={Routes.ammo_group_index_path(Endpoint, :clone, ammo_group)} | ||||
|             class="text-primary-600 link" | ||||
|             aria-label={ | ||||
|               dgettext("actions", "Clone ammo group of %{ammo_group_count} bullets", | ||||
|                 ammo_group_count: ammo_group_count | ||||
|               ) | ||||
|             } | ||||
|           > | ||||
|             <i class="fa-fw fa-lg fas fa-copy"></i> | ||||
|           </.link> | ||||
|  | ||||
|           <.link | ||||
|             href="#" | ||||
|             class="text-primary-600 link" | ||||
|             phx-click="delete" | ||||
|             phx-value-id={ammo_group.id} | ||||
|             data-confirm={dgettext("prompts", "Are you sure you want to delete this ammo?")} | ||||
|             aria-label={ | ||||
|               dgettext("actions", "Delete ammo group of %{ammo_group_count} bullets", | ||||
|                 ammo_group_count: ammo_group_count | ||||
|               ) | ||||
|             } | ||||
|           > | ||||
|             <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|           </.link> | ||||
|         </div> | ||||
|       </:actions> | ||||
|     </.live_component> | ||||
|   <% end %> | ||||
| </div> | ||||
|  | ||||
| <%= case @live_action do %> | ||||
|   <% create when create in [:new, :edit, :clone] -> %> | ||||
|     <.modal return_to={Routes.ammo_group_index_path(Endpoint, :index)}> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.AmmoGroupLive.FormComponent} | ||||
|         id={@ammo_group.id || :new} | ||||
|         title={@page_title} | ||||
|         action={@live_action} | ||||
|         ammo_group={@ammo_group} | ||||
|         return_to={Routes.ammo_group_index_path(Endpoint, :index)} | ||||
|         current_user={@current_user} | ||||
|       /> | ||||
|     </.modal> | ||||
|   <% :add_shot_group -> %> | ||||
|     <.modal return_to={Routes.ammo_group_index_path(Endpoint, :index)}> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.Components.AddShotGroupComponent} | ||||
|         id={:new} | ||||
|         title={@page_title} | ||||
|         action={@live_action} | ||||
|         ammo_group={@ammo_group} | ||||
|         return_to={Routes.ammo_group_index_path(Endpoint, :index)} | ||||
|         current_user={@current_user} | ||||
|       /> | ||||
|     </.modal> | ||||
|   <% :move -> %> | ||||
|     <.modal return_to={Routes.ammo_group_index_path(Endpoint, :index)}> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.Components.MoveAmmoGroupComponent} | ||||
|         id={@ammo_group.id} | ||||
|         title={@page_title} | ||||
|         action={@live_action} | ||||
|         ammo_group={@ammo_group} | ||||
|         return_to={Routes.ammo_group_index_path(Endpoint, :index)} | ||||
|         current_user={@current_user} | ||||
|       /> | ||||
|     </.modal> | ||||
|   <% _ -> %> | ||||
| <% end %> | ||||
| @@ -1,164 +0,0 @@ | ||||
| <div> | ||||
|   <h2 class="mb-8 text-center title text-xl text-primary-600"> | ||||
|     <%= @title %> | ||||
|   </h2> | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={@changeset} | ||||
|     id="ammo_type-form" | ||||
|     phx-target={@myself} | ||||
|     phx-change="validate" | ||||
|     phx-submit="save" | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|   > | ||||
|     <div | ||||
|       :if={@changeset.action && not @changeset.valid?()} | ||||
|       class="invalid-feedback col-span-3 text-center" | ||||
|     > | ||||
|       <%= changeset_errors(@changeset) %> | ||||
|     </div> | ||||
|  | ||||
|     <%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :name, class: "text-center col-span-2 input input-primary") %> | ||||
|     <%= error_tag(f, :name, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :desc, gettext("Description"), class: "title text-lg text-primary-600") %> | ||||
|     <%= textarea(f, :desc, | ||||
|       id: "ammo-type-form-desc", | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       phx_hook: "MaintainAttrs", | ||||
|       phx_update: "ignore" | ||||
|     ) %> | ||||
|     <%= error_tag(f, :desc, "col-span-3 text-center") %> | ||||
|  | ||||
|     <.link | ||||
|       href="https://shootersreference.com/reloadingdata/bullet_abbreviations/" | ||||
|       class="col-span-3 text-center link title text-md text-primary-600" | ||||
|     > | ||||
|       <%= gettext("Example bullet type abbreviations") %> | ||||
|     </.link> | ||||
|     <%= label(f, :bullet_type, gettext("Bullet type"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :bullet_type, | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       placeholder: gettext("FMJ") | ||||
|     ) %> | ||||
|     <%= error_tag(f, :bullet_type, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :bullet_core, gettext("Bullet core"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :bullet_core, | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       placeholder: gettext("Steel") | ||||
|     ) %> | ||||
|     <%= error_tag(f, :bullet_core, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :cartridge, gettext("Cartridge"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :cartridge, | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       placeholder: gettext("5.56x46mm NATO") | ||||
|     ) %> | ||||
|     <%= error_tag(f, :cartridge, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :caliber, gettext("Caliber"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :caliber, | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       placeholder: gettext(".223") | ||||
|     ) %> | ||||
|     <%= error_tag(f, :caliber, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :case_material, gettext("Case material"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :case_material, | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       placeholder: gettext("Brass") | ||||
|     ) %> | ||||
|     <%= error_tag(f, :case_material, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :jacket_type, gettext("Jacket type"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :jacket_type, | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       placeholder: gettext("Bimetal") | ||||
|     ) %> | ||||
|     <%= error_tag(f, :case_material, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :muzzle_velocity, gettext("Muzzle velocity"), | ||||
|       class: "title text-lg text-primary-600" | ||||
|     ) %> | ||||
|     <%= number_input(f, :muzzle_velocity, | ||||
|       step: "1", | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       min: 1 | ||||
|     ) %> | ||||
|     <%= error_tag(f, :muzzle_velocity, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :powder_type, gettext("Powder type"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :powder_type, class: "text-center col-span-2 input input-primary") %> | ||||
|     <%= error_tag(f, :powder_type, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :powder_grains_per_charge, gettext("Powder grains per charge"), | ||||
|       class: "title text-lg text-primary-600" | ||||
|     ) %> | ||||
|     <%= number_input(f, :powder_grains_per_charge, | ||||
|       step: "1", | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       min: 1 | ||||
|     ) %> | ||||
|     <%= error_tag(f, :powder_grains_per_charge, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :grains, gettext("Grains"), class: "title text-lg text-primary-600") %> | ||||
|     <%= number_input(f, :grains, | ||||
|       step: "1", | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       min: 1 | ||||
|     ) %> | ||||
|     <%= error_tag(f, :grains, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :pressure, gettext("Pressure"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :pressure, | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       placeholder: gettext("+P") | ||||
|     ) %> | ||||
|     <%= error_tag(f, :pressure, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :primer_type, gettext("Primer type"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :primer_type, | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       placeholder: gettext("Boxer") | ||||
|     ) %> | ||||
|     <%= error_tag(f, :primer_type, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :firing_type, gettext("Firing type"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :firing_type, | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       placeholder: gettext("Centerfire") | ||||
|     ) %> | ||||
|     <%= error_tag(f, :firing_type, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :tracer, gettext("Tracer"), class: "title text-lg text-primary-600") %> | ||||
|     <%= checkbox(f, :tracer, class: "text-center col-span-2 checkbox") %> | ||||
|     <%= error_tag(f, :tracer, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :incendiary, gettext("Incendiary"), class: "title text-lg text-primary-600") %> | ||||
|     <%= checkbox(f, :incendiary, class: "text-center col-span-2 checkbox") %> | ||||
|     <%= error_tag(f, :incendiary, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :blank, gettext("Blank"), class: "title text-lg text-primary-600") %> | ||||
|     <%= checkbox(f, :blank, class: "text-center col-span-2 checkbox") %> | ||||
|     <%= error_tag(f, :blank, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :corrosive, gettext("Corrosive"), class: "title text-lg text-primary-600") %> | ||||
|     <%= checkbox(f, :corrosive, class: "text-center col-span-2 checkbox") %> | ||||
|     <%= error_tag(f, :corrosive, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :manufacturer, gettext("Manufacturer"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :manufacturer, class: "text-center col-span-2 input input-primary") %> | ||||
|     <%= error_tag(f, :manufacturer, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= label(f, :upc, gettext("UPC"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :upc, class: "text-center col-span-2 input input-primary") %> | ||||
|     <%= error_tag(f, :upc, "col-span-3 text-center") %> | ||||
|  | ||||
|     <%= submit(dgettext("actions", "Save"), | ||||
|       phx_disable_with: dgettext("prompts", "Saving..."), | ||||
|       class: "mx-auto col-span-3 btn btn-primary" | ||||
|     ) %> | ||||
|   </.form> | ||||
| </div> | ||||
| @@ -1,92 +0,0 @@ | ||||
| defmodule CanneryWeb.AmmoTypeLive.Index do | ||||
|   @moduledoc """ | ||||
|   Liveview for showing a Cannery.Ammo.AmmoType index | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :live_view | ||||
|   alias Cannery.{Ammo, Ammo.AmmoType} | ||||
|  | ||||
|   @impl true | ||||
|   def mount(%{"search" => search}, _session, socket) do | ||||
|     {:ok, socket |> assign(show_used: false, search: search) |> list_ammo_types()} | ||||
|   end | ||||
|  | ||||
|   def mount(_params, _session, socket) do | ||||
|     {:ok, socket |> assign(show_used: false, search: nil) |> list_ammo_types()} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_params(params, _url, %{assigns: %{live_action: live_action}} = socket) do | ||||
|     {:noreply, apply_action(socket, live_action, params)} | ||||
|   end | ||||
|  | ||||
|   defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"id" => id}) do | ||||
|     %{name: ammo_type_name} = ammo_type = Ammo.get_ammo_type!(id, current_user) | ||||
|  | ||||
|     socket | ||||
|     |> assign( | ||||
|       page_title: gettext("Edit %{ammo_type_name}", ammo_type_name: ammo_type_name), | ||||
|       ammo_type: ammo_type | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   defp apply_action(%{assigns: %{current_user: current_user}} = socket, :clone, %{"id" => id}) do | ||||
|     socket | ||||
|     |> assign( | ||||
|       page_title: gettext("New Ammo type"), | ||||
|       ammo_type: %{Ammo.get_ammo_type!(id, current_user) | id: nil} | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   defp apply_action(socket, :new, _params) do | ||||
|     socket | ||||
|     |> assign( | ||||
|       page_title: gettext("New Ammo type"), | ||||
|       ammo_type: %AmmoType{} | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   defp apply_action(socket, :index, _params) do | ||||
|     socket | ||||
|     |> assign( | ||||
|       page_title: gettext("Catalog"), | ||||
|       search: nil, | ||||
|       ammo_type: nil | ||||
|     ) | ||||
|     |> list_ammo_types() | ||||
|   end | ||||
|  | ||||
|   defp apply_action(socket, :search, %{"search" => search}) do | ||||
|     socket | ||||
|     |> assign( | ||||
|       page_title: gettext("Catalog"), | ||||
|       search: search, | ||||
|       ammo_type: nil | ||||
|     ) | ||||
|     |> list_ammo_types() | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("delete", %{"id" => id}, %{assigns: %{current_user: current_user}} = socket) do | ||||
|     %{name: name} = Ammo.get_ammo_type!(id, current_user) |> Ammo.delete_ammo_type!(current_user) | ||||
|     prompt = dgettext("prompts", "%{name} deleted succesfully", name: name) | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> list_ammo_types()} | ||||
|   end | ||||
|  | ||||
|   def handle_event("toggle_show_used", _params, %{assigns: %{show_used: show_used}} = socket) do | ||||
|     {:noreply, socket |> assign(:show_used, !show_used) |> list_ammo_types()} | ||||
|   end | ||||
|  | ||||
|   def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do | ||||
|     {:noreply, socket |> push_patch(to: Routes.ammo_type_index_path(Endpoint, :index))} | ||||
|   end | ||||
|  | ||||
|   def handle_event("search", %{"search" => %{"search_term" => search_term}}, socket) do | ||||
|     search_path = Routes.ammo_type_index_path(Endpoint, :search, search_term) | ||||
|     {:noreply, socket |> push_patch(to: search_path)} | ||||
|   end | ||||
|  | ||||
|   defp list_ammo_types(%{assigns: %{search: search, current_user: current_user}} = socket) do | ||||
|     socket |> assign(ammo_types: Ammo.list_ammo_types(search, current_user)) | ||||
|   end | ||||
| end | ||||
| @@ -1,151 +0,0 @@ | ||||
| defmodule CanneryWeb.AmmoTypeLive.Show do | ||||
|   @moduledoc """ | ||||
|   Liveview for showing and editing an Cannery.Ammo.AmmoType | ||||
|   """ | ||||
|  | ||||
|   use CanneryWeb, :live_view | ||||
|   alias Cannery.{ActivityLog, Ammo, Ammo.AmmoType} | ||||
|   alias CanneryWeb.Endpoint | ||||
|  | ||||
|   @fields_list [ | ||||
|     %{label: gettext("Bullet type:"), key: :bullet_type, type: :string}, | ||||
|     %{label: gettext("Bullet core:"), key: :bullet_core, type: :string}, | ||||
|     %{label: gettext("Cartridge:"), key: :cartridge, type: :string}, | ||||
|     %{label: gettext("Caliber:"), key: :caliber, type: :string}, | ||||
|     %{label: gettext("Case material:"), key: :case_material, type: :string}, | ||||
|     %{label: gettext("Jacket type:"), key: :jacket_type, type: :string}, | ||||
|     %{label: gettext("Muzzle velocity:"), key: :muzzle_velocity, type: :string}, | ||||
|     %{label: gettext("Powder type:"), key: :powder_type, type: :string}, | ||||
|     %{label: gettext("Powder grains per charge:"), key: :powder_grains_per_charge, type: :string}, | ||||
|     %{label: gettext("Grains:"), key: :grains, type: :string}, | ||||
|     %{label: gettext("Pressure:"), key: :pressure, type: :string}, | ||||
|     %{label: gettext("Primer type:"), key: :primer_type, type: :string}, | ||||
|     %{label: gettext("Firing type:"), key: :firing_type, type: :string}, | ||||
|     %{label: gettext("Tracer:"), key: :tracer, type: :boolean}, | ||||
|     %{label: gettext("Incendiary:"), key: :incendiary, type: :boolean}, | ||||
|     %{label: gettext("Blank:"), key: :blank, type: :boolean}, | ||||
|     %{label: gettext("Corrosive:"), key: :corrosive, type: :boolean}, | ||||
|     %{label: gettext("Manufacturer:"), key: :manufacturer, type: :string}, | ||||
|     %{label: gettext("UPC:"), key: :upc, type: :string} | ||||
|   ] | ||||
|  | ||||
|   @impl true | ||||
|   def mount(_params, _session, %{assigns: %{live_action: live_action}} = socket), | ||||
|     do: {:ok, socket |> assign(show_used: false, view_table: live_action == :table)} | ||||
|  | ||||
|   @impl true | ||||
|   def handle_params(%{"id" => id}, _params, %{assigns: %{live_action: live_action}} = socket) do | ||||
|     socket = | ||||
|       socket | ||||
|       |> assign(view_table: live_action == :table) | ||||
|       |> display_ammo_type(id) | ||||
|  | ||||
|     {:noreply, socket} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event( | ||||
|         "delete", | ||||
|         _params, | ||||
|         %{assigns: %{ammo_type: ammo_type, current_user: current_user}} = socket | ||||
|       ) do | ||||
|     %{name: ammo_type_name} = ammo_type |> Ammo.delete_ammo_type!(current_user) | ||||
|  | ||||
|     prompt = dgettext("prompts", "%{name} deleted succesfully", name: ammo_type_name) | ||||
|     redirect_to = Routes.ammo_type_index_path(socket, :index) | ||||
|  | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> push_navigate(to: redirect_to)} | ||||
|   end | ||||
|  | ||||
|   def handle_event("toggle_show_used", _params, %{assigns: %{show_used: show_used}} = socket) do | ||||
|     {:noreply, socket |> assign(:show_used, !show_used) |> display_ammo_type()} | ||||
|   end | ||||
|  | ||||
|   def handle_event( | ||||
|         "toggle_table", | ||||
|         _params, | ||||
|         %{assigns: %{view_table: view_table, ammo_type: ammo_type}} = socket | ||||
|       ) do | ||||
|     new_path = | ||||
|       if view_table, | ||||
|         do: Routes.ammo_type_show_path(Endpoint, :show, ammo_type), | ||||
|         else: Routes.ammo_type_show_path(Endpoint, :table, ammo_type) | ||||
|  | ||||
|     {:noreply, socket |> push_patch(to: new_path)} | ||||
|   end | ||||
|  | ||||
|   defp display_ammo_type( | ||||
|          %{assigns: %{live_action: live_action, current_user: current_user, show_used: show_used}} = | ||||
|            socket, | ||||
|          %AmmoType{} = ammo_type | ||||
|        ) do | ||||
|     fields_to_display = | ||||
|       @fields_list | ||||
|       |> Enum.any?(fn %{key: field, type: type} -> | ||||
|         default_value = | ||||
|           case type do | ||||
|             :boolean -> false | ||||
|             _other_type -> nil | ||||
|           end | ||||
|  | ||||
|         ammo_type |> Map.get(field) != default_value | ||||
|       end) | ||||
|  | ||||
|     ammo_groups = ammo_type |> Ammo.list_ammo_groups_for_type(current_user, show_used) | ||||
|  | ||||
|     [ | ||||
|       original_counts, | ||||
|       used_packs_count, | ||||
|       historical_packs_count, | ||||
|       used_rounds, | ||||
|       historical_round_count | ||||
|     ] = | ||||
|       if show_used do | ||||
|         [ | ||||
|           ammo_groups |> Ammo.get_original_counts(current_user), | ||||
|           ammo_type |> Ammo.get_used_ammo_groups_count_for_type(current_user), | ||||
|           ammo_type |> Ammo.get_ammo_groups_count_for_type(current_user, true), | ||||
|           ammo_type |> ActivityLog.get_used_count_for_ammo_type(current_user), | ||||
|           ammo_type |> Ammo.get_historical_count_for_ammo_type(current_user) | ||||
|         ] | ||||
|       else | ||||
|         [nil, nil, nil, nil, nil] | ||||
|       end | ||||
|  | ||||
|     socket | ||||
|     |> assign( | ||||
|       page_title: page_title(live_action, ammo_type), | ||||
|       ammo_type: ammo_type, | ||||
|       ammo_groups: ammo_groups, | ||||
|       cprs: ammo_groups |> Ammo.get_cprs(current_user), | ||||
|       last_used_dates: ammo_groups |> ActivityLog.get_last_used_dates(current_user), | ||||
|       avg_cost_per_round: ammo_type |> Ammo.get_average_cost_for_ammo_type(current_user), | ||||
|       rounds: ammo_type |> Ammo.get_round_count_for_ammo_type(current_user), | ||||
|       original_counts: original_counts, | ||||
|       used_rounds: used_rounds, | ||||
|       historical_round_count: historical_round_count, | ||||
|       packs_count: ammo_type |> Ammo.get_ammo_groups_count_for_type(current_user), | ||||
|       used_packs_count: used_packs_count, | ||||
|       historical_packs_count: historical_packs_count, | ||||
|       fields_list: @fields_list, | ||||
|       fields_to_display: fields_to_display | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   defp display_ammo_type(%{assigns: %{current_user: current_user}} = socket, ammo_type_id) do | ||||
|     socket |> display_ammo_type(Ammo.get_ammo_type!(ammo_type_id, current_user)) | ||||
|   end | ||||
|  | ||||
|   defp display_ammo_type(%{assigns: %{ammo_type: ammo_type}} = socket) do | ||||
|     socket |> display_ammo_type(ammo_type) | ||||
|   end | ||||
|  | ||||
|   @spec display_currency(float()) :: String.t() | ||||
|   defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2) | ||||
|  | ||||
|   defp page_title(action, %{name: ammo_type_name}) when action in [:show, :table], | ||||
|     do: ammo_type_name | ||||
|  | ||||
|   defp page_title(:edit, %{name: ammo_type_name}), | ||||
|     do: gettext("Edit %{ammo_type_name}", ammo_type_name: ammo_type_name) | ||||
| end | ||||
| @@ -21,7 +21,8 @@ | ||||
|     <%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :name, | ||||
|       class: "input input-primary col-span-2", | ||||
|       placeholder: gettext("My cool ammo can") | ||||
|       placeholder: gettext("My cool ammo can"), | ||||
|       maxlength: 255 | ||||
|     ) %> | ||||
|     <%= error_tag(f, :name, "col-span-3 text-center") %> | ||||
|  | ||||
| @@ -38,7 +39,8 @@ | ||||
|     <%= label(f, :type, gettext("Type"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :type, | ||||
|       class: "input input-primary col-span-2", | ||||
|       placeholder: gettext("Magazine, Clip, Ammo Box, etc") | ||||
|       placeholder: gettext("Magazine, Clip, Ammo Box, etc"), | ||||
|       maxlength: 255 | ||||
|     ) %> | ||||
|     <%= error_tag(f, :type, "col-span-3 text-center") %> | ||||
|  | ||||
|   | ||||
| @@ -79,15 +79,15 @@ defmodule CanneryWeb.ContainerLive.Index do | ||||
|               prompt = dgettext("prompts", "%{name} has been deleted", name: container_name) | ||||
|               socket |> put_flash(:info, prompt) |> display_containers() | ||||
|  | ||||
|             {:error, %{action: :delete, errors: [ammo_groups: _error], valid?: false} = changeset} -> | ||||
|               ammo_groups_error = changeset |> changeset_errors(:ammo_groups) |> Enum.join(", ") | ||||
|             {:error, %{action: :delete, errors: [packs: _error], valid?: false} = changeset} -> | ||||
|               packs_error = changeset |> changeset_errors(:packs) |> Enum.join(", ") | ||||
|  | ||||
|               prompt = | ||||
|                 dgettext( | ||||
|                   "errors", | ||||
|                   "Could not delete %{name}: %{error}", | ||||
|                   name: changeset |> Changeset.get_field(:name, "container"), | ||||
|                   error: ammo_groups_error | ||||
|                   error: packs_error | ||||
|                 ) | ||||
|  | ||||
|               socket |> put_flash(:error, prompt) | ||||
| @@ -105,12 +105,11 @@ defmodule CanneryWeb.ContainerLive.Index do | ||||
|   end | ||||
|  | ||||
|   def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do | ||||
|     {:noreply, socket |> push_patch(to: Routes.container_index_path(Endpoint, :index))} | ||||
|     {:noreply, socket |> push_patch(to: ~p"/containers")} | ||||
|   end | ||||
|  | ||||
|   def handle_event("search", %{"search" => %{"search_term" => search_term}}, socket) do | ||||
|     {:noreply, | ||||
|      socket |> push_patch(to: Routes.container_index_path(Endpoint, :search, search_term))} | ||||
|     {:noreply, socket |> push_patch(to: ~p"/containers/search/#{search_term}")} | ||||
|   end | ||||
|  | ||||
|   defp display_containers(%{assigns: %{search: search, current_user: current_user}} = socket) do | ||||
|   | ||||
| @@ -9,25 +9,25 @@ | ||||
|       <%= display_emoji("😔") %> | ||||
|     </h2> | ||||
|  | ||||
|     <.link patch={Routes.container_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|     <.link patch={~p"/containers/new"} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Add your first container!") %> | ||||
|     </.link> | ||||
|   <% else %> | ||||
|     <.link patch={Routes.container_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|     <.link patch={~p"/containers/new"} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "New Container") %> | ||||
|     </.link> | ||||
|  | ||||
|     <div class="w-full flex flex-col sm:flex-row justify-center items-center space-y-4 sm:space-y-0 sm:space-x-4 max-w-xl"> | ||||
|     <div class="w-full flex flex-col sm:flex-row justify-center items-center space-y-4 sm:space-y-0 sm:space-x-4 max-w-2xl"> | ||||
|       <.form | ||||
|         :let={f} | ||||
|         for={%{}} | ||||
|         as={:search} | ||||
|         phx-change="search" | ||||
|         phx-submit="search" | ||||
|         class="grow self-stretch flex flex-col items-stretch" | ||||
|         class="grow flex items-center" | ||||
|       > | ||||
|         <%= text_input(f, :search_term, | ||||
|           class: "input input-primary", | ||||
|           class: "grow input input-primary", | ||||
|           value: @search, | ||||
|           role: "search", | ||||
|           phx_debounce: 300, | ||||
| @@ -41,83 +41,25 @@ | ||||
|         </span> | ||||
|       </.toggle_button> | ||||
|     </div> | ||||
|   <% end %> | ||||
|  | ||||
|   <%= if @containers |> Enum.empty?() do %> | ||||
|     <h2 class="title text-xl text-primary-600"> | ||||
|       <%= gettext("No containers") %> | ||||
|       <%= display_emoji("😔") %> | ||||
|     </h2> | ||||
|   <% else %> | ||||
|     <%= if @view_table do %> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.Components.ContainerTableComponent} | ||||
|         id="containers_index_table" | ||||
|         action={@live_action} | ||||
|         containers={@containers} | ||||
|         current_user={@current_user} | ||||
|       > | ||||
|         <:tag_actions :let={container}> | ||||
|           <div class="mx-4 my-2"> | ||||
|             <.link | ||||
|               patch={Routes.container_index_path(Endpoint, :edit_tags, container)} | ||||
|               class="text-primary-600 link" | ||||
|               aria-label={ | ||||
|                 dgettext("actions", "Tag %{container_name}", container_name: container.name) | ||||
|               } | ||||
|             > | ||||
|               <i class="fa-fw fa-lg fas fa-tags"></i> | ||||
|             </.link> | ||||
|           </div> | ||||
|         </:tag_actions> | ||||
|         <:actions :let={container}> | ||||
|           <.link | ||||
|             patch={Routes.container_index_path(Endpoint, :edit, container)} | ||||
|             class="text-primary-600 link" | ||||
|             aria-label={ | ||||
|               dgettext("actions", "Edit %{container_name}", container_name: container.name) | ||||
|             } | ||||
|           > | ||||
|             <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|           </.link> | ||||
|  | ||||
|           <.link | ||||
|             patch={Routes.container_index_path(Endpoint, :clone, container)} | ||||
|             class="text-primary-600 link" | ||||
|             aria-label={ | ||||
|               dgettext("actions", "Clone %{container_name}", container_name: container.name) | ||||
|             } | ||||
|           > | ||||
|             <i class="fa-fw fa-lg fas fa-copy"></i> | ||||
|           </.link> | ||||
|  | ||||
|           <.link | ||||
|             href="#" | ||||
|             class="text-primary-600 link" | ||||
|             phx-click="delete" | ||||
|             phx-value-id={container.id} | ||||
|             data-confirm={ | ||||
|               dgettext("prompts", "Are you sure you want to delete %{name}?", name: container.name) | ||||
|             } | ||||
|             aria-label={ | ||||
|               dgettext("actions", "Delete %{container_name}", container_name: container.name) | ||||
|             } | ||||
|           > | ||||
|             <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|           </.link> | ||||
|         </:actions> | ||||
|       </.live_component> | ||||
|     <%= if @containers |> Enum.empty?() do %> | ||||
|       <h2 class="title text-xl text-primary-600"> | ||||
|         <%= gettext("No containers") %> | ||||
|         <%= display_emoji("😔") %> | ||||
|       </h2> | ||||
|     <% else %> | ||||
|       <div class="w-full flex flex-row flex-wrap justify-center items-stretch"> | ||||
|         <.container_card | ||||
|           :for={container <- @containers} | ||||
|           container={container} | ||||
|       <%= if @view_table do %> | ||||
|         <.live_component | ||||
|           module={CanneryWeb.Components.ContainerTableComponent} | ||||
|           id="containers_index_table" | ||||
|           action={@live_action} | ||||
|           containers={@containers} | ||||
|           current_user={@current_user} | ||||
|         > | ||||
|           <:tag_actions> | ||||
|           <:tag_actions :let={container}> | ||||
|             <div class="mx-4 my-2"> | ||||
|               <.link | ||||
|                 patch={Routes.container_index_path(Endpoint, :edit_tags, container)} | ||||
|                 patch={~p"/containers/edit_tags/#{container}"} | ||||
|                 class="text-primary-600 link" | ||||
|                 aria-label={ | ||||
|                   dgettext("actions", "Tag %{container_name}", container_name: container.name) | ||||
| @@ -127,68 +69,130 @@ | ||||
|               </.link> | ||||
|             </div> | ||||
|           </:tag_actions> | ||||
|           <.link | ||||
|             patch={Routes.container_index_path(Endpoint, :edit, container)} | ||||
|             class="text-primary-600 link" | ||||
|             aria-label={ | ||||
|               dgettext("actions", "Edit %{container_name}", container_name: container.name) | ||||
|             } | ||||
|           > | ||||
|             <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|           </.link> | ||||
|           <:actions :let={container}> | ||||
|             <.link | ||||
|               patch={~p"/containers/edit/#{container}"} | ||||
|               class="text-primary-600 link" | ||||
|               aria-label={ | ||||
|                 dgettext("actions", "Edit %{container_name}", container_name: container.name) | ||||
|               } | ||||
|             > | ||||
|               <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|             </.link> | ||||
|  | ||||
|           <.link | ||||
|             patch={Routes.container_index_path(Endpoint, :clone, container)} | ||||
|             class="text-primary-600 link" | ||||
|             aria-label={ | ||||
|               dgettext("actions", "Clone %{container_name}", container_name: container.name) | ||||
|             } | ||||
|           > | ||||
|             <i class="fa-fw fa-lg fas fa-copy"></i> | ||||
|           </.link> | ||||
|             <.link | ||||
|               patch={~p"/containers/clone/#{container}"} | ||||
|               class="text-primary-600 link" | ||||
|               aria-label={ | ||||
|                 dgettext("actions", "Clone %{container_name}", container_name: container.name) | ||||
|               } | ||||
|             > | ||||
|               <i class="fa-fw fa-lg fas fa-copy"></i> | ||||
|             </.link> | ||||
|  | ||||
|           <.link | ||||
|             href="#" | ||||
|             class="text-primary-600 link" | ||||
|             phx-click="delete" | ||||
|             phx-value-id={container.id} | ||||
|             data-confirm={ | ||||
|               dgettext("prompts", "Are you sure you want to delete %{name}?", name: container.name) | ||||
|             } | ||||
|             aria-label={ | ||||
|               dgettext("actions", "Delete %{container_name}", container_name: container.name) | ||||
|             } | ||||
|             <.link | ||||
|               href="#" | ||||
|               class="text-primary-600 link" | ||||
|               phx-click="delete" | ||||
|               phx-value-id={container.id} | ||||
|               data-confirm={ | ||||
|                 dgettext("prompts", "Are you sure you want to delete %{name}?", | ||||
|                   name: container.name | ||||
|                 ) | ||||
|               } | ||||
|               aria-label={ | ||||
|                 dgettext("actions", "Delete %{container_name}", container_name: container.name) | ||||
|               } | ||||
|             > | ||||
|               <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|             </.link> | ||||
|           </:actions> | ||||
|         </.live_component> | ||||
|       <% else %> | ||||
|         <div class="w-full flex flex-row flex-wrap justify-center items-stretch"> | ||||
|           <.container_card | ||||
|             :for={container <- @containers} | ||||
|             container={container} | ||||
|             current_user={@current_user} | ||||
|           > | ||||
|             <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|           </.link> | ||||
|         </.container_card> | ||||
|       </div> | ||||
|             <:tag_actions> | ||||
|               <div class="mx-4 my-2"> | ||||
|                 <.link | ||||
|                   patch={~p"/containers/edit_tags/#{container}"} | ||||
|                   class="text-primary-600 link" | ||||
|                   aria-label={ | ||||
|                     dgettext("actions", "Tag %{container_name}", container_name: container.name) | ||||
|                   } | ||||
|                 > | ||||
|                   <i class="fa-fw fa-lg fas fa-tags"></i> | ||||
|                 </.link> | ||||
|               </div> | ||||
|             </:tag_actions> | ||||
|             <.link | ||||
|               patch={~p"/containers/edit/#{container}"} | ||||
|               class="text-primary-600 link" | ||||
|               aria-label={ | ||||
|                 dgettext("actions", "Edit %{container_name}", container_name: container.name) | ||||
|               } | ||||
|             > | ||||
|               <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|             </.link> | ||||
|  | ||||
|             <.link | ||||
|               patch={~p"/containers/clone/#{container}"} | ||||
|               class="text-primary-600 link" | ||||
|               aria-label={ | ||||
|                 dgettext("actions", "Clone %{container_name}", container_name: container.name) | ||||
|               } | ||||
|             > | ||||
|               <i class="fa-fw fa-lg fas fa-copy"></i> | ||||
|             </.link> | ||||
|  | ||||
|             <.link | ||||
|               href="#" | ||||
|               class="text-primary-600 link" | ||||
|               phx-click="delete" | ||||
|               phx-value-id={container.id} | ||||
|               data-confirm={ | ||||
|                 dgettext("prompts", "Are you sure you want to delete %{name}?", | ||||
|                   name: container.name | ||||
|                 ) | ||||
|               } | ||||
|               aria-label={ | ||||
|                 dgettext("actions", "Delete %{container_name}", container_name: container.name) | ||||
|               } | ||||
|             > | ||||
|               <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|             </.link> | ||||
|           </.container_card> | ||||
|         </div> | ||||
|       <% end %> | ||||
|     <% end %> | ||||
|   <% end %> | ||||
| </div> | ||||
|  | ||||
| <%= case @live_action do %> | ||||
|   <% modifying when modifying in [:new, :edit, :clone] -> %> | ||||
|     <.modal return_to={Routes.container_index_path(Endpoint, :index)}> | ||||
|     <.modal return_to={~p"/containers"}> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.ContainerLive.FormComponent} | ||||
|         id={@container.id || :new} | ||||
|         title={@page_title} | ||||
|         action={@live_action} | ||||
|         container={@container} | ||||
|         return_to={Routes.container_index_path(Endpoint, :index)} | ||||
|         return_to={~p"/containers"} | ||||
|         current_user={@current_user} | ||||
|       /> | ||||
|     </.modal> | ||||
|   <% :edit_tags -> %> | ||||
|     <.modal return_to={Routes.container_index_path(Endpoint, :index)}> | ||||
|     <.modal return_to={~p"/containers"}> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.ContainerLive.EditTagsComponent} | ||||
|         id={@container.id} | ||||
|         title={@page_title} | ||||
|         action={@live_action} | ||||
|         container={@container} | ||||
|         current_path={Routes.container_index_path(Endpoint, :edit_tags, @container)} | ||||
|         current_path={~p"/containers/edit_tags/#{@container}"} | ||||
|         current_user={@current_user} | ||||
|       /> | ||||
|     </.modal> | ||||
|   | ||||
| @@ -5,19 +5,18 @@ defmodule CanneryWeb.ContainerLive.Show do | ||||
|  | ||||
|   use CanneryWeb, :live_view | ||||
|   alias Cannery.{Accounts.User, ActivityLog, Ammo, Containers, Containers.Container} | ||||
|   alias CanneryWeb.Endpoint | ||||
|   alias Ecto.Changeset | ||||
|   alias Phoenix.LiveView.Socket | ||||
|  | ||||
|   @impl true | ||||
|   def mount(_params, _session, socket), | ||||
|     do: {:ok, socket |> assign(show_used: false, view_table: true)} | ||||
|     do: {:ok, socket |> assign(class: :all, view_table: true)} | ||||
|  | ||||
|   @impl true | ||||
|   def handle_params(%{"id" => id}, _session, %{assigns: %{current_user: current_user}} = socket) do | ||||
|     socket = | ||||
|       socket | ||||
|       |> assign(view_table: true) | ||||
|       |> assign(:view_table, true) | ||||
|       |> render_container(id, current_user) | ||||
|  | ||||
|     {:noreply, socket} | ||||
| @@ -59,18 +58,15 @@ defmodule CanneryWeb.ContainerLive.Show do | ||||
|       |> case do | ||||
|         {:ok, %{name: container_name}} -> | ||||
|           prompt = dgettext("prompts", "%{name} has been deleted", name: container_name) | ||||
|           socket |> put_flash(:info, prompt) |> push_navigate(to: ~p"/containers") | ||||
|  | ||||
|           socket | ||||
|           |> put_flash(:info, prompt) | ||||
|           |> push_navigate(to: Routes.container_index_path(socket, :index)) | ||||
|  | ||||
|         {:error, %{action: :delete, errors: [ammo_groups: _error], valid?: false} = changeset} -> | ||||
|           ammo_groups_error = changeset |> changeset_errors(:ammo_groups) |> Enum.join(", ") | ||||
|         {:error, %{action: :delete, errors: [packs: _error], valid?: false} = changeset} -> | ||||
|           packs_error = changeset |> changeset_errors(:packs) |> Enum.join(", ") | ||||
|  | ||||
|           prompt = | ||||
|             dgettext("errors", "Could not delete %{name}: %{error}", | ||||
|               name: changeset |> Changeset.get_field(:name, "container"), | ||||
|               error: ammo_groups_error | ||||
|               error: packs_error | ||||
|             ) | ||||
|  | ||||
|           socket |> put_flash(:error, prompt) | ||||
| @@ -82,29 +78,41 @@ defmodule CanneryWeb.ContainerLive.Show do | ||||
|     {:noreply, socket} | ||||
|   end | ||||
|  | ||||
|   def handle_event("toggle_show_used", _params, %{assigns: %{show_used: show_used}} = socket) do | ||||
|     {:noreply, socket |> assign(:show_used, !show_used) |> render_container()} | ||||
|   end | ||||
|  | ||||
|   def handle_event("toggle_table", _params, %{assigns: %{view_table: view_table}} = socket) do | ||||
|     {:noreply, socket |> assign(:view_table, !view_table) |> render_container()} | ||||
|   end | ||||
|  | ||||
|   def handle_event("change_class", %{"type" => %{"class" => "rifle"}}, socket) do | ||||
|     {:noreply, socket |> assign(:class, :rifle) |> render_container()} | ||||
|   end | ||||
|  | ||||
|   def handle_event("change_class", %{"type" => %{"class" => "shotgun"}}, socket) do | ||||
|     {:noreply, socket |> assign(:class, :shotgun) |> render_container()} | ||||
|   end | ||||
|  | ||||
|   def handle_event("change_class", %{"type" => %{"class" => "pistol"}}, socket) do | ||||
|     {:noreply, socket |> assign(:class, :pistol) |> render_container()} | ||||
|   end | ||||
|  | ||||
|   def handle_event("change_class", %{"type" => %{"class" => _all}}, socket) do | ||||
|     {:noreply, socket |> assign(:class, :all) |> render_container()} | ||||
|   end | ||||
|  | ||||
|   @spec render_container(Socket.t(), Container.id(), User.t()) :: Socket.t() | ||||
|   defp render_container( | ||||
|          %{assigns: %{live_action: live_action, show_used: show_used}} = socket, | ||||
|          %{assigns: %{class: class, live_action: live_action}} = socket, | ||||
|          id, | ||||
|          current_user | ||||
|        ) do | ||||
|     %{name: container_name} = container = Containers.get_container!(id, current_user) | ||||
|     ammo_groups = Ammo.list_ammo_groups_for_container(container, current_user, show_used) | ||||
|     original_counts = ammo_groups |> Ammo.get_original_counts(current_user) | ||||
|     cprs = ammo_groups |> Ammo.get_cprs(current_user) | ||||
|     last_used_dates = ammo_groups |> ActivityLog.get_last_used_dates(current_user) | ||||
|     packs = Ammo.list_packs_for_container(container, class, current_user) | ||||
|     original_counts = packs |> Ammo.get_original_counts(current_user) | ||||
|     cprs = packs |> Ammo.get_cprs(current_user) | ||||
|     last_used_dates = packs |> ActivityLog.get_last_used_dates(current_user) | ||||
|  | ||||
|     page_title = | ||||
|       case live_action do | ||||
|         action when action in [:show, :table] -> container_name | ||||
|         :show -> container_name | ||||
|         :edit -> gettext("Edit %{name}", name: container_name) | ||||
|         :edit_tags -> gettext("Edit %{name} tags", name: container_name) | ||||
|       end | ||||
| @@ -113,7 +121,8 @@ defmodule CanneryWeb.ContainerLive.Show do | ||||
|     |> assign( | ||||
|       container: container, | ||||
|       round_count: Ammo.get_round_count_for_container!(container, current_user), | ||||
|       ammo_groups: ammo_groups, | ||||
|       packs_count: Ammo.get_packs_count_for_container!(container, current_user), | ||||
|       packs: packs, | ||||
|       original_counts: original_counts, | ||||
|       cprs: cprs, | ||||
|       last_used_dates: last_used_dates, | ||||
|   | ||||
| @@ -18,26 +18,19 @@ | ||||
|     <%= @container.location %> | ||||
|   </span> | ||||
|  | ||||
|   <%= unless @ammo_groups |> Enum.empty?() do %> | ||||
|     <span class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Packs:") %> | ||||
|       <%= @ammo_groups |> Enum.reject(fn %{count: count} -> count in [0, nil] end) |> Enum.count() %> | ||||
|     </span> | ||||
|   <span class="rounded-lg title text-lg"> | ||||
|     <%= gettext("Packs:") %> | ||||
|     <%= @packs_count %> | ||||
|   </span> | ||||
|  | ||||
|     <span :if={@show_used} class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Total packs:") %> | ||||
|       <%= Enum.count(@ammo_groups) %> | ||||
|     </span> | ||||
|  | ||||
|     <span class="rounded-lg title text-lg"> | ||||
|       <%= gettext("Rounds:") %> | ||||
|       <%= @round_count %> | ||||
|     </span> | ||||
|   <% end %> | ||||
|   <span class="rounded-lg title text-lg"> | ||||
|     <%= gettext("Rounds:") %> | ||||
|     <%= @round_count %> | ||||
|   </span> | ||||
|  | ||||
|   <div class="flex space-x-4 justify-center items-center text-primary-600"> | ||||
|     <.link | ||||
|       patch={Routes.container_show_path(Endpoint, :edit, @container)} | ||||
|       patch={~p"/container/edit/#{@container}"} | ||||
|       class="text-primary-600 link" | ||||
|       aria-label={dgettext("actions", "Edit %{container_name}", container_name: @container.name)} | ||||
|     > | ||||
| @@ -68,10 +61,7 @@ | ||||
|         <%= display_emoji("😔") %> | ||||
|       </h2> | ||||
|  | ||||
|       <.link | ||||
|         patch={Routes.container_show_path(Endpoint, :edit_tags, @container)} | ||||
|         class="btn btn-primary" | ||||
|       > | ||||
|       <.link patch={~p"/container/edit_tags/#{@container}"} class="btn btn-primary"> | ||||
|         <%= dgettext("actions", "Why not add one?") %> | ||||
|       </.link> | ||||
|     </div> | ||||
| @@ -80,10 +70,7 @@ | ||||
|       <.simple_tag_card :for={tag <- @container.tags} tag={tag} /> | ||||
|  | ||||
|       <div class="mx-4 my-2"> | ||||
|         <.link | ||||
|           patch={Routes.container_show_path(Endpoint, :edit_tags, @container)} | ||||
|           class="text-primary-600 link" | ||||
|         > | ||||
|         <.link patch={~p"/container/edit_tags/#{@container}"} class="text-primary-600 link"> | ||||
|           <i class="fa-fw fa-lg fas fa-tags"></i> | ||||
|         </.link> | ||||
|       </div> | ||||
| @@ -93,11 +80,29 @@ | ||||
|   <hr class="mb-4 hr" /> | ||||
|  | ||||
|   <div class="flex justify-center items-center space-x-4"> | ||||
|     <.toggle_button action="toggle_show_used" value={@show_used}> | ||||
|       <span class="title text-lg text-primary-600"> | ||||
|         <%= gettext("Show used") %> | ||||
|       </span> | ||||
|     </.toggle_button> | ||||
|     <.form | ||||
|       :let={f} | ||||
|       for={%{}} | ||||
|       as={:type} | ||||
|       phx-change="change_class" | ||||
|       phx-submit="change_class" | ||||
|       class="flex items-center" | ||||
|     > | ||||
|       <%= label(f, :class, gettext("Class"), class: "title text-primary-600 text-lg text-center") %> | ||||
|  | ||||
|       <%= select( | ||||
|         f, | ||||
|         :class, | ||||
|         [ | ||||
|           {gettext("All"), :all}, | ||||
|           {gettext("Rifle"), :rifle}, | ||||
|           {gettext("Shotgun"), :shotgun}, | ||||
|           {gettext("Pistol"), :pistol} | ||||
|         ], | ||||
|         class: "mx-2 my-1 min-w-md input input-primary", | ||||
|         value: @class | ||||
|       ) %> | ||||
|     </.form> | ||||
|  | ||||
|     <.toggle_button action="toggle_table" value={@view_table}> | ||||
|       <span class="title text-lg text-primary-600"> | ||||
| @@ -107,32 +112,33 @@ | ||||
|   </div> | ||||
|  | ||||
|   <div class="w-full p-4"> | ||||
|     <%= if @ammo_groups |> Enum.empty?() do %> | ||||
|     <%= if @packs |> Enum.empty?() do %> | ||||
|       <h2 class="mx-4 title text-lg text-primary-600 text-center"> | ||||
|         <%= gettext("No ammo in this container") %> | ||||
|       </h2> | ||||
|     <% else %> | ||||
|       <%= if @view_table do %> | ||||
|         <.live_component | ||||
|           module={CanneryWeb.Components.AmmoGroupTableComponent} | ||||
|           id="ammo-type-show-table" | ||||
|           ammo_groups={@ammo_groups} | ||||
|           module={CanneryWeb.Components.PackTableComponent} | ||||
|           id="type-show-table" | ||||
|           packs={@packs} | ||||
|           current_user={@current_user} | ||||
|           show_used={false} | ||||
|         > | ||||
|           <:ammo_type :let={%{name: ammo_type_name} = ammo_type}> | ||||
|             <.link navigate={Routes.ammo_type_show_path(Endpoint, :show, ammo_type)} class="link"> | ||||
|               <%= ammo_type_name %> | ||||
|           <:type :let={%{name: type_name} = type}> | ||||
|             <.link navigate={~p"/type/#{type}"} class="link"> | ||||
|               <%= type_name %> | ||||
|             </.link> | ||||
|           </:ammo_type> | ||||
|           </:type> | ||||
|         </.live_component> | ||||
|       <% else %> | ||||
|         <div class="flex flex-wrap justify-center items-stretch"> | ||||
|           <.ammo_group_card | ||||
|             :for={%{id: ammo_group_id} = ammo_group <- @ammo_groups} | ||||
|             ammo_group={ammo_group} | ||||
|             original_count={Map.fetch!(@original_counts, ammo_group_id)} | ||||
|             cpr={Map.get(@cprs, ammo_group_id)} | ||||
|             last_used_date={Map.get(@last_used_dates, ammo_group_id)} | ||||
|           <.pack_card | ||||
|             :for={%{id: pack_id} = pack <- @packs} | ||||
|             pack={pack} | ||||
|             original_count={Map.fetch!(@original_counts, pack_id)} | ||||
|             cpr={Map.get(@cprs, pack_id)} | ||||
|             last_used_date={Map.get(@last_used_dates, pack_id)} | ||||
|             current_user={@current_user} | ||||
|           /> | ||||
|         </div> | ||||
| @@ -143,27 +149,27 @@ | ||||
|  | ||||
| <%= case @live_action do %> | ||||
|   <% :edit -> %> | ||||
|     <.modal return_to={Routes.container_show_path(Endpoint, :show, @container)}> | ||||
|     <.modal return_to={~p"/container/#{@container}"}> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.ContainerLive.FormComponent} | ||||
|         id={@container.id} | ||||
|         title={@page_title} | ||||
|         action={@live_action} | ||||
|         container={@container} | ||||
|         return_to={Routes.container_show_path(Endpoint, :show, @container)} | ||||
|         return_to={~p"/container/#{@container}"} | ||||
|         current_user={@current_user} | ||||
|       /> | ||||
|     </.modal> | ||||
|   <% :edit_tags -> %> | ||||
|     <.modal return_to={Routes.container_show_path(Endpoint, :show, @container)}> | ||||
|     <.modal return_to={~p"/container/#{@container}"}> | ||||
|       <.live_component | ||||
|         module={CanneryWeb.ContainerLive.EditTagsComponent} | ||||
|         id={@container.id} | ||||
|         title={@page_title} | ||||
|         action={@live_action} | ||||
|         container={@container} | ||||
|         return_to={Routes.container_show_path(Endpoint, :show, @container)} | ||||
|         current_path={Routes.container_show_path(Endpoint, :edit_tags, @container)} | ||||
|         return_to={~p"/container/#{@container}"} | ||||
|         current_path={~p"/container/edit_tags/#{@container}"} | ||||
|         current_user={@current_user} | ||||
|       /> | ||||
|     </.modal> | ||||
|   | ||||
| @@ -5,7 +5,6 @@ defmodule CanneryWeb.HomeLive do | ||||
|  | ||||
|   use CanneryWeb, :live_view | ||||
|   alias Cannery.Accounts | ||||
|   alias CanneryWeb.Endpoint | ||||
|  | ||||
|   @version Mix.Project.config()[:version] | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| <div class="mx-auto px-8 sm:px-16 flex flex-col justify-center items-center text-center space-y-4 max-w-3xl"> | ||||
|   <img | ||||
|     src={Routes.static_path(Endpoint, "/images/cannery.svg")} | ||||
|     src={~p"/images/cannery.svg"} | ||||
|     alt={gettext("Cannery logo")} | ||||
|     class="inline-block w-32 hover:-mt-2 hover:mb-2 transition-all duration-500 ease-in-out" | ||||
|     title={gettext("isn't he cute >:3")} | ||||
| @@ -59,7 +59,7 @@ | ||||
|       </b> | ||||
|       <p> | ||||
|         <%= if @admins |> Enum.empty?() do %> | ||||
|           <.link href={Routes.user_registration_path(Endpoint, :new)} class="hover:underline"> | ||||
|           <.link href={~p"/users/register"} class="hover:underline"> | ||||
|             <%= dgettext("prompts", "Register to setup Cannery") %> | ||||
|           </.link> | ||||
|         <% else %> | ||||
|   | ||||
| @@ -18,7 +18,10 @@ | ||||
|       <%= changeset_errors(@changeset) %> | ||||
|     </div> | ||||
|  | ||||
|     <%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %> | ||||
|     <%= label(f, :name, gettext("Name"), | ||||
|       class: "title text-lg text-primary-600", | ||||
|       maxlength: 255 | ||||
|     ) %> | ||||
|     <%= text_input(f, :name, class: "input input-primary col-span-2") %> | ||||
|     <%= error_tag(f, :name, "col-span-3") %> | ||||
|  | ||||
|   | ||||
| @@ -9,11 +9,11 @@ | ||||
|       <%= display_emoji("😔") %> | ||||
|     </h1> | ||||
|  | ||||
|     <.link patch={Routes.invite_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|     <.link patch={~p"/invites/new"} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Invite someone new!") %> | ||||
|     </.link> | ||||
|   <% else %> | ||||
|     <.link patch={Routes.invite_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|     <.link patch={~p"/invites/new"} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Create Invite") %> | ||||
|     </.link> | ||||
|   <% end %> | ||||
| @@ -40,7 +40,7 @@ | ||||
|         </form> | ||||
|       </:code_actions> | ||||
|       <.link | ||||
|         patch={Routes.invite_index_path(Endpoint, :edit, invite)} | ||||
|         patch={~p"/invites/#{invite}/edit"} | ||||
|         class="text-primary-600 link" | ||||
|         aria-label={ | ||||
|           dgettext("actions", "Edit invite for %{invite_name}", invite_name: invite.name) | ||||
| @@ -149,14 +149,14 @@ | ||||
|   <% end %> | ||||
| </div> | ||||
|  | ||||
| <.modal :if={@live_action in [:new, :edit]} return_to={Routes.invite_index_path(Endpoint, :index)}> | ||||
| <.modal :if={@live_action in [:new, :edit]} return_to={~p"/invites"}> | ||||
|   <.live_component | ||||
|     module={CanneryWeb.InviteLive.FormComponent} | ||||
|     id={@invite.id || :new} | ||||
|     title={@page_title} | ||||
|     action={@live_action} | ||||
|     invite={@invite} | ||||
|     return_to={Routes.invite_index_path(Endpoint, :index)} | ||||
|     return_to={~p"/invites"} | ||||
|     current_user={@current_user} | ||||
|   /> | ||||
| </.modal> | ||||
|   | ||||
| @@ -1,38 +1,38 @@ | ||||
| defmodule CanneryWeb.AmmoGroupLive.FormComponent do | ||||
| defmodule CanneryWeb.PackLive.FormComponent do | ||||
|   @moduledoc """ | ||||
|   Livecomponent that can update or create an Cannery.Ammo.AmmoGroup | ||||
|   Livecomponent that can update or create an Cannery.Ammo.Pack | ||||
|   """ | ||||
| 
 | ||||
|   use CanneryWeb, :live_component | ||||
|   alias Cannery.Ammo.{AmmoGroup, AmmoType} | ||||
|   alias Cannery.Ammo.{Pack, Type} | ||||
|   alias Cannery.{Accounts.User, Ammo, Containers, Containers.Container} | ||||
|   alias Ecto.Changeset | ||||
|   alias Phoenix.LiveView.Socket | ||||
| 
 | ||||
|   @ammo_group_create_limit 10_000 | ||||
|   @pack_create_limit 10_000 | ||||
| 
 | ||||
|   @impl true | ||||
|   @spec update( | ||||
|           %{:ammo_group => AmmoGroup.t(), :current_user => User.t(), optional(any) => any}, | ||||
|           %{:pack => Pack.t(), :current_user => User.t(), optional(any) => any}, | ||||
|           Socket.t() | ||||
|         ) :: {:ok, Socket.t()} | ||||
|   def update(%{ammo_group: _ammo_group} = assigns, socket) do | ||||
|   def update(%{pack: _pack} = assigns, socket) do | ||||
|     socket |> assign(assigns) |> update() | ||||
|   end | ||||
| 
 | ||||
|   @spec update(Socket.t()) :: {:ok, Socket.t()} | ||||
|   def update(%{assigns: %{current_user: current_user}} = socket) do | ||||
|     %{assigns: %{ammo_types: ammo_types, containers: containers}} = | ||||
|     %{assigns: %{types: types, containers: containers}} = | ||||
|       socket = | ||||
|       socket | ||||
|       |> assign(:ammo_group_create_limit, @ammo_group_create_limit) | ||||
|       |> assign(:ammo_types, Ammo.list_ammo_types(current_user)) | ||||
|       |> assign(:pack_create_limit, @pack_create_limit) | ||||
|       |> assign(:types, Ammo.list_types(current_user, :all)) | ||||
|       |> assign_new(:containers, fn -> Containers.list_containers(current_user) end) | ||||
| 
 | ||||
|     params = | ||||
|       if ammo_types |> List.first() |> is_nil(), | ||||
|       if types |> List.first() |> is_nil(), | ||||
|         do: %{}, | ||||
|         else: %{} |> Map.put("ammo_type_id", ammo_types |> List.first() |> Map.get(:id)) | ||||
|         else: %{} |> Map.put("type_id", types |> List.first() |> Map.get(:id)) | ||||
| 
 | ||||
|     params = | ||||
|       if containers |> List.first() |> is_nil(), | ||||
| @@ -43,16 +43,16 @@ defmodule CanneryWeb.AmmoGroupLive.FormComponent do | ||||
|   end | ||||
| 
 | ||||
|   @impl true | ||||
|   def handle_event("validate", %{"ammo_group" => ammo_group_params}, socket) do | ||||
|     {:noreply, socket |> assign_changeset(ammo_group_params, :validate)} | ||||
|   def handle_event("validate", %{"pack" => pack_params}, socket) do | ||||
|     {:noreply, socket |> assign_changeset(pack_params, :validate)} | ||||
|   end | ||||
| 
 | ||||
|   def handle_event( | ||||
|         "save", | ||||
|         %{"ammo_group" => ammo_group_params}, | ||||
|         %{"pack" => pack_params}, | ||||
|         %{assigns: %{action: action}} = socket | ||||
|       ) do | ||||
|     save_ammo_group(socket, action, ammo_group_params) | ||||
|     save_pack(socket, action, pack_params) | ||||
|   end | ||||
| 
 | ||||
|   # HTML Helpers | ||||
| @@ -62,16 +62,16 @@ defmodule CanneryWeb.AmmoGroupLive.FormComponent do | ||||
|     containers |> Enum.map(fn %{id: id, name: name} -> {name, id} end) | ||||
|   end | ||||
| 
 | ||||
|   @spec ammo_type_options([AmmoType.t()]) :: [{String.t(), AmmoType.id()}] | ||||
|   defp ammo_type_options(ammo_types) do | ||||
|     ammo_types |> Enum.map(fn %{id: id, name: name} -> {name, id} end) | ||||
|   @spec type_options([Type.t()]) :: [{String.t(), Type.id()}] | ||||
|   defp type_options(types) do | ||||
|     types |> Enum.map(fn %{id: id, name: name} -> {name, id} end) | ||||
|   end | ||||
| 
 | ||||
|   # Save Helpers | ||||
| 
 | ||||
|   defp assign_changeset( | ||||
|          %{assigns: %{action: action, ammo_group: ammo_group, current_user: user}} = socket, | ||||
|          ammo_group_params, | ||||
|          %{assigns: %{action: action, pack: pack, current_user: user}} = socket, | ||||
|          pack_params, | ||||
|          changeset_action \\ nil | ||||
|        ) do | ||||
|     default_action = | ||||
| @@ -83,12 +83,12 @@ defmodule CanneryWeb.AmmoGroupLive.FormComponent do | ||||
|     changeset = | ||||
|       case default_action do | ||||
|         :insert -> | ||||
|           ammo_type = maybe_get_ammo_type(ammo_group_params, user) | ||||
|           container = maybe_get_container(ammo_group_params, user) | ||||
|           ammo_group |> AmmoGroup.create_changeset(ammo_type, container, user, ammo_group_params) | ||||
|           type = maybe_get_type(pack_params, user) | ||||
|           container = maybe_get_container(pack_params, user) | ||||
|           pack |> Pack.create_changeset(type, container, user, pack_params) | ||||
| 
 | ||||
|         :update -> | ||||
|           ammo_group |> AmmoGroup.update_changeset(ammo_group_params, user) | ||||
|           pack |> Pack.update_changeset(pack_params, user) | ||||
|       end | ||||
| 
 | ||||
|     changeset = | ||||
| @@ -107,22 +107,21 @@ defmodule CanneryWeb.AmmoGroupLive.FormComponent do | ||||
| 
 | ||||
|   defp maybe_get_container(_params_not_found, _user), do: nil | ||||
| 
 | ||||
|   defp maybe_get_ammo_type(%{"ammo_type_id" => ammo_type_id}, user) | ||||
|        when is_binary(ammo_type_id) do | ||||
|     ammo_type_id |> Ammo.get_ammo_type!(user) | ||||
|   defp maybe_get_type(%{"type_id" => type_id}, user) | ||||
|        when is_binary(type_id) do | ||||
|     type_id |> Ammo.get_type!(user) | ||||
|   end | ||||
| 
 | ||||
|   defp maybe_get_ammo_type(_params_not_found, _user), do: nil | ||||
|   defp maybe_get_type(_params_not_found, _user), do: nil | ||||
| 
 | ||||
|   defp save_ammo_group( | ||||
|          %{assigns: %{ammo_group: ammo_group, current_user: current_user, return_to: return_to}} = | ||||
|            socket, | ||||
|   defp save_pack( | ||||
|          %{assigns: %{pack: pack, current_user: current_user, return_to: return_to}} = socket, | ||||
|          :edit, | ||||
|          ammo_group_params | ||||
|          pack_params | ||||
|        ) do | ||||
|     socket = | ||||
|       case Ammo.update_ammo_group(ammo_group, ammo_group_params, current_user) do | ||||
|         {:ok, _ammo_group} -> | ||||
|       case Ammo.update_pack(pack, pack_params, current_user) do | ||||
|         {:ok, _pack} -> | ||||
|           prompt = dgettext("prompts", "Ammo updated successfully") | ||||
|           socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) | ||||
| 
 | ||||
| @@ -133,24 +132,24 @@ defmodule CanneryWeb.AmmoGroupLive.FormComponent do | ||||
|     {:noreply, socket} | ||||
|   end | ||||
| 
 | ||||
|   defp save_ammo_group( | ||||
|   defp save_pack( | ||||
|          %{assigns: %{changeset: changeset}} = socket, | ||||
|          action, | ||||
|          %{"multiplier" => multiplier_str} = ammo_group_params | ||||
|          %{"multiplier" => multiplier_str} = pack_params | ||||
|        ) | ||||
|        when action in [:new, :clone] do | ||||
|     socket = | ||||
|       case multiplier_str |> Integer.parse() do | ||||
|         {multiplier, _remainder} | ||||
|         when multiplier >= 1 and multiplier <= @ammo_group_create_limit -> | ||||
|           socket |> create_multiple(ammo_group_params, multiplier) | ||||
|         when multiplier >= 1 and multiplier <= @pack_create_limit -> | ||||
|           socket |> create_multiple(pack_params, multiplier) | ||||
| 
 | ||||
|         {multiplier, _remainder} -> | ||||
|           error_msg = | ||||
|             dgettext( | ||||
|               "errors", | ||||
|               "Invalid number of copies, must be between 1 and %{max}. Was %{multiplier}", | ||||
|               max: @ammo_group_create_limit, | ||||
|               max: @pack_create_limit, | ||||
|               multiplier: multiplier | ||||
|             ) | ||||
| 
 | ||||
| @@ -176,11 +175,11 @@ defmodule CanneryWeb.AmmoGroupLive.FormComponent do | ||||
| 
 | ||||
|   defp create_multiple( | ||||
|          %{assigns: %{current_user: current_user, return_to: return_to}} = socket, | ||||
|          ammo_group_params, | ||||
|          pack_params, | ||||
|          multiplier | ||||
|        ) do | ||||
|     case Ammo.create_ammo_groups(ammo_group_params, multiplier, current_user) do | ||||
|       {:ok, {count, _ammo_groups}} -> | ||||
|     case Ammo.create_packs(pack_params, multiplier, current_user) do | ||||
|       {:ok, {count, _packs}} -> | ||||
|         prompt = | ||||
|           dngettext( | ||||
|             "prompts", | ||||
| @@ -6,7 +6,7 @@ | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={@changeset} | ||||
|     id="ammo_group-form" | ||||
|     id="pack-form" | ||||
|     phx-target={@myself} | ||||
|     phx-change="validate" | ||||
|     phx-submit="save" | ||||
| @@ -19,11 +19,11 @@ | ||||
|       <%= changeset_errors(@changeset) %> | ||||
|     </div> | ||||
| 
 | ||||
|     <%= label(f, :ammo_type_id, gettext("Ammo type"), class: "title text-lg text-primary-600") %> | ||||
|     <%= select(f, :ammo_type_id, ammo_type_options(@ammo_types), | ||||
|     <%= label(f, :type_id, gettext("Type"), class: "title text-lg text-primary-600") %> | ||||
|     <%= select(f, :type_id, type_options(@types), | ||||
|       class: "text-center col-span-2 input input-primary" | ||||
|     ) %> | ||||
|     <%= error_tag(f, :ammo_type_id, "col-span-3 text-center") %> | ||||
|     <%= error_tag(f, :type_id, "col-span-3 text-center") %> | ||||
| 
 | ||||
|     <%= label(f, :count, gettext("Count"), class: "title text-lg text-primary-600") %> | ||||
|     <%= number_input(f, :count, | ||||
| @@ -39,6 +39,13 @@ | ||||
|     ) %> | ||||
|     <%= error_tag(f, :price_paid, "col-span-3 text-center") %> | ||||
| 
 | ||||
|     <%= label(f, :lot_number, gettext("Lot number"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :lot_number, | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       maxlength: 255 | ||||
|     ) %> | ||||
|     <%= error_tag(f, :price_paid, "col-span-3 text-center") %> | ||||
| 
 | ||||
|     <%= label(f, :purchased_on, gettext("Purchased on"), class: "title text-lg text-primary-600") %> | ||||
|     <%= date_input(f, :purchased_on, | ||||
|       class: "input input-primary col-span-2", | ||||
| @@ -49,7 +56,7 @@ | ||||
| 
 | ||||
|     <%= label(f, :notes, gettext("Notes"), class: "title text-lg text-primary-600") %> | ||||
|     <%= textarea(f, :notes, | ||||
|       id: "ammo-group-form-notes", | ||||
|       id: "pack-form-notes", | ||||
|       class: "text-center col-span-2 input input-primary", | ||||
|       phx_hook: "MaintainAttrs", | ||||
|       phx_update: "ignore" | ||||
| @@ -68,7 +75,7 @@ | ||||
| 
 | ||||
|         <%= label(f, :multiplier, gettext("Copies"), class: "title text-lg text-primary-600") %> | ||||
|         <%= number_input(f, :multiplier, | ||||
|           max: @ammo_group_create_limit, | ||||
|           max: @pack_create_limit, | ||||
|           class: "text-center input input-primary", | ||||
|           value: 1, | ||||
|           phx_update: "ignore" | ||||
| @@ -1,34 +1,39 @@ | ||||
| defmodule CanneryWeb.AmmoGroupLive.Index do | ||||
| defmodule CanneryWeb.PackLive.Index do | ||||
|   @moduledoc """ | ||||
|   Liveview to show a Cannery.Ammo.AmmoGroup index | ||||
|   Liveview to show a Cannery.Ammo.Pack index | ||||
|   """ | ||||
| 
 | ||||
|   use CanneryWeb, :live_view | ||||
|   alias Cannery.{Ammo, Ammo.AmmoGroup, Containers} | ||||
|   alias Cannery.{Ammo, Ammo.Pack, Containers} | ||||
| 
 | ||||
|   @impl true | ||||
|   def mount(%{"search" => search}, _session, socket) do | ||||
|     {:ok, socket |> assign(show_used: false, search: search) |> display_ammo_groups()} | ||||
|     socket = | ||||
|       socket | ||||
|       |> assign(class: :all, show_used: false, search: search) | ||||
|       |> display_packs() | ||||
| 
 | ||||
|     {:ok, socket} | ||||
|   end | ||||
| 
 | ||||
|   def mount(_params, _session, socket) do | ||||
|     {:ok, socket |> assign(show_used: false, search: nil) |> display_ammo_groups()} | ||||
|     {:ok, socket |> assign(class: :all, show_used: false, search: nil) |> display_packs()} | ||||
|   end | ||||
| 
 | ||||
|   @impl true | ||||
|   def handle_params(params, _url, %{assigns: %{live_action: live_action}} = socket) do | ||||
|     {:noreply, apply_action(socket, live_action, params) |> display_ammo_groups()} | ||||
|     {:noreply, apply_action(socket, live_action, params) |> display_packs()} | ||||
|   end | ||||
| 
 | ||||
|   defp apply_action( | ||||
|          %{assigns: %{current_user: current_user}} = socket, | ||||
|          :add_shot_group, | ||||
|          :add_shot_record, | ||||
|          %{"id" => id} | ||||
|        ) do | ||||
|     socket | ||||
|     |> assign( | ||||
|       page_title: gettext("Record shots"), | ||||
|       ammo_group: Ammo.get_ammo_group!(id, current_user) | ||||
|       pack: Ammo.get_pack!(id, current_user) | ||||
|     ) | ||||
|   end | ||||
| 
 | ||||
| @@ -36,7 +41,7 @@ defmodule CanneryWeb.AmmoGroupLive.Index do | ||||
|     socket | ||||
|     |> assign( | ||||
|       page_title: gettext("Move ammo"), | ||||
|       ammo_group: Ammo.get_ammo_group!(id, current_user) | ||||
|       pack: Ammo.get_pack!(id, current_user) | ||||
|     ) | ||||
|   end | ||||
| 
 | ||||
| @@ -44,7 +49,7 @@ defmodule CanneryWeb.AmmoGroupLive.Index do | ||||
|     socket | ||||
|     |> assign( | ||||
|       page_title: gettext("Edit ammo"), | ||||
|       ammo_group: Ammo.get_ammo_group!(id, current_user) | ||||
|       pack: Ammo.get_pack!(id, current_user) | ||||
|     ) | ||||
|   end | ||||
| 
 | ||||
| @@ -52,7 +57,7 @@ defmodule CanneryWeb.AmmoGroupLive.Index do | ||||
|     socket | ||||
|     |> assign( | ||||
|       page_title: dgettext("actions", "Add Ammo"), | ||||
|       ammo_group: %{Ammo.get_ammo_group!(id, current_user) | id: nil} | ||||
|       pack: %{Ammo.get_pack!(id, current_user) | id: nil} | ||||
|     ) | ||||
|   end | ||||
| 
 | ||||
| @@ -60,7 +65,7 @@ defmodule CanneryWeb.AmmoGroupLive.Index do | ||||
|     socket | ||||
|     |> assign( | ||||
|       page_title: dgettext("actions", "Add Ammo"), | ||||
|       ammo_group: %AmmoGroup{} | ||||
|       pack: %Pack{} | ||||
|     ) | ||||
|   end | ||||
| 
 | ||||
| @@ -69,7 +74,7 @@ defmodule CanneryWeb.AmmoGroupLive.Index do | ||||
|     |> assign( | ||||
|       page_title: gettext("Ammo"), | ||||
|       search: nil, | ||||
|       ammo_group: nil | ||||
|       pack: nil | ||||
|     ) | ||||
|   end | ||||
| 
 | ||||
| @@ -78,59 +83,82 @@ defmodule CanneryWeb.AmmoGroupLive.Index do | ||||
|     |> assign( | ||||
|       page_title: gettext("Ammo"), | ||||
|       search: search, | ||||
|       ammo_group: nil | ||||
|       pack: nil | ||||
|     ) | ||||
|   end | ||||
| 
 | ||||
|   @impl true | ||||
|   def handle_event("delete", %{"id" => id}, %{assigns: %{current_user: current_user}} = socket) do | ||||
|     Ammo.get_ammo_group!(id, current_user) |> Ammo.delete_ammo_group!(current_user) | ||||
|     Ammo.get_pack!(id, current_user) |> Ammo.delete_pack!(current_user) | ||||
| 
 | ||||
|     prompt = dgettext("prompts", "Ammo deleted succesfully") | ||||
| 
 | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> display_ammo_groups()} | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> display_packs()} | ||||
|   end | ||||
| 
 | ||||
|   def handle_event( | ||||
|         "toggle_staged", | ||||
|         %{"ammo_group_id" => id}, | ||||
|         %{"pack_id" => id}, | ||||
|         %{assigns: %{current_user: current_user}} = socket | ||||
|       ) do | ||||
|     ammo_group = Ammo.get_ammo_group!(id, current_user) | ||||
|     pack = Ammo.get_pack!(id, current_user) | ||||
| 
 | ||||
|     {:ok, _ammo_group} = | ||||
|       ammo_group |> Ammo.update_ammo_group(%{"staged" => !ammo_group.staged}, current_user) | ||||
|     {:ok, _pack} = pack |> Ammo.update_pack(%{"staged" => !pack.staged}, current_user) | ||||
| 
 | ||||
|     {:noreply, socket |> display_ammo_groups()} | ||||
|     {:noreply, socket |> display_packs()} | ||||
|   end | ||||
| 
 | ||||
|   def handle_event("toggle_show_used", _params, %{assigns: %{show_used: show_used}} = socket) do | ||||
|     {:noreply, socket |> assign(:show_used, !show_used) |> display_ammo_groups()} | ||||
|     {:noreply, socket |> assign(:show_used, !show_used) |> display_packs()} | ||||
|   end | ||||
| 
 | ||||
|   def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do | ||||
|     {:noreply, socket |> push_patch(to: Routes.ammo_group_index_path(Endpoint, :index))} | ||||
|     {:noreply, socket |> push_patch(to: ~p"/ammo")} | ||||
|   end | ||||
| 
 | ||||
|   def handle_event("search", %{"search" => %{"search_term" => search_term}}, socket) do | ||||
|     socket = | ||||
|       socket |> push_patch(to: Routes.ammo_group_index_path(Endpoint, :search, search_term)) | ||||
| 
 | ||||
|     {:noreply, socket} | ||||
|     {:noreply, socket |> push_patch(to: ~p"/ammo/search/#{search_term}")} | ||||
|   end | ||||
| 
 | ||||
|   defp display_ammo_groups( | ||||
|          %{assigns: %{search: search, current_user: current_user, show_used: show_used}} = socket | ||||
|   def handle_event("change_class", %{"type" => %{"class" => "rifle"}}, socket) do | ||||
|     {:noreply, socket |> assign(:class, :rifle) |> display_packs()} | ||||
|   end | ||||
| 
 | ||||
|   def handle_event("change_class", %{"type" => %{"class" => "shotgun"}}, socket) do | ||||
|     {:noreply, socket |> assign(:class, :shotgun) |> display_packs()} | ||||
|   end | ||||
| 
 | ||||
|   def handle_event("change_class", %{"type" => %{"class" => "pistol"}}, socket) do | ||||
|     {:noreply, socket |> assign(:class, :pistol) |> display_packs()} | ||||
|   end | ||||
| 
 | ||||
|   def handle_event("change_class", %{"type" => %{"class" => _all}}, socket) do | ||||
|     {:noreply, socket |> assign(:class, :all) |> display_packs()} | ||||
|   end | ||||
| 
 | ||||
|   defp display_packs( | ||||
|          %{ | ||||
|            assigns: %{ | ||||
|              class: class, | ||||
|              search: search, | ||||
|              current_user: current_user, | ||||
|              show_used: show_used | ||||
|            } | ||||
|          } = socket | ||||
|        ) do | ||||
|     ammo_groups = Ammo.list_ammo_groups(search, show_used, current_user) | ||||
|     ammo_types_count = Ammo.get_ammo_types_count!(current_user) | ||||
|     # get total number of packs to determine whether to display onboarding | ||||
|     # prompts | ||||
|     packs_count = Ammo.get_packs_count!(current_user, true) | ||||
|     packs = Ammo.list_packs(search, class, current_user, show_used) | ||||
|     types_count = Ammo.get_types_count!(current_user) | ||||
|     containers_count = Containers.get_containers_count!(current_user) | ||||
| 
 | ||||
|     socket | ||||
|     |> assign( | ||||
|       ammo_groups: ammo_groups, | ||||
|       ammo_types_count: ammo_types_count, | ||||
|       containers_count: containers_count | ||||
|       packs: packs, | ||||
|       types_count: types_count, | ||||
|       containers_count: containers_count, | ||||
|       packs_count: packs_count | ||||
|     ) | ||||
|   end | ||||
| end | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user