Compare commits

..

No commits in common. "dev" and "0.8.6" have entirely different histories.
dev ... 0.8.6

215 changed files with 23339 additions and 20642 deletions

View File

@ -17,7 +17,7 @@ steps:
- .mix - .mix
- name: test - name: test
image: elixir:1.17.0-otp-27-alpine image: elixir:1.14.1-alpine
environment: environment:
TEST_DATABASE_URL: ecto://postgres:postgres@database/cannery_test TEST_DATABASE_URL: ecto://postgres:postgres@database/cannery_test
HOST: testing.example.tld HOST: testing.example.tld
@ -26,8 +26,8 @@ steps:
MIX_ENV: test MIX_ENV: test
commands: commands:
- apk add --no-cache build-base npm git - apk add --no-cache build-base npm git
- mix local.rebar --force - mix local.rebar --force --if-missing
- mix local.hex --force - mix local.hex --force --if-missing
- mix deps.get - mix deps.get
- npm set cache .npm - npm set cache .npm
- npm --prefix ./assets ci --no-audit --prefer-offline - npm --prefix ./assets ci --no-audit --prefer-offline
@ -36,16 +36,13 @@ steps:
- mix test.all - mix test.all
- name: build and publish stable - name: build and publish stable
image: plugins/docker image: thegeeklab/drone-docker-buildx
privileged: true privileged: true
settings: settings:
repo: shibaobun/cannery repo: shibaobun/cannery
purge: true purge: true
compress: true compress: true
platforms: platforms: linux/amd64,linux/arm/v7
- linux/amd64
- linux/arm64
- linux/arm/v7
username: username:
from_secret: docker_username from_secret: docker_username
password: password:
@ -56,16 +53,13 @@ steps:
- stable - stable
- name: build and publish tagged version - name: build and publish tagged version
image: plugins/docker image: thegeeklab/drone-docker-buildx
privileged: true privileged: true
settings: settings:
repo: shibaobun/cannery repo: shibaobun/cannery
purge: true purge: true
compress: true compress: true
platforms: platforms: linux/amd64,linux/arm/v7
- linux/amd64
- linux/arm64
- linux/arm/v7
username: username:
from_secret: docker_username from_secret: docker_username
password: password:

View File

@ -1,6 +1,6 @@
[ [
import_deps: [:ecto, :ecto_sql, :phoenix], import_deps: [:ecto, :phoenix],
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"], inputs: ["*.{heex,ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{heex,ex,exs}"],
subdirectories: ["priv/*/migrations"], subdirectories: ["priv/*/migrations"],
plugins: [Phoenix.LiveView.HTMLFormatter] plugins: [Phoenix.LiveView.HTMLFormatter]
] ]

View File

@ -1,3 +1,3 @@
elixir 1.17.0-otp-27 elixir 1.14.1-otp-25
erlang 27.0 erlang 25.1.2
nodejs 22.3.0 nodejs 18.9.1

View File

@ -1,68 +1,3 @@
# v0.9.11
- Fix an issue with emails not being able to be sent for real this time
- Fix some dropdowns not filling in the correct data
- Add debounces to more fields
- Update deps
# v0.9.10
- Fix issue with logger failing on oban exceptions
- Fix an issue with emails not being able to be sent
- Update deps
# v0.9.9
- Actually fix bar graph
# v0.9.8
- Make bar graph ignore empty days
- Update dependencies
# v0.9.7
- Fix margin on bottom of page
- Use bar graph instead of line graph
- Improve login page autocomplete behavior
# v0.9.6
- Make ammo packs in containers directly navigable in table view
- Update dependencies
# v0.9.5
- Update dependencies
# v0.9.4
- Code quality fixes
- Fix error/404 pages not rendering properly
- Update dependencies
- Fix Range page title
# v0.9.3
- Update dependencies
- Add pack lot number to search
- Improve tests
- Change invite path slightly
- Disable arm builds since ci fails to build
# 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 # v0.8.6
- Fix duplicate entries showing up - Fix duplicate entries showing up
- Show ammo packs under a type in a table by default - Show ammo packs under a type in a table by default
@ -109,7 +44,7 @@
# v0.8.0 # v0.8.0
- Add search to catalog, ammo, container, tag and range index pages - Add search to catalog, ammo, container, tag and range index pages
- Tweak urls for catalog, ammo, containers, tags and shot records - Tweak urls for catalog, ammo, containers, tags and shot records
- Fix bug with shot record chart not drawing lines between days correctly - Fix bug with shot group chart not drawing lines between days correctly
- Improve cards across app (make them line up with each other) - Improve cards across app (make them line up with each other)
- Update translations and add spanish!!! (thank you Brea and Hannah!) - Update translations and add spanish!!! (thank you Brea and Hannah!)
@ -121,7 +56,7 @@
- Fix toggle button styling - Fix toggle button styling
- Miscellanous code improvements - Miscellanous code improvements
- Improve container index table - Improve container index table
- Fix bug with ammo not updating after deleting shot record - Fix bug with ammo not updating after deleting shot group
- Replace ammo "added on" with "purchased on" - Replace ammo "added on" with "purchased on"
- Miscellaneous wording improvements - Miscellaneous wording improvements
- Update translations - Update translations
@ -130,8 +65,8 @@
- Add shading to table component - Add shading to table component
- Fix chart to sum by day - Fix chart to sum by day
- Fix whitespace when copying invite url - Fix whitespace when copying invite url
- Make ammo type show page also display packs as table - Make ammo type show page also display ammo groups as table
- Make container show page also display packs as table - Make container show page also display ammo groups as table
- Display CPR for ammo packs - Display CPR for ammo packs
- Add original count for ammo packs - Add original count for ammo packs
- Add ammo pack CPR and original count to json export - Add ammo pack CPR and original count to json export
@ -155,7 +90,7 @@
- Add ammo type cloning - Add ammo type cloning
- Add container cloning - Add container cloning
- Fix bug with moving ammo packs between containers - Fix bug with moving ammo packs between containers
- Add button to set rounds left to 0 when creating a shot record - Add button to set rounds left to 0 when creating a shot group
- Update project dependencies - Update project dependencies
# v0.5.4 # v0.5.4
@ -207,8 +142,8 @@
# v0.3.0 # v0.3.0
- Fix ammo type counts not showing when count is 0 - Fix ammo type counts not showing when count is 0
- Add prompt to create first container before first ammo group - Add prompt to create first container before first ammo group
- Edit and delete shot records from ammo group show page - Edit and delete shot groups from ammo group show page
- Use today's date when adding new shot records - Use today's date when adding new shot groups
- Create multiple ammo groups at one time - Create multiple ammo groups at one time
# v0.2.3 # v0.2.3

View File

@ -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 functions as short as possible while keeping variable names descriptive! For
instance, use inline `do:` blocks for short functions and make your aliases as instance, use inline `do:` blocks for short functions and make your aliases as
short as possible without introducing ambiguity. short as possible without introducing ambiguity.
- I.e. since there's only one `Pack` in the app, please alias - I.e. since there's only one `AmmoGroup` in the app, please alias
`Pack.t()` instead of using `Cannery.Ammo.Pack.t()` `AmmoGroup.t()` instead of using `Cannery.Ammo.AmmoGroup.t()`
- Use pipelines when possible. If only calling a single method, a pipeline isn't - Use pipelines when possible. If only calling a single method, a pipeline isn't
strictly necessary but still encouraged for future modification. strictly necessary but still encouraged for future modification.
- Please add typespecs to your functions! Even your private functions may be - Please add typespecs to your functions! Even your private functions may be

View File

@ -1,4 +1,4 @@
FROM elixir:1.17.0-otp-27-alpine AS build FROM elixir:1.14.1-alpine AS build
# install build dependencies # install build dependencies
RUN apk add --no-cache build-base npm git python3 RUN apk add --no-cache build-base npm git python3
@ -7,8 +7,8 @@ RUN apk add --no-cache build-base npm git python3
WORKDIR /app WORKDIR /app
# install hex + rebar # install hex + rebar
RUN mix local.rebar --force && \ RUN mix local.hex --force && \
mix local.hex --force mix local.rebar --force
# set build ENV # set build ENV
ENV MIX_ENV=prod ENV MIX_ENV=prod
@ -37,7 +37,7 @@ RUN mix do compile, release
FROM alpine:latest AS app FROM alpine:latest AS app
RUN apk upgrade --no-cache && \ RUN apk upgrade --no-cache && \
apk add --no-cache bash openssl libssl3 libcrypto3 libgcc libstdc++ ncurses-libs apk add --no-cache bash openssl libssl1.1 libcrypto1.1 libgcc libstdc++ ncurses-libs
WORKDIR /app WORKDIR /app

View File

@ -13,8 +13,8 @@ The self-hosted firearm tracker website.
# Features # Features
- Create containers to store your ammunition, and tag them with custom tags - Create containers to store your ammunition, and tag them with custom tags
- Add ammunition types to Cannery, and then ammo packs to your containers - Add ammunition types to Cannery, and then ammunition groups to your containers
- Stage ammo packs for range day and track your usage with shot records - Stage groups of ammo for range day and record your ammo usage
- Invitations via invite tokens or public registration - Invitations via invite tokens or public registration
# Installation # Installation
@ -94,7 +94,6 @@ license can be found at
# Links # Links
- [Website](https://cannery.app): Project website
- [Gitea](https://gitea.bubbletea.dev/shibao/cannery): Main repo, feature - [Gitea](https://gitea.bubbletea.dev/shibao/cannery): Main repo, feature
requests and bug reports requests and bug reports
- [Github](https://github.com/shibaobun/cannery): Source code mirror, please - [Github](https://github.com/shibaobun/cannery): Source code mirror, please

View File

@ -25,6 +25,7 @@ import 'phoenix_html'
import { Socket } from 'phoenix' import { Socket } from 'phoenix'
import { LiveSocket } from 'phoenix_live_view' import { LiveSocket } from 'phoenix_live_view'
import topbar from 'topbar' import topbar from 'topbar'
import MaintainAttrs from './maintain_attrs'
import ShotLogChart from './shot_log_chart' import ShotLogChart from './shot_log_chart'
import Date from './date' import Date from './date'
import DateTime from './datetime' import DateTime from './datetime'
@ -32,7 +33,7 @@ import DateTime from './datetime'
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content') const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content')
const liveSocket = new LiveSocket('/live', Socket, { const liveSocket = new LiveSocket('/live', Socket, {
params: { _csrf_token: csrfToken }, params: { _csrf_token: csrfToken },
hooks: { Date, DateTime, ShotLogChart } hooks: { Date, DateTime, MaintainAttrs, ShotLogChart }
}) })
// Show progress bar on live navigation and form submits // Show progress bar on live navigation and form submits

View File

@ -0,0 +1,11 @@
// maintain user adjusted attributes, like textbox length on phoenix liveview
// update. https://github.com/phoenixframework/phoenix_live_view/issues/1011
export default {
attrs () {
const attrs = this.el.getAttribute('data-attrs')
if (attrs) { return attrs.split(', ') } else { return [] }
},
beforeUpdate () { this.prevAttrs = this.attrs().map(name => [name, this.el.getAttribute(name)]) },
updated () { this.prevAttrs.forEach(([name, val]) => this.el.setAttribute(name, val)) }
}

View File

@ -1,12 +1,13 @@
import Chart from 'chart.js/auto' import { Chart, Title, Tooltip, Legend, LineController, LineElement, PointElement, TimeScale, LinearScale } from 'chart.js'
import 'chartjs-adapter-date-fns' import 'chartjs-adapter-date-fns'
Chart.register(Title, Tooltip, Legend, LineController, LineElement, PointElement, TimeScale, LinearScale)
export default { export default {
initalizeChart (el) { initalizeChart (el) {
const data = JSON.parse(el.dataset.chartData) const data = JSON.parse(el.dataset.chartData)
this.el.chart = new Chart(el, { this.el.chart = new Chart(el, {
type: 'bar', type: 'line',
data: { data: {
datasets: [{ datasets: [{
label: el.dataset.label, label: el.dataset.label,
@ -50,17 +51,13 @@ export default {
stacked: true, stacked: true,
grace: '15%', grace: '15%',
ticks: { ticks: {
padding: 15, padding: 15
precision: 0
} }
}, },
x: { x: {
type: 'timeseries', type: 'time',
time: { time: {
unit: 'day' unit: 'day'
},
ticks: {
source: 'data'
} }
} }
}, },

19432
assets/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,8 +3,8 @@
"description": " ", "description": " ",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "v22.3.0", "node": "v18.9.1",
"npm": "10.8.1" "npm": "8.19.1"
}, },
"scripts": { "scripts": {
"deploy": "NODE_ENV=production webpack --mode production", "deploy": "NODE_ENV=production webpack --mode production",
@ -13,37 +13,37 @@
"test": "standard" "test": "standard"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^6.6.0", "@fortawesome/fontawesome-free": "^6.3.0",
"chart.js": "^4.4.3", "chart.js": "^4.2.1",
"chartjs-adapter-date-fns": "^3.0.0", "chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^3.6.0", "date-fns": "^2.29.3",
"phoenix": "file:../deps/phoenix", "phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html", "phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../deps/phoenix_live_view", "phoenix_live_view": "file:../deps/phoenix_live_view",
"topbar": "^3.0.0" "topbar": "^2.0.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.24.9", "@babel/core": "^7.21.3",
"@babel/preset-env": "^7.25.0", "@babel/preset-env": "^7.20.2",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.14",
"babel-loader": "^9.1.3", "babel-loader": "^9.1.2",
"copy-webpack-plugin": "^12.0.2", "copy-webpack-plugin": "^11.0.0",
"css-loader": "^7.1.2", "css-loader": "^6.7.3",
"css-minimizer-webpack-plugin": "^7.0.0", "css-minimizer-webpack-plugin": "^4.2.2",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"mini-css-extract-plugin": "^2.9.0", "mini-css-extract-plugin": "^2.7.5",
"npm-check-updates": "^16.14.20", "npm-check-updates": "^16.7.12",
"postcss": "^8.4.40", "postcss": "^8.4.21",
"postcss-import": "^16.1.0", "postcss-import": "^15.1.0",
"postcss-loader": "^8.1.1", "postcss-loader": "^7.1.0",
"postcss-preset-env": "^9.6.0", "postcss-preset-env": "^8.0.1",
"sass": "^1.77.8", "sass": "^1.59.3",
"sass-loader": "^16.0.0", "sass-loader": "^13.2.1",
"standard": "^17.1.0", "standard": "^17.0.0",
"tailwindcss": "^3.4.7", "tailwindcss": "^3.2.7",
"terser-webpack-plugin": "^5.3.10", "terser-webpack-plugin": "^5.3.7",
"webpack": "^5.93.0", "webpack": "^5.76.2",
"webpack-cli": "^5.1.4", "webpack-cli": "^5.0.1",
"webpack-dev-server": "^5.0.4" "webpack-dev-server": "^4.13.1"
} }
} }

View File

@ -45,7 +45,7 @@ module.exports = (env, options) => {
{ {
test: /\.(woff(2)?|ttf|eot|svg|otf)(\?v=[0-9]\.[0-9]\.[0-9])?$/, test: /\.(woff(2)?|ttf|eot|svg|otf)(\?v=[0-9]\.[0-9]\.[0-9])?$/,
type: 'asset/resource', type: 'asset/resource',
generator: { filename: 'fonts/[name].[ext]' } generator: { filename: 'fonts/[name][ext]' }
} }
] ]
}, },

View File

@ -18,10 +18,7 @@ config :cannery, CanneryWeb.Endpoint,
url: [scheme: "https", host: System.get_env("HOST") || "localhost", port: "443"], url: [scheme: "https", host: System.get_env("HOST") || "localhost", port: "443"],
http: [port: String.to_integer(System.get_env("PORT") || "4000")], http: [port: String.to_integer(System.get_env("PORT") || "4000")],
secret_key_base: "KH59P0iZixX5gP/u+zkxxG8vAAj6vgt0YqnwEB5JP5K+E567SsqkCz69uWShjE7I", secret_key_base: "KH59P0iZixX5gP/u+zkxxG8vAAj6vgt0YqnwEB5JP5K+E567SsqkCz69uWShjE7I",
render_errors: [ render_errors: [view: CanneryWeb.ErrorView, accepts: ~w(html json), layout: false],
formats: [html: CanneryWeb.ErrorHTML, json: CanneryWeb.ErrorJSON],
layout: false
],
pubsub_server: Cannery.PubSub, pubsub_server: Cannery.PubSub,
live_view: [signing_salt: "zOLgd3lr"] live_view: [signing_salt: "zOLgd3lr"]

View File

@ -59,7 +59,8 @@ config :cannery, CanneryWeb.Endpoint,
patterns: [ patterns: [
~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/gettext/.*(po)$", ~r"priv/gettext/.*(po)$",
~r"lib/cannery_web/*/.*(ex)$" ~r"lib/cannery_web/(live|views)/.*(ex)$",
~r"lib/cannery_web/templates/.*(eex)$"
] ]
] ]

View File

@ -12,7 +12,7 @@ if System.get_env("PHX_SERVER") && System.get_env("RELEASE_NAME") do
config :cannery, CanneryWeb.Endpoint, server: true config :cannery, CanneryWeb.Endpoint, server: true
end end
config :cannery, CanneryWeb.HTMLHelpers, shibao_mode: System.get_env("SHIBAO_MODE") == "true" config :cannery, CanneryWeb.ViewHelpers, shibao_mode: System.get_env("SHIBAO_MODE") == "true"
# Set default locale # Set default locale
config :gettext, :default_locale, System.get_env("LOCALE", "en_US") config :gettext, :default_locale, System.get_env("LOCALE", "en_US")

View File

@ -9,9 +9,8 @@ config :bcrypt_elixir, :log_rounds, 1
# to provide built-in test partitioning in CI environment. # to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information. # Run `mix help test` for more information.
config :cannery, Cannery.Repo, config :cannery, Cannery.Repo,
pool_size: 10,
pool: Ecto.Adapters.SQL.Sandbox, pool: Ecto.Adapters.SQL.Sandbox,
timeout: 60000 pool_size: 10
# We don't run a server during test. If one is required, # We don't run a server during test. If one is required,
# you can enable the server option below. # you can enable the server option below.
@ -27,10 +26,10 @@ config :cannery, Cannery.Mailer, adapter: Swoosh.Adapters.Test
config :cannery, Cannery.Accounts, registration: "public" config :cannery, Cannery.Accounts, registration: "public"
# Print only warnings and errors during test # Print only warnings and errors during test
config :logger, level: :warning config :logger, level: :warn
# Initialize plugs at runtime for faster test compilation # Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime config :phoenix, :plug_init_mode, :runtime
# Disable Oban # Disable Oban
config :cannery, Oban, queues: false, plugins: false, testing: :manual config :cannery, Oban, queues: false, plugins: false

View File

@ -374,8 +374,8 @@ defmodule Cannery.Accounts do
@doc """ @doc """
Deletes the signed token with the given context. Deletes the signed token with the given context.
""" """
@spec delete_user_session_token(token :: String.t()) :: :ok @spec delete_session_token(token :: String.t()) :: :ok
def delete_user_session_token(token) do def delete_session_token(token) do
UserToken.token_and_context_query(token, "session") |> Repo.delete_all() UserToken.token_and_context_query(token, "session") |> Repo.delete_all()
:ok :ok
end end
@ -404,15 +404,15 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> admin?(%User{role: :admin}) iex> is_admin?(%User{role: :admin})
true true
iex> admin?(%User{}) iex> is_admin?(%User{})
false false
""" """
@spec admin?(User.t()) :: boolean() @spec is_admin?(User.t()) :: boolean()
def admin?(%User{id: user_id}) do def is_admin?(%User{id: user_id}) do
Repo.exists?(from u in User, where: u.id == ^user_id, where: u.role == :admin) Repo.exists?(from u in User, where: u.id == ^user_id, where: u.role == :admin)
end end
@ -421,16 +421,16 @@ defmodule Cannery.Accounts do
## Examples ## Examples
iex> already_admin?(%User{role: :admin}) iex> is_already_admin?(%User{role: :admin})
true true
iex> already_admin?(%User{}) iex> is_already_admin?(%User{})
false false
""" """
@spec already_admin?(User.t() | nil) :: boolean() @spec is_already_admin?(User.t() | nil) :: boolean()
def already_admin?(%User{role: :admin}), do: true def is_already_admin?(%User{role: :admin}), do: true
def already_admin?(_invalid_user), do: false def is_already_admin?(_invalid_user), do: false
## Confirmation ## Confirmation

View File

@ -3,15 +3,14 @@ defmodule Cannery.Email do
Emails that can be sent using Swoosh. Emails that can be sent using Swoosh.
You can find the base email templates at You can find the base email templates at
`lib/cannery_web/components/layouts/email_html.html.heex` for html emails and `lib/cannery_web/templates/layout/email.html.heex` for html emails and
`lib/cannery_web/components/layouts/email_text.txt.eex` for text emails. `lib/cannery_web/templates/layout/email.txt.heex` for text emails.
""" """
import Swoosh.Email use Phoenix.Swoosh, view: CanneryWeb.EmailView, layout: {CanneryWeb.LayoutView, :email}
import CanneryWeb.Gettext import CanneryWeb.Gettext
import Phoenix.Template
alias Cannery.Accounts.User alias Cannery.Accounts.User
alias CanneryWeb.{EmailHTML, Layouts} alias CanneryWeb.EmailView
@typedoc """ @typedoc """
Represents an HTML and text body email that can be sent Represents an HTML and text body email that can be sent
@ -29,33 +28,21 @@ defmodule Cannery.Email do
def generate_email("welcome", user, %{"url" => url}) do def generate_email("welcome", user, %{"url" => url}) do
user user
|> base_email(dgettext("emails", "Confirm your Cannery account")) |> base_email(dgettext("emails", "Confirm your Cannery account"))
|> html_email(:confirm_email_html, %{user: user, url: url}) |> render_body("confirm_email.html", %{user: user, url: url})
|> text_email(:confirm_email_text, %{user: user, url: url}) |> text_body(EmailView.render("confirm_email.txt", %{user: user, url: url}))
end end
def generate_email("reset_password", user, %{"url" => url}) do def generate_email("reset_password", user, %{"url" => url}) do
user user
|> base_email(dgettext("emails", "Reset your Cannery password")) |> base_email(dgettext("emails", "Reset your Cannery password"))
|> html_email(:reset_password_html, %{user: user, url: url}) |> render_body("reset_password.html", %{user: user, url: url})
|> text_email(:reset_password_text, %{user: user, url: url}) |> text_body(EmailView.render("reset_password.txt", %{user: user, url: url}))
end end
def generate_email("update_email", user, %{"url" => url}) do def generate_email("update_email", user, %{"url" => url}) do
user user
|> base_email(dgettext("emails", "Update your Cannery email")) |> base_email(dgettext("emails", "Update your Cannery email"))
|> html_email(:update_email_html, %{user: user, url: url}) |> render_body("update_email.html", %{user: user, url: url})
|> text_email(:update_email_text, %{user: user, url: url}) |> text_body(EmailView.render("update_email.txt", %{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
end end

View File

@ -48,9 +48,8 @@ defmodule Cannery.Accounts.Invite do
%__MODULE__{} %__MODULE__{}
|> change(token: token, created_by_id: user_id) |> change(token: token, created_by_id: user_id)
|> cast(attrs, [:name, :uses_left, :disabled_at]) |> cast(attrs, [:name, :uses_left, :disabled_at])
|> validate_length(:name, max: 255)
|> validate_number(:uses_left, greater_than_or_equal_to: 0)
|> validate_required([:name, :token, :created_by_id]) |> validate_required([:name, :token, :created_by_id])
|> validate_number(:uses_left, greater_than_or_equal_to: 0)
end end
@doc false @doc false
@ -58,8 +57,7 @@ defmodule Cannery.Accounts.Invite do
def update_changeset(invite, attrs) do def update_changeset(invite, attrs) do
invite invite
|> cast(attrs, [:name, :uses_left, :disabled_at]) |> cast(attrs, [:name, :uses_left, :disabled_at])
|> validate_length(:name, max: 255)
|> validate_number(:uses_left, greater_than_or_equal_to: 0)
|> validate_required([:name]) |> validate_required([:name])
|> validate_number(:uses_left, greater_than_or_equal_to: 0)
end end
end end

View File

@ -79,7 +79,6 @@ defmodule Cannery.Accounts.User do
%User{} %User{}
|> cast(attrs, [:email, :password, :locale]) |> cast(attrs, [:email, :password, :locale])
|> put_change(:invite_id, if(invite, do: invite.id)) |> put_change(:invite_id, if(invite, do: invite.id))
|> validate_length(:locale, max: 255)
|> validate_email() |> validate_email()
|> validate_password(opts) |> validate_password(opts)
end end
@ -210,7 +209,6 @@ defmodule Cannery.Accounts.User do
def locale_changeset(user_or_changeset, locale) do def locale_changeset(user_or_changeset, locale) do
user_or_changeset user_or_changeset
|> cast(%{"locale" => locale}, [:locale]) |> cast(%{"locale" => locale}, [:locale])
|> validate_length(:locale, max: 255)
|> validate_required(:locale) |> validate_required(:locale)
end end
end end

View File

@ -4,436 +4,350 @@ defmodule Cannery.ActivityLog do
""" """
import Ecto.Query, warn: false import Ecto.Query, warn: false
alias Cannery.Ammo.{Pack, Type} alias Cannery.Ammo.{AmmoGroup, AmmoType}
alias Cannery.{Accounts.User, ActivityLog.ShotRecord, Repo} alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Repo}
alias Ecto.{Multi, Queryable} alias Ecto.Multi
@type list_shot_records_option ::
{:search, String.t() | nil}
| {:class, Type.class() | :all | nil}
| {:pack_id, Pack.id() | nil}
@type list_shot_records_options :: [list_shot_records_option()]
@doc """ @doc """
Returns the list of shot_records. Returns the list of shot_groups.
## Examples ## Examples
iex> list_shot_records(%User{id: 123}) iex> list_shot_groups(%User{id: 123})
[%ShotRecord{}, ...] [%ShotGroup{}, ...]
iex> list_shot_records(%User{id: 123}, search: "cool") iex> list_shot_groups("cool", %User{id: 123})
[%ShotRecord{notes: "My cool shot record"}, ...] [%ShotGroup{notes: "My cool shot group"}, ...]
iex> list_shot_records(%User{id: 123}, search: "cool", class: :rifle)
[%ShotRecord{notes: "Shot some rifle rounds"}, ...]
iex> list_shot_records(%User{id: 123}, pack_id: 456)
[%ShotRecord{pack_id: 456}, ...]
""" """
@spec list_shot_records(User.t()) :: [ShotRecord.t()] @spec list_shot_groups(User.t()) :: [ShotGroup.t()]
@spec list_shot_records(User.t(), list_shot_records_options()) :: [ShotRecord.t()] @spec list_shot_groups(search :: nil | String.t(), User.t()) :: [ShotGroup.t()]
def list_shot_records(%User{id: user_id}, opts \\ []) do def list_shot_groups(search \\ nil, user)
from(sr in ShotRecord,
as: :sr,
left_join: p in Pack,
as: :p,
on: sr.pack_id == p.id,
on: p.user_id == ^user_id,
left_join: t in Type,
as: :t,
on: p.type_id == t.id,
on: t.user_id == ^user_id,
where: sr.user_id == ^user_id,
distinct: sr.id
)
|> list_shot_records_search(Keyword.get(opts, :search))
|> list_shot_records_class(Keyword.get(opts, :class))
|> list_shot_records_pack_id(Keyword.get(opts, :pack_id))
|> Repo.all()
end
@spec list_shot_records_search(Queryable.t(), search :: String.t() | nil) :: def list_shot_groups(search, %{id: user_id}) when search |> is_nil() or search == "",
Queryable.t() do: Repo.all(from sg in ShotGroup, where: sg.user_id == ^user_id)
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 def list_shot_groups(search, %{id: user_id}) when search |> is_binary() do
trimmed_search = String.trim(search) trimmed_search = String.trim(search)
query Repo.all(
|> where( from sg in ShotGroup,
[sr: sr, p: p, t: t], 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( fragment(
"? @@ websearch_to_tsquery('english', ?)", "? @@ websearch_to_tsquery('english', ?)",
sr.search, sg.search,
^trimmed_search ^trimmed_search
) or ) or
fragment( fragment(
"? @@ websearch_to_tsquery('english', ?)", "? @@ websearch_to_tsquery('english', ?)",
p.search, ag.search,
^trimmed_search ^trimmed_search
) or ) or
fragment( fragment(
"? @@ websearch_to_tsquery('english', ?)", "? @@ websearch_to_tsquery('english', ?)",
t.search, at.search,
^trimmed_search ^trimmed_search
) ),
) order_by: {
|> order_by([sr: sr], {
:desc, :desc,
fragment( fragment(
"ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)", "ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)",
sr.search, sg.search,
^trimmed_search ^trimmed_search
) )
}) },
distinct: sg.id
)
end end
@spec list_shot_records_class(Queryable.t(), Type.class() | :all | nil) :: Queryable.t() @spec list_shot_groups_for_ammo_group(AmmoGroup.t(), User.t()) :: [ShotGroup.t()]
defp list_shot_records_class(query, class) when class in [:rifle, :pistol, :shotgun], def list_shot_groups_for_ammo_group(
do: query |> where([t: t], t.class == ^class) %AmmoGroup{id: ammo_group_id, user_id: user_id},
%User{id: user_id}
defp list_shot_records_class(query, _all), do: query ) do
Repo.all(
@spec list_shot_records_pack_id(Queryable.t(), Pack.id() | nil) :: Queryable.t() from sg in ShotGroup,
defp list_shot_records_pack_id(query, pack_id) when pack_id |> is_binary(), where: sg.ammo_group_id == ^ammo_group_id,
do: query |> where([sr: sr], sr.pack_id == ^pack_id) where: sg.user_id == ^user_id
)
defp list_shot_records_pack_id(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 sr in ShotRecord,
where: sr.user_id == ^user_id,
select: count(sr.id),
distinct: true
) || 0
end end
@doc """ @doc """
Gets a single shot_record. Gets a single shot_group.
Raises `Ecto.NoResultsError` if the shot record does not exist. Raises `Ecto.NoResultsError` if the Shot group does not exist.
## Examples ## Examples
iex> get_shot_record!(123, %User{id: 123}) iex> get_shot_group!(123, %User{id: 123})
%ShotRecord{} %ShotGroup{}
iex> get_shot_record!(456, %User{id: 123}) iex> get_shot_group!(456, %User{id: 123})
** (Ecto.NoResultsError) ** (Ecto.NoResultsError)
""" """
@spec get_shot_record!(ShotRecord.id(), User.t()) :: ShotRecord.t() @spec get_shot_group!(ShotGroup.id(), User.t()) :: ShotGroup.t()
def get_shot_record!(id, %User{id: user_id}) do def get_shot_group!(id, %User{id: user_id}) do
Repo.one!( Repo.one!(
from sr in ShotRecord, from sg in ShotGroup,
where: sr.id == ^id, where: sg.id == ^id,
where: sr.user_id == ^user_id, where: sg.user_id == ^user_id,
order_by: sr.date order_by: sg.date
) )
end end
@doc """ @doc """
Creates a shot_record. Creates a shot_group.
## Examples ## Examples
iex> create_shot_record(%{field: value}, %User{id: 123}) iex> create_shot_group(%{field: value}, %User{id: 123})
{:ok, %ShotRecord{}} {:ok, %ShotGroup{}}
iex> create_shot_record(%{field: bad_value}, %User{id: 123}) iex> create_shot_group(%{field: bad_value}, %User{id: 123})
{:error, %Ecto.Changeset{}} {:error, %Ecto.Changeset{}}
""" """
@spec create_shot_record(attrs :: map(), User.t(), Pack.t()) :: @spec create_shot_group(attrs :: map(), User.t(), AmmoGroup.t()) ::
{:ok, ShotRecord.t()} | {:error, ShotRecord.changeset() | nil} {:ok, ShotGroup.t()} | {:error, ShotGroup.changeset() | nil}
def create_shot_record(attrs, user, pack) do def create_shot_group(attrs, user, ammo_group) do
Multi.new() Multi.new()
|> Multi.insert( |> Multi.insert(
:create_shot_record, :create_shot_group,
%ShotRecord{} |> ShotRecord.create_changeset(user, pack, attrs) %ShotGroup{} |> ShotGroup.create_changeset(user, ammo_group, attrs)
) )
|> Multi.run( |> Multi.run(
:pack, :ammo_group,
fn _repo, %{create_shot_record: %{pack_id: pack_id, user_id: user_id}} -> fn _repo, %{create_shot_group: %{ammo_group_id: ammo_group_id, user_id: user_id}} ->
pack = ammo_group =
Repo.one( Repo.one(
from p in Pack, from ag in AmmoGroup,
where: p.id == ^pack_id, where: ag.id == ^ammo_group_id,
where: p.user_id == ^user_id where: ag.user_id == ^user_id
) )
{:ok, pack} {:ok, ammo_group}
end end
) )
|> Multi.update( |> Multi.update(
:update_pack, :update_ammo_group,
fn %{create_shot_record: %{count: shot_record_count}, pack: %{count: pack_count}} -> fn %{create_shot_group: %{count: shot_group_count}, ammo_group: %{count: ammo_group_count}} ->
pack |> Pack.range_changeset(%{"count" => pack_count - shot_record_count}) ammo_group |> AmmoGroup.range_changeset(%{"count" => ammo_group_count - shot_group_count})
end end
) )
|> Repo.transaction() |> Repo.transaction()
|> case do |> case do
{:ok, %{create_shot_record: shot_record}} -> {:ok, shot_record} {:ok, %{create_shot_group: shot_group}} -> {:ok, shot_group}
{:error, :create_shot_record, changeset, _changes_so_far} -> {:error, changeset} {:error, :create_shot_group, changeset, _changes_so_far} -> {:error, changeset}
{:error, _other_transaction, _value, _changes_so_far} -> {:error, nil} {:error, _other_transaction, _value, _changes_so_far} -> {:error, nil}
end end
end end
@doc """ @doc """
Updates a shot_record. Updates a shot_group.
## Examples ## Examples
iex> update_shot_record(shot_record, %{field: new_value}, %User{id: 123}) iex> update_shot_group(shot_group, %{field: new_value}, %User{id: 123})
{:ok, %ShotRecord{}} {:ok, %ShotGroup{}}
iex> update_shot_record(shot_record, %{field: bad_value}, %User{id: 123}) iex> update_shot_group(shot_group, %{field: bad_value}, %User{id: 123})
{:error, %Ecto.Changeset{}} {:error, %Ecto.Changeset{}}
""" """
@spec update_shot_record(ShotRecord.t(), attrs :: map(), User.t()) :: @spec update_shot_group(ShotGroup.t(), attrs :: map(), User.t()) ::
{:ok, ShotRecord.t()} | {:error, ShotRecord.changeset() | nil} {:ok, ShotGroup.t()} | {:error, ShotGroup.changeset() | nil}
def update_shot_record( def update_shot_group(
%ShotRecord{count: count, user_id: user_id} = shot_record, %ShotGroup{count: count, user_id: user_id} = shot_group,
attrs, attrs,
%User{id: user_id} = user %User{id: user_id} = user
) do ) do
Multi.new() Multi.new()
|> Multi.update( |> Multi.update(
:update_shot_record, :update_shot_group,
shot_record |> ShotRecord.update_changeset(user, attrs) shot_group |> ShotGroup.update_changeset(user, attrs)
) )
|> Multi.run( |> Multi.run(
:pack, :ammo_group,
fn repo, %{update_shot_record: %{pack_id: pack_id, user_id: user_id}} -> fn repo, %{update_shot_group: %{ammo_group_id: ammo_group_id, user_id: user_id}} ->
{:ok, repo.one(from p in Pack, where: p.id == ^pack_id and p.user_id == ^user_id)} {:ok,
repo.one(from ag in AmmoGroup, where: ag.id == ^ammo_group_id and ag.user_id == ^user_id)}
end end
) )
|> Multi.update( |> Multi.update(
:update_pack, :update_ammo_group,
fn %{ fn %{
update_shot_record: %{count: new_count}, update_shot_group: %{count: new_count},
pack: %{count: pack_count} = pack ammo_group: %{count: ammo_group_count} = ammo_group
} -> } ->
shot_diff_to_add = new_count - count shot_diff_to_add = new_count - count
new_pack_count = pack_count - shot_diff_to_add new_ammo_group_count = ammo_group_count - shot_diff_to_add
pack |> Pack.range_changeset(%{"count" => new_pack_count}) ammo_group |> AmmoGroup.range_changeset(%{"count" => new_ammo_group_count})
end end
) )
|> Repo.transaction() |> Repo.transaction()
|> case do |> case do
{:ok, %{update_shot_record: shot_record}} -> {:ok, shot_record} {:ok, %{update_shot_group: shot_group}} -> {:ok, shot_group}
{:error, :update_shot_record, changeset, _changes_so_far} -> {:error, changeset} {:error, :update_shot_group, changeset, _changes_so_far} -> {:error, changeset}
{:error, _other_transaction, _value, _changes_so_far} -> {:error, nil} {:error, _other_transaction, _value, _changes_so_far} -> {:error, nil}
end end
end end
@doc """ @doc """
Deletes a shot_record. Deletes a shot_group.
## Examples ## Examples
iex> delete_shot_record(shot_record, %User{id: 123}) iex> delete_shot_group(shot_group, %User{id: 123})
{:ok, %ShotRecord{}} {:ok, %ShotGroup{}}
iex> delete_shot_record(shot_record, %User{id: 123}) iex> delete_shot_group(shot_group, %User{id: 123})
{:error, %Ecto.Changeset{}} {:error, %Ecto.Changeset{}}
""" """
@spec delete_shot_record(ShotRecord.t(), User.t()) :: @spec delete_shot_group(ShotGroup.t(), User.t()) ::
{:ok, ShotRecord.t()} | {:error, ShotRecord.changeset()} {:ok, ShotGroup.t()} | {:error, ShotGroup.changeset()}
def delete_shot_record( def delete_shot_group(
%ShotRecord{user_id: user_id} = shot_record, %ShotGroup{user_id: user_id} = shot_group,
%User{id: user_id} %User{id: user_id}
) do ) do
Multi.new() Multi.new()
|> Multi.delete(:delete_shot_record, shot_record) |> Multi.delete(:delete_shot_group, shot_group)
|> Multi.run( |> Multi.run(
:pack, :ammo_group,
fn repo, %{delete_shot_record: %{pack_id: pack_id, user_id: user_id}} -> fn repo, %{delete_shot_group: %{ammo_group_id: ammo_group_id, user_id: user_id}} ->
{:ok, repo.one(from p in Pack, where: p.id == ^pack_id and p.user_id == ^user_id)} {:ok,
repo.one(from ag in AmmoGroup, where: ag.id == ^ammo_group_id and ag.user_id == ^user_id)}
end end
) )
|> Multi.update( |> Multi.update(
:update_pack, :update_ammo_group,
fn %{ fn %{
delete_shot_record: %{count: count}, delete_shot_group: %{count: count},
pack: %{count: pack_count} = pack ammo_group: %{count: ammo_group_count} = ammo_group
} -> } ->
new_pack_count = pack_count + count new_ammo_group_count = ammo_group_count + count
pack |> Pack.range_changeset(%{"count" => new_pack_count}) ammo_group |> AmmoGroup.range_changeset(%{"count" => new_ammo_group_count})
end end
) )
|> Repo.transaction() |> Repo.transaction()
|> case do |> case do
{:ok, %{delete_shot_record: shot_record}} -> {:ok, shot_record} {:ok, %{delete_shot_group: shot_group}} -> {:ok, shot_group}
{:error, :delete_shot_record, changeset, _changes_so_far} -> {:error, changeset} {:error, :delete_shot_group, changeset, _changes_so_far} -> {:error, changeset}
{:error, _other_transaction, _value, _changes_so_far} -> {:error, nil} {:error, _other_transaction, _value, _changes_so_far} -> {:error, nil}
end end
end end
@doc """ @doc """
Returns the last entered shot record date for a pack Returns the number of shot rounds for an ammo group
""" """
@spec get_last_used_date(Pack.t(), User.t()) :: Date.t() | nil @spec get_used_count(AmmoGroup.t(), User.t()) :: non_neg_integer()
def get_last_used_date(%Pack{id: pack_id} = pack, user) do def get_used_count(%AmmoGroup{id: ammo_group_id} = ammo_group, user) do
[pack] [ammo_group]
|> get_last_used_dates(user) |> get_used_counts(user)
|> Map.get(pack_id) |> Map.get(ammo_group_id, 0)
end end
@doc """ @doc """
Returns the last entered shot record date for a pack Returns the number of shot rounds for multiple ammo groups
""" """
@spec get_last_used_dates([Pack.t()], User.t()) :: %{optional(Pack.id()) => Date.t()} @spec get_used_counts([AmmoGroup.t()], User.t()) ::
def get_last_used_dates(packs, %User{id: user_id}) do %{optional(AmmoGroup.id()) => non_neg_integer()}
pack_ids = def get_used_counts(ammo_groups, %User{id: user_id}) do
packs ammo_group_ids =
|> Enum.map(fn %Pack{id: pack_id, user_id: ^user_id} -> pack_id end) ammo_groups
|> Enum.map(fn %{id: ammo_group_id} -> ammo_group_id end)
Repo.all( Repo.all(
from sr in ShotRecord, from sg in ShotGroup,
where: sr.pack_id in ^pack_ids, where: sg.ammo_group_id in ^ammo_group_ids,
where: sr.user_id == ^user_id, where: sg.user_id == ^user_id,
group_by: sr.pack_id, group_by: sg.ammo_group_id,
select: {sr.pack_id, max(sr.date)} select: {sg.ammo_group_id, sum(sg.count)}
) )
|> Map.new() |> Map.new()
end end
@type get_used_count_option :: {:pack_id, Pack.id() | nil} | {:type_id, Type.id() | nil} @doc """
@type get_used_count_options :: [get_used_count_option()] Returns the last entered shot group date for an ammo group
"""
@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]
|> get_last_used_dates(user)
|> Map.get(ammo_group_id)
end
@doc """ @doc """
Gets the total number of rounds shot for a type Returns the last entered shot group date for an ammo group
Raises `Ecto.NoResultsError` if the type does not exist.
## Examples
iex> get_used_count(%User{id: 123}, type_id: 123)
35
iex> get_used_count(%User{id: 123}, pack_id: 456)
50
""" """
@spec get_used_count(User.t(), get_used_count_options()) :: non_neg_integer() @spec get_last_used_dates([AmmoGroup.t()], User.t()) :: %{optional(AmmoGroup.id()) => Date.t()}
def get_used_count(%User{id: user_id}, opts) do def get_last_used_dates(ammo_groups, %User{id: user_id}) do
from(sr in ShotRecord, ammo_group_ids =
as: :sr, ammo_groups
left_join: p in Pack, |> Enum.map(fn %AmmoGroup{id: ammo_group_id, user_id: ^user_id} -> ammo_group_id end)
on: sr.pack_id == p.id,
on: p.user_id == ^user_id, Repo.all(
as: :p, from sg in ShotGroup,
where: sr.user_id == ^user_id, where: sg.ammo_group_id in ^ammo_group_ids,
where: not (sr.count |> is_nil()), where: sg.user_id == ^user_id,
select: sum(sr.count), group_by: sg.ammo_group_id,
distinct: true select: {sg.ammo_group_id, max(sg.date)}
) )
|> get_used_count_type_id(Keyword.get(opts, :type_id))
|> get_used_count_pack_id(Keyword.get(opts, :pack_id))
|> Repo.one() || 0
end
@spec get_used_count_pack_id(Queryable.t(), Pack.id() | nil) :: Queryable.t()
defp get_used_count_pack_id(query, pack_id) when pack_id |> is_binary() do
query |> where([sr: sr], sr.pack_id == ^pack_id)
end
defp get_used_count_pack_id(query, _nil), do: query
@spec get_used_count_type_id(Queryable.t(), Type.id() | nil) :: Queryable.t()
defp get_used_count_type_id(query, type_id) when type_id |> is_binary() do
query |> where([p: p], p.type_id == ^type_id)
end
defp get_used_count_type_id(query, _nil), do: query
@type get_grouped_used_counts_option ::
{:packs, [Pack.t()] | nil}
| {:types, [Type.t()] | nil}
| {:group_by, :type_id | :pack_id}
@type get_grouped_used_counts_options :: [get_grouped_used_counts_option()]
@doc """
Gets the total number of rounds shot for multiple types or packs
## Examples
iex> get_grouped_used_counts(
...> %User{id: 123},
...> group_by: :type_id,
...> types: [%Type{id: 456, user_id: 123}]
...> )
35
iex> get_grouped_used_counts(
...> %User{id: 123},
...> group_by: :pack_id,
...> packs: [%Pack{id: 456, user_id: 123}]
...> )
22
"""
@spec get_grouped_used_counts(User.t(), get_grouped_used_counts_options()) ::
%{optional(Type.id() | Pack.id()) => non_neg_integer()}
def get_grouped_used_counts(%User{id: user_id}, opts) do
from(p in Pack,
as: :p,
left_join: sr in ShotRecord,
on: p.id == sr.pack_id,
on: p.user_id == ^user_id,
as: :sr,
where: sr.user_id == ^user_id,
where: not (sr.count |> is_nil())
)
|> get_grouped_used_counts_group_by(Keyword.fetch!(opts, :group_by))
|> get_grouped_used_counts_types(Keyword.get(opts, :types))
|> get_grouped_used_counts_packs(Keyword.get(opts, :packs))
|> Repo.all()
|> Map.new() |> Map.new()
end end
@spec get_grouped_used_counts_group_by(Queryable.t(), :type_id | :pack_id) :: Queryable.t() @doc """
defp get_grouped_used_counts_group_by(query, :type_id) do Gets the total number of rounds shot for an ammo type
query
|> group_by([p: p], p.type_id) Raises `Ecto.NoResultsError` if the Ammo type does not exist.
|> select([sr: sr, p: p], {p.type_id, sum(sr.count)})
## Examples
iex> get_used_count_for_ammo_type(123, %User{id: 123})
35
iex> get_used_count_for_ammo_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)
end end
defp get_grouped_used_counts_group_by(query, :pack_id) do @doc """
query Gets the total number of rounds shot for multiple ammo types
|> group_by([sr: sr], sr.pack_id)
|> select([sr: sr], {sr.pack_id, sum(sr.count)}) ## Examples
iex> get_used_count_for_ammo_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)
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,
where: not (sg.count |> is_nil()),
group_by: ag.ammo_type_id,
select: {ag.ammo_type_id, sum(sg.count)}
)
|> Map.new()
end end
@spec get_grouped_used_counts_types(Queryable.t(), [Type.t()] | nil) :: Queryable.t()
defp get_grouped_used_counts_types(query, types) when types |> is_list() do
type_ids = types |> Enum.map(fn %Type{id: type_id} -> type_id end)
query |> where([p: p], p.type_id in ^type_ids)
end
defp get_grouped_used_counts_types(query, _nil), do: query
@spec get_grouped_used_counts_packs(Queryable.t(), [Pack.t()] | nil) :: Queryable.t()
defp get_grouped_used_counts_packs(query, packs) when packs |> is_list() do
pack_ids = packs |> Enum.map(fn %Pack{id: pack_id} -> pack_id end)
query |> where([p: p], p.id in ^pack_ids)
end
defp get_grouped_used_counts_packs(query, _nil), do: query
end end

View File

@ -1,12 +1,12 @@
defmodule Cannery.ActivityLog.ShotRecord do defmodule Cannery.ActivityLog.ShotGroup do
@moduledoc """ @moduledoc """
A shot record records a group of ammo shot during a range trip A shot group records a group of ammo shot during a range trip
""" """
use Ecto.Schema use Ecto.Schema
import CanneryWeb.Gettext import CanneryWeb.Gettext
import Ecto.Changeset import Ecto.Changeset
alias Cannery.{Accounts.User, Ammo, Ammo.Pack} alias Cannery.{Accounts.User, Ammo, Ammo.AmmoGroup}
alias Ecto.{Changeset, UUID} alias Ecto.{Changeset, UUID}
@derive {Jason.Encoder, @derive {Jason.Encoder,
@ -15,17 +15,17 @@ defmodule Cannery.ActivityLog.ShotRecord do
:count, :count,
:date, :date,
:notes, :notes,
:pack_id :ammo_group_id
]} ]}
@primary_key {:id, :binary_id, autogenerate: true} @primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id @foreign_key_type :binary_id
schema "shot_records" do schema "shot_groups" do
field :count, :integer field :count, :integer
field :date, :date field :date, :date
field :notes, :string field :notes, :string
field :user_id, :binary_id field :user_id, :binary_id
field :pack_id, :binary_id field :ammo_group_id, :binary_id
timestamps() timestamps()
end end
@ -35,57 +35,57 @@ defmodule Cannery.ActivityLog.ShotRecord do
count: integer, count: integer,
notes: String.t() | nil, notes: String.t() | nil,
date: Date.t() | nil, date: Date.t() | nil,
pack_id: Pack.id(), ammo_group_id: AmmoGroup.id(),
user_id: User.id(), user_id: User.id(),
inserted_at: NaiveDateTime.t(), inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t() updated_at: NaiveDateTime.t()
} }
@type new_shot_record :: %__MODULE__{} @type new_shot_group :: %__MODULE__{}
@type id :: UUID.t() @type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_shot_record()) @type changeset :: Changeset.t(t() | new_shot_group())
@doc false @doc false
@spec create_changeset( @spec create_changeset(
new_shot_record(), new_shot_group(),
User.t() | any(), User.t() | any(),
Pack.t() | any(), AmmoGroup.t() | any(),
attrs :: map() attrs :: map()
) :: changeset() ) :: changeset()
def create_changeset( def create_changeset(
shot_record, shot_group,
%User{id: user_id}, %User{id: user_id},
%Pack{id: pack_id, user_id: user_id} = pack, %AmmoGroup{id: ammo_group_id, user_id: user_id} = ammo_group,
attrs attrs
) do ) do
shot_record shot_group
|> change(user_id: user_id) |> change(user_id: user_id)
|> change(pack_id: pack_id) |> change(ammo_group_id: ammo_group_id)
|> cast(attrs, [:count, :notes, :date]) |> cast(attrs, [:count, :notes, :date])
|> validate_length(:notes, max: 255) |> validate_create_shot_group_count(ammo_group)
|> validate_create_shot_record_count(pack) |> validate_required([:date, :ammo_group_id, :user_id])
|> validate_required([:date, :pack_id, :user_id])
end end
def create_changeset(shot_record, _invalid_user, _invalid_pack, attrs) do def create_changeset(shot_group, _invalid_user, _invalid_ammo_group, attrs) do
shot_record shot_group
|> cast(attrs, [:count, :notes, :date]) |> cast(attrs, [:count, :notes, :date])
|> validate_length(:notes, max: 255) |> validate_required([:ammo_group_id, :user_id])
|> validate_required([:pack_id, :user_id])
|> add_error(:invalid, dgettext("errors", "Please select a valid user and ammo pack")) |> add_error(:invalid, dgettext("errors", "Please select a valid user and ammo pack"))
end end
defp validate_create_shot_record_count(changeset, %Pack{count: pack_count}) do defp validate_create_shot_group_count(changeset, %AmmoGroup{count: ammo_group_count}) do
case changeset |> Changeset.get_field(:count) do case changeset |> Changeset.get_field(:count) do
nil -> nil ->
changeset |> Changeset.add_error(:ammo_left, dgettext("errors", "can't be blank")) changeset |> Changeset.add_error(:ammo_left, dgettext("errors", "can't be blank"))
count when count > pack_count -> count when count > ammo_group_count ->
changeset changeset
|> Changeset.add_error(:ammo_left, dgettext("errors", "Ammo left must be at least 0")) |> Changeset.add_error(:ammo_left, dgettext("errors", "Ammo left must be at least 0"))
count when count <= 0 -> count when count <= 0 ->
error = error =
dgettext("errors", "Ammo left can be at most %{count} rounds", count: pack_count - 1) dgettext("errors", "Ammo left can be at most %{count} rounds",
count: ammo_group_count - 1
)
changeset |> Changeset.add_error(:ammo_left, error) changeset |> Changeset.add_error(:ammo_left, error)
@ -95,28 +95,28 @@ defmodule Cannery.ActivityLog.ShotRecord do
end end
@doc false @doc false
@spec update_changeset(t() | new_shot_record(), User.t(), attrs :: map()) :: changeset() @spec update_changeset(t() | new_shot_group(), User.t(), attrs :: map()) :: changeset()
def update_changeset(%__MODULE__{} = shot_record, user, attrs) do def update_changeset(%__MODULE__{} = shot_group, user, attrs) do
shot_record shot_group
|> cast(attrs, [:count, :notes, :date]) |> cast(attrs, [:count, :notes, :date])
|> validate_length(:notes, max: 255)
|> validate_number(:count, greater_than: 0) |> validate_number(:count, greater_than: 0)
|> validate_required([:count, :date]) |> validate_required([:count, :date])
|> validate_update_shot_record_count(shot_record, user) |> validate_update_shot_group_count(shot_group, user)
end end
defp validate_update_shot_record_count( defp validate_update_shot_group_count(
changeset, changeset,
%__MODULE__{pack_id: pack_id, count: count}, %__MODULE__{ammo_group_id: ammo_group_id, count: count},
user user
) do ) do
%{count: pack_count} = Ammo.get_pack!(pack_id, user) %{count: ammo_group_count} = Ammo.get_ammo_group!(ammo_group_id, user)
new_shot_record_count = changeset |> Changeset.get_field(:count) new_shot_group_count = changeset |> Changeset.get_field(:count)
shot_diff_to_add = new_shot_record_count - count shot_diff_to_add = new_shot_group_count - count
if shot_diff_to_add > pack_count do if shot_diff_to_add > ammo_group_count do
error = dgettext("errors", "Count can be at most %{count} shots", count: pack_count + count) error =
dgettext("errors", "Count can be at most %{count} shots", count: ammo_group_count + count)
changeset |> Changeset.add_error(:count, error) changeset |> Changeset.add_error(:count, error)
else else

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
defmodule Cannery.Ammo.Pack do defmodule Cannery.Ammo.AmmoGroup do
@moduledoc """ @moduledoc """
A group of a certain ammunition type. A group of a certain ammunition type.
@ -9,7 +9,7 @@ defmodule Cannery.Ammo.Pack do
use Ecto.Schema use Ecto.Schema
import CanneryWeb.Gettext import CanneryWeb.Gettext
import Ecto.Changeset import Ecto.Changeset
alias Cannery.Ammo.Type alias Cannery.Ammo.AmmoType
alias Cannery.{Accounts.User, Containers, Containers.Container} alias Cannery.{Accounts.User, Containers, Containers.Container}
alias Ecto.{Changeset, UUID} alias Ecto.{Changeset, UUID}
@ -19,22 +19,20 @@ defmodule Cannery.Ammo.Pack do
:count, :count,
:notes, :notes,
:price_paid, :price_paid,
:lot_number,
:staged, :staged,
:type_id, :ammo_type_id,
:container_id :container_id
]} ]}
@primary_key {:id, :binary_id, autogenerate: true} @primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id @foreign_key_type :binary_id
schema "packs" do schema "ammo_groups" do
field :count, :integer field :count, :integer
field :notes, :string field :notes, :string
field :price_paid, :float field :price_paid, :float
field :staged, :boolean, default: false field :staged, :boolean, default: false
field :lot_number, :string
field :purchased_on, :date field :purchased_on, :date
belongs_to :type, Type belongs_to :ammo_type, AmmoType
field :container_id, :binary_id field :container_id, :binary_id
field :user_id, :binary_id field :user_id, :binary_id
@ -47,76 +45,60 @@ defmodule Cannery.Ammo.Pack do
notes: String.t() | nil, notes: String.t() | nil,
price_paid: float() | nil, price_paid: float() | nil,
staged: boolean(), staged: boolean(),
lot_number: String.t() | nil,
purchased_on: Date.t(), purchased_on: Date.t(),
type: Type.t() | nil, ammo_type: AmmoType.t() | nil,
type_id: Type.id(), ammo_type_id: AmmoType.id(),
container_id: Container.id(), container_id: Container.id(),
user_id: User.id(), user_id: User.id(),
inserted_at: NaiveDateTime.t(), inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t() updated_at: NaiveDateTime.t()
} }
@type new_pack :: %__MODULE__{} @type new_ammo_group :: %__MODULE__{}
@type id :: UUID.t() @type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_pack()) @type changeset :: Changeset.t(t() | new_ammo_group())
@doc false @doc false
@spec create_changeset( @spec create_changeset(
new_pack(), new_ammo_group(),
Type.t() | nil, AmmoType.t() | nil,
Container.t() | nil, Container.t() | nil,
User.t(), User.t(),
attrs :: map() attrs :: map()
) :: changeset() ) :: changeset()
def create_changeset( def create_changeset(
pack, ammo_group,
%Type{id: type_id}, %AmmoType{id: ammo_type_id},
%Container{id: container_id, user_id: user_id}, %Container{id: container_id, user_id: user_id},
%User{id: user_id}, %User{id: user_id},
attrs attrs
) )
when is_binary(type_id) and is_binary(container_id) and is_binary(user_id) do when is_binary(ammo_type_id) and is_binary(container_id) and is_binary(user_id) do
pack ammo_group
|> change(type_id: type_id) |> change(ammo_type_id: ammo_type_id)
|> change(user_id: user_id) |> change(user_id: user_id)
|> change(container_id: container_id) |> change(container_id: container_id)
|> cast(attrs, [:count, :price_paid, :notes, :staged, :purchased_on, :lot_number]) |> cast(attrs, [:count, :price_paid, :notes, :staged, :purchased_on])
|> validate_number(:count, greater_than: 0) |> validate_number(:count, greater_than: 0)
|> validate_number(:price_paid, greater_than_or_equal_to: 0) |> validate_required([:count, :staged, :purchased_on, :ammo_type_id, :container_id, :user_id])
|> validate_length(:lot_number, max: 255)
|> validate_required([:count, :staged, :purchased_on, :type_id, :container_id, :user_id])
end end
@doc """ @doc """
Invalid changeset, used to prompt user to select type and container Invalid changeset, used to prompt user to select ammo type and container
""" """
def create_changeset(pack, _invalid_type, _invalid_container, _invalid_user, attrs) do def create_changeset(ammo_group, _invalid_ammo_type, _invalid_container, _invalid_user, attrs) do
pack ammo_group
|> cast(attrs, [:type_id, :container_id]) |> cast(attrs, [:ammo_type_id, :container_id])
|> validate_required([:type_id, :container_id]) |> validate_required([:ammo_type_id, :container_id])
|> validate_number(:count, greater_than: 0) |> add_error(:invalid, dgettext("errors", "Please select an ammo type and container"))
|> 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 end
@doc false @doc false
@spec update_changeset(t() | new_pack(), attrs :: map(), User.t()) :: changeset() @spec update_changeset(t() | new_ammo_group(), attrs :: map(), User.t()) :: changeset()
def update_changeset(pack, attrs, user) do def update_changeset(ammo_group, attrs, user) do
pack ammo_group
|> cast(attrs, [ |> cast(attrs, [:count, :price_paid, :notes, :staged, :purchased_on, :container_id])
:count,
:price_paid,
:notes,
:staged,
:purchased_on,
:lot_number,
:container_id
])
|> validate_number(:count, greater_than_or_equal_to: 0) |> validate_number(:count, greater_than_or_equal_to: 0)
|> validate_number(:price_paid, greater_than_or_equal_to: 0)
|> validate_container_id(user) |> validate_container_id(user)
|> validate_length(:lot_number, max: 255)
|> validate_required([:count, :staged, :purchased_on, :container_id]) |> validate_required([:count, :staged, :purchased_on, :container_id])
end end
@ -131,12 +113,12 @@ defmodule Cannery.Ammo.Pack do
end end
@doc """ @doc """
This range changeset is used when "using up" packs, and allows for This range changeset is used when "using up" ammo groups, and allows for
updating the count to 0 updating the count to 0
""" """
@spec range_changeset(t() | new_pack(), attrs :: map()) :: changeset() @spec range_changeset(t() | new_ammo_group(), attrs :: map()) :: changeset()
def range_changeset(pack, attrs) do def range_changeset(ammo_group, attrs) do
pack ammo_group
|> cast(attrs, [:count, :staged]) |> cast(attrs, [:count, :staged])
|> validate_required([:count, :staged]) |> validate_required([:count, :staged])
end end

View File

@ -1,4 +1,4 @@
defmodule Cannery.Ammo.Type do defmodule Cannery.Ammo.AmmoType do
@moduledoc """ @moduledoc """
An ammunition type. An ammunition type.
@ -8,92 +8,65 @@ defmodule Cannery.Ammo.Type do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
alias Cannery.Accounts.User alias Cannery.Accounts.User
alias Cannery.Ammo.Pack alias Cannery.Ammo.AmmoGroup
alias Ecto.{Changeset, UUID} alias Ecto.{Changeset, UUID}
@derive {Jason.Encoder, @derive {Jason.Encoder,
only: [ only: [
:id,
:name, :name,
:desc, :desc,
:class,
:bullet_type, :bullet_type,
:bullet_core, :bullet_core,
:cartridge,
:caliber, :caliber,
:case_material, :case_material,
:jacket_type,
:muzzle_velocity,
:powder_type, :powder_type,
:powder_grains_per_charge,
:grains, :grains,
:pressure, :pressure,
:primer_type, :primer_type,
:firing_type, :firing_type,
:manufacturer,
:upc,
:tracer, :tracer,
:incendiary, :incendiary,
:blank, :blank,
:corrosive, :corrosive,
:cartridge, :manufacturer,
:jacket_type, :upc
: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} @primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id @foreign_key_type :binary_id
schema "types" do schema "ammo_types" do
field :name, :string field :name, :string
field :desc, :string field :desc, :string
field :class, Ecto.Enum, values: [:rifle, :shotgun, :pistol], default: :rifle # https://en.wikipedia.org/wiki/Bullet#Abbreviations
field :bullet_type, :string
# common fields
field :bullet_core, :string field :bullet_core, :string
# also gauge for shotguns field :cartridge, :string
field :caliber, :string field :caliber, :string
field :case_material, :string field :case_material, :string
field :jacket_type, :string
field :muzzle_velocity, :integer
field :powder_type, :string field :powder_type, :string
field :powder_grains_per_charge, :integer
field :grains, :integer field :grains, :integer
field :pressure, :string field :pressure, :string
field :primer_type, :string field :primer_type, :string
field :firing_type, :string field :firing_type, :string
field :manufacturer, :string
field :upc, :string
field :tracer, :boolean, default: false field :tracer, :boolean, default: false
field :incendiary, :boolean, default: false field :incendiary, :boolean, default: false
field :blank, :boolean, default: false field :blank, :boolean, default: false
field :corrosive, :boolean, default: false field :corrosive, :boolean, default: false
# rifle/pistol fields field :manufacturer, :string
# https://shootersreference.com/reloadingdata/bullet_abbreviations/ field :upc, :string
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 field :user_id, :binary_id
has_many :packs, Pack
has_many :ammo_groups, AmmoGroup
timestamps() timestamps()
end end
@ -102,133 +75,74 @@ defmodule Cannery.Ammo.Type do
id: id(), id: id(),
name: String.t(), name: String.t(),
desc: String.t() | nil, desc: String.t() | nil,
class: class(),
bullet_type: String.t() | nil, bullet_type: String.t() | nil,
bullet_core: String.t() | nil, bullet_core: String.t() | nil,
cartridge: String.t() | nil,
caliber: String.t() | nil, caliber: String.t() | nil,
case_material: String.t() | nil, case_material: String.t() | nil,
jacket_type: String.t() | nil,
muzzle_velocity: integer() | nil,
powder_type: String.t() | nil, powder_type: String.t() | nil,
powder_grains_per_charge: integer() | nil,
grains: integer() | nil, grains: integer() | nil,
pressure: String.t() | nil, pressure: String.t() | nil,
primer_type: String.t() | nil, primer_type: String.t() | nil,
firing_type: String.t() | nil, firing_type: String.t() | nil,
manufacturer: String.t() | nil,
upc: String.t() | nil,
tracer: boolean(), tracer: boolean(),
incendiary: boolean(), incendiary: boolean(),
blank: boolean(), blank: boolean(),
corrosive: boolean(), corrosive: boolean(),
cartridge: String.t() | nil, manufacturer: String.t() | nil,
jacket_type: String.t() | nil, upc: 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(), user_id: User.id(),
packs: [Pack.t()] | nil, ammo_groups: [AmmoGroup.t()] | nil,
inserted_at: NaiveDateTime.t(), inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t() updated_at: NaiveDateTime.t()
} }
@type new_type :: %__MODULE__{} @type new_ammo_type :: %__MODULE__{}
@type id :: UUID.t() @type id :: UUID.t()
@type changeset :: Changeset.t(t() | new_type()) @type changeset :: Changeset.t(t() | new_ammo_type())
@type class :: :rifle | :shotgun | :pistol | nil
@spec changeset_fields() :: [atom()] @spec changeset_fields() :: [atom()]
defp changeset_fields, defp changeset_fields,
do: [ do: [
:name, :name,
:desc, :desc,
:class,
:bullet_type, :bullet_type,
:bullet_core, :bullet_core,
:cartridge,
:caliber, :caliber,
:case_material, :case_material,
:jacket_type,
:muzzle_velocity,
:powder_type, :powder_type,
:powder_grains_per_charge,
:grains, :grains,
:pressure, :pressure,
:primer_type, :primer_type,
:firing_type, :firing_type,
:manufacturer,
:upc,
:tracer, :tracer,
:incendiary, :incendiary,
:blank, :blank,
:corrosive, :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, :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 @doc false
@spec create_changeset(new_type(), User.t(), attrs :: map()) :: changeset() @spec create_changeset(new_ammo_type(), User.t(), attrs :: map()) :: changeset()
def create_changeset(type, %User{id: user_id}, attrs) do def create_changeset(ammo_type, %User{id: user_id}, attrs) do
changeset = ammo_type
type
|> change(user_id: user_id) |> change(user_id: user_id)
|> cast(attrs, changeset_fields()) |> cast(attrs, changeset_fields())
string_fields()
|> Enum.reduce(changeset, fn field, acc -> acc |> validate_length(field, max: 255) end)
|> validate_required([:name, :user_id]) |> validate_required([:name, :user_id])
end end
@doc false @doc false
@spec update_changeset(t() | new_type(), attrs :: map()) :: changeset() @spec update_changeset(t() | new_ammo_type(), attrs :: map()) :: changeset()
def update_changeset(type, attrs) do def update_changeset(ammo_type, attrs) do
changeset = ammo_type
type
|> cast(attrs, changeset_fields()) |> cast(attrs, changeset_fields())
string_fields()
|> Enum.reduce(changeset, fn field, acc -> acc |> validate_length(field, max: 255) end)
|> validate_required(:name) |> validate_required(:name)
end end
end end

View File

@ -5,15 +5,12 @@ defmodule Cannery.Containers do
import CanneryWeb.Gettext import CanneryWeb.Gettext
import Ecto.Query, warn: false import Ecto.Query, warn: false
alias Cannery.{Accounts.User, Ammo.Pack, Repo} alias Cannery.{Accounts.User, Ammo.AmmoGroup, Repo}
alias Cannery.Containers.{Container, ContainerTag, Tag} alias Cannery.Containers.{Container, ContainerTag, Tag}
alias Ecto.{Changeset, Queryable} alias Ecto.Changeset
@container_preloads [:tags] @container_preloads [:tags]
@type list_containers_option :: {:search, String.t() | nil}
@type list_containers_options :: [list_containers_option()]
@doc """ @doc """
Returns the list of containers. Returns the list of containers.
@ -22,31 +19,30 @@ defmodule Cannery.Containers do
iex> list_containers(%User{id: 123}) iex> list_containers(%User{id: 123})
[%Container{}, ...] [%Container{}, ...]
iex> list_containers(%User{id: 123}, search: "cool") iex> list_containers("cool", %User{id: 123})
[%Container{name: "my cool container"}, ...] [%Container{name: "my cool container"}, ...]
""" """
@spec list_containers(User.t()) :: [Container.t()] @spec list_containers(User.t()) :: [Container.t()]
@spec list_containers(User.t(), list_containers_options()) :: [Container.t()] @spec list_containers(search :: nil | String.t(), User.t()) :: [Container.t()]
def list_containers(%User{id: user_id}, opts \\ []) do def list_containers(search \\ nil, %User{id: user_id}) do
from(c in Container, from(c in Container,
as: :c, as: :c,
left_join: t in assoc(c, :tags), left_join: t in assoc(c, :tags),
on: c.user_id == t.user_id,
as: :t, as: :t,
where: c.user_id == ^user_id, where: c.user_id == ^user_id,
order_by: c.name,
distinct: c.id, distinct: c.id,
preload: ^@container_preloads preload: ^@container_preloads
) )
|> list_containers_search(Keyword.get(opts, :search)) |> list_containers_search(search)
|> Repo.all() |> Repo.all()
end end
@spec list_containers_search(Queryable.t(), search :: String.t() | nil) :: Queryable.t() defp list_containers_search(query, nil), do: query
defp list_containers_search(query, search) when search in ["", nil], defp list_containers_search(query, ""), do: query
do: query |> order_by([c: c], c.name)
defp list_containers_search(query, search) when search |> is_binary() do defp list_containers_search(query, search) do
trimmed_search = String.trim(search) trimmed_search = String.trim(search)
query query
@ -207,9 +203,9 @@ defmodule Cannery.Containers do
{:ok, Container.t()} | {:error, Container.changeset()} {:ok, Container.t()} | {:error, Container.changeset()}
def delete_container(%Container{user_id: user_id} = container, %User{id: user_id}) do def delete_container(%Container{user_id: user_id} = container, %User{id: user_id}) do
Repo.one( Repo.one(
from p in Pack, from ag in AmmoGroup,
where: p.container_id == ^container.id, where: ag.container_id == ^container.id,
select: count(p.id) select: count(ag.id)
) )
|> case do |> case do
0 -> 0 ->
@ -225,7 +221,7 @@ defmodule Cannery.Containers do
container container
|> Container.update_changeset(%{}) |> Container.update_changeset(%{})
|> Changeset.add_error(:packs, error) |> Changeset.add_error(:ammo_groups, error)
|> Changeset.apply_action(:delete) |> Changeset.apply_action(:delete)
end end
end end
@ -293,9 +289,6 @@ defmodule Cannery.Containers do
# Container Tags # Container Tags
@type list_tags_option :: {:search, String.t() | nil}
@type list_tags_options :: [list_tags_option()]
@doc """ @doc """
Returns the list of tags. Returns the list of tags.
@ -304,42 +297,38 @@ defmodule Cannery.Containers do
iex> list_tags(%User{id: 123}) iex> list_tags(%User{id: 123})
[%Tag{}, ...] [%Tag{}, ...]
iex> list_tags(%User{id: 123}, search: "cool") iex> list_tags("cool", %User{id: 123})
[%Tag{name: "my cool tag"}, ...] [%Tag{name: "my cool tag"}, ...]
""" """
@spec list_tags(User.t()) :: [Tag.t()] @spec list_tags(User.t()) :: [Tag.t()]
@spec list_tags(User.t(), list_tags_options()) :: [Tag.t()] @spec list_tags(search :: nil | String.t(), User.t()) :: [Tag.t()]
def list_tags(%User{id: user_id}, opts \\ []) do def list_tags(search \\ nil, user)
from(t in Tag, as: :t, where: t.user_id == ^user_id)
|> list_tags_search(Keyword.get(opts, :search))
|> Repo.all()
end
@spec list_tags_search(Queryable.t(), search :: String.t() | nil) :: Queryable.t() def list_tags(search, %{id: user_id}) when search |> is_nil() or search == "",
defp list_tags_search(query, search) when search in ["", nil], do: Repo.all(from t in Tag, where: t.user_id == ^user_id, order_by: t.name)
do: query |> order_by([t: t], t.name)
defp list_tags_search(query, search) when search |> is_binary() do def list_tags(search, %{id: user_id}) when search |> is_binary() do
trimmed_search = String.trim(search) trimmed_search = String.trim(search)
query Repo.all(
|> where( from t in Tag,
[t: t], where: t.user_id == ^user_id,
where:
fragment( fragment(
"? @@ websearch_to_tsquery('english', ?)", "? @@ websearch_to_tsquery('english', ?)",
t.search, t.search,
^trimmed_search ^trimmed_search
) ),
) order_by: {
|> order_by([t: t], {
:desc, :desc,
fragment( fragment(
"ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)", "ts_rank_cd(?, websearch_to_tsquery('english', ?), 4)",
t.search, t.search,
^trimmed_search ^trimmed_search
) )
}) }
)
end end
@doc """ @doc """

View File

@ -53,8 +53,6 @@ defmodule Cannery.Containers.Container do
container container
|> change(user_id: user_id) |> change(user_id: user_id)
|> cast(attrs, [:name, :desc, :type, :location]) |> cast(attrs, [:name, :desc, :type, :location])
|> validate_length(:name, max: 255)
|> validate_length(:type, max: 255)
|> validate_required([:name, :type, :user_id]) |> validate_required([:name, :type, :user_id])
end end
@ -63,8 +61,6 @@ defmodule Cannery.Containers.Container do
def update_changeset(container, attrs) do def update_changeset(container, attrs) do
container container
|> cast(attrs, [:name, :desc, :type, :location]) |> cast(attrs, [:name, :desc, :type, :location])
|> validate_length(:name, max: 255)
|> validate_length(:type, max: 255)
|> validate_required([:name, :type]) |> validate_required([:name, :type])
end end
end end

View File

@ -47,9 +47,6 @@ defmodule Cannery.Containers.Tag do
tag tag
|> change(user_id: user_id) |> change(user_id: user_id)
|> cast(attrs, [:name, :bg_color, :text_color]) |> 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]) |> validate_required([:name, :bg_color, :text_color, :user_id])
end end
@ -58,9 +55,6 @@ defmodule Cannery.Containers.Tag do
def update_changeset(tag, attrs) do def update_changeset(tag, attrs) do
tag tag
|> cast(attrs, [:name, :bg_color, :text_color]) |> 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]) |> validate_required([:name, :bg_color, :text_color])
end end
end end

View File

@ -14,17 +14,17 @@ defmodule Cannery.Logger do
|> Map.put(:stacktrace, Exception.format_stacktrace(stacktrace)) |> Map.put(:stacktrace, Exception.format_stacktrace(stacktrace))
|> pretty_encode() |> pretty_encode()
Logger.error("Oban exception: #{data}") Logger.error(meta.reason, data: data)
end end
def handle_event([:oban, :job, :start], measure, meta, _config) do def handle_event([:oban, :job, :start], measure, meta, _config) do
data = get_oban_job_data(meta, measure) |> pretty_encode() data = get_oban_job_data(meta, measure) |> pretty_encode()
Logger.info("Started oban job: #{data}") Logger.info("Started oban job", data: data)
end end
def handle_event([:oban, :job, :stop], measure, meta, _config) do def handle_event([:oban, :job, :stop], measure, meta, _config) do
data = get_oban_job_data(meta, measure) |> pretty_encode() data = get_oban_job_data(meta, measure) |> pretty_encode()
Logger.info("Finished oban job: #{data}") Logger.info("Finished oban job", data: data)
end end
def handle_event([:oban, :job, unhandled_event], measure, meta, _config) do def handle_event([:oban, :job, unhandled_event], measure, meta, _config) do
@ -33,7 +33,7 @@ defmodule Cannery.Logger do
|> Map.put(:event, unhandled_event) |> Map.put(:event, unhandled_event)
|> pretty_encode() |> pretty_encode()
Logger.warning("Unhandled oban job event: #{data}") Logger.warning("Unhandled oban job event", data: data)
end end
def handle_event(unhandled_event, measure, meta, config) do def handle_event(unhandled_event, measure, meta, config) do
@ -45,7 +45,7 @@ defmodule Cannery.Logger do
config: config config: config
}) })
Logger.warning("Unhandled telemetry event: #{data}") Logger.warning("Unhandled telemetry event", data: data)
end end
defp get_oban_job_data(%{job: job}, measure) do defp get_oban_job_data(%{job: job}, measure) do

View File

@ -1,61 +1,53 @@
defmodule CanneryWeb do defmodule CanneryWeb do
@moduledoc """ @moduledoc """
The entrypoint for defining your web interface, such The entrypoint for defining your web interface, such
as controllers, components, channels, and so on. as controllers, views, channels and so on.
This can be used in your application as: This can be used in your application as:
use CanneryWeb, :controller use CanneryWeb, :controller
use CanneryWeb, :html use CanneryWeb, :view
The definitions below will be executed for every controller, The definitions below will be executed for every view,
component, etc, so keep them short and clean, focused controller, etc, so keep them short and clean, focused
on imports, uses and aliases. on imports, uses and aliases.
Do NOT define functions inside the quoted expressions Do NOT define functions inside the quoted expressions
below. Instead, define additional modules and import below. Instead, define any helper function in modules
those modules here. and import those modules here.
""" """
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 Phoenix.Controller
import Phoenix.LiveView.Router
end
end
def channel do
quote do
use Phoenix.Channel
end
end
def controller do def controller do
quote do quote do
use Phoenix.Controller, use Phoenix.Controller, namespace: CanneryWeb
formats: [:html, :json],
layouts: [html: CanneryWeb.Layouts]
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
import Plug.Conn import Plug.Conn
import CanneryWeb.Gettext import CanneryWeb.Gettext
alias CanneryWeb.Router.Helpers, as: Routes
end
end
unquote(verified_routes()) def view do
quote do
use Phoenix.View,
root: "lib/cannery_web/templates",
namespace: CanneryWeb
# Import convenience functions from controllers
import Phoenix.Controller,
only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1]
# Include shared imports and aliases for views
unquote(view_helpers())
end end
end end
def live_view do def live_view do
quote do quote do
use Phoenix.LiveView, use Phoenix.LiveView, layout: {CanneryWeb.LayoutView, :live}
layout: {CanneryWeb.Layouts, :app}
unquote(html_helpers()) on_mount CanneryWeb.InitAssigns
unquote(view_helpers())
end end
end end
@ -63,44 +55,49 @@ defmodule CanneryWeb do
quote do quote do
use Phoenix.LiveComponent use Phoenix.LiveComponent
unquote(html_helpers()) unquote(view_helpers())
end end
end end
def html do def component do
quote do quote do
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
use Phoenix.Component use Phoenix.Component
# Import convenience functions from controllers unquote(view_helpers())
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
end end
defp html_helpers do def router do
quote do quote do
use PhoenixHTMLHelpers use Phoenix.Router
import Phoenix.{Component, HTML, HTML.Form}
import CanneryWeb.{ErrorHelpers, Gettext, CoreComponents, HTMLHelpers}
# Shortcut for generating JS commands import Phoenix.{Controller, LiveView.Router}
alias Phoenix.LiveView.JS # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
import Plug.Conn
# Routes generation with the ~p sigil
unquote(verified_routes())
end end
end end
def verified_routes do def channel do
quote do quote do
use Phoenix.VerifiedRoutes, use Phoenix.Channel
endpoint: CanneryWeb.Endpoint, # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
router: CanneryWeb.Router, import CanneryWeb.Gettext
statics: CanneryWeb.static_paths() 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}
alias CanneryWeb.Endpoint
alias CanneryWeb.Router.Helpers, as: Routes
end end
end end

View File

@ -1,10 +1,10 @@
defmodule CanneryWeb.Components.AddShotRecordComponent do defmodule CanneryWeb.Components.AddShotGroupComponent do
@moduledoc """ @moduledoc """
Livecomponent that can create a ShotRecord Livecomponent that can create a ShotGroup
""" """
use CanneryWeb, :live_component use CanneryWeb, :live_component
alias Cannery.{Accounts.User, ActivityLog, ActivityLog.ShotRecord, Ammo.Pack} alias Cannery.{Accounts.User, ActivityLog, ActivityLog.ShotGroup, Ammo.AmmoGroup}
alias Ecto.Changeset alias Ecto.Changeset
alias Phoenix.LiveView.{JS, Socket} alias Phoenix.LiveView.{JS, Socket}
@ -12,15 +12,15 @@ defmodule CanneryWeb.Components.AddShotRecordComponent do
@spec update( @spec update(
%{ %{
required(:current_user) => User.t(), required(:current_user) => User.t(),
required(:pack) => Pack.t(), required(:ammo_group) => AmmoGroup.t(),
optional(any()) => any() optional(any()) => any()
}, },
Socket.t() Socket.t()
) :: {:ok, Socket.t()} ) :: {:ok, Socket.t()}
def update(%{pack: pack, current_user: current_user} = assigns, socket) do def update(%{ammo_group: ammo_group, current_user: current_user} = assigns, socket) do
changeset = changeset =
%ShotRecord{date: Date.utc_today()} %ShotGroup{date: Date.utc_today()}
|> ShotRecord.create_changeset(current_user, pack, %{}) |> ShotGroup.create_changeset(current_user, ammo_group, %{})
{:ok, socket |> assign(assigns) |> assign(:changeset, changeset)} {:ok, socket |> assign(assigns) |> assign(:changeset, changeset)}
end end
@ -28,14 +28,12 @@ defmodule CanneryWeb.Components.AddShotRecordComponent do
@impl true @impl true
def handle_event( def handle_event(
"validate", "validate",
%{"shot_record" => shot_record_params}, %{"shot_group" => shot_group_params},
%{assigns: %{pack: pack, current_user: current_user}} = socket %{assigns: %{ammo_group: ammo_group, current_user: current_user}} = socket
) do ) do
params = shot_record_params |> process_params(pack) params = shot_group_params |> process_params(ammo_group)
changeset = changeset = %ShotGroup{} |> ShotGroup.create_changeset(current_user, ammo_group, params)
%ShotRecord{}
|> ShotRecord.create_changeset(current_user, pack, params)
changeset = changeset =
case changeset |> Changeset.apply_action(:validate) do case changeset |> Changeset.apply_action(:validate) do
@ -48,17 +46,17 @@ defmodule CanneryWeb.Components.AddShotRecordComponent do
def handle_event( def handle_event(
"save", "save",
%{"shot_record" => shot_record_params}, %{"shot_group" => shot_group_params},
%{ %{
assigns: %{pack: pack, current_user: current_user, return_to: return_to} assigns: %{ammo_group: ammo_group, current_user: current_user, return_to: return_to}
} = socket } = socket
) do ) do
socket = socket =
shot_record_params shot_group_params
|> process_params(pack) |> process_params(ammo_group)
|> ActivityLog.create_shot_record(current_user, pack) |> ActivityLog.create_shot_group(current_user, ammo_group)
|> case do |> case do
{:ok, _shot_record} -> {:ok, _shot_group} ->
prompt = dgettext("prompts", "Shots recorded successfully") prompt = dgettext("prompts", "Shots recorded successfully")
socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) socket |> put_flash(:info, prompt) |> push_navigate(to: return_to)
@ -70,8 +68,8 @@ defmodule CanneryWeb.Components.AddShotRecordComponent do
end end
# calculate count from shots left # calculate count from shots left
defp process_params(params, %Pack{count: count}) do defp process_params(params, %AmmoGroup{count: count}) do
shot_record_count = shot_group_count =
if params |> Map.get("ammo_left", "") == "" do if params |> Map.get("ammo_left", "") == "" do
nil nil
else else
@ -79,6 +77,6 @@ defmodule CanneryWeb.Components.AddShotRecordComponent do
count - new_count count - new_count
end end
params |> Map.put("count", shot_record_count) params |> Map.put("count", shot_group_count)
end end
end end

View File

@ -6,14 +6,14 @@
<.form <.form
:let={f} :let={f}
for={@changeset} for={@changeset}
id="shot-record-form" id="shot-group-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" 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-target={@myself}
phx-change="validate" phx-change="validate"
phx-submit="save" phx-submit="save"
> >
<div <div
:if={@changeset.action && not @changeset.valid?} :if={@changeset.action && not @changeset.valid?()}
class="invalid-feedback col-span-3 text-center" class="invalid-feedback col-span-3 text-center"
> >
<%= changeset_errors(@changeset) %> <%= changeset_errors(@changeset) %>
@ -22,14 +22,14 @@
<%= label(f, :ammo_left, gettext("Rounds left"), class: "title text-lg text-primary-600") %> <%= label(f, :ammo_left, gettext("Rounds left"), class: "title text-lg text-primary-600") %>
<%= number_input(f, :ammo_left, <%= number_input(f, :ammo_left,
min: 0, min: 0,
max: @pack.count - 1, max: @ammo_group.count - 1,
placeholder: gettext("Rounds left"), placeholder: gettext("Rounds left"),
class: "input input-primary" class: "input input-primary"
) %> ) %>
<button <button
type="button" type="button"
class="mx-2 my-1 text-sm btn btn-primary" class="mx-2 my-1 text-sm btn btn-primary"
phx-click={JS.dispatch("cannery:set-zero", to: "#shot-record-form_ammo_left")} phx-click={JS.dispatch("cannery:set-zero", to: "#shot-group-form_ammo_left")}
> >
<%= gettext("Used up!") %> <%= gettext("Used up!") %>
</button> </button>
@ -37,12 +37,11 @@
<%= label(f, :notes, gettext("Notes"), class: "title text-lg text-primary-600") %> <%= label(f, :notes, gettext("Notes"), class: "title text-lg text-primary-600") %>
<%= textarea(f, :notes, <%= textarea(f, :notes,
id: "add-shot-group-form-notes",
class: "input input-primary col-span-2", class: "input input-primary col-span-2",
id: "add-shot-record-form-notes", placeholder: gettext("Really great weather"),
maxlength: 255, phx_hook: "MaintainAttrs",
phx_debounce: 300, phx_update: "ignore"
phx_update: "ignore",
placeholder: gettext("Really great weather")
) %> ) %>
<%= error_tag(f, :notes, "col-span-3") %> <%= error_tag(f, :notes, "col-span-3") %>

View File

@ -0,0 +1,273 @@
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()],
required(:show_used) => boolean(),
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, show_used: _show_used} =
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,
show_used: show_used
}
} = 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("Price paid"), key: :price_paid},
%{label: gettext("CPR"), key: :cpr}
| columns
]
columns =
if show_used do
[
%{label: gettext("Original Count"), key: :original_count},
%{label: gettext("% left"), key: :remaining}
| columns
]
else
columns
end
columns = [
%{label: if(show_used, do: gettext("Current Count"), else: gettext("Count")), key: :count}
| columns
]
columns =
if ammo_type == [] do
columns
else
[%{label: gettext("Ammo type"), key: :ammo_type} | columns]
end
containers =
ammo_groups
|> Enum.map(fn %{container_id: container_id} -> container_id end)
|> Containers.get_containers(current_user)
extra_data = %{
current_user: current_user,
ammo_type: ammo_type,
columns: columns,
container: container,
containers: containers,
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),
percentages_remaining: Ammo.get_percentages_remaining(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: {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: 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,
%{id: ammo_group_id},
%{percentages_remaining: percentages_remaining}
) do
percentage = Map.fetch!(percentages_remaining, ammo_group_id)
{percentage, gettext("%{percentage}%", percentage: percentage)}
end
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_block, containers: containers}
) do
container = %{name: container_name} = Map.fetch!(containers, container_id)
assigns = %{
container: container,
container_block: container_block,
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: {0, gettext("No cost information")}
defp get_value_for_key(:cpr, %{id: ammo_group_id}, %{cprs: cprs}) do
amount = Map.fetch!(cprs, ammo_group_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, 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

View File

@ -0,0 +1,264 @@
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_pack_count,
type: :used_pack_count
},
%{
label: gettext("Total ever packs"),
key: :historical_pack_count,
type: :historical_pack_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)
packs_count = ammo_types |> Ammo.get_ammo_groups_count_for_types(current_user)
average_costs = ammo_types |> Ammo.get_average_cost_for_ammo_types(current_user)
[used_counts, historical_round_counts, historical_pack_counts, used_pack_counts] =
if show_used do
[
ammo_types |> ActivityLog.get_used_count_for_ammo_types(current_user),
ammo_types |> Ammo.get_historical_count_for_ammo_types(current_user),
ammo_types |> Ammo.get_ammo_groups_count_for_types(current_user, true),
ammo_types |> Ammo.get_used_ammo_groups_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 =
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, 0)
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, 0)
end
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, 0)
end
defp get_ammo_type_value(
:historical_pack_count,
_key,
%{id: ammo_type_id},
%{historical_pack_counts: historical_pack_counts}
) do
Map.get(historical_pack_counts, ammo_type_id, 0)
end
defp get_ammo_type_value(
:used_pack_count,
_key,
%{id: ammo_type_id},
%{used_pack_counts: used_pack_counts}
) do
Map.get(used_pack_counts, ammo_type_id, 0)
end
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 -> {0, gettext("No cost information")}
count -> {count, gettext("$%{amount}", amount: display_currency(count))}
end
end
defp get_ammo_type_value(:name, _key, %{name: ammo_type_name} = ammo_type, _other_data) do
assigns = %{ammo_type: ammo_type}
{ammo_type_name,
~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

View File

@ -71,16 +71,8 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
current_user: current_user, current_user: current_user,
tag_actions: tag_actions, tag_actions: tag_actions,
actions: actions, actions: actions,
pack_count: pack_count: Ammo.get_ammo_groups_count_for_containers(containers, current_user),
Ammo.get_grouped_packs_count(current_user, round_count: Ammo.get_round_count_for_containers(containers, current_user)
containers: containers,
group_by: :container_id
),
round_count:
Ammo.get_grouped_round_count(current_user,
containers: containers,
group_by: :container_id
)
} }
rows = rows =
@ -117,12 +109,14 @@ defmodule CanneryWeb.Components.ContainerTableComponent do
end end
@spec get_value_for_key(atom(), Container.t(), extra_data :: map) :: any() @spec get_value_for_key(atom(), Container.t(), extra_data :: map) :: any()
defp get_value_for_key(:name, %{name: container_name} = assigns, _extra_data) do defp get_value_for_key(:name, %{id: id, name: container_name}, _extra_data) do
assigns = %{id: id, container_name: container_name}
{container_name, {container_name,
~H""" ~H"""
<div class="flex flex-wrap justify-center items-center"> <div class="flex flex-wrap justify-center items-center">
<.link navigate={~p"/container/#{@id}"} class="link"> <.link navigate={Routes.container_show_path(Endpoint, :show, @id)} class="link">
<%= @name %> <%= @container_name %>
</.link> </.link>
</div> </div>
"""} """}

View File

@ -3,11 +3,11 @@ defmodule CanneryWeb.CoreComponents do
Provides core UI components. Provides core UI components.
""" """
use Phoenix.Component use Phoenix.Component
use CanneryWeb, :verified_routes import CanneryWeb.{Gettext, ViewHelpers}
import CanneryWeb.{Gettext, HTMLHelpers}
alias Cannery.{Accounts, Accounts.Invite, Accounts.User} alias Cannery.{Accounts, Accounts.Invite, Accounts.User}
alias Cannery.{Ammo, Ammo.Pack} alias Cannery.{Ammo, Ammo.AmmoGroup}
alias Cannery.{Containers.Container, Containers.Tag} alias Cannery.{Containers.Container, Containers.Tag}
alias CanneryWeb.{Endpoint, HomeLive}
alias CanneryWeb.Router.Helpers, as: Routes alias CanneryWeb.Router.Helpers, as: Routes
alias Phoenix.LiveView.{JS, Rendered} alias Phoenix.LiveView.{JS, Rendered}
@ -29,13 +29,13 @@ defmodule CanneryWeb.CoreComponents do
## Examples ## Examples
<.modal return_to={~p"/\#{<%= schema.plural %>}"}> <.modal return_to={Routes.<%= schema.singular %>_index_path(Endpoint, :index)}>
<.live_component <.live_component
module={<%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.FormComponent} module={<%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.FormComponent}
id={@<%= schema.singular %>.id || :new} id={@<%= schema.singular %>.id || :new}
title={@page_title} title={@page_title}
action={@live_action} action={@live_action}
return_to={~p"/\#{<%= schema.singular %>}"} return_to={Routes.<%= schema.singular %>_index_path(Endpoint, :index)}
<%= schema.singular %>: @<%= schema.singular %> <%= schema.singular %>: @<%= schema.singular %>
/> />
</.modal> </.modal>
@ -86,7 +86,7 @@ defmodule CanneryWeb.CoreComponents do
def simple_tag_card(assigns) def simple_tag_card(assigns)
attr :pack, Pack, required: true attr :ammo_group, AmmoGroup, required: true
attr :current_user, User, required: true attr :current_user, User, required: true
attr :original_count, :integer, default: nil attr :original_count, :integer, default: nil
attr :cpr, :integer, default: nil attr :cpr, :integer, default: nil
@ -94,7 +94,7 @@ defmodule CanneryWeb.CoreComponents do
attr :container, Container, default: nil attr :container, Container, default: nil
slot(:inner_block) slot(:inner_block)
def pack_card(assigns) def ammo_group_card(assigns)
@spec display_currency(float()) :: String.t() @spec display_currency(float()) :: String.t()
defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2) defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2)

View File

@ -1,45 +1,48 @@
<div <div
id={"pack-#{@pack.id}"} id={"ammo_group-#{@ammo_group.id}"}
class="mx-4 my-2 px-8 py-4 class="mx-4 my-2 px-8 py-4
flex flex-col justify-center items-center flex flex-col justify-center items-center
border border-gray-400 rounded-lg shadow-lg hover:shadow-md border border-gray-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out" transition-all duration-300 ease-in-out"
> >
<.link navigate={~p"/ammo/show/#{@pack}"} class="mb-2 link"> <.link navigate={Routes.ammo_group_show_path(Endpoint, :show, @ammo_group)} class="mb-2 link">
<h1 class="title text-xl title-primary-500"> <h1 class="title text-xl title-primary-500">
<%= @pack.type.name %> <%= @ammo_group.ammo_type.name %>
</h1> </h1>
</.link> </.link>
<div class="flex flex-col justify-center items-center"> <div class="flex flex-col justify-center items-center">
<span class="rounded-lg title text-lg"> <span class="rounded-lg title text-lg">
<%= gettext("Count:") %> <%= gettext("Count:") %>
<%= if @pack.count == 0, do: gettext("Empty"), else: @pack.count %> <%= if @ammo_group.count == 0, do: gettext("Empty"), else: @ammo_group.count %>
</span> </span>
<span :if={@original_count && @original_count != @pack.count} class="rounded-lg title text-lg"> <span
:if={@original_count && @original_count != @ammo_group.count}
class="rounded-lg title text-lg"
>
<%= gettext("Original Count:") %> <%= gettext("Original Count:") %>
<%= @original_count %> <%= @original_count %>
</span> </span>
<span :if={@pack.notes} class="rounded-lg title text-lg"> <span :if={@ammo_group.notes} class="rounded-lg title text-lg">
<%= gettext("Notes:") %> <%= gettext("Notes:") %>
<%= @pack.notes %> <%= @ammo_group.notes %>
</span> </span>
<span :if={@pack.purchased_on} class="rounded-lg title text-lg"> <span :if={@ammo_group.purchased_on} class="rounded-lg title text-lg">
<%= gettext("Purchased on:") %> <%= gettext("Purchased on:") %>
<.date id={"#{@pack.id}-purchased-on"} date={@pack.purchased_on} /> <.date id={"#{@ammo_group.id}-purchased-on"} date={@ammo_group.purchased_on} />
</span> </span>
<span :if={@last_used_date} class="rounded-lg title text-lg"> <span :if={@last_used_date} class="rounded-lg title text-lg">
<%= gettext("Last used on:") %> <%= gettext("Last used on:") %>
<.date id={"#{@pack.id}-last-used-on"} date={@last_used_date} /> <.date id={"#{@ammo_group.id}-last-used-on"} date={@last_used_date} />
</span> </span>
<span :if={@pack.price_paid} class="rounded-lg title text-lg"> <span :if={@ammo_group.price_paid} class="rounded-lg title text-lg">
<%= gettext("Price paid:") %> <%= gettext("Price paid:") %>
<%= gettext("$%{amount}", amount: display_currency(@pack.price_paid)) %> <%= gettext("$%{amount}", amount: display_currency(@ammo_group.price_paid)) %>
</span> </span>
<span :if={@cpr} class="rounded-lg title text-lg"> <span :if={@cpr} class="rounded-lg title text-lg">
@ -47,15 +50,10 @@
<%= gettext("$%{amount}", amount: display_currency(@cpr)) %> <%= gettext("$%{amount}", amount: display_currency(@cpr)) %>
</span> </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"> <span :if={@container} class="rounded-lg title text-lg">
<%= gettext("Container:") %> <%= gettext("Container:") %>
<.link navigate={~p"/container/#{@container}"} class="link"> <.link navigate={Routes.container_show_path(Endpoint, :show, @container)} class="link">
<%= @container.name %> <%= @container.name %>
</.link> </.link>
</span> </span>

View File

@ -5,7 +5,7 @@
border border-gray-400 rounded-lg shadow-lg hover:shadow-md border border-gray-400 rounded-lg shadow-lg hover:shadow-md
transition-all duration-300 ease-in-out" transition-all duration-300 ease-in-out"
> >
<.link navigate={~p"/container/#{@container}"} class="link"> <.link navigate={Routes.container_show_path(Endpoint, :show, @container)} class="link">
<h1 class="px-4 py-2 rounded-lg title text-xl"> <h1 class="px-4 py-2 rounded-lg title text-xl">
<%= @container.name %> <%= @container.name %>
</h1> </h1>
@ -27,15 +27,15 @@
<%= @container.location %> <%= @container.location %>
</span> </span>
<%= if Ammo.get_packs_count(@current_user, container_id: @container.id) != 0 do %> <%= if @container |> Ammo.get_ammo_groups_count_for_container!(@current_user) != 0 do %>
<span class="rounded-lg title text-lg"> <span class="rounded-lg title text-lg">
<%= gettext("Packs:") %> <%= gettext("Packs:") %>
<%= Ammo.get_packs_count(@current_user, container_id: @container.id) %> <%= @container |> Ammo.get_ammo_groups_count_for_container!(@current_user) %>
</span> </span>
<span class="rounded-lg title text-lg"> <span class="rounded-lg title text-lg">
<%= gettext("Rounds:") %> <%= gettext("Rounds:") %>
<%= Ammo.get_round_count(@current_user, container_id: @container.id) %> <%= @container |> Ammo.get_round_count_for_container!(@current_user) %>
</span> </span>
<% end %> <% end %>

View File

@ -23,7 +23,7 @@
<% end %> <% end %>
<.qr_code <.qr_code
content={url(CanneryWeb.Endpoint, ~p"/users/register?invite=#{@invite.token}")} content={Routes.user_registration_url(Endpoint, :new, invite: @invite.token)}
filename={@invite.name} filename={@invite.name}
/> />
@ -36,7 +36,7 @@
id={"code-#{@invite.id}"} 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" 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 phx-no-format
><%= url(CanneryWeb.Endpoint, ~p"/users/register?invite=#{@invite.token}") %></code> ><%= Routes.user_registration_url(Endpoint, :new, invite: @invite.token) %></code>
<%= if @code_actions, do: render_slot(@code_actions) %> <%= if @code_actions, do: render_slot(@code_actions) %>
</div> </div>

View File

@ -6,7 +6,6 @@
p-8 flex flex-col justify-center items-center cursor-auto" p-8 flex flex-col justify-center items-center cursor-auto"
style="background-color: rgba(0,0,0,0.4);" style="background-color: rgba(0,0,0,0.4);"
phx-remove={hide_modal()} phx-remove={hide_modal()}
aria-label={gettext("Close modal")}
> >
<span class="hidden"></span> <span class="hidden"></span>
</.link> </.link>
@ -32,7 +31,6 @@
text-gray-500 hover:text-gray-800 text-gray-500 hover:text-gray-800
transition-all duration-500 ease-in-out" transition-all duration-500 ease-in-out"
phx-remove={hide_modal()} phx-remove={hide_modal()}
aria-label={gettext("Close modal")}
> >
<i class="fa-fw fa-lg fas fa-times"></i> <i class="fa-fw fa-lg fas fa-times"></i>
</.link> </.link>

View File

@ -1,4 +1,4 @@
<label for={@id || @action} class="relative inline-flex items-center cursor-pointer"> <label for={@id || @action} class="inline-flex relative items-center cursor-pointer">
<input <input
id={@id || @action} id={@id || @action}
type="checkbox" type="checkbox"
@ -23,7 +23,7 @@
</div> </div>
<span <span
id={"#{@id || @action}-label"} id={"#{@id || @action}-label"}
class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300 whitespace-nowrap" class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"
> >
<%= render_slot(@inner_block) %> <%= render_slot(@inner_block) %>
</span> </span>

View File

@ -1,9 +1,12 @@
<nav role="navigation" class="mb-8 px-8 py-4 w-full bg-primary-500"> <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="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"> <div class="mb-4 sm:mb-0 sm:mr-8 flex flex-row justify-start items-center space-x-2">
<.link navigate={~p"/"} class="inline mx-2 my-1 leading-5 text-xl text-white"> <.link
navigate={Routes.live_path(Endpoint, HomeLive)}
class="inline mx-2 my-1 leading-5 text-xl text-white"
>
<img <img
src={~p"/images/cannery.svg"} src={Routes.static_path(Endpoint, "/images/cannery.svg")}
alt={gettext("Cannery logo")} alt={gettext("Cannery logo")}
class="inline-block h-8 mx-1" class="inline-block h-8 mx-1"
/> />
@ -24,43 +27,64 @@
text-lg text-white text-ellipsis"> text-lg text-white text-ellipsis">
<%= if @current_user do %> <%= if @current_user do %>
<li class="mx-2 my-1"> <li class="mx-2 my-1">
<.link navigate={~p"/tags"} class="text-white hover:underline"> <.link
navigate={Routes.tag_index_path(Endpoint, :index)}
class="text-white hover:underline"
>
<%= gettext("Tags") %> <%= gettext("Tags") %>
</.link> </.link>
</li> </li>
<li class="mx-2 my-1"> <li class="mx-2 my-1">
<.link navigate={~p"/containers"} class="text-white hover:underline"> <.link
navigate={Routes.container_index_path(Endpoint, :index)}
class="text-white hover:underline"
>
<%= gettext("Containers") %> <%= gettext("Containers") %>
</.link> </.link>
</li> </li>
<li class="mx-2 my-1"> <li class="mx-2 my-1">
<.link navigate={~p"/catalog"} class="text-white hover:underline"> <.link
navigate={Routes.ammo_type_index_path(Endpoint, :index)}
class="text-white hover:underline"
>
<%= gettext("Catalog") %> <%= gettext("Catalog") %>
</.link> </.link>
</li> </li>
<li class="mx-2 my-1"> <li class="mx-2 my-1">
<.link navigate={~p"/ammo"} class="text-white hover:underline"> <.link
navigate={Routes.ammo_group_index_path(Endpoint, :index)}
class="text-white hover:underline"
>
<%= gettext("Ammo") %> <%= gettext("Ammo") %>
</.link> </.link>
</li> </li>
<li class="mx-2 my-1"> <li class="mx-2 my-1">
<.link navigate={~p"/range"} class="text-white hover:underline"> <.link
navigate={Routes.range_index_path(Endpoint, :index)}
class="text-white hover:underline"
>
<%= gettext("Range") %> <%= gettext("Range") %>
</.link> </.link>
</li> </li>
<li :if={@current_user |> Accounts.already_admin?()} class="mx-2 my-1"> <li :if={@current_user |> Accounts.is_already_admin?()} class="mx-2 my-1">
<.link navigate={~p"/invites"} class="text-white hover:underline"> <.link
navigate={Routes.invite_index_path(Endpoint, :index)}
class="text-white hover:underline"
>
<%= gettext("Invites") %> <%= gettext("Invites") %>
</.link> </.link>
</li> </li>
<li class="mx-2 my-1"> <li class="mx-2 my-1">
<.link href={~p"/users/settings"} class="text-white hover:underline truncate"> <.link
href={Routes.user_settings_path(Endpoint, :edit)}
class="text-white hover:underline truncate"
>
<%= @current_user.email %> <%= @current_user.email %>
</.link> </.link>
</li> </li>
<li class="mx-2 my-1"> <li class="mx-2 my-1">
<.link <.link
href={~p"/users/log_out"} href={Routes.user_session_path(Endpoint, :delete)}
method="delete" method="delete"
data-confirm={dgettext("prompts", "Are you sure you want to log out?")} data-confirm={dgettext("prompts", "Are you sure you want to log out?")}
aria-label={gettext("Log out")} aria-label={gettext("Log out")}
@ -70,13 +94,13 @@
</li> </li>
<li <li
:if={ :if={
@current_user |> Accounts.already_admin?() and @current_user |> Accounts.is_already_admin?() and
function_exported?(Routes, :live_dashboard_path, 2) function_exported?(Routes, :live_dashboard_path, 2)
} }
class="mx-2 my-1" class="mx-2 my-1"
> >
<.link <.link
navigate={~p"/dashboard"} navigate={Routes.live_dashboard_path(Endpoint, :home)}
class="text-white hover:underline" class="text-white hover:underline"
aria-label={gettext("Live Dashboard")} aria-label={gettext("Live Dashboard")}
> >
@ -85,12 +109,18 @@
</li> </li>
<% else %> <% else %>
<li :if={Accounts.allow_registration?()} class="mx-2 my-1"> <li :if={Accounts.allow_registration?()} class="mx-2 my-1">
<.link href={~p"/users/register"} class="text-white hover:underline truncate"> <.link
href={Routes.user_registration_path(Endpoint, :new)}
class="text-white hover:underline truncate"
>
<%= dgettext("actions", "Register") %> <%= dgettext("actions", "Register") %>
</.link> </.link>
</li> </li>
<li class="mx-2 my-1"> <li class="mx-2 my-1">
<.link href={~p"/users/log_in"} class="text-white hover:underline truncate"> <.link
href={Routes.user_session_path(Endpoint, :new)}
class="text-white hover:underline truncate"
>
<%= dgettext("actions", "Log in") %> <%= dgettext("actions", "Log in") %>
</.link> </.link>
</li> </li>

View File

@ -1,10 +0,0 @@
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

View File

@ -1,17 +0,0 @@
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

View File

@ -1 +0,0 @@
<%= @inner_block %>

View File

@ -1,10 +1,11 @@
defmodule CanneryWeb.Components.MovePackComponent do defmodule CanneryWeb.Components.MoveAmmoGroupComponent do
@moduledoc """ @moduledoc """
Livecomponent that can move a pack to another container Livecomponent that can move an ammo group to another container
""" """
use CanneryWeb, :live_component use CanneryWeb, :live_component
alias Cannery.{Accounts.User, Ammo, Ammo.Pack, Containers, Containers.Container} alias Cannery.{Accounts.User, Ammo, Ammo.AmmoGroup, Containers, Containers.Container}
alias CanneryWeb.Endpoint
alias Ecto.Changeset alias Ecto.Changeset
alias Phoenix.LiveView.Socket alias Phoenix.LiveView.Socket
@ -12,16 +13,17 @@ defmodule CanneryWeb.Components.MovePackComponent do
@spec update( @spec update(
%{ %{
required(:current_user) => User.t(), required(:current_user) => User.t(),
required(:pack) => Pack.t(), required(:ammo_group) => AmmoGroup.t(),
optional(any()) => any() optional(any()) => any()
}, },
Socket.t() Socket.t()
) :: {:ok, Socket.t()} ) :: {:ok, Socket.t()}
def update( def update(
%{pack: %{container_id: container_id} = pack, current_user: current_user} = assigns, %{ammo_group: %{container_id: container_id} = ammo_group, current_user: current_user} =
assigns,
socket socket
) do ) do
changeset = pack |> Pack.update_changeset(%{}, current_user) changeset = ammo_group |> AmmoGroup.update_changeset(%{}, current_user)
containers = containers =
Containers.list_containers(current_user) Containers.list_containers(current_user)
@ -39,15 +41,16 @@ defmodule CanneryWeb.Components.MovePackComponent do
def handle_event( def handle_event(
"move", "move",
%{"container_id" => container_id}, %{"container_id" => container_id},
%{assigns: %{pack: pack, current_user: current_user, return_to: return_to}} = socket %{assigns: %{ammo_group: ammo_group, current_user: current_user, return_to: return_to}} =
socket
) do ) do
%{name: container_name} = Containers.get_container!(container_id, current_user) %{name: container_name} = Containers.get_container!(container_id, current_user)
socket = socket =
pack ammo_group
|> Ammo.update_pack(%{"container_id" => container_id}, current_user) |> Ammo.update_ammo_group(%{"container_id" => container_id}, current_user)
|> case do |> case do
{:ok, _pack} -> {:ok, _ammo_group} ->
prompt = dgettext("prompts", "Ammo moved to %{name} successfully", name: container_name) prompt = dgettext("prompts", "Ammo moved to %{name} successfully", name: container_name)
socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) socket |> put_flash(:info, prompt) |> push_navigate(to: return_to)
@ -83,13 +86,13 @@ defmodule CanneryWeb.Components.MovePackComponent do
<%= display_emoji("😔") %> <%= display_emoji("😔") %>
</h2> </h2>
<.link navigate={~p"/containers/new"} class="btn btn-primary"> <.link navigate={Routes.container_index_path(Endpoint, :new)} class="btn btn-primary">
<%= dgettext("actions", "Add another container!") %> <%= dgettext("actions", "Add another container!") %>
</.link> </.link>
<% else %> <% else %>
<.live_component <.live_component
module={CanneryWeb.Components.TableComponent} module={CanneryWeb.Components.TableComponent}
id="move-pack-table" id="move_ammo_group_table"
columns={@columns} columns={@columns}
rows={@rows} rows={@rows}
/> />

View File

@ -1,274 +0,0 @@
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={"pack-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

View File

@ -1,9 +1,9 @@
defmodule CanneryWeb.Components.ShotRecordTableComponent do defmodule CanneryWeb.Components.ShotGroupTableComponent do
@moduledoc """ @moduledoc """
A component that displays a list of shot records A component that displays a list of shot groups
""" """
use CanneryWeb, :live_component use CanneryWeb, :live_component
alias Cannery.{Accounts.User, ActivityLog.ShotRecord, Ammo, ComparableDate} alias Cannery.{Accounts.User, ActivityLog.ShotGroup, Ammo, ComparableDate}
alias Ecto.UUID alias Ecto.UUID
alias Phoenix.LiveView.{Rendered, Socket} alias Phoenix.LiveView.{Rendered, Socket}
@ -12,29 +12,26 @@ defmodule CanneryWeb.Components.ShotRecordTableComponent do
%{ %{
required(:id) => UUID.t(), required(:id) => UUID.t(),
required(:current_user) => User.t(), required(:current_user) => User.t(),
optional(:shot_records) => [ShotRecord.t()], optional(:shot_groups) => [ShotGroup.t()],
optional(:actions) => Rendered.t(), optional(:actions) => Rendered.t(),
optional(any()) => any() optional(any()) => any()
}, },
Socket.t() Socket.t()
) :: {:ok, Socket.t()} ) :: {:ok, Socket.t()}
def update( def update(%{id: _id, shot_groups: _shot_groups, current_user: _current_user} = assigns, socket) do
%{id: _id, shot_records: _shot_records, current_user: _current_user} = assigns,
socket
) do
socket = socket =
socket socket
|> assign(assigns) |> assign(assigns)
|> assign_new(:actions, fn -> [] end) |> assign_new(:actions, fn -> [] end)
|> display_shot_records() |> display_shot_groups()
{:ok, socket} {:ok, socket}
end end
defp display_shot_records( defp display_shot_groups(
%{ %{
assigns: %{ assigns: %{
shot_records: shot_records, shot_groups: shot_groups,
current_user: current_user, current_user: current_user,
actions: actions actions: actions
} }
@ -48,17 +45,17 @@ defmodule CanneryWeb.Components.ShotRecordTableComponent do
%{label: gettext("Actions"), key: :actions, sortable: false} %{label: gettext("Actions"), key: :actions, sortable: false}
] ]
packs = ammo_groups =
shot_records shot_groups
|> Enum.map(fn %{pack_id: pack_id} -> pack_id end) |> Enum.map(fn %{ammo_group_id: ammo_group_id} -> ammo_group_id end)
|> Ammo.get_packs(current_user) |> Ammo.get_ammo_groups(current_user)
extra_data = %{current_user: current_user, actions: actions, packs: packs} extra_data = %{current_user: current_user, actions: actions, ammo_groups: ammo_groups}
rows = rows =
shot_records shot_groups
|> Enum.map(fn shot_record -> |> Enum.map(fn shot_group ->
shot_record |> get_row_data_for_shot_record(columns, extra_data) shot_group |> get_row_data_for_shot_group(columns, extra_data)
end) end)
socket socket
@ -74,7 +71,7 @@ defmodule CanneryWeb.Components.ShotRecordTableComponent do
<div id={@id} class="w-full"> <div id={@id} class="w-full">
<.live_component <.live_component
module={CanneryWeb.Components.TableComponent} module={CanneryWeb.Components.TableComponent}
id={"shot-record-table-#{@id}"} id={"table-#{@id}"}
columns={@columns} columns={@columns}
rows={@rows} rows={@rows}
initial_key={:date} initial_key={:date}
@ -84,22 +81,22 @@ defmodule CanneryWeb.Components.ShotRecordTableComponent do
""" """
end end
@spec get_row_data_for_shot_record(ShotRecord.t(), columns :: [map()], extra_data :: map()) :: @spec get_row_data_for_shot_group(ShotGroup.t(), columns :: [map()], extra_data :: map()) ::
map() map()
defp get_row_data_for_shot_record(shot_record, columns, extra_data) do defp get_row_data_for_shot_group(shot_group, columns, extra_data) do
columns columns
|> Map.new(fn %{key: key} -> |> Map.new(fn %{key: key} ->
{key, get_row_value(key, shot_record, extra_data)} {key, get_row_value(key, shot_group, extra_data)}
end) end)
end end
defp get_row_value(:name, %{pack_id: pack_id}, %{packs: packs}) do defp get_row_value(:name, %{ammo_group_id: ammo_group_id}, %{ammo_groups: ammo_groups}) do
assigns = %{pack: pack = Map.fetch!(packs, pack_id)} assigns = %{ammo_group: ammo_group = Map.fetch!(ammo_groups, ammo_group_id)}
{pack.type.name, {ammo_group.ammo_type.name,
~H""" ~H"""
<.link navigate={~p"/ammo/show/#{@pack}"} class="link"> <.link navigate={Routes.ammo_group_show_path(Endpoint, :show, @ammo_group)} class="link">
<%= @pack.type.name %> <%= @ammo_group.ammo_type.name %>
</.link> </.link>
"""} """}
end end
@ -111,13 +108,13 @@ defmodule CanneryWeb.Components.ShotRecordTableComponent do
"""} """}
end end
defp get_row_value(:actions, shot_record, %{actions: actions}) do defp get_row_value(:actions, shot_group, %{actions: actions}) do
assigns = %{actions: actions, shot_record: shot_record} assigns = %{actions: actions, shot_group: shot_group}
~H""" ~H"""
<%= render_slot(@actions, @shot_record) %> <%= render_slot(@actions, @shot_group) %>
""" """
end end
defp get_row_value(key, shot_record, _extra_data), do: shot_record |> Map.get(key) defp get_row_value(key, shot_group, _extra_data), do: shot_group |> Map.get(key)
end end

View File

@ -135,25 +135,4 @@ defmodule CanneryWeb.Components.TableComponent do
sort_mode sort_mode
) )
end 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 end

View File

@ -1,300 +0,0 @@
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 = Ammo.get_grouped_round_count(current_user, types: types, group_by: :type_id)
packs_count = Ammo.get_grouped_packs_count(current_user, types: types, group_by: :type_id)
average_costs = Ammo.get_average_costs(types, current_user)
[used_counts, historical_round_counts, historical_pack_counts, used_pack_counts] =
if show_used do
[
ActivityLog.get_grouped_used_counts(current_user, types: types, group_by: :type_id),
Ammo.get_historical_counts(types, current_user),
Ammo.get_grouped_packs_count(current_user,
types: types,
group_by: :type_id,
show_used: true
),
Ammo.get_grouped_packs_count(current_user,
types: types,
group_by: :type_id,
show_used: :only_used
)
]
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={"type-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

View File

@ -6,8 +6,7 @@ defmodule CanneryWeb.EmailController do
use CanneryWeb, :controller use CanneryWeb, :controller
alias Cannery.Accounts.User alias Cannery.Accounts.User
plug :put_root_layout, html: {CanneryWeb.Layouts, :email_html} plug :put_layout, {CanneryWeb.LayoutView, :email}
plug :put_layout, false
@sample_assigns %{ @sample_assigns %{
email: %{subject: "Example subject"}, email: %{subject: "Example subject"},
@ -19,6 +18,6 @@ defmodule CanneryWeb.EmailController do
Debug route used to preview emails Debug route used to preview emails
""" """
def preview(conn, %{"id" => template}) do def preview(conn, %{"id" => template}) do
render(conn, String.to_existing_atom(template), @sample_assigns) render(conn, "#{template |> to_string()}.html", @sample_assigns)
end end
end end

View File

@ -1,16 +0,0 @@
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

View File

@ -1,14 +0,0 @@
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

View File

@ -3,80 +3,73 @@ defmodule CanneryWeb.ExportController do
alias Cannery.{ActivityLog, Ammo, Containers} alias Cannery.{ActivityLog, Ammo, Containers}
def export(%{assigns: %{current_user: current_user}} = conn, %{"mode" => "json"}) do def export(%{assigns: %{current_user: current_user}} = conn, %{"mode" => "json"}) do
types = Ammo.list_types(current_user) 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)
used_counts = total_ammo_group_counts =
ActivityLog.get_grouped_used_counts(current_user, types: types, group_by: :type_id) ammo_types |> Ammo.get_ammo_groups_count_for_types(current_user, true)
round_counts = Ammo.get_grouped_round_count(current_user, types: types, group_by: :type_id) average_costs = ammo_types |> Ammo.get_average_cost_for_ammo_types(current_user)
pack_counts = Ammo.get_grouped_packs_count(current_user, types: types, group_by: :type_id)
total_pack_counts = ammo_types =
Ammo.get_grouped_packs_count(current_user, ammo_types
types: types, |> Enum.map(fn %{id: ammo_type_id} = ammo_type ->
group_by: :type_id, ammo_type
show_used: true
)
average_costs = Ammo.get_average_costs(types, current_user)
types =
types
|> Enum.map(fn %{id: type_id} = type ->
type
|> Jason.encode!() |> Jason.encode!()
|> Jason.decode!() |> Jason.decode!()
|> Map.merge(%{ |> Map.merge(%{
"average_cost" => Map.get(average_costs, type_id), "average_cost" => Map.get(average_costs, ammo_type_id),
"round_count" => Map.get(round_counts, type_id, 0), "round_count" => Map.get(round_counts, ammo_type_id, 0),
"used_count" => Map.get(used_counts, type_id, 0), "used_count" => Map.get(used_counts, ammo_type_id, 0),
"pack_count" => Map.get(pack_counts, type_id, 0), "ammo_group_count" => Map.get(ammo_group_counts, ammo_type_id, 0),
"total_pack_count" => Map.get(total_pack_counts, type_id, 0) "total_ammo_group_count" => Map.get(total_ammo_group_counts, ammo_type_id, 0)
}) })
end) end)
packs = Ammo.list_packs(current_user, show_used: true) 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)
percentages_remaining = ammo_groups |> Ammo.get_percentages_remaining(current_user)
used_counts = ammo_groups =
ActivityLog.get_grouped_used_counts(current_user, packs: packs, group_by: :pack_id) ammo_groups
|> Enum.map(fn %{id: ammo_group_id} = ammo_group ->
original_counts = packs |> Ammo.get_original_counts(current_user) ammo_group
cprs = packs |> Ammo.get_cprs(current_user)
percentages_remaining = packs |> Ammo.get_percentages_remaining(current_user)
packs =
packs
|> Enum.map(fn %{id: pack_id} = pack ->
pack
|> Jason.encode!() |> Jason.encode!()
|> Jason.decode!() |> Jason.decode!()
|> Map.merge(%{ |> Map.merge(%{
"used_count" => Map.get(used_counts, pack_id), "used_count" => Map.get(used_counts, ammo_group_id),
"percentage_remaining" => Map.fetch!(percentages_remaining, pack_id), "percentage_remaining" => Map.fetch!(percentages_remaining, ammo_group_id),
"original_count" => Map.get(original_counts, pack_id), "original_count" => Map.get(original_counts, ammo_group_id),
"cpr" => Map.get(cprs, pack_id) "cpr" => Map.get(cprs, ammo_group_id)
}) })
end) end)
shot_records = ActivityLog.list_shot_records(current_user) shot_groups = ActivityLog.list_shot_groups(current_user)
containers = containers =
Containers.list_containers(current_user) Containers.list_containers(current_user)
|> Enum.map(fn container -> |> Enum.map(fn container ->
ammo_group_count = container |> Ammo.get_ammo_groups_count_for_container!(current_user)
round_count = container |> Ammo.get_round_count_for_container!(current_user)
container container
|> Jason.encode!() |> Jason.encode!()
|> Jason.decode!() |> Jason.decode!()
|> Map.merge(%{ |> Map.merge(%{
"pack_count" => Ammo.get_packs_count(current_user, container_id: container.id), "ammo_group_count" => ammo_group_count,
"round_count" => Ammo.get_round_count(current_user, container_id: container.id) "round_count" => round_count
}) })
end) end)
json(conn, %{ json(conn, %{
user: current_user, user: current_user,
types: types, ammo_types: ammo_types,
packs: packs, ammo_groups: ammo_groups,
shot_records: shot_records, shot_groups: shot_groups,
containers: containers containers: containers
}) })
end end

View File

@ -0,0 +1,11 @@
defmodule CanneryWeb.HomeController do
@moduledoc """
Controller for home page
"""
use CanneryWeb, :controller
def index(conn, _params) do
render(conn, "index.html")
end
end

View File

@ -1,5 +0,0 @@
defmodule CanneryWeb.HomeHTML do
use CanneryWeb, :html
embed_templates "home_html/*"
end

View File

@ -3,11 +3,12 @@ defmodule CanneryWeb.UserAuth do
Functions for user session and authentication Functions for user session and authentication
""" """
use CanneryWeb, :verified_routes
import Plug.Conn import Plug.Conn
import Phoenix.Controller import Phoenix.Controller
import CanneryWeb.Gettext import CanneryWeb.Gettext
alias Cannery.{Accounts, Accounts.User} alias Cannery.{Accounts, Accounts.User}
alias CanneryWeb.HomeLive
alias CanneryWeb.Router.Helpers, as: Routes
# Make the remember me cookie valid for 60 days. # Make the remember me cookie valid for 60 days.
# If you want bump or reduce this value, also change # If you want bump or reduce this value, also change
@ -38,7 +39,7 @@ defmodule CanneryWeb.UserAuth do
dgettext("errors", "You must confirm your account and log in to access this page.") dgettext("errors", "You must confirm your account and log in to access this page.")
) )
|> maybe_store_return_to() |> maybe_store_return_to()
|> redirect(to: ~p"/users/log_in") |> redirect(to: Routes.user_session_path(conn, :new))
|> halt() |> halt()
end end
@ -48,7 +49,8 @@ defmodule CanneryWeb.UserAuth do
conn conn
|> renew_session() |> renew_session()
|> put_token_in_session(token) |> put_session(:user_token, token)
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
|> maybe_write_remember_me_cookie(token, params) |> maybe_write_remember_me_cookie(token, params)
|> redirect(to: user_return_to || signed_in_path(conn)) |> redirect(to: user_return_to || signed_in_path(conn))
end end
@ -94,7 +96,7 @@ defmodule CanneryWeb.UserAuth do
""" """
def log_out_user(conn) do def log_out_user(conn) do
user_token = get_session(conn, :user_token) user_token = get_session(conn, :user_token)
user_token && Accounts.delete_user_session_token(user_token) user_token && Accounts.delete_session_token(user_token)
if live_socket_id = get_session(conn, :live_socket_id) do if live_socket_id = get_session(conn, :live_socket_id) do
CanneryWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) CanneryWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
@ -103,7 +105,7 @@ defmodule CanneryWeb.UserAuth do
conn conn
|> renew_session() |> renew_session()
|> delete_resp_cookie(@remember_me_cookie) |> delete_resp_cookie(@remember_me_cookie)
|> redirect(to: ~p"/") |> redirect(to: "/")
end end
@doc """ @doc """
@ -117,110 +119,19 @@ defmodule CanneryWeb.UserAuth do
end end
defp ensure_user_token(conn) do defp ensure_user_token(conn) do
if token = get_session(conn, :user_token) do if user_token = get_session(conn, :user_token) do
{token, conn} {user_token, conn}
else else
conn = fetch_cookies(conn, signed: [@remember_me_cookie]) conn = fetch_cookies(conn, signed: [@remember_me_cookie])
if token = conn.cookies[@remember_me_cookie] do if user_token = conn.cookies[@remember_me_cookie] do
{token, put_token_in_session(conn, token)} {user_token, put_session(conn, :user_token, user_token)}
else else
{nil, conn} {nil, conn}
end end
end 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 """ @doc """
Used for routes that require the user to not be authenticated. Used for routes that require the user to not be authenticated.
""" """
@ -250,7 +161,7 @@ defmodule CanneryWeb.UserAuth do
dgettext("errors", "You must confirm your account and log in to access this page.") dgettext("errors", "You must confirm your account and log in to access this page.")
) )
|> maybe_store_return_to() |> maybe_store_return_to()
|> redirect(to: ~p"/users/log_in") |> redirect(to: Routes.user_session_path(conn, :new))
|> halt() |> halt()
end end
end end
@ -265,34 +176,16 @@ defmodule CanneryWeb.UserAuth do
conn conn
|> put_flash(:error, dgettext("errors", "You are not authorized to view this page.")) |> put_flash(:error, dgettext("errors", "You are not authorized to view this page."))
|> maybe_store_return_to() |> maybe_store_return_to()
|> redirect(to: ~p"/") |> redirect(to: Routes.live_path(conn, HomeLive))
|> halt() |> halt()
end end
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 defp maybe_store_return_to(%{method: "GET"} = conn) do
put_session(conn, :user_return_to, current_path(conn)) put_session(conn, :user_return_to, current_path(conn))
end end
defp maybe_store_return_to(conn), do: conn defp maybe_store_return_to(conn), do: conn
defp signed_in_path(_conn), do: ~p"/" defp signed_in_path(_conn), do: "/"
end end

View File

@ -5,14 +5,14 @@ defmodule CanneryWeb.UserConfirmationController do
alias Cannery.Accounts alias Cannery.Accounts
def new(conn, _params) do def new(conn, _params) do
render(conn, :new, page_title: gettext("Confirm your account")) render(conn, "new.html", page_title: gettext("Confirm your account"))
end end
def create(conn, %{"user" => %{"email" => email}}) do def create(conn, %{"user" => %{"email" => email}}) do
if user = Accounts.get_user_by_email(email) do if user = Accounts.get_user_by_email(email) do
Accounts.deliver_user_confirmation_instructions( Accounts.deliver_user_confirmation_instructions(
user, user,
fn token -> url(CanneryWeb.Endpoint, ~p"/users/confirm/#{token}") end &Routes.user_confirmation_url(conn, :confirm, &1)
) )
end end
@ -22,10 +22,11 @@ defmodule CanneryWeb.UserConfirmationController do
:info, :info,
dgettext( dgettext(
"prompts", "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: ~p"/") |> redirect(to: "/")
end end
# Do not log in the user after confirmation to avoid a # Do not log in the user after confirmation to avoid a
@ -35,7 +36,7 @@ defmodule CanneryWeb.UserConfirmationController do
{:ok, %{email: email}} -> {:ok, %{email: email}} ->
conn conn
|> put_flash(:info, dgettext("prompts", "%{email} confirmed successfully.", email: email)) |> put_flash(:info, dgettext("prompts", "%{email} confirmed successfully.", email: email))
|> redirect(to: ~p"/") |> redirect(to: "/")
:error -> :error ->
# If there is a current user and the account was already confirmed, # If there is a current user and the account was already confirmed,
@ -44,7 +45,7 @@ defmodule CanneryWeb.UserConfirmationController do
# a warning message. # a warning message.
case conn.assigns do case conn.assigns do
%{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
redirect(conn, to: ~p"/") redirect(conn, to: "/")
%{} -> %{} ->
conn conn
@ -52,7 +53,7 @@ defmodule CanneryWeb.UserConfirmationController do
:error, :error,
dgettext("errors", "User confirmation link is invalid or it has expired.") dgettext("errors", "User confirmation link is invalid or it has expired.")
) )
|> redirect(to: ~p"/") |> redirect(to: "/")
end end
end end
end end

View File

@ -1,6 +0,0 @@
defmodule CanneryWeb.UserConfirmationHTML do
use CanneryWeb, :html
alias Cannery.Accounts
embed_templates "user_confirmation_html/*"
end

View File

@ -2,6 +2,7 @@ defmodule CanneryWeb.UserRegistrationController do
use CanneryWeb, :controller use CanneryWeb, :controller
import CanneryWeb.Gettext import CanneryWeb.Gettext
alias Cannery.{Accounts, Accounts.Invites} alias Cannery.{Accounts, Accounts.Invites}
alias CanneryWeb.{Endpoint, HomeLive}
alias Ecto.Changeset alias Ecto.Changeset
def new(conn, %{"invite" => invite_token}) do def new(conn, %{"invite" => invite_token}) do
@ -10,7 +11,7 @@ defmodule CanneryWeb.UserRegistrationController do
else else
conn conn
|> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired")) |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired"))
|> redirect(to: ~p"/") |> redirect(to: Routes.live_path(Endpoint, HomeLive))
end end
end end
@ -20,13 +21,13 @@ defmodule CanneryWeb.UserRegistrationController do
else else
conn conn
|> put_flash(:error, dgettext("errors", "Sorry, public registration is disabled")) |> put_flash(:error, dgettext("errors", "Sorry, public registration is disabled"))
|> redirect(to: ~p"/") |> redirect(to: Routes.live_path(Endpoint, HomeLive))
end end
end end
# renders new user registration page # renders new user registration page
defp render_new(conn, invite_token \\ nil) do defp render_new(conn, invite_token \\ nil) do
render(conn, :new, render(conn, "new.html",
changeset: Accounts.change_user_registration(), changeset: Accounts.change_user_registration(),
invite_token: invite_token, invite_token: invite_token,
page_title: gettext("Register") page_title: gettext("Register")
@ -39,7 +40,7 @@ defmodule CanneryWeb.UserRegistrationController do
else else
conn conn
|> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired")) |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired"))
|> redirect(to: ~p"/") |> redirect(to: Routes.live_path(Endpoint, HomeLive))
end end
end end
@ -49,7 +50,7 @@ defmodule CanneryWeb.UserRegistrationController do
else else
conn conn
|> put_flash(:error, dgettext("errors", "Sorry, public registration is disabled")) |> put_flash(:error, dgettext("errors", "Sorry, public registration is disabled"))
|> redirect(to: ~p"/") |> redirect(to: Routes.live_path(Endpoint, HomeLive))
end end
end end
@ -58,20 +59,20 @@ defmodule CanneryWeb.UserRegistrationController do
{:ok, user} -> {:ok, user} ->
Accounts.deliver_user_confirmation_instructions( Accounts.deliver_user_confirmation_instructions(
user, user,
fn token -> url(CanneryWeb.Endpoint, ~p"/users/confirm/#{token}") end &Routes.user_confirmation_url(conn, :confirm, &1)
) )
conn conn
|> put_flash(:info, dgettext("prompts", "Please check your email to verify your account")) |> put_flash(:info, dgettext("prompts", "Please check your email to verify your account"))
|> redirect(to: ~p"/users/log_in") |> redirect(to: Routes.user_session_path(Endpoint, :new))
{:error, :invalid_token} -> {:error, :invalid_token} ->
conn conn
|> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired")) |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired"))
|> redirect(to: ~p"/") |> redirect(to: Routes.live_path(Endpoint, HomeLive))
{:error, %Changeset{} = changeset} -> {:error, %Changeset{} = changeset} ->
conn |> render(:new, changeset: changeset, invite_token: invite_token) conn |> render("new.html", changeset: changeset, invite_token: invite_token)
end end
end end
end end

View File

@ -1,5 +0,0 @@
defmodule CanneryWeb.UserRegistrationHTML do
use CanneryWeb, :html
embed_templates "user_registration_html/*"
end

View File

@ -6,14 +6,14 @@ defmodule CanneryWeb.UserResetPasswordController do
plug :get_user_by_reset_password_token when action in [:edit, :update] plug :get_user_by_reset_password_token when action in [:edit, :update]
def new(conn, _params) do def new(conn, _params) do
render(conn, :new, page_title: gettext("Forgot your password?")) render(conn, "new.html", page_title: gettext("Forgot your password?"))
end end
def create(conn, %{"user" => %{"email" => email}}) do def create(conn, %{"user" => %{"email" => email}}) do
if user = Accounts.get_user_by_email(email) do if user = Accounts.get_user_by_email(email) do
Accounts.deliver_user_reset_password_instructions( Accounts.deliver_user_reset_password_instructions(
user, user,
fn token -> url(CanneryWeb.Endpoint, ~p"/users/reset_password/#{token}") end &Routes.user_reset_password_url(conn, :edit, &1)
) )
end end
@ -23,14 +23,15 @@ defmodule CanneryWeb.UserResetPasswordController do
:info, :info,
dgettext( dgettext(
"prompts", "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: ~p"/") |> redirect(to: "/")
end end
def edit(conn, _params) do def edit(conn, _params) do
render(conn, :edit, render(conn, "edit.html",
changeset: Accounts.change_user_password(conn.assigns.user), changeset: Accounts.change_user_password(conn.assigns.user),
page_title: gettext("Reset your password") page_title: gettext("Reset your password")
) )
@ -43,10 +44,10 @@ defmodule CanneryWeb.UserResetPasswordController do
{:ok, _} -> {:ok, _} ->
conn conn
|> put_flash(:info, dgettext("prompts", "Password reset successfully.")) |> put_flash(:info, dgettext("prompts", "Password reset successfully."))
|> redirect(to: ~p"/users/log_in") |> redirect(to: Routes.user_session_path(conn, :new))
{:error, changeset} -> {:error, changeset} ->
render(conn, :edit, changeset: changeset) render(conn, "edit.html", changeset: changeset)
end end
end end
@ -61,7 +62,7 @@ defmodule CanneryWeb.UserResetPasswordController do
:error, :error,
dgettext("errors", "Reset password link is invalid or it has expired.") dgettext("errors", "Reset password link is invalid or it has expired.")
) )
|> redirect(to: ~p"/") |> redirect(to: "/")
|> halt() |> halt()
end end
end end

View File

@ -1,6 +0,0 @@
defmodule CanneryWeb.UserResetPasswordHTML do
use CanneryWeb, :html
alias Cannery.Accounts
embed_templates "user_reset_password_html/*"
end

View File

@ -5,7 +5,7 @@ defmodule CanneryWeb.UserSessionController do
alias CanneryWeb.UserAuth alias CanneryWeb.UserAuth
def new(conn, _params) do def new(conn, _params) do
render(conn, :new, error_message: nil, page_title: gettext("Log in")) render(conn, "new.html", error_message: nil, page_title: gettext("Log in"))
end end
def create(conn, %{"user" => user_params}) do 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 if user = Accounts.get_user_by_email_and_password(email, password) do
UserAuth.log_in_user(conn, user, user_params) UserAuth.log_in_user(conn, user, user_params)
else else
render(conn, :new, error_message: dgettext("errors", "Invalid email or password")) render(conn, "new.html", error_message: dgettext("errors", "Invalid email or password"))
end end
end end

View File

@ -1,6 +0,0 @@
defmodule CanneryWeb.UserSessionHTML do
use CanneryWeb, :html
alias Cannery.Accounts
embed_templates "user_session_html/*"
end

View File

@ -2,12 +2,12 @@ defmodule CanneryWeb.UserSettingsController do
use CanneryWeb, :controller use CanneryWeb, :controller
import CanneryWeb.Gettext import CanneryWeb.Gettext
alias Cannery.Accounts alias Cannery.Accounts
alias CanneryWeb.UserAuth alias CanneryWeb.{HomeLive, UserAuth}
plug :assign_email_and_password_changesets plug :assign_email_and_password_changesets
def edit(conn, _params) do def edit(conn, _params) do
render(conn, :edit, page_title: gettext("Settings")) render(conn, "edit.html", page_title: gettext("Settings"))
end end
def update(%{assigns: %{current_user: user}} = conn, %{ def update(%{assigns: %{current_user: user}} = conn, %{
@ -20,7 +20,7 @@ defmodule CanneryWeb.UserSettingsController do
Accounts.deliver_update_email_instructions( Accounts.deliver_update_email_instructions(
applied_user, applied_user,
user.email, user.email,
fn token -> url(CanneryWeb.Endpoint, ~p"/users/settings/confirm_email/#{token}") end &Routes.user_settings_url(conn, :confirm_email, &1)
) )
conn conn
@ -31,10 +31,10 @@ defmodule CanneryWeb.UserSettingsController do
"A link to confirm your email change has been sent to the new address." "A link to confirm your email change has been sent to the new address."
) )
) )
|> redirect(to: ~p"/users/settings") |> redirect(to: Routes.user_settings_path(conn, :edit))
{:error, changeset} -> {:error, changeset} ->
conn |> render(:edit, email_changeset: changeset) conn |> render("edit.html", email_changeset: changeset)
end end
end end
@ -47,11 +47,11 @@ defmodule CanneryWeb.UserSettingsController do
{:ok, user} -> {:ok, user} ->
conn conn
|> put_flash(:info, dgettext("prompts", "Password updated successfully.")) |> put_flash(:info, dgettext("prompts", "Password updated successfully."))
|> put_session(:user_return_to, ~p"/users/settings") |> put_session(:user_return_to, Routes.user_settings_path(conn, :edit))
|> UserAuth.log_in_user(user) |> UserAuth.log_in_user(user)
{:error, changeset} -> {:error, changeset} ->
conn |> render(:edit, password_changeset: changeset) conn |> render("edit.html", password_changeset: changeset)
end end
end end
@ -63,10 +63,10 @@ defmodule CanneryWeb.UserSettingsController do
{:ok, _user} -> {:ok, _user} ->
conn conn
|> put_flash(:info, dgettext("prompts", "Language updated successfully.")) |> put_flash(:info, dgettext("prompts", "Language updated successfully."))
|> redirect(to: ~p"/users/settings") |> redirect(to: Routes.user_settings_path(conn, :edit))
{:error, changeset} -> {:error, changeset} ->
conn |> render(:edit, locale_changeset: changeset) conn |> render("edit.html", locale_changeset: changeset)
end end
end end
@ -75,7 +75,7 @@ defmodule CanneryWeb.UserSettingsController do
:ok -> :ok ->
conn conn
|> put_flash(:info, dgettext("prompts", "Email changed successfully.")) |> put_flash(:info, dgettext("prompts", "Email changed successfully."))
|> redirect(to: ~p"/users/settings") |> redirect(to: Routes.user_settings_path(conn, :edit))
:error -> :error ->
conn conn
@ -83,7 +83,7 @@ defmodule CanneryWeb.UserSettingsController do
:error, :error,
dgettext("errors", "Email change link is invalid or it has expired.") dgettext("errors", "Email change link is invalid or it has expired.")
) )
|> redirect(to: ~p"/users/settings") |> redirect(to: Routes.user_settings_path(conn, :edit))
end end
end end
@ -93,11 +93,11 @@ defmodule CanneryWeb.UserSettingsController do
conn conn
|> put_flash(:error, dgettext("prompts", "Your account has been deleted")) |> put_flash(:error, dgettext("prompts", "Your account has been deleted"))
|> redirect(to: ~p"/") |> redirect(to: Routes.live_path(conn, HomeLive))
else else
conn conn
|> put_flash(:error, dgettext("errors", "Unable to delete user")) |> put_flash(:error, dgettext("errors", "Unable to delete user"))
|> redirect(to: ~p"/users/settings") |> redirect(to: Routes.user_settings_path(conn, :edit))
end end
end end

View File

@ -1,5 +0,0 @@
defmodule CanneryWeb.UserSettingsHTML do
use CanneryWeb, :html
embed_templates "user_settings_html/*"
end

View File

@ -20,7 +20,7 @@ defmodule CanneryWeb.Endpoint do
at: "/", at: "/",
from: :cannery, from: :cannery,
gzip: false, gzip: false,
only: CanneryWeb.static_paths() only: ~w(css fonts images js favicon.ico robots.txt)
# Code reloading can be explicitly enabled under the # Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint. # :code_reloader configuration of your endpoint.

View File

@ -1,47 +1,58 @@
defmodule CanneryWeb.PackLive.FormComponent do defmodule CanneryWeb.AmmoGroupLive.FormComponent do
@moduledoc """ @moduledoc """
Livecomponent that can update or create an Cannery.Ammo.Pack Livecomponent that can update or create an Cannery.Ammo.AmmoGroup
""" """
use CanneryWeb, :live_component use CanneryWeb, :live_component
alias Cannery.Ammo.{Pack, Type} alias Cannery.Ammo.{AmmoGroup, AmmoType}
alias Cannery.{Accounts.User, Ammo, Containers, Containers.Container} alias Cannery.{Accounts.User, Ammo, Containers, Containers.Container}
alias Ecto.Changeset alias Ecto.Changeset
alias Phoenix.LiveView.Socket alias Phoenix.LiveView.Socket
@pack_create_limit 10_000 @ammo_group_create_limit 10_000
@impl true @impl true
@spec update( @spec update(
%{:pack => Pack.t(), :current_user => User.t(), optional(any) => any}, %{:ammo_group => AmmoGroup.t(), :current_user => User.t(), optional(any) => any},
Socket.t() Socket.t()
) :: {:ok, Socket.t()} ) :: {:ok, Socket.t()}
def update(%{pack: _pack} = assigns, socket) do def update(%{ammo_group: _ammo_group} = assigns, socket) do
socket |> assign(assigns) |> update() socket |> assign(assigns) |> update()
end end
@spec update(Socket.t()) :: {:ok, Socket.t()} @spec update(Socket.t()) :: {:ok, Socket.t()}
def update(%{assigns: %{current_user: current_user}} = socket) do def update(%{assigns: %{current_user: current_user}} = socket) do
%{assigns: %{ammo_types: ammo_types, containers: containers}} =
socket = socket =
socket socket
|> assign(:pack_create_limit, @pack_create_limit) |> assign(:ammo_group_create_limit, @ammo_group_create_limit)
|> assign(:types, Ammo.list_types(current_user)) |> assign(:ammo_types, Ammo.list_ammo_types(current_user))
|> assign_new(:containers, fn -> Containers.list_containers(current_user) end) |> assign_new(:containers, fn -> Containers.list_containers(current_user) end)
{:ok, socket |> assign_changeset(%{})} params =
if ammo_types |> List.first() |> is_nil(),
do: %{},
else: %{} |> Map.put("ammo_type_id", ammo_types |> List.first() |> Map.get(:id))
params =
if containers |> List.first() |> is_nil(),
do: params,
else: params |> Map.put("container_id", containers |> List.first() |> Map.get(:id))
{:ok, socket |> assign_changeset(params)}
end end
@impl true @impl true
def handle_event("validate", %{"pack" => pack_params}, socket) do def handle_event("validate", %{"ammo_group" => ammo_group_params}, socket) do
{:noreply, socket |> assign_changeset(pack_params, :validate)} {:noreply, socket |> assign_changeset(ammo_group_params, :validate)}
end end
def handle_event( def handle_event(
"save", "save",
%{"pack" => pack_params}, %{"ammo_group" => ammo_group_params},
%{assigns: %{action: action}} = socket %{assigns: %{action: action}} = socket
) do ) do
save_pack(socket, action, pack_params) save_ammo_group(socket, action, ammo_group_params)
end end
# HTML Helpers # HTML Helpers
@ -51,16 +62,16 @@ defmodule CanneryWeb.PackLive.FormComponent do
containers |> Enum.map(fn %{id: id, name: name} -> {name, id} end) containers |> Enum.map(fn %{id: id, name: name} -> {name, id} end)
end end
@spec type_options([Type.t()]) :: [{String.t(), Type.id()}] @spec ammo_type_options([AmmoType.t()]) :: [{String.t(), AmmoType.id()}]
defp type_options(types) do defp ammo_type_options(ammo_types) do
types |> Enum.map(fn %{id: id, name: name} -> {name, id} end) ammo_types |> Enum.map(fn %{id: id, name: name} -> {name, id} end)
end end
# Save Helpers # Save Helpers
defp assign_changeset( defp assign_changeset(
%{assigns: %{action: action, pack: pack, current_user: user}} = socket, %{assigns: %{action: action, ammo_group: ammo_group, current_user: user}} = socket,
pack_params, ammo_group_params,
changeset_action \\ nil changeset_action \\ nil
) do ) do
default_action = default_action =
@ -72,23 +83,19 @@ defmodule CanneryWeb.PackLive.FormComponent do
changeset = changeset =
case default_action do case default_action do
:insert -> :insert ->
type = maybe_get_type(pack_params, user) ammo_type = maybe_get_ammo_type(ammo_group_params, user)
container = maybe_get_container(pack_params, user) container = maybe_get_container(ammo_group_params, user)
pack |> Pack.create_changeset(type, container, user, pack_params) ammo_group |> AmmoGroup.create_changeset(ammo_type, container, user, ammo_group_params)
:update -> :update ->
pack |> Pack.update_changeset(pack_params, user) ammo_group |> AmmoGroup.update_changeset(ammo_group_params, user)
end end
changeset = changeset =
if changeset_action do case changeset |> Changeset.apply_action(changeset_action || default_action) do
case changeset |> Changeset.apply_action(changeset_action) do
{:ok, _data} -> changeset {:ok, _data} -> changeset
{:error, changeset} -> changeset {:error, changeset} -> changeset
end end
else
changeset
end
socket |> assign(:changeset, changeset) socket |> assign(:changeset, changeset)
end end
@ -100,21 +107,22 @@ defmodule CanneryWeb.PackLive.FormComponent do
defp maybe_get_container(_params_not_found, _user), do: nil defp maybe_get_container(_params_not_found, _user), do: nil
defp maybe_get_type(%{"type_id" => type_id}, user) defp maybe_get_ammo_type(%{"ammo_type_id" => ammo_type_id}, user)
when is_binary(type_id) do when is_binary(ammo_type_id) do
type_id |> Ammo.get_type!(user) ammo_type_id |> Ammo.get_ammo_type!(user)
end end
defp maybe_get_type(_params_not_found, _user), do: nil defp maybe_get_ammo_type(_params_not_found, _user), do: nil
defp save_pack( defp save_ammo_group(
%{assigns: %{pack: pack, current_user: current_user, return_to: return_to}} = socket, %{assigns: %{ammo_group: ammo_group, current_user: current_user, return_to: return_to}} =
socket,
:edit, :edit,
pack_params ammo_group_params
) do ) do
socket = socket =
case Ammo.update_pack(pack, pack_params, current_user) do case Ammo.update_ammo_group(ammo_group, ammo_group_params, current_user) do
{:ok, _pack} -> {:ok, _ammo_group} ->
prompt = dgettext("prompts", "Ammo updated successfully") prompt = dgettext("prompts", "Ammo updated successfully")
socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) socket |> put_flash(:info, prompt) |> push_navigate(to: return_to)
@ -125,24 +133,24 @@ defmodule CanneryWeb.PackLive.FormComponent do
{:noreply, socket} {:noreply, socket}
end end
defp save_pack( defp save_ammo_group(
%{assigns: %{changeset: changeset}} = socket, %{assigns: %{changeset: changeset}} = socket,
action, action,
%{"multiplier" => multiplier_str} = pack_params %{"multiplier" => multiplier_str} = ammo_group_params
) )
when action in [:new, :clone] do when action in [:new, :clone] do
socket = socket =
case multiplier_str |> Integer.parse() do case multiplier_str |> Integer.parse() do
{multiplier, _remainder} {multiplier, _remainder}
when multiplier >= 1 and multiplier <= @pack_create_limit -> when multiplier >= 1 and multiplier <= @ammo_group_create_limit ->
socket |> create_multiple(pack_params, multiplier) socket |> create_multiple(ammo_group_params, multiplier)
{multiplier, _remainder} -> {multiplier, _remainder} ->
error_msg = error_msg =
dgettext( dgettext(
"errors", "errors",
"Invalid number of copies, must be between 1 and %{max}. Was %{multiplier}", "Invalid number of copies, must be between 1 and %{max}. Was %{multiplier}",
max: @pack_create_limit, max: @ammo_group_create_limit,
multiplier: multiplier multiplier: multiplier
) )
@ -168,11 +176,11 @@ defmodule CanneryWeb.PackLive.FormComponent do
defp create_multiple( defp create_multiple(
%{assigns: %{current_user: current_user, return_to: return_to}} = socket, %{assigns: %{current_user: current_user, return_to: return_to}} = socket,
pack_params, ammo_group_params,
multiplier multiplier
) do ) do
case Ammo.create_packs(pack_params, multiplier, current_user) do case Ammo.create_ammo_groups(ammo_group_params, multiplier, current_user) do
{:ok, {count, _packs}} -> {:ok, {count, _ammo_groups}} ->
prompt = prompt =
dngettext( dngettext(
"prompts", "prompts",

View File

@ -6,24 +6,24 @@
<.form <.form
:let={f} :let={f}
for={@changeset} for={@changeset}
id="pack-form" id="ammo_group-form"
phx-target={@myself} phx-target={@myself}
phx-change="validate" phx-change="validate"
phx-submit="save" 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" 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 <div
:if={@changeset.action && not @changeset.valid?} :if={@changeset.action && not @changeset.valid?()}
class="invalid-feedback col-span-3 text-center" class="invalid-feedback col-span-3 text-center"
> >
<%= changeset_errors(@changeset) %> <%= changeset_errors(@changeset) %>
</div> </div>
<%= label(f, :type_id, gettext("Type"), class: "title text-lg text-primary-600") %> <%= label(f, :ammo_type_id, gettext("Ammo type"), class: "title text-lg text-primary-600") %>
<%= select(f, :type_id, type_options(@types), <%= select(f, :ammo_type_id, ammo_type_options(@ammo_types),
class: "text-center col-span-2 input input-primary" class: "text-center col-span-2 input input-primary"
) %> ) %>
<%= error_tag(f, :type_id, "col-span-3 text-center") %> <%= error_tag(f, :ammo_type_id, "col-span-3 text-center") %>
<%= label(f, :count, gettext("Count"), class: "title text-lg text-primary-600") %> <%= label(f, :count, gettext("Count"), class: "title text-lg text-primary-600") %>
<%= number_input(f, :count, <%= number_input(f, :count,
@ -39,14 +39,6 @@
) %> ) %>
<%= error_tag(f, :price_paid, "col-span-3 text-center") %> <%= 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,
phx_debounce: 300
) %>
<%= error_tag(f, :price_paid, "col-span-3 text-center") %>
<%= label(f, :purchased_on, gettext("Purchased on"), class: "title text-lg text-primary-600") %> <%= label(f, :purchased_on, gettext("Purchased on"), class: "title text-lg text-primary-600") %>
<%= date_input(f, :purchased_on, <%= date_input(f, :purchased_on,
class: "input input-primary col-span-2", class: "input input-primary col-span-2",
@ -57,9 +49,9 @@
<%= label(f, :notes, gettext("Notes"), class: "title text-lg text-primary-600") %> <%= label(f, :notes, gettext("Notes"), class: "title text-lg text-primary-600") %>
<%= textarea(f, :notes, <%= textarea(f, :notes,
id: "ammo-group-form-notes",
class: "text-center col-span-2 input input-primary", class: "text-center col-span-2 input input-primary",
id: "pack-form-notes", phx_hook: "MaintainAttrs",
phx_debounce: 300,
phx_update: "ignore" phx_update: "ignore"
) %> ) %>
<%= error_tag(f, :notes, "col-span-3 text-center") %> <%= error_tag(f, :notes, "col-span-3 text-center") %>
@ -76,7 +68,7 @@
<%= label(f, :multiplier, gettext("Copies"), class: "title text-lg text-primary-600") %> <%= label(f, :multiplier, gettext("Copies"), class: "title text-lg text-primary-600") %>
<%= number_input(f, :multiplier, <%= number_input(f, :multiplier,
max: @pack_create_limit, max: @ammo_group_create_limit,
class: "text-center input input-primary", class: "text-center input input-primary",
value: 1, value: 1,
phx_update: "ignore" phx_update: "ignore"

View File

@ -0,0 +1,136 @@
defmodule CanneryWeb.AmmoGroupLive.Index do
@moduledoc """
Liveview to show a Cannery.Ammo.AmmoGroup index
"""
use CanneryWeb, :live_view
alias Cannery.{Ammo, Ammo.AmmoGroup, Containers}
@impl true
def mount(%{"search" => search}, _session, socket) do
{:ok, socket |> assign(show_used: false, search: search) |> display_ammo_groups()}
end
def mount(_params, _session, socket) do
{:ok, socket |> assign(show_used: false, search: nil) |> display_ammo_groups()}
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()}
end
defp apply_action(
%{assigns: %{current_user: current_user}} = socket,
:add_shot_group,
%{"id" => id}
) do
socket
|> assign(
page_title: gettext("Record shots"),
ammo_group: Ammo.get_ammo_group!(id, current_user)
)
end
defp apply_action(%{assigns: %{current_user: current_user}} = socket, :move, %{"id" => id}) do
socket
|> assign(
page_title: gettext("Move ammo"),
ammo_group: Ammo.get_ammo_group!(id, current_user)
)
end
defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"id" => id}) do
socket
|> assign(
page_title: gettext("Edit ammo"),
ammo_group: Ammo.get_ammo_group!(id, current_user)
)
end
defp apply_action(%{assigns: %{current_user: current_user}} = socket, :clone, %{"id" => id}) do
socket
|> assign(
page_title: dgettext("actions", "Add Ammo"),
ammo_group: %{Ammo.get_ammo_group!(id, current_user) | id: nil}
)
end
defp apply_action(socket, :new, _params) do
socket
|> assign(
page_title: dgettext("actions", "Add Ammo"),
ammo_group: %AmmoGroup{}
)
end
defp apply_action(socket, :index, _params) do
socket
|> assign(
page_title: gettext("Ammo"),
search: nil,
ammo_group: nil
)
end
defp apply_action(socket, :search, %{"search" => search}) do
socket
|> assign(
page_title: gettext("Ammo"),
search: search,
ammo_group: 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)
prompt = dgettext("prompts", "Ammo deleted succesfully")
{:noreply, socket |> put_flash(:info, prompt) |> display_ammo_groups()}
end
def handle_event(
"toggle_staged",
%{"ammo_group_id" => id},
%{assigns: %{current_user: current_user}} = socket
) do
ammo_group = Ammo.get_ammo_group!(id, current_user)
{:ok, _ammo_group} =
ammo_group |> Ammo.update_ammo_group(%{"staged" => !ammo_group.staged}, current_user)
{:noreply, socket |> display_ammo_groups()}
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()}
end
def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do
{:noreply, socket |> push_patch(to: Routes.ammo_group_index_path(Endpoint, :index))}
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}
end
defp display_ammo_groups(
%{assigns: %{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)
containers_count = Containers.get_containers_count!(current_user)
socket
|> assign(
ammo_groups: ammo_groups,
ammo_types_count: ammo_types_count,
containers_count: containers_count
)
end
end

View File

@ -0,0 +1,222 @@
<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}
show_used={@show_used}
>
<: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 %>

View File

@ -1,12 +1,13 @@
defmodule CanneryWeb.PackLive.Show do defmodule CanneryWeb.AmmoGroupLive.Show do
@moduledoc """ @moduledoc """
Liveview for showing and editing an Cannery.Ammo.Pack Liveview for showing and editing an Cannery.Ammo.AmmoGroup
""" """
use CanneryWeb, :live_view use CanneryWeb, :live_view
alias Cannery.{ActivityLog, ActivityLog.ShotRecord} alias Cannery.{ActivityLog, ActivityLog.ShotGroup}
alias Cannery.{Ammo, Ammo.Pack} alias Cannery.{Ammo, Ammo.AmmoGroup}
alias Cannery.{ComparableDate, Containers} alias Cannery.{ComparableDate, Containers}
alias CanneryWeb.Endpoint
alias Phoenix.LiveView.Socket alias Phoenix.LiveView.Socket
@impl true @impl true
@ -14,16 +15,16 @@ defmodule CanneryWeb.PackLive.Show do
@impl true @impl true
def handle_params( def handle_params(
%{"id" => id, "shot_record_id" => shot_record_id}, %{"id" => id, "shot_group_id" => shot_group_id},
_url, _url,
%{assigns: %{live_action: live_action, current_user: current_user}} = socket %{assigns: %{live_action: live_action, current_user: current_user}} = socket
) do ) do
shot_record = ActivityLog.get_shot_record!(shot_record_id, current_user) shot_group = ActivityLog.get_shot_group!(shot_group_id, current_user)
socket = socket =
socket socket
|> assign(page_title: page_title(live_action), shot_record: shot_record) |> assign(page_title: page_title(live_action), shot_group: shot_group)
|> display_pack(id) |> display_ammo_group(id)
{:noreply, socket} {:noreply, socket}
end end
@ -32,13 +33,13 @@ defmodule CanneryWeb.PackLive.Show do
socket = socket =
socket socket
|> assign(page_title: page_title(live_action)) |> assign(page_title: page_title(live_action))
|> display_pack(id) |> display_ammo_group(id)
{:noreply, socket} {:noreply, socket}
end end
defp page_title(:add_shot_record), do: gettext("Record Shots") defp page_title(:add_shot_group), do: gettext("Record Shots")
defp page_title(:edit_shot_record), do: gettext("Edit Shot Record") defp page_title(:edit_shot_group), do: gettext("Edit Shot Records")
defp page_title(:move), do: gettext("Move Ammo") defp page_title(:move), do: gettext("Move Ammo")
defp page_title(:show), do: gettext("Show Ammo") defp page_title(:show), do: gettext("Show Ammo")
defp page_title(:edit), do: gettext("Edit Ammo") defp page_title(:edit), do: gettext("Edit Ammo")
@ -47,12 +48,12 @@ defmodule CanneryWeb.PackLive.Show do
def handle_event( def handle_event(
"delete", "delete",
_params, _params,
%{assigns: %{pack: pack, current_user: current_user}} = socket %{assigns: %{ammo_group: ammo_group, current_user: current_user}} = socket
) do ) do
pack |> Ammo.delete_pack!(current_user) ammo_group |> Ammo.delete_ammo_group!(current_user)
prompt = dgettext("prompts", "Ammo deleted succesfully") prompt = dgettext("prompts", "Ammo deleted succesfully")
redirect_to = ~p"/ammo" redirect_to = Routes.ammo_group_index_path(socket, :index)
{:noreply, socket |> put_flash(:info, prompt) |> push_navigate(to: redirect_to)} {:noreply, socket |> put_flash(:info, prompt) |> push_navigate(to: redirect_to)}
end end
@ -60,30 +61,31 @@ defmodule CanneryWeb.PackLive.Show do
def handle_event( def handle_event(
"toggle_staged", "toggle_staged",
_params, _params,
%{assigns: %{pack: pack, current_user: current_user}} = socket %{assigns: %{ammo_group: ammo_group, current_user: current_user}} = socket
) do ) do
{:ok, pack} = pack |> Ammo.update_pack(%{"staged" => !pack.staged}, current_user) {:ok, ammo_group} =
ammo_group |> Ammo.update_ammo_group(%{"staged" => !ammo_group.staged}, current_user)
{:noreply, socket |> display_pack(pack)} {:noreply, socket |> display_ammo_group(ammo_group)}
end end
def handle_event( def handle_event(
"delete_shot_record", "delete_shot_group",
%{"id" => id}, %{"id" => id},
%{assigns: %{pack: %{id: pack_id}, current_user: current_user}} = socket %{assigns: %{ammo_group: %{id: ammo_group_id}, current_user: current_user}} = socket
) do ) do
{:ok, _} = {:ok, _} =
ActivityLog.get_shot_record!(id, current_user) ActivityLog.get_shot_group!(id, current_user)
|> ActivityLog.delete_shot_record(current_user) |> ActivityLog.delete_shot_group(current_user)
prompt = dgettext("prompts", "Shot records deleted succesfully") prompt = dgettext("prompts", "Shot records deleted succesfully")
{:noreply, socket |> put_flash(:info, prompt) |> display_pack(pack_id)} {:noreply, socket |> put_flash(:info, prompt) |> display_ammo_group(ammo_group_id)}
end end
@spec display_pack(Socket.t(), Pack.t() | Pack.id()) :: Socket.t() @spec display_ammo_group(Socket.t(), AmmoGroup.t() | AmmoGroup.id()) :: Socket.t()
defp display_pack( defp display_ammo_group(
%{assigns: %{current_user: current_user}} = socket, %{assigns: %{current_user: current_user}} = socket,
%Pack{container_id: container_id} = pack %AmmoGroup{container_id: container_id} = ammo_group
) do ) do
columns = [ columns = [
%{label: gettext("Rounds shot"), key: :count}, %{label: gettext("Rounds shot"), key: :count},
@ -92,35 +94,35 @@ defmodule CanneryWeb.PackLive.Show do
%{label: gettext("Actions"), key: :actions, sortable: false} %{label: gettext("Actions"), key: :actions, sortable: false}
] ]
shot_records = ActivityLog.list_shot_records(current_user, pack_id: pack.id) shot_groups = ActivityLog.list_shot_groups_for_ammo_group(ammo_group, current_user)
rows = rows =
shot_records shot_groups
|> Enum.map(fn shot_record -> |> Enum.map(fn shot_group ->
pack |> get_table_row_for_shot_record(shot_record, columns) ammo_group |> get_table_row_for_shot_group(shot_group, columns)
end) end)
socket socket
|> assign( |> assign(
pack: pack, ammo_group: ammo_group,
original_count: Ammo.get_original_count(pack, current_user), original_count: Ammo.get_original_count(ammo_group, current_user),
percentage_remaining: Ammo.get_percentage_remaining(pack, current_user), percentage_remaining: Ammo.get_percentage_remaining(ammo_group, current_user),
container: container_id && Containers.get_container!(container_id, current_user), container: container_id && Containers.get_container!(container_id, current_user),
shot_records: shot_records, shot_groups: shot_groups,
columns: columns, columns: columns,
rows: rows rows: rows
) )
end end
defp display_pack(%{assigns: %{current_user: current_user}} = socket, id), defp display_ammo_group(%{assigns: %{current_user: current_user}} = socket, id),
do: display_pack(socket, Ammo.get_pack!(id, current_user)) do: display_ammo_group(socket, Ammo.get_ammo_group!(id, current_user))
@spec display_currency(float()) :: String.t() @spec display_currency(float()) :: String.t()
defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2) defp display_currency(float), do: :erlang.float_to_binary(float, decimals: 2)
@spec get_table_row_for_shot_record(Pack.t(), ShotRecord.t(), [map()]) :: map() @spec get_table_row_for_shot_group(AmmoGroup.t(), ShotGroup.t(), [map()]) :: map()
defp get_table_row_for_shot_record(pack, %{id: id, date: date} = shot_record, columns) do defp get_table_row_for_shot_group(ammo_group, %{id: id, date: date} = shot_group, columns) do
assigns = %{pack: pack, shot_record: shot_record} assigns = %{ammo_group: ammo_group, shot_group: shot_group}
columns columns
|> Map.new(fn %{key: key} -> |> Map.new(fn %{key: key} ->
@ -138,11 +140,11 @@ defmodule CanneryWeb.PackLive.Show do
~H""" ~H"""
<div class="px-4 py-2 space-x-4 flex justify-center items-center"> <div class="px-4 py-2 space-x-4 flex justify-center items-center">
<.link <.link
patch={~p"/ammo/show/#{@pack}/edit/#{@shot_record}"} patch={Routes.ammo_group_show_path(Endpoint, :edit_shot_group, @ammo_group, @shot_group)}
class="text-primary-600 link" class="text-primary-600 link"
aria-label={ aria-label={
dgettext("actions", "Edit shot record of %{shot_record_count} shots", dgettext("actions", "Edit shot group of %{shot_group_count} shots",
shot_record_count: @shot_record.count shot_group_count: @shot_group.count
) )
} }
> >
@ -152,12 +154,12 @@ defmodule CanneryWeb.PackLive.Show do
<.link <.link
href="#" href="#"
class="text-primary-600 link" class="text-primary-600 link"
phx-click="delete_shot_record" phx-click="delete_shot_group"
phx-value-id={@shot_record.id} phx-value-id={@shot_group.id}
data-confirm={dgettext("prompts", "Are you sure you want to delete this shot record?")} data-confirm={dgettext("prompts", "Are you sure you want to delete this shot record?")}
aria-label={ aria-label={
dgettext("actions", "Delete shot record of %{shot_record_count} shots", dgettext("actions", "Delete shot record of %{shot_group_count} shots",
shot_record_count: @shot_record.count shot_group_count: @shot_group.count
) )
} }
> >
@ -167,7 +169,7 @@ defmodule CanneryWeb.PackLive.Show do
""" """
key -> key ->
shot_record |> Map.get(key) shot_group |> Map.get(key)
end end
{key, value} {key, value}

View File

@ -1,12 +1,12 @@
<div class="mx-auto space-y-4 max-w-3xl flex flex-col justify-center items-center"> <div class="mx-auto space-y-4 max-w-3xl flex flex-col justify-center items-center">
<h1 class="title text-2xl title-primary-500"> <h1 class="title text-2xl title-primary-500">
<%= @pack.type.name %> <%= @ammo_group.ammo_type.name %>
</h1> </h1>
<div class="space-y-2 flex flex-col justify-center items-center"> <div class="space-y-2 flex flex-col justify-center items-center">
<span class="rounded-lg title text-lg"> <span class="rounded-lg title text-lg">
<%= gettext("Count:") %> <%= gettext("Count:") %>
<%= @pack.count %> <%= @ammo_group.count %>
</span> </span>
<span class="rounded-lg title text-lg"> <span class="rounded-lg title text-lg">
@ -19,28 +19,28 @@
<%= gettext("%{percentage}%", percentage: @percentage_remaining) %> <%= gettext("%{percentage}%", percentage: @percentage_remaining) %>
</span> </span>
<%= if @pack.notes do %> <%= if @ammo_group.notes do %>
<span class="rounded-lg title text-lg"> <span class="rounded-lg title text-lg">
<%= gettext("Notes:") %> <%= gettext("Notes:") %>
<%= @pack.notes %> <%= @ammo_group.notes %>
</span> </span>
<% end %> <% end %>
<span class="rounded-lg title text-lg"> <span class="rounded-lg title text-lg">
<%= gettext("Purchased on:") %> <%= gettext("Purchased on:") %>
<.date id={"#{@pack.id}-purchased-on"} date={@pack.purchased_on} /> <.date id={"#{@ammo_group.id}-purchased-on"} date={@ammo_group.purchased_on} />
</span> </span>
<%= if @pack.price_paid do %> <%= if @ammo_group.price_paid do %>
<span class="rounded-lg title text-lg"> <span class="rounded-lg title text-lg">
<%= gettext("Original cost:") %> <%= gettext("Original cost:") %>
<%= gettext("$%{amount}", amount: display_currency(@pack.price_paid)) %> <%= gettext("$%{amount}", amount: display_currency(@ammo_group.price_paid)) %>
</span> </span>
<span class="rounded-lg title text-lg"> <span class="rounded-lg title text-lg">
<%= gettext("Current value:") %> <%= gettext("Current value:") %>
<%= gettext("$%{amount}", <%= gettext("$%{amount}",
amount: display_currency(@pack.price_paid * @percentage_remaining / 100) amount: display_currency(@ammo_group.price_paid * @percentage_remaining / 100)
) %> ) %>
</span> </span>
<% end %> <% end %>
@ -48,15 +48,20 @@
<div class="flex flex-col justify-center items-center"> <div class="flex flex-col justify-center items-center">
<div class="flex flex-wrap justify-center items-center text-primary-600"> <div class="flex flex-wrap justify-center items-center text-primary-600">
<.link navigate={~p"/type/#{@pack.type}"} class="mx-4 my-2 btn btn-primary"> <.link
navigate={Routes.ammo_type_show_path(Endpoint, :show, @ammo_group.ammo_type)}
class="mx-4 my-2 btn btn-primary"
>
<%= dgettext("actions", "View in Catalog") %> <%= dgettext("actions", "View in Catalog") %>
</.link> </.link>
<.link <.link
patch={~p"/ammo/show/edit/#{@pack}"} patch={Routes.ammo_group_show_path(Endpoint, :edit, @ammo_group)}
class="mx-4 my-2 text-primary-600 link" class="mx-4 my-2 text-primary-600 link"
aria-label={ aria-label={
dgettext("actions", "Edit pack of %{pack_count} bullets", pack_count: @pack.count) 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> <i class="fa-fw fa-lg fas fa-edit"></i>
@ -68,7 +73,9 @@
phx-click="delete" phx-click="delete"
data-confirm={dgettext("prompts", "Are you sure you want to delete this ammo?")} data-confirm={dgettext("prompts", "Are you sure you want to delete this ammo?")}
aria-label={ aria-label={
dgettext("actions", "Delete pack of %{pack_count} bullets", pack_count: @pack.count) 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> <i class="fa-fw fa-lg fas fa-trash"></i>
@ -77,16 +84,22 @@
<div class="flex flex-wrap justify-center items-center text-primary-600"> <div class="flex flex-wrap justify-center items-center text-primary-600">
<button type="button" class="mx-4 my-2 btn btn-primary" phx-click="toggle_staged"> <button type="button" class="mx-4 my-2 btn btn-primary" phx-click="toggle_staged">
<%= if @pack.staged, <%= if @ammo_group.staged,
do: dgettext("actions", "Unstage from range"), do: dgettext("actions", "Unstage from range"),
else: dgettext("actions", "Stage for range") %> else: dgettext("actions", "Stage for range") %>
</button> </button>
<.link patch={~p"/ammo/show/move/#{@pack}"} class="btn btn-primary"> <.link
patch={Routes.ammo_group_show_path(Endpoint, :move, @ammo_group)}
class="btn btn-primary"
>
<%= dgettext("actions", "Move ammo") %> <%= dgettext("actions", "Move ammo") %>
</.link> </.link>
<.link patch={~p"/ammo/show/add_shot_record/#{@pack}"} class="mx-4 my-2 btn btn-primary"> <.link
patch={Routes.ammo_group_show_path(Endpoint, :add_shot_group, @ammo_group)}
class="mx-4 my-2 btn btn-primary"
>
<%= dgettext("actions", "Record shots") %> <%= dgettext("actions", "Record shots") %>
</.link> </.link>
</div> </div>
@ -106,7 +119,7 @@
<% end %> <% end %>
</div> </div>
<%= unless @shot_records |> Enum.empty?() do %> <%= unless @shot_groups |> Enum.empty?() do %>
<hr class="mb-4 w-full" /> <hr class="mb-4 w-full" />
<h1 class="mb-4 px-4 py-2 text-center rounded-lg title text-xl"> <h1 class="mb-4 px-4 py-2 text-center rounded-lg title text-xl">
@ -115,7 +128,7 @@
<.live_component <.live_component
module={CanneryWeb.Components.TableComponent} module={CanneryWeb.Components.TableComponent}
id="pack-shot-records-table" id="ammo_group_shot_groups_table"
columns={@columns} columns={@columns}
rows={@rows} rows={@rows}
/> />
@ -124,50 +137,50 @@
<%= case @live_action do %> <%= case @live_action do %>
<% :edit -> %> <% :edit -> %>
<.modal return_to={~p"/ammo/show/#{@pack}"}> <.modal return_to={Routes.ammo_group_show_path(Endpoint, :show, @ammo_group)}>
<.live_component <.live_component
module={CanneryWeb.PackLive.FormComponent} module={CanneryWeb.AmmoGroupLive.FormComponent}
id={@pack.id} id={@ammo_group.id}
title={@page_title} title={@page_title}
action={@live_action} action={@live_action}
pack={@pack} ammo_group={@ammo_group}
return_to={~p"/ammo/show/#{@pack}"} return_to={Routes.ammo_group_show_path(Endpoint, :show, @ammo_group)}
current_user={@current_user} current_user={@current_user}
/> />
</.modal> </.modal>
<% :edit_shot_record -> %> <% :edit_shot_group -> %>
<.modal return_to={~p"/ammo/show/#{@pack}"}> <.modal return_to={Routes.ammo_group_show_path(Endpoint, :show, @ammo_group)}>
<.live_component <.live_component
module={CanneryWeb.RangeLive.FormComponent} module={CanneryWeb.RangeLive.FormComponent}
id={@shot_record.id} id={@shot_group.id}
title={@page_title} title={@page_title}
action={@live_action} action={@live_action}
shot_record={@shot_record} shot_group={@shot_group}
return_to={~p"/ammo/show/#{@pack}"} return_to={Routes.ammo_group_show_path(Endpoint, :show, @ammo_group)}
current_user={@current_user} current_user={@current_user}
/> />
</.modal> </.modal>
<% :add_shot_record -> %> <% :add_shot_group -> %>
<.modal return_to={~p"/ammo/show/#{@pack}"}> <.modal return_to={Routes.ammo_group_show_path(Endpoint, :show, @ammo_group)}>
<.live_component <.live_component
module={CanneryWeb.Components.AddShotRecordComponent} module={CanneryWeb.Components.AddShotGroupComponent}
id={:new} id={:new}
title={@page_title} title={@page_title}
action={@live_action} action={@live_action}
pack={@pack} ammo_group={@ammo_group}
return_to={~p"/ammo/show/#{@pack}"} return_to={Routes.ammo_group_show_path(Endpoint, :show, @ammo_group)}
current_user={@current_user} current_user={@current_user}
/> />
</.modal> </.modal>
<% :move -> %> <% :move -> %>
<.modal return_to={~p"/ammo/show/#{@pack}"}> <.modal return_to={Routes.ammo_group_show_path(Endpoint, :show, @ammo_group)}>
<.live_component <.live_component
module={CanneryWeb.Components.MovePackComponent} module={CanneryWeb.Components.MoveAmmoGroupComponent}
id={@pack.id} id={@ammo_group.id}
title={@page_title} title={@page_title}
action={@live_action} action={@live_action}
pack={@pack} ammo_group={@ammo_group}
return_to={~p"/ammo/show/#{@pack}"} return_to={Routes.ammo_group_show_path(Endpoint, :show, @ammo_group)}
current_user={@current_user} current_user={@current_user}
/> />
</.modal> </.modal>

View File

@ -0,0 +1,98 @@
defmodule CanneryWeb.AmmoTypeLive.FormComponent do
@moduledoc """
Livecomponent that can update or create an Cannery.Ammo.AmmoType
"""
use CanneryWeb, :live_component
alias Cannery.{Accounts.User, Ammo, Ammo.AmmoType}
alias Ecto.Changeset
alias Phoenix.LiveView.Socket
@impl true
@spec update(
%{:ammo_type => AmmoType.t(), :current_user => User.t(), optional(any) => any},
Socket.t()
) :: {:ok, Socket.t()}
def update(%{current_user: _current_user} = assigns, socket) do
{:ok, socket |> assign(assigns) |> assign_changeset(%{})}
end
@impl true
def handle_event("validate", %{"ammo_type" => ammo_type_params}, socket) do
{:noreply, socket |> assign_changeset(ammo_type_params)}
end
def handle_event(
"save",
%{"ammo_type" => ammo_type_params},
%{assigns: %{action: action}} = socket
) do
save_ammo_type(socket, action, ammo_type_params)
end
defp assign_changeset(
%{assigns: %{action: action, ammo_type: ammo_type, current_user: user}} = socket,
ammo_type_params
) do
changeset_action =
case action do
create when create in [:new, :clone] -> :insert
:edit -> :update
end
changeset =
case action do
create when create in [:new, :clone] ->
ammo_type |> AmmoType.create_changeset(user, ammo_type_params)
:edit ->
ammo_type |> AmmoType.update_changeset(ammo_type_params)
end
changeset =
case changeset |> Changeset.apply_action(changeset_action) do
{:ok, _data} -> changeset
{:error, changeset} -> changeset
end
socket |> assign(changeset: changeset)
end
defp save_ammo_type(
%{assigns: %{ammo_type: ammo_type, current_user: current_user, return_to: return_to}} =
socket,
:edit,
ammo_type_params
) do
socket =
case Ammo.update_ammo_type(ammo_type, ammo_type_params, current_user) do
{:ok, %{name: ammo_type_name}} ->
prompt = dgettext("prompts", "%{name} updated successfully", name: ammo_type_name)
socket |> put_flash(:info, prompt) |> push_navigate(to: return_to)
{:error, %Changeset{} = changeset} ->
socket |> assign(:changeset, changeset)
end
{:noreply, socket}
end
defp save_ammo_type(
%{assigns: %{current_user: current_user, return_to: return_to}} = socket,
action,
ammo_type_params
)
when action in [:new, :clone] do
socket =
case Ammo.create_ammo_type(ammo_type_params, current_user) do
{:ok, %{name: ammo_type_name}} ->
prompt = dgettext("prompts", "%{name} created successfully", name: ammo_type_name)
socket |> put_flash(:info, prompt) |> push_navigate(to: return_to)
{:error, %Changeset{} = changeset} ->
socket |> assign(changeset: changeset)
end
{:noreply, socket}
end
end

View File

@ -0,0 +1,164 @@
<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>

View File

@ -0,0 +1,92 @@
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

View File

@ -3,61 +3,35 @@
<%= gettext("Catalog") %> <%= gettext("Catalog") %>
</h1> </h1>
<%= if @types_count == 0 do %> <%= if @ammo_types |> Enum.empty?() and @search |> is_nil() do %>
<h2 class="title text-xl text-primary-600"> <h2 class="title text-xl text-primary-600">
<%= gettext("No Types") %> <%= gettext("No Ammo types") %>
<%= display_emoji("😔") %> <%= display_emoji("😔") %>
</h2> </h2>
<.link patch={~p"/catalog/new"} class="btn btn-primary"> <.link patch={Routes.ammo_type_index_path(Endpoint, :new)} class="btn btn-primary">
<%= dgettext("actions", "Add your first type!") %> <%= dgettext("actions", "Add your first type!") %>
</.link> </.link>
<% else %> <% else %>
<.link patch={~p"/catalog/new"} class="btn btn-primary"> <.link patch={Routes.ammo_type_index_path(Endpoint, :new)} class="btn btn-primary">
<%= dgettext("actions", "New Type") %> <%= dgettext("actions", "New Ammo type") %>
</.link> </.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-2xl"> <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={: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>
<.form <.form
:let={f} :let={f}
for={%{}} for={%{}}
as={:search} as={:search}
phx-change="search" phx-change="search"
phx-submit="search" phx-submit="search"
class="grow flex items-center" class="grow self-stretch flex flex-col items-stretch"
> >
<%= text_input(f, :search_term, <%= text_input(f, :search_term,
class: "grow input input-primary", class: "input input-primary",
phx_debounce: 300, value: @search,
placeholder: gettext("Search catalog"),
role: "search", role: "search",
value: @search phx_debounce: 300,
placeholder: gettext("Search catalog")
) %> ) %>
</.form> </.form>
@ -68,43 +42,48 @@
</.toggle_button> </.toggle_button>
</div> </div>
<%= if @types |> Enum.empty?() do %> <%= if @ammo_types |> Enum.empty?() do %>
<h2 class="title text-xl text-primary-600"> <h2 class="title text-xl text-primary-600">
<%= gettext("No Types") %> <%= gettext("No Ammo types") %>
<%= display_emoji("😔") %> <%= display_emoji("😔") %>
</h2> </h2>
<% else %> <% else %>
<.live_component <.live_component
module={CanneryWeb.Components.TypeTableComponent} module={CanneryWeb.Components.AmmoTypeTableComponent}
id="types-index-table" id="ammo_types_index_table"
action={@live_action} action={@live_action}
types={@types} ammo_types={@ammo_types}
current_user={@current_user} current_user={@current_user}
show_used={@show_used} show_used={@show_used}
class={@class}
> >
<:actions :let={type}> <:actions :let={ammo_type}>
<div class="px-4 py-2 space-x-4 flex justify-center items-center"> <div class="px-4 py-2 space-x-4 flex justify-center items-center">
<.link <.link
navigate={~p"/type/#{type}"} navigate={Routes.ammo_type_show_path(Endpoint, :show, ammo_type)}
class="text-primary-600 link" class="text-primary-600 link"
aria-label={dgettext("actions", "View %{type_name}", type_name: type.name)} aria-label={
dgettext("actions", "View %{ammo_type_name}", ammo_type_name: ammo_type.name)
}
> >
<i class="fa-fw fa-lg fas fa-eye"></i> <i class="fa-fw fa-lg fas fa-eye"></i>
</.link> </.link>
<.link <.link
patch={~p"/catalog/edit/#{type}"} patch={Routes.ammo_type_index_path(Endpoint, :edit, ammo_type)}
class="text-primary-600 link" class="text-primary-600 link"
aria-label={dgettext("actions", "Edit %{type_name}", type_name: type.name)} aria-label={
dgettext("actions", "Edit %{ammo_type_name}", ammo_type_name: ammo_type.name)
}
> >
<i class="fa-fw fa-lg fas fa-edit"></i> <i class="fa-fw fa-lg fas fa-edit"></i>
</.link> </.link>
<.link <.link
patch={~p"/catalog/clone/#{type}"} patch={Routes.ammo_type_index_path(Endpoint, :clone, ammo_type)}
class="text-primary-600 link" class="text-primary-600 link"
aria-label={dgettext("actions", "Clone %{type_name}", type_name: type.name)} aria-label={
dgettext("actions", "Clone %{ammo_type_name}", ammo_type_name: ammo_type.name)
}
> >
<i class="fa-fw fa-lg fas fa-copy"></i> <i class="fa-fw fa-lg fas fa-copy"></i>
</.link> </.link>
@ -113,15 +92,17 @@
href="#" href="#"
class="text-primary-600 link" class="text-primary-600 link"
phx-click="delete" phx-click="delete"
phx-value-id={type.id} phx-value-id={ammo_type.id}
data-confirm={ data-confirm={
dgettext( dgettext(
"prompts", "prompts",
"Are you sure you want to delete %{name}? This will delete all %{name} type ammo as well!", "Are you sure you want to delete %{name}? This will delete all %{name} type ammo as well!",
name: type.name name: ammo_type.name
) )
} }
aria-label={dgettext("actions", "Delete %{type_name}", type_name: type.name)} aria-label={
dgettext("actions", "Delete %{ammo_type_name}", ammo_type_name: ammo_type.name)
}
> >
<i class="fa-lg fas fa-trash"></i> <i class="fa-lg fas fa-trash"></i>
</.link> </.link>
@ -132,14 +113,17 @@
<% end %> <% end %>
</div> </div>
<.modal :if={@live_action in [:new, :edit, :clone]} return_to={~p"/catalog"}> <.modal
:if={@live_action in [:new, :edit, :clone]}
return_to={Routes.ammo_type_index_path(Endpoint, :index)}
>
<.live_component <.live_component
module={CanneryWeb.TypeLive.FormComponent} module={CanneryWeb.AmmoTypeLive.FormComponent}
id={@type.id || :new} id={@ammo_type.id || :new}
title={@page_title} title={@page_title}
action={@live_action} action={@live_action}
type={@type} ammo_type={@ammo_type}
return_to={~p"/catalog"} return_to={Routes.ammo_type_index_path(Endpoint, :index)}
current_user={@current_user} current_user={@current_user}
} }
/> />

View File

@ -0,0 +1,143 @@
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, Containers}
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, socket),
do: {:ok, socket |> assign(show_used: false, view_table: true)}
@impl true
def handle_params(%{"id" => id}, _params, socket) do
{:noreply, socket |> display_ammo_type(id)}
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}} = socket) do
{:noreply, socket |> assign(:view_table, !view_table)}
end
defp display_ammo_type(
%{assigns: %{live_action: live_action, current_user: current_user, show_used: show_used}} =
socket,
%AmmoType{name: ammo_type_name} = 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
page_title =
case live_action do
:show -> ammo_type_name
:edit -> gettext("Edit %{ammo_type_name}", ammo_type_name: ammo_type_name)
end
containers =
ammo_groups
|> Enum.map(fn %{container_id: container_id} -> container_id end)
|> Containers.get_containers(current_user)
socket
|> assign(
page_title: page_title,
ammo_type: ammo_type,
ammo_groups: ammo_groups,
containers: containers,
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)
end

View File

@ -1,22 +1,22 @@
<div class="space-y-4 flex flex-col justify-center items-center"> <div class="space-y-4 flex flex-col justify-center items-center">
<h1 class="title text-2xl title-primary-500"> <h1 class="title text-2xl title-primary-500">
<%= @type.name %> <%= @ammo_type.name %>
</h1> </h1>
<span <span
:if={@type.desc} :if={@ammo_type.desc}
class="max-w-2xl w-full px-8 py-4 rounded-lg class="max-w-2xl w-full px-8 py-4 rounded-lg
text-center title text-lg text-center title text-lg
border border-primary-600" border border-primary-600"
> >
<%= @type.desc %> <%= @ammo_type.desc %>
</span> </span>
<div class="flex space-x-4 justify-center items-center text-primary-600"> <div class="flex space-x-4 justify-center items-center text-primary-600">
<.link <.link
patch={~p"/type/#{@type}/edit"} patch={Routes.ammo_type_show_path(Endpoint, :edit, @ammo_type)}
class="text-primary-600 link" class="text-primary-600 link"
aria-label={dgettext("actions", "Edit %{type_name}", type_name: @type.name)} aria-label={dgettext("actions", "Edit %{ammo_type_name}", ammo_type_name: @ammo_type.name)}
> >
<i class="fa-fw fa-lg fas fa-edit"></i> <i class="fa-fw fa-lg fas fa-edit"></i>
</.link> </.link>
@ -29,10 +29,12 @@
dgettext( dgettext(
"prompts", "prompts",
"Are you sure you want to delete %{name}? This will delete all %{name} type ammo as well!", "Are you sure you want to delete %{name}? This will delete all %{name} type ammo as well!",
name: @type.name name: @ammo_type.name
) )
} }
aria-label={dgettext("actions", "Delete %{type_name}", type_name: @type.name)} aria-label={
dgettext("actions", "Delete %{ammo_type_name}", ammo_type_name: @ammo_type.name)
}
> >
<i class="fa-fw fa-lg fas fa-trash"></i> <i class="fa-fw fa-lg fas fa-trash"></i>
</.link> </.link>
@ -40,27 +42,10 @@
<hr class="hr" /> <hr class="hr" />
<%= if @type.class || @custom_fields? do %> <%= if @fields_to_display do %>
<div class="grid sm:grid-cols-2 gap-4 text-center justify-center items-center"> <div class="grid sm:grid-cols-2 gap-4 text-center justify-center items-center">
<h3 class="title text-lg"> <%= for %{label: label, key: key, type: type} <- @fields_list do %>
<%= gettext("Class") %> <%= if @ammo_type |> Map.get(key) do %>
</h3>
<span class="text-primary-600">
<%= case @type.class do %>
<% :shotgun -> %>
<%= gettext("Shotgun") %>
<% :rifle -> %>
<%= gettext("Rifle") %>
<% :pistol -> %>
<%= gettext("Pistol") %>
<% _ -> %>
<%= gettext("None specified") %>
<% end %>
</span>
<%= for %{label: label, key: key, type: type} <- @fields_to_display do %>
<%= if @type |> Map.get(key) do %>
<h3 class="title text-lg"> <h3 class="title text-lg">
<%= label %> <%= label %>
</h3> </h3>
@ -68,9 +53,9 @@
<span class="text-primary-600"> <span class="text-primary-600">
<%= case type do %> <%= case type do %>
<% :boolean -> %> <% :boolean -> %>
<%= @type |> Map.get(key) |> humanize() %> <%= @ammo_type |> Map.get(key) |> humanize() %>
<% _ -> %> <% _ -> %>
<%= @type |> Map.get(key) %> <%= @ammo_type |> Map.get(key) %>
<% end %> <% end %>
</span> </span>
<% end %> <% end %>
@ -138,7 +123,7 @@
</h3> </h3>
<span class="text-primary-600"> <span class="text-primary-600">
<.datetime id={"#{@type.id}-inserted-at"} datetime={@type.inserted_at} /> <.datetime id={"#{@ammo_type.id}-inserted-at"} datetime={@ammo_type.inserted_at} />
</span> </span>
<%= if @avg_cost_per_round do %> <%= if @avg_cost_per_round do %>
@ -173,7 +158,7 @@
</div> </div>
<div class="w-full p-4"> <div class="w-full p-4">
<%= if @packs |> Enum.empty?() do %> <%= if @ammo_groups |> Enum.empty?() do %>
<h2 class="px-4 title text-lg text-primary-600"> <h2 class="px-4 title text-lg text-primary-600">
<%= gettext("No ammo for this type") %> <%= gettext("No ammo for this type") %>
<%= display_emoji("😔") %> <%= display_emoji("😔") %>
@ -181,25 +166,28 @@
<% else %> <% else %>
<%= if @view_table do %> <%= if @view_table do %>
<.live_component <.live_component
module={CanneryWeb.Components.PackTableComponent} module={CanneryWeb.Components.AmmoGroupTableComponent}
id="type-show-table" id="ammo-type-show-table"
packs={@packs} ammo_groups={@ammo_groups}
current_user={@current_user} current_user={@current_user}
show_used={@show_used} show_used={@show_used}
> >
<:container :let={{_pack, %{name: container_name} = container}}> <:container :let={{_ammo_group, %{name: container_name} = container}}>
<.link navigate={~p"/container/#{container}"} class="mx-2 my-1 link"> <.link
navigate={Routes.container_show_path(Endpoint, :show, container)}
class="mx-2 my-1 link"
>
<%= container_name %> <%= container_name %>
</.link> </.link>
</:container> </:container>
<:actions :let={%{count: pack_count} = pack}> <:actions :let={%{count: ammo_group_count} = ammo_group}>
<div class="py-2 px-4 h-full space-x-4 flex justify-center items-center"> <div class="py-2 px-4 h-full space-x-4 flex justify-center items-center">
<.link <.link
navigate={~p"/ammo/show/#{pack}"} navigate={Routes.ammo_group_show_path(Endpoint, :show, ammo_group)}
class="text-primary-600 link" class="text-primary-600 link"
aria-label={ aria-label={
dgettext("actions", "View pack of %{pack_count} bullets", dgettext("actions", "View ammo group of %{ammo_group_count} bullets",
pack_count: pack_count ammo_group_count: ammo_group_count
) )
} }
> >
@ -210,12 +198,12 @@
</.live_component> </.live_component>
<% else %> <% else %>
<div class="flex flex-wrap justify-center items-stretch"> <div class="flex flex-wrap justify-center items-stretch">
<.pack_card <.ammo_group_card
:for={%{id: pack_id, container_id: container_id} = pack <- @packs} :for={%{id: ammo_group_id, container_id: container_id} = ammo_group <- @ammo_groups}
pack={pack} ammo_group={ammo_group}
original_count={@original_counts && Map.fetch!(@original_counts, pack_id)} original_count={@original_counts && Map.fetch!(@original_counts, ammo_group_id)}
cpr={Map.get(@cprs, pack_id)} cpr={Map.get(@cprs, ammo_group_id)}
last_used_date={Map.get(@last_used_dates, pack_id)} last_used_date={Map.get(@last_used_dates, ammo_group_id)}
current_user={@current_user} current_user={@current_user}
container={Map.fetch!(@containers, container_id)} container={Map.fetch!(@containers, container_id)}
/> />
@ -225,14 +213,17 @@
</div> </div>
</div> </div>
<.modal :if={@live_action == :edit} return_to={~p"/type/#{@type}"}> <.modal
:if={@live_action == :edit}
return_to={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)}
>
<.live_component <.live_component
module={CanneryWeb.TypeLive.FormComponent} module={CanneryWeb.AmmoTypeLive.FormComponent}
id={@type.id} id={@ammo_type.id}
title={@page_title} title={@page_title}
action={@live_action} action={@live_action}
type={@type} ammo_type={@ammo_type}
return_to={~p"/type/#{@type}"} return_to={Routes.ammo_type_show_path(Endpoint, :show, @ammo_type)}
current_user={@current_user} current_user={@current_user}
/> />
</.modal> </.modal>

View File

@ -19,7 +19,7 @@ defmodule CanneryWeb.ContainerLive.FormComponent do
@impl true @impl true
def handle_event("validate", %{"container" => container_params}, socket) do def handle_event("validate", %{"container" => container_params}, socket) do
{:noreply, socket |> assign_changeset(container_params, :validate)} {:noreply, socket |> assign_changeset(container_params)}
end end
def handle_event( def handle_event(
@ -32,9 +32,14 @@ defmodule CanneryWeb.ContainerLive.FormComponent do
defp assign_changeset( defp assign_changeset(
%{assigns: %{action: action, container: container, current_user: user}} = socket, %{assigns: %{action: action, container: container, current_user: user}} = socket,
container_params, container_params
changeset_action \\ nil
) do ) do
changeset_action =
case action do
create when create in [:new, :clone] -> :insert
:edit -> :update
end
changeset = changeset =
case action do case action do
create when create in [:new, :clone] -> create when create in [:new, :clone] ->
@ -45,14 +50,10 @@ defmodule CanneryWeb.ContainerLive.FormComponent do
end end
changeset = changeset =
if changeset_action do
case changeset |> Changeset.apply_action(changeset_action) do case changeset |> Changeset.apply_action(changeset_action) do
{:ok, _data} -> changeset {:ok, _data} -> changeset
{:error, changeset} -> changeset {:error, changeset} -> changeset
end end
else
changeset
end
socket |> assign(:changeset, changeset) socket |> assign(:changeset, changeset)
end end

View File

@ -12,7 +12,7 @@
phx-submit="save" phx-submit="save"
> >
<div <div
:if={@changeset.action && not @changeset.valid?} :if={@changeset.action && not @changeset.valid?()}
class="invalid-feedback col-span-3 text-center" class="invalid-feedback col-span-3 text-center"
> >
<%= changeset_errors(@changeset) %> <%= changeset_errors(@changeset) %>
@ -21,38 +21,34 @@
<%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %> <%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :name, <%= text_input(f, :name,
class: "input input-primary col-span-2", class: "input input-primary col-span-2",
maxlength: 255,
phx_debounce: 300,
placeholder: gettext("My cool ammo can") placeholder: gettext("My cool ammo can")
) %> ) %>
<%= error_tag(f, :name, "col-span-3 text-center") %> <%= error_tag(f, :name, "col-span-3 text-center") %>
<%= label(f, :desc, gettext("Description"), class: "title text-lg text-primary-600") %> <%= label(f, :desc, gettext("Description"), class: "title text-lg text-primary-600") %>
<%= textarea(f, :desc, <%= textarea(f, :desc,
class: "input input-primary col-span-2",
id: "container-form-desc", id: "container-form-desc",
phx_debounce: 300, class: "input input-primary col-span-2",
phx_update: "ignore", placeholder: gettext("Metal ammo can with the anime girl sticker"),
placeholder: gettext("Metal ammo can with the anime girl sticker") phx_hook: "MaintainAttrs",
phx_update: "ignore"
) %> ) %>
<%= error_tag(f, :desc, "col-span-3 text-center") %> <%= error_tag(f, :desc, "col-span-3 text-center") %>
<%= label(f, :type, gettext("Type"), class: "title text-lg text-primary-600") %> <%= label(f, :type, gettext("Type"), class: "title text-lg text-primary-600") %>
<%= text_input(f, :type, <%= text_input(f, :type,
class: "input input-primary col-span-2", class: "input input-primary col-span-2",
maxlength: 255,
phx_debounce: 300,
placeholder: gettext("Magazine, Clip, Ammo Box, etc") placeholder: gettext("Magazine, Clip, Ammo Box, etc")
) %> ) %>
<%= error_tag(f, :type, "col-span-3 text-center") %> <%= error_tag(f, :type, "col-span-3 text-center") %>
<%= label(f, :location, gettext("Location"), class: "title text-lg text-primary-600") %> <%= label(f, :location, gettext("Location"), class: "title text-lg text-primary-600") %>
<%= textarea(f, :location, <%= textarea(f, :location,
class: "input input-primary col-span-2",
id: "container-form-location", id: "container-form-location",
phx_debounce: 300, class: "input input-primary col-span-2",
phx_update: "ignore", placeholder: gettext("On the bookshelf"),
placeholder: gettext("On the bookshelf") phx_hook: "MaintainAttrs",
phx_update: "ignore"
) %> ) %>
<%= error_tag(f, :location, "col-span-3 text-center") %> <%= error_tag(f, :location, "col-span-3 text-center") %>

View File

@ -79,15 +79,15 @@ defmodule CanneryWeb.ContainerLive.Index do
prompt = dgettext("prompts", "%{name} has been deleted", name: container_name) prompt = dgettext("prompts", "%{name} has been deleted", name: container_name)
socket |> put_flash(:info, prompt) |> display_containers() socket |> put_flash(:info, prompt) |> display_containers()
{:error, %{action: :delete, errors: [packs: _error], valid?: false} = changeset} -> {:error, %{action: :delete, errors: [ammo_groups: _error], valid?: false} = changeset} ->
packs_error = changeset |> changeset_errors(:packs) |> Enum.join(", ") ammo_groups_error = changeset |> changeset_errors(:ammo_groups) |> Enum.join(", ")
prompt = prompt =
dgettext( dgettext(
"errors", "errors",
"Could not delete %{name}: %{error}", "Could not delete %{name}: %{error}",
name: changeset |> Changeset.get_field(:name, "container"), name: changeset |> Changeset.get_field(:name, "container"),
error: packs_error error: ammo_groups_error
) )
socket |> put_flash(:error, prompt) socket |> put_flash(:error, prompt)
@ -105,14 +105,15 @@ defmodule CanneryWeb.ContainerLive.Index do
end end
def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do
{:noreply, socket |> push_patch(to: ~p"/containers")} {:noreply, socket |> push_patch(to: Routes.container_index_path(Endpoint, :index))}
end end
def handle_event("search", %{"search" => %{"search_term" => search_term}}, socket) do def handle_event("search", %{"search" => %{"search_term" => search_term}}, socket) do
{:noreply, socket |> push_patch(to: ~p"/containers/search/#{search_term}")} {:noreply,
socket |> push_patch(to: Routes.container_index_path(Endpoint, :search, search_term))}
end end
defp display_containers(%{assigns: %{search: search, current_user: current_user}} = socket) do defp display_containers(%{assigns: %{search: search, current_user: current_user}} = socket) do
socket |> assign(:containers, Containers.list_containers(current_user, search: search)) socket |> assign(:containers, Containers.list_containers(search, current_user))
end end
end end

View File

@ -9,29 +9,29 @@
<%= display_emoji("😔") %> <%= display_emoji("😔") %>
</h2> </h2>
<.link patch={~p"/containers/new"} class="btn btn-primary"> <.link patch={Routes.container_index_path(Endpoint, :new)} class="btn btn-primary">
<%= dgettext("actions", "Add your first container!") %> <%= dgettext("actions", "Add your first container!") %>
</.link> </.link>
<% else %> <% else %>
<.link patch={~p"/containers/new"} class="btn btn-primary"> <.link patch={Routes.container_index_path(Endpoint, :new)} class="btn btn-primary">
<%= dgettext("actions", "New Container") %> <%= dgettext("actions", "New Container") %>
</.link> </.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-2xl"> <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 <.form
:let={f} :let={f}
for={%{}} for={%{}}
as={:search} as={:search}
phx-change="search" phx-change="search"
phx-submit="search" phx-submit="search"
class="grow flex items-center" class="grow self-stretch flex flex-col items-stretch"
> >
<%= text_input(f, :search_term, <%= text_input(f, :search_term,
class: "grow input input-primary", class: "input input-primary",
phx_debounce: 300, value: @search,
placeholder: gettext("Search containers"),
role: "search", role: "search",
value: @search phx_debounce: 300,
placeholder: gettext("Search containers")
) %> ) %>
</.form> </.form>
@ -41,6 +41,7 @@
</span> </span>
</.toggle_button> </.toggle_button>
</div> </div>
<% end %>
<%= if @containers |> Enum.empty?() do %> <%= if @containers |> Enum.empty?() do %>
<h2 class="title text-xl text-primary-600"> <h2 class="title text-xl text-primary-600">
@ -51,7 +52,7 @@
<%= if @view_table do %> <%= if @view_table do %>
<.live_component <.live_component
module={CanneryWeb.Components.ContainerTableComponent} module={CanneryWeb.Components.ContainerTableComponent}
id="containers-index-table" id="containers_index_table"
action={@live_action} action={@live_action}
containers={@containers} containers={@containers}
current_user={@current_user} current_user={@current_user}
@ -59,7 +60,7 @@
<:tag_actions :let={container}> <:tag_actions :let={container}>
<div class="mx-4 my-2"> <div class="mx-4 my-2">
<.link <.link
patch={~p"/containers/edit_tags/#{container}"} patch={Routes.container_index_path(Endpoint, :edit_tags, container)}
class="text-primary-600 link" class="text-primary-600 link"
aria-label={ aria-label={
dgettext("actions", "Tag %{container_name}", container_name: container.name) dgettext("actions", "Tag %{container_name}", container_name: container.name)
@ -71,7 +72,7 @@
</:tag_actions> </:tag_actions>
<:actions :let={container}> <:actions :let={container}>
<.link <.link
patch={~p"/containers/edit/#{container}"} patch={Routes.container_index_path(Endpoint, :edit, container)}
class="text-primary-600 link" class="text-primary-600 link"
aria-label={ aria-label={
dgettext("actions", "Edit %{container_name}", container_name: container.name) dgettext("actions", "Edit %{container_name}", container_name: container.name)
@ -81,7 +82,7 @@
</.link> </.link>
<.link <.link
patch={~p"/containers/clone/#{container}"} patch={Routes.container_index_path(Endpoint, :clone, container)}
class="text-primary-600 link" class="text-primary-600 link"
aria-label={ aria-label={
dgettext("actions", "Clone %{container_name}", container_name: container.name) dgettext("actions", "Clone %{container_name}", container_name: container.name)
@ -96,9 +97,7 @@
phx-click="delete" phx-click="delete"
phx-value-id={container.id} phx-value-id={container.id}
data-confirm={ data-confirm={
dgettext("prompts", "Are you sure you want to delete %{name}?", dgettext("prompts", "Are you sure you want to delete %{name}?", name: container.name)
name: container.name
)
} }
aria-label={ aria-label={
dgettext("actions", "Delete %{container_name}", container_name: container.name) dgettext("actions", "Delete %{container_name}", container_name: container.name)
@ -118,7 +117,7 @@
<:tag_actions> <:tag_actions>
<div class="mx-4 my-2"> <div class="mx-4 my-2">
<.link <.link
patch={~p"/containers/edit_tags/#{container}"} patch={Routes.container_index_path(Endpoint, :edit_tags, container)}
class="text-primary-600 link" class="text-primary-600 link"
aria-label={ aria-label={
dgettext("actions", "Tag %{container_name}", container_name: container.name) dgettext("actions", "Tag %{container_name}", container_name: container.name)
@ -129,7 +128,7 @@
</div> </div>
</:tag_actions> </:tag_actions>
<.link <.link
patch={~p"/containers/edit/#{container}"} patch={Routes.container_index_path(Endpoint, :edit, container)}
class="text-primary-600 link" class="text-primary-600 link"
aria-label={ aria-label={
dgettext("actions", "Edit %{container_name}", container_name: container.name) dgettext("actions", "Edit %{container_name}", container_name: container.name)
@ -139,7 +138,7 @@
</.link> </.link>
<.link <.link
patch={~p"/containers/clone/#{container}"} patch={Routes.container_index_path(Endpoint, :clone, container)}
class="text-primary-600 link" class="text-primary-600 link"
aria-label={ aria-label={
dgettext("actions", "Clone %{container_name}", container_name: container.name) dgettext("actions", "Clone %{container_name}", container_name: container.name)
@ -154,9 +153,7 @@
phx-click="delete" phx-click="delete"
phx-value-id={container.id} phx-value-id={container.id}
data-confirm={ data-confirm={
dgettext("prompts", "Are you sure you want to delete %{name}?", dgettext("prompts", "Are you sure you want to delete %{name}?", name: container.name)
name: container.name
)
} }
aria-label={ aria-label={
dgettext("actions", "Delete %{container_name}", container_name: container.name) dgettext("actions", "Delete %{container_name}", container_name: container.name)
@ -168,31 +165,30 @@
</div> </div>
<% end %> <% end %>
<% end %> <% end %>
<% end %>
</div> </div>
<%= case @live_action do %> <%= case @live_action do %>
<% modifying when modifying in [:new, :edit, :clone] -> %> <% modifying when modifying in [:new, :edit, :clone] -> %>
<.modal return_to={~p"/containers"}> <.modal return_to={Routes.container_index_path(Endpoint, :index)}>
<.live_component <.live_component
module={CanneryWeb.ContainerLive.FormComponent} module={CanneryWeb.ContainerLive.FormComponent}
id={@container.id || :new} id={@container.id || :new}
title={@page_title} title={@page_title}
action={@live_action} action={@live_action}
container={@container} container={@container}
return_to={~p"/containers"} return_to={Routes.container_index_path(Endpoint, :index)}
current_user={@current_user} current_user={@current_user}
/> />
</.modal> </.modal>
<% :edit_tags -> %> <% :edit_tags -> %>
<.modal return_to={~p"/containers"}> <.modal return_to={Routes.container_index_path(Endpoint, :index)}>
<.live_component <.live_component
module={CanneryWeb.ContainerLive.EditTagsComponent} module={CanneryWeb.ContainerLive.EditTagsComponent}
id={@container.id} id={@container.id}
title={@page_title} title={@page_title}
action={@live_action} action={@live_action}
container={@container} container={@container}
current_path={~p"/containers/edit_tags/#{@container}"} current_path={Routes.container_index_path(Endpoint, :edit_tags, @container)}
current_user={@current_user} current_user={@current_user}
/> />
</.modal> </.modal>

View File

@ -5,18 +5,19 @@ defmodule CanneryWeb.ContainerLive.Show do
use CanneryWeb, :live_view use CanneryWeb, :live_view
alias Cannery.{Accounts.User, ActivityLog, Ammo, Containers, Containers.Container} alias Cannery.{Accounts.User, ActivityLog, Ammo, Containers, Containers.Container}
alias CanneryWeb.Endpoint
alias Ecto.Changeset alias Ecto.Changeset
alias Phoenix.LiveView.Socket alias Phoenix.LiveView.Socket
@impl true @impl true
def mount(_params, _session, socket), def mount(_params, _session, socket),
do: {:ok, socket |> assign(class: :all, view_table: true)} do: {:ok, socket |> assign(show_used: false, view_table: true)}
@impl true @impl true
def handle_params(%{"id" => id}, _session, %{assigns: %{current_user: current_user}} = socket) do def handle_params(%{"id" => id}, _session, %{assigns: %{current_user: current_user}} = socket) do
socket = socket =
socket socket
|> assign(:view_table, true) |> assign(view_table: true)
|> render_container(id, current_user) |> render_container(id, current_user)
{:noreply, socket} {:noreply, socket}
@ -58,15 +59,18 @@ defmodule CanneryWeb.ContainerLive.Show do
|> case do |> case do
{:ok, %{name: container_name}} -> {:ok, %{name: container_name}} ->
prompt = dgettext("prompts", "%{name} has been deleted", name: container_name) prompt = dgettext("prompts", "%{name} has been deleted", name: container_name)
socket |> put_flash(:info, prompt) |> push_navigate(to: ~p"/containers")
{:error, %{action: :delete, errors: [packs: _error], valid?: false} = changeset} -> socket
packs_error = changeset |> changeset_errors(:packs) |> Enum.join(", ") |> 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(", ")
prompt = prompt =
dgettext("errors", "Could not delete %{name}: %{error}", dgettext("errors", "Could not delete %{name}: %{error}",
name: changeset |> Changeset.get_field(:name, "container"), name: changeset |> Changeset.get_field(:name, "container"),
error: packs_error error: ammo_groups_error
) )
socket |> put_flash(:error, prompt) socket |> put_flash(:error, prompt)
@ -78,39 +82,25 @@ defmodule CanneryWeb.ContainerLive.Show do
{:noreply, socket} {:noreply, socket}
end 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 def handle_event("toggle_table", _params, %{assigns: %{view_table: view_table}} = socket) do
{:noreply, socket |> assign(:view_table, !view_table) |> render_container()} {:noreply, socket |> assign(:view_table, !view_table) |> render_container()}
end 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() @spec render_container(Socket.t(), Container.id(), User.t()) :: Socket.t()
defp render_container( defp render_container(
%{assigns: %{class: class, live_action: live_action}} = socket, %{assigns: %{live_action: live_action, show_used: show_used}} = socket,
id, id,
current_user current_user
) do ) do
%{id: container_id, name: container_name} = %{name: container_name} = container = Containers.get_container!(id, current_user)
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)
packs = Ammo.list_packs(current_user, container_id: container_id, class: class) cprs = ammo_groups |> Ammo.get_cprs(current_user)
original_counts = packs |> Ammo.get_original_counts(current_user) last_used_dates = ammo_groups |> ActivityLog.get_last_used_dates(current_user)
cprs = packs |> Ammo.get_cprs(current_user)
last_used_dates = packs |> ActivityLog.get_last_used_dates(current_user)
page_title = page_title =
case live_action do case live_action do
@ -122,9 +112,8 @@ defmodule CanneryWeb.ContainerLive.Show do
socket socket
|> assign( |> assign(
container: container, container: container,
round_count: Ammo.get_round_count(current_user, container_id: container.id), round_count: Ammo.get_round_count_for_container!(container, current_user),
packs_count: Ammo.get_packs_count(current_user, container_id: container.id), ammo_groups: ammo_groups,
packs: packs,
original_counts: original_counts, original_counts: original_counts,
cprs: cprs, cprs: cprs,
last_used_dates: last_used_dates, last_used_dates: last_used_dates,

View File

@ -18,19 +18,26 @@
<%= @container.location %> <%= @container.location %>
</span> </span>
<%= unless @ammo_groups |> Enum.empty?() do %>
<span class="rounded-lg title text-lg"> <span class="rounded-lg title text-lg">
<%= gettext("Packs:") %> <%= gettext("Packs:") %>
<%= @packs_count %> <%= @ammo_groups |> Enum.reject(fn %{count: count} -> count in [0, nil] end) |> Enum.count() %>
</span>
<span :if={@show_used} class="rounded-lg title text-lg">
<%= gettext("Total packs:") %>
<%= Enum.count(@ammo_groups) %>
</span> </span>
<span class="rounded-lg title text-lg"> <span class="rounded-lg title text-lg">
<%= gettext("Rounds:") %> <%= gettext("Rounds:") %>
<%= @round_count %> <%= @round_count %>
</span> </span>
<% end %>
<div class="flex space-x-4 justify-center items-center text-primary-600"> <div class="flex space-x-4 justify-center items-center text-primary-600">
<.link <.link
patch={~p"/container/edit/#{@container}"} patch={Routes.container_show_path(Endpoint, :edit, @container)}
class="text-primary-600 link" class="text-primary-600 link"
aria-label={dgettext("actions", "Edit %{container_name}", container_name: @container.name)} aria-label={dgettext("actions", "Edit %{container_name}", container_name: @container.name)}
> >
@ -61,7 +68,10 @@
<%= display_emoji("😔") %> <%= display_emoji("😔") %>
</h2> </h2>
<.link patch={~p"/container/edit_tags/#{@container}"} class="btn btn-primary"> <.link
patch={Routes.container_show_path(Endpoint, :edit_tags, @container)}
class="btn btn-primary"
>
<%= dgettext("actions", "Why not add one?") %> <%= dgettext("actions", "Why not add one?") %>
</.link> </.link>
</div> </div>
@ -70,7 +80,10 @@
<.simple_tag_card :for={tag <- @container.tags} tag={tag} /> <.simple_tag_card :for={tag <- @container.tags} tag={tag} />
<div class="mx-4 my-2"> <div class="mx-4 my-2">
<.link patch={~p"/container/edit_tags/#{@container}"} class="text-primary-600 link"> <.link
patch={Routes.container_show_path(Endpoint, :edit_tags, @container)}
class="text-primary-600 link"
>
<i class="fa-fw fa-lg fas fa-tags"></i> <i class="fa-fw fa-lg fas fa-tags"></i>
</.link> </.link>
</div> </div>
@ -80,29 +93,11 @@
<hr class="mb-4 hr" /> <hr class="mb-4 hr" />
<div class="flex justify-center items-center space-x-4"> <div class="flex justify-center items-center space-x-4">
<.form <.toggle_button action="toggle_show_used" value={@show_used}>
:let={f} <span class="title text-lg text-primary-600">
for={%{}} <%= gettext("Show used") %>
as={:type} </span>
phx-change="change_class" </.toggle_button>
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}> <.toggle_button action="toggle_table" value={@view_table}>
<span class="title text-lg text-primary-600"> <span class="title text-lg text-primary-600">
@ -112,48 +107,33 @@
</div> </div>
<div class="w-full p-4"> <div class="w-full p-4">
<%= if @packs |> Enum.empty?() do %> <%= if @ammo_groups |> Enum.empty?() do %>
<h2 class="mx-4 title text-lg text-primary-600 text-center"> <h2 class="mx-4 title text-lg text-primary-600 text-center">
<%= gettext("No ammo in this container") %> <%= gettext("No ammo in this container") %>
</h2> </h2>
<% else %> <% else %>
<%= if @view_table do %> <%= if @view_table do %>
<.live_component <.live_component
module={CanneryWeb.Components.PackTableComponent} module={CanneryWeb.Components.AmmoGroupTableComponent}
id="pack-show-table" id="ammo-type-show-table"
packs={@packs} ammo_groups={@ammo_groups}
current_user={@current_user} current_user={@current_user}
show_used={false} show_used={@show_used}
> >
<:type :let={%{name: type_name} = type}> <:ammo_type :let={%{name: ammo_type_name} = ammo_type}>
<.link navigate={~p"/type/#{type}"} class="link"> <.link navigate={Routes.ammo_type_show_path(Endpoint, :show, ammo_type)} class="link">
<%= type_name %> <%= ammo_type_name %>
</.link> </.link>
</:type> </:ammo_type>
<:actions :let={%{count: pack_count} = pack}>
<div class="py-2 px-4 h-full space-x-4 flex justify-center items-center">
<.link
navigate={~p"/ammo/show/#{pack}"}
class="text-primary-600 link"
aria-label={
dgettext("actions", "View pack of %{pack_count} bullets",
pack_count: pack_count
)
}
>
<i class="fa-fw fa-lg fas fa-eye"></i>
</.link>
</div>
</:actions>
</.live_component> </.live_component>
<% else %> <% else %>
<div class="flex flex-wrap justify-center items-stretch"> <div class="flex flex-wrap justify-center items-stretch">
<.pack_card <.ammo_group_card
:for={%{id: pack_id} = pack <- @packs} :for={%{id: ammo_group_id} = ammo_group <- @ammo_groups}
pack={pack} ammo_group={ammo_group}
original_count={Map.fetch!(@original_counts, pack_id)} original_count={Map.fetch!(@original_counts, ammo_group_id)}
cpr={Map.get(@cprs, pack_id)} cpr={Map.get(@cprs, ammo_group_id)}
last_used_date={Map.get(@last_used_dates, pack_id)} last_used_date={Map.get(@last_used_dates, ammo_group_id)}
current_user={@current_user} current_user={@current_user}
/> />
</div> </div>
@ -164,27 +144,27 @@
<%= case @live_action do %> <%= case @live_action do %>
<% :edit -> %> <% :edit -> %>
<.modal return_to={~p"/container/#{@container}"}> <.modal return_to={Routes.container_show_path(Endpoint, :show, @container)}>
<.live_component <.live_component
module={CanneryWeb.ContainerLive.FormComponent} module={CanneryWeb.ContainerLive.FormComponent}
id={@container.id} id={@container.id}
title={@page_title} title={@page_title}
action={@live_action} action={@live_action}
container={@container} container={@container}
return_to={~p"/container/#{@container}"} return_to={Routes.container_show_path(Endpoint, :show, @container)}
current_user={@current_user} current_user={@current_user}
/> />
</.modal> </.modal>
<% :edit_tags -> %> <% :edit_tags -> %>
<.modal return_to={~p"/container/#{@container}"}> <.modal return_to={Routes.container_show_path(Endpoint, :show, @container)}>
<.live_component <.live_component
module={CanneryWeb.ContainerLive.EditTagsComponent} module={CanneryWeb.ContainerLive.EditTagsComponent}
id={@container.id} id={@container.id}
title={@page_title} title={@page_title}
action={@live_action} action={@live_action}
container={@container} container={@container}
return_to={~p"/container/#{@container}"} return_to={Routes.container_show_path(Endpoint, :show, @container)}
current_path={~p"/container/edit_tags/#{@container}"} current_path={Routes.container_show_path(Endpoint, :edit_tags, @container)}
current_user={@current_user} current_user={@current_user}
/> />
</.modal> </.modal>

View File

@ -5,6 +5,7 @@ defmodule CanneryWeb.HomeLive do
use CanneryWeb, :live_view use CanneryWeb, :live_view
alias Cannery.Accounts alias Cannery.Accounts
alias CanneryWeb.Endpoint
@version Mix.Project.config()[:version] @version Mix.Project.config()[:version]

View File

@ -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"> <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 <img
src={~p"/images/cannery.svg"} src={Routes.static_path(Endpoint, "/images/cannery.svg")}
alt={gettext("Cannery logo")} alt={gettext("Cannery logo")}
class="inline-block w-32 hover:-mt-2 hover:mb-2 transition-all duration-500 ease-in-out" 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")} title={gettext("isn't he cute >:3")}
@ -59,7 +59,7 @@
</b> </b>
<p> <p>
<%= if @admins |> Enum.empty?() do %> <%= if @admins |> Enum.empty?() do %>
<.link href={~p"/users/register"} class="hover:underline"> <.link href={Routes.user_registration_path(Endpoint, :new)} class="hover:underline">
<%= dgettext("prompts", "Register to setup Cannery") %> <%= dgettext("prompts", "Register to setup Cannery") %>
</.link> </.link>
<% else %> <% else %>

View File

@ -19,7 +19,7 @@ defmodule CanneryWeb.InviteLive.FormComponent do
@impl true @impl true
def handle_event("validate", %{"invite" => invite_params}, socket) do def handle_event("validate", %{"invite" => invite_params}, socket) do
{:noreply, socket |> assign_changeset(invite_params, :validate)} {:noreply, socket |> assign_changeset(invite_params)}
end end
def handle_event("save", %{"invite" => invite_params}, %{assigns: %{action: action}} = socket) do def handle_event("save", %{"invite" => invite_params}, %{assigns: %{action: action}} = socket) do
@ -28,9 +28,14 @@ defmodule CanneryWeb.InviteLive.FormComponent do
defp assign_changeset( defp assign_changeset(
%{assigns: %{action: action, current_user: user, invite: invite}} = socket, %{assigns: %{action: action, current_user: user, invite: invite}} = socket,
invite_params, invite_params
changeset_action \\ nil
) do ) do
changeset_action =
case action do
:new -> :insert
:edit -> :update
end
changeset = changeset =
case action do case action do
:new -> Invite.create_changeset(user, "example_token", invite_params) :new -> Invite.create_changeset(user, "example_token", invite_params)
@ -38,14 +43,10 @@ defmodule CanneryWeb.InviteLive.FormComponent do
end end
changeset = changeset =
if changeset_action do
case changeset |> Changeset.apply_action(changeset_action) do case changeset |> Changeset.apply_action(changeset_action) do
{:ok, _data} -> changeset {:ok, _data} -> changeset
{:error, changeset} -> changeset {:error, changeset} -> changeset
end end
else
changeset
end
socket |> assign(:changeset, changeset) socket |> assign(:changeset, changeset)
end end

View File

@ -12,20 +12,14 @@
phx-submit="save" phx-submit="save"
> >
<div <div
:if={@changeset.action && not @changeset.valid?} :if={@changeset.action && not @changeset.valid?()}
class="invalid-feedback col-span-3 text-center" class="invalid-feedback col-span-3 text-center"
> >
<%= changeset_errors(@changeset) %> <%= changeset_errors(@changeset) %>
</div> </div>
<%= label(f, :name, gettext("Name"), <%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %>
class: "title text-lg text-primary-600", <%= text_input(f, :name, class: "input input-primary col-span-2") %>
maxlength: 255
) %>
<%= text_input(f, :name,
class: "input input-primary col-span-2",
phx_debounce: 300
) %>
<%= error_tag(f, :name, "col-span-3") %> <%= error_tag(f, :name, "col-span-3") %>
<%= label(f, :uses_left, gettext("Uses left"), class: "title text-lg text-primary-600") %> <%= label(f, :uses_left, gettext("Uses left"), class: "title text-lg text-primary-600") %>

View File

@ -9,11 +9,11 @@
<%= display_emoji("😔") %> <%= display_emoji("😔") %>
</h1> </h1>
<.link patch={~p"/invites/new"} class="btn btn-primary"> <.link patch={Routes.invite_index_path(Endpoint, :new)} class="btn btn-primary">
<%= dgettext("actions", "Invite someone new!") %> <%= dgettext("actions", "Invite someone new!") %>
</.link> </.link>
<% else %> <% else %>
<.link patch={~p"/invites/new"} class="btn btn-primary"> <.link patch={Routes.invite_index_path(Endpoint, :new)} class="btn btn-primary">
<%= dgettext("actions", "Create Invite") %> <%= dgettext("actions", "Create Invite") %>
</.link> </.link>
<% end %> <% end %>
@ -40,7 +40,7 @@
</form> </form>
</:code_actions> </:code_actions>
<.link <.link
patch={~p"/invites/edit/#{invite}"} patch={Routes.invite_index_path(Endpoint, :edit, invite)}
class="text-primary-600 link" class="text-primary-600 link"
aria-label={ aria-label={
dgettext("actions", "Edit invite for %{invite_name}", invite_name: invite.name) dgettext("actions", "Edit invite for %{invite_name}", invite_name: invite.name)
@ -149,14 +149,14 @@
<% end %> <% end %>
</div> </div>
<.modal :if={@live_action in [:new, :edit]} return_to={~p"/invites"}> <.modal :if={@live_action in [:new, :edit]} return_to={Routes.invite_index_path(Endpoint, :index)}>
<.live_component <.live_component
module={CanneryWeb.InviteLive.FormComponent} module={CanneryWeb.InviteLive.FormComponent}
id={@invite.id || :new} id={@invite.id || :new}
title={@page_title} title={@page_title}
action={@live_action} action={@live_action}
invite={@invite} invite={@invite}
return_to={~p"/invites"} return_to={Routes.invite_index_path(Endpoint, :index)}
current_user={@current_user} current_user={@current_user}
/> />
</.modal> </.modal>

View File

@ -1,164 +0,0 @@
defmodule CanneryWeb.PackLive.Index do
@moduledoc """
Liveview to show a Cannery.Ammo.Pack index
"""
use CanneryWeb, :live_view
alias Cannery.{Ammo, Ammo.Pack, Containers}
@impl true
def mount(%{"search" => search}, _session, socket) do
socket =
socket
|> assign(class: :all, show_used: false, search: search)
|> display_packs()
{:ok, socket}
end
def mount(_params, _session, socket) do
{: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_packs()}
end
defp apply_action(
%{assigns: %{current_user: current_user}} = socket,
:add_shot_record,
%{"id" => id}
) do
socket
|> assign(
page_title: gettext("Record shots"),
pack: Ammo.get_pack!(id, current_user)
)
end
defp apply_action(%{assigns: %{current_user: current_user}} = socket, :move, %{"id" => id}) do
socket
|> assign(
page_title: gettext("Move ammo"),
pack: Ammo.get_pack!(id, current_user)
)
end
defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"id" => id}) do
socket
|> assign(
page_title: gettext("Edit ammo"),
pack: Ammo.get_pack!(id, current_user)
)
end
defp apply_action(%{assigns: %{current_user: current_user}} = socket, :clone, %{"id" => id}) do
socket
|> assign(
page_title: dgettext("actions", "Add Ammo"),
pack: %{Ammo.get_pack!(id, current_user) | id: nil}
)
end
defp apply_action(socket, :new, _params) do
socket
|> assign(
page_title: dgettext("actions", "Add Ammo"),
pack: %Pack{}
)
end
defp apply_action(socket, :index, _params) do
socket
|> assign(
page_title: gettext("Ammo"),
search: nil,
pack: nil
)
end
defp apply_action(socket, :search, %{"search" => search}) do
socket
|> assign(
page_title: gettext("Ammo"),
search: search,
pack: nil
)
end
@impl true
def handle_event("delete", %{"id" => id}, %{assigns: %{current_user: current_user}} = socket) do
Ammo.get_pack!(id, current_user) |> Ammo.delete_pack!(current_user)
prompt = dgettext("prompts", "Ammo deleted succesfully")
{:noreply, socket |> put_flash(:info, prompt) |> display_packs()}
end
def handle_event(
"toggle_staged",
%{"pack_id" => id},
%{assigns: %{current_user: current_user}} = socket
) do
pack = Ammo.get_pack!(id, current_user)
{:ok, _pack} = pack |> Ammo.update_pack(%{"staged" => !pack.staged}, current_user)
{: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_packs()}
end
def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do
{:noreply, socket |> push_patch(to: ~p"/ammo")}
end
def handle_event("search", %{"search" => %{"search_term" => search_term}}, socket) do
{:noreply, socket |> push_patch(to: ~p"/ammo/search/#{search_term}")}
end
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
# get total number of packs to determine whether to display onboarding
# prompts
packs_count = Ammo.get_packs_count(current_user, show_used: true)
packs = Ammo.list_packs(current_user, search: search, class: class, show_used: show_used)
types_count = Ammo.get_types_count!(current_user)
containers_count = Containers.get_containers_count!(current_user)
socket
|> assign(
packs: packs,
types_count: types_count,
containers_count: containers_count,
packs_count: packs_count
)
end
end

View File

@ -1,239 +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>
<%= 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={~p"/containers/new"} class="btn btn-primary">
<%= dgettext("actions", "add a container first") %>
</.link>
</div>
<% @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={~p"/catalog/new"} class="btn btn-primary">
<%= dgettext("actions", "add a type first") %>
</.link>
</div>
<% @packs_count == 0 -> %>
<h2 class="title text-xl text-primary-600">
<%= gettext("No ammo") %>
<%= display_emoji("😔") %>
</h2>
<.link patch={~p"/ammo/new"} class="btn btn-primary">
<%= dgettext("actions", "Add your first box!") %>
</.link>
<% true -> %>
<.link patch={~p"/ammo/new"} class="btn btn-primary">
<%= dgettext("actions", "Add Ammo") %>
</.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-2xl">
<.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>
<.form
:let={f}
for={%{}}
as={:search}
phx-change="search"
phx-submit="search"
class="grow flex items-center"
>
<%= text_input(f, :search_term,
class: "grow input input-primary",
phx_debounce: 300,
placeholder: gettext("Search ammo"),
role: "search",
value: @search
) %>
</.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 @packs |> Enum.empty?() do %>
<h2 class="title text-xl text-primary-600">
<%= gettext("No Ammo") %>
<%= display_emoji("😔") %>
</h2>
<% else %>
<.live_component
module={CanneryWeb.Components.PackTableComponent}
id="pack-index-table"
packs={@packs}
current_user={@current_user}
show_used={@show_used}
>
<:type :let={%{name: type_name} = type}>
<.link navigate={~p"/type/#{type}"} class="link">
<%= type_name %>
</.link>
</:type>
<:range :let={pack}>
<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-pack_id={pack.id}
>
<%= if pack.staged,
do: dgettext("actions", "Unstage"),
else: dgettext("actions", "Stage") %>
</button>
<.link
patch={~p"/ammo/add_shot_record/#{pack}"}
class="mx-2 my-1 text-sm btn btn-primary"
>
<%= dgettext("actions", "Record shots") %>
</.link>
</div>
</:range>
<:container :let={{pack, %{name: container_name} = container}}>
<div class="min-w-20 py-2 px-4 h-full flex flew-wrap justify-center items-center">
<.link navigate={~p"/container/#{container}"} class="mx-2 my-1 link">
<%= container_name %>
</.link>
<.link patch={~p"/ammo/move/#{pack}"} class="mx-2 my-1 text-sm btn btn-primary">
<%= dgettext("actions", "Move ammo") %>
</.link>
</div>
</:container>
<:actions :let={%{count: pack_count} = pack}>
<div class="py-2 px-4 h-full space-x-4 flex justify-center items-center">
<.link
navigate={~p"/ammo/show/#{pack}"}
class="text-primary-600 link"
aria-label={
dgettext("actions", "View pack of %{pack_count} bullets",
pack_count: pack_count
)
}
>
<i class="fa-fw fa-lg fas fa-eye"></i>
</.link>
<.link
patch={~p"/ammo/edit/#{pack}"}
class="text-primary-600 link"
aria-label={
dgettext("actions", "Edit pack of %{pack_count} bullets",
pack_count: pack_count
)
}
>
<i class="fa-fw fa-lg fas fa-edit"></i>
</.link>
<.link
patch={~p"/ammo/clone/#{pack}"}
class="text-primary-600 link"
aria-label={
dgettext("actions", "Clone pack of %{pack_count} bullets",
pack_count: pack_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={pack.id}
data-confirm={dgettext("prompts", "Are you sure you want to delete this ammo?")}
aria-label={
dgettext("actions", "Delete pack of %{pack_count} bullets",
pack_count: pack_count
)
}
>
<i class="fa-fw fa-lg fas fa-trash"></i>
</.link>
</div>
</:actions>
</.live_component>
<% end %>
<% end %>
</div>
<%= case @live_action do %>
<% create when create in [:new, :edit, :clone] -> %>
<.modal return_to={~p"/ammo"}>
<.live_component
module={CanneryWeb.PackLive.FormComponent}
id={@pack.id || :new}
title={@page_title}
action={@live_action}
pack={@pack}
return_to={~p"/ammo"}
current_user={@current_user}
/>
</.modal>
<% :add_shot_record -> %>
<.modal return_to={~p"/ammo"}>
<.live_component
module={CanneryWeb.Components.AddShotRecordComponent}
id={:new}
title={@page_title}
action={@live_action}
pack={@pack}
return_to={~p"/ammo"}
current_user={@current_user}
/>
</.modal>
<% :move -> %>
<.modal return_to={~p"/ammo"}>
<.live_component
module={CanneryWeb.Components.MovePackComponent}
id={@pack.id}
title={@page_title}
action={@live_action}
pack={@pack}
return_to={~p"/ammo"}
current_user={@current_user}
/>
</.modal>
<% _ -> %>
<% end %>

View File

@ -1,56 +1,56 @@
defmodule CanneryWeb.RangeLive.FormComponent do defmodule CanneryWeb.RangeLive.FormComponent do
@moduledoc """ @moduledoc """
Livecomponent that can update a ShotRecord Livecomponent that can update a ShotGroup
""" """
use CanneryWeb, :live_component use CanneryWeb, :live_component
alias Cannery.{Accounts.User, ActivityLog, ActivityLog.ShotRecord, Ammo, Ammo.Pack} alias Cannery.{Accounts.User, ActivityLog, ActivityLog.ShotGroup, Ammo, Ammo.AmmoGroup}
alias Ecto.Changeset alias Ecto.Changeset
alias Phoenix.LiveView.Socket alias Phoenix.LiveView.Socket
@impl true @impl true
def mount(socket), do: {:ok, socket |> assign(:pack, nil)} def mount(socket), do: {:ok, socket |> assign(:ammo_group, nil)}
@impl true @impl true
@spec update( @spec update(
%{ %{
required(:shot_record) => ShotRecord.t(), required(:shot_group) => ShotGroup.t(),
required(:current_user) => User.t(), required(:current_user) => User.t(),
optional(:pack) => Pack.t(), optional(:ammo_group) => AmmoGroup.t(),
optional(any()) => any() optional(any()) => any()
}, },
Socket.t() Socket.t()
) :: {:ok, Socket.t()} ) :: {:ok, Socket.t()}
def update( def update(
%{ %{
shot_record: %ShotRecord{pack_id: pack_id}, shot_group: %ShotGroup{ammo_group_id: ammo_group_id},
current_user: current_user current_user: current_user
} = assigns, } = assigns,
socket socket
) )
when is_binary(pack_id) do when is_binary(ammo_group_id) do
pack = Ammo.get_pack!(pack_id, current_user) ammo_group = Ammo.get_ammo_group!(ammo_group_id, current_user)
{:ok, socket |> assign(assigns) |> assign(:pack, pack) |> assign_changeset(%{})} {:ok, socket |> assign(assigns) |> assign(:ammo_group, ammo_group) |> assign_changeset(%{})}
end end
def update(%{shot_record: %ShotRecord{}} = assigns, socket) do def update(%{shot_group: %ShotGroup{}} = assigns, socket) do
{:ok, socket |> assign(assigns) |> assign_changeset(%{})} {:ok, socket |> assign(assigns) |> assign_changeset(%{})}
end end
@impl true @impl true
def handle_event("validate", %{"shot_record" => shot_record_params}, socket) do def handle_event("validate", %{"shot_group" => shot_group_params}, socket) do
{:noreply, socket |> assign_changeset(shot_record_params, :validate)} {:noreply, socket |> assign_changeset(shot_group_params, :validate)}
end end
def handle_event( def handle_event(
"save", "save",
%{"shot_record" => shot_record_params}, %{"shot_group" => shot_group_params},
%{assigns: %{shot_record: shot_record, current_user: current_user, return_to: return_to}} = %{assigns: %{shot_group: shot_group, current_user: current_user, return_to: return_to}} =
socket socket
) do ) do
socket = socket =
case ActivityLog.update_shot_record(shot_record, shot_record_params, current_user) do case ActivityLog.update_shot_group(shot_group, shot_group_params, current_user) do
{:ok, _shot_record} -> {:ok, _shot_group} ->
prompt = dgettext("prompts", "Shot records updated successfully") prompt = dgettext("prompts", "Shot records updated successfully")
socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) socket |> put_flash(:info, prompt) |> push_navigate(to: return_to)
@ -66,31 +66,30 @@ defmodule CanneryWeb.RangeLive.FormComponent do
assigns: %{ assigns: %{
action: live_action, action: live_action,
current_user: user, current_user: user,
pack: pack, ammo_group: ammo_group,
shot_record: shot_record shot_group: shot_group
} }
} = socket, } = socket,
shot_record_params, shot_group_params,
changeset_action \\ nil action \\ nil
) do ) do
changeset = default_action =
case live_action do case live_action do
:add_shot_record -> :add_shot_group -> :insert
shot_record |> ShotRecord.create_changeset(user, pack, shot_record_params) editing when editing in [:edit, :edit_shot_group] -> :update
editing when editing in [:edit, :edit_shot_record] ->
shot_record |> ShotRecord.update_changeset(user, shot_record_params)
end end
changeset = changeset =
if changeset_action do case default_action do
case changeset |> Changeset.apply_action(changeset_action) do :insert -> shot_group |> ShotGroup.create_changeset(user, ammo_group, shot_group_params)
:update -> shot_group |> ShotGroup.update_changeset(user, shot_group_params)
end
changeset =
case changeset |> Changeset.apply_action(action || default_action) do
{:ok, _data} -> changeset {:ok, _data} -> changeset
{:error, changeset} -> changeset {:error, changeset} -> changeset
end end
else
changeset
end
socket |> assign(:changeset, changeset) socket |> assign(:changeset, changeset)
end end

View File

@ -6,14 +6,14 @@
<.form <.form
:let={f} :let={f}
for={@changeset} for={@changeset}
id="shot-record-form" id="shot-group-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" 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-target={@myself}
phx-change="validate" phx-change="validate"
phx-submit="save" phx-submit="save"
> >
<div <div
:if={@changeset.action && not @changeset.valid?} :if={@changeset.action && not @changeset.valid?()}
class="invalid-feedback col-span-3 text-center" class="invalid-feedback col-span-3 text-center"
> >
<%= changeset_errors(@changeset) %> <%= changeset_errors(@changeset) %>
@ -22,19 +22,18 @@
<%= label(f, :count, gettext("Shots fired"), class: "title text-lg text-primary-600") %> <%= label(f, :count, gettext("Shots fired"), class: "title text-lg text-primary-600") %>
<%= number_input(f, :count, <%= number_input(f, :count,
min: 1, min: 1,
max: @shot_record.count + @pack.count, max: @shot_group.count + @ammo_group.count,
class: "input input-primary col-span-2" class: "input input-primary col-span-2"
) %> ) %>
<%= error_tag(f, :count, "col-span-3") %> <%= error_tag(f, :count, "col-span-3") %>
<%= label(f, :notes, gettext("Notes"), class: "title text-lg text-primary-600") %> <%= label(f, :notes, gettext("Notes"), class: "title text-lg text-primary-600") %>
<%= textarea(f, :notes, <%= textarea(f, :notes,
id: "shot-group-form-notes",
class: "input input-primary col-span-2", class: "input input-primary col-span-2",
id: "shot-record-form-notes", placeholder: gettext("Really great weather"),
maxlength: 255, phx_hook: "MaintainAttrs",
phx_debounce: 300, phx_update: "ignore"
phx_update: "ignore",
placeholder: gettext("Really great weather")
) %> ) %>
<%= error_tag(f, :notes, "col-span-3") %> <%= error_tag(f, :notes, "col-span-3") %>

View File

@ -1,19 +1,20 @@
defmodule CanneryWeb.RangeLive.Index do defmodule CanneryWeb.RangeLive.Index do
@moduledoc """ @moduledoc """
Main page for range day mode, where `Pack`s can be used up. Main page for range day mode, where `AmmoGroup`s can be used up.
""" """
use CanneryWeb, :live_view use CanneryWeb, :live_view
alias Cannery.{ActivityLog, ActivityLog.ShotRecord, Ammo} alias Cannery.{ActivityLog, ActivityLog.ShotGroup, Ammo}
alias CanneryWeb.Endpoint
alias Phoenix.LiveView.Socket alias Phoenix.LiveView.Socket
@impl true @impl true
def mount(%{"search" => search}, _session, socket) do def mount(%{"search" => search}, _session, socket) do
{:ok, socket |> assign(class: :all, search: search) |> display_shot_records()} {:ok, socket |> assign(search: search) |> display_shot_groups()}
end end
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
{:ok, socket |> assign(class: :all, search: nil) |> display_shot_records()} {:ok, socket |> assign(search: nil) |> display_shot_groups()}
end end
@impl true @impl true
@ -23,126 +24,107 @@ defmodule CanneryWeb.RangeLive.Index do
defp apply_action( defp apply_action(
%{assigns: %{current_user: current_user}} = socket, %{assigns: %{current_user: current_user}} = socket,
:add_shot_record, :add_shot_group,
%{"id" => id} %{"id" => id}
) do ) do
socket socket
|> assign( |> assign(
page_title: gettext("Record Shots"), page_title: gettext("Record Shots"),
pack: Ammo.get_pack!(id, current_user) ammo_group: Ammo.get_ammo_group!(id, current_user)
) )
end end
defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"id" => id}) do defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"id" => id}) do
socket socket
|> assign( |> assign(
page_title: gettext("Edit Shot Record"), page_title: gettext("Edit Shot Records"),
shot_record: ActivityLog.get_shot_record!(id, current_user) shot_group: ActivityLog.get_shot_group!(id, current_user)
) )
end end
defp apply_action(socket, :new, _params) do defp apply_action(socket, :new, _params) do
socket socket
|> assign( |> assign(
page_title: gettext("Record Shots"), page_title: gettext("New Shot Records"),
shot_record: %ShotRecord{} shot_group: %ShotGroup{}
) )
end end
defp apply_action(socket, :index, _params) do defp apply_action(socket, :index, _params) do
socket socket
|> assign( |> assign(
page_title: gettext("Range"), page_title: gettext("Shot Records"),
search: nil, search: nil,
shot_record: nil shot_group: nil
) )
|> display_shot_records() |> display_shot_groups()
end end
defp apply_action(socket, :search, %{"search" => search}) do defp apply_action(socket, :search, %{"search" => search}) do
socket socket
|> assign( |> assign(
page_title: gettext("Range"), page_title: gettext("Shot Records"),
search: search, search: search,
shot_record: nil shot_group: nil
) )
|> display_shot_records() |> display_shot_groups()
end end
@impl true @impl true
def handle_event("delete", %{"id" => id}, %{assigns: %{current_user: current_user}} = socket) do def handle_event("delete", %{"id" => id}, %{assigns: %{current_user: current_user}} = socket) do
{:ok, _} = {:ok, _} =
ActivityLog.get_shot_record!(id, current_user) ActivityLog.get_shot_group!(id, current_user)
|> ActivityLog.delete_shot_record(current_user) |> ActivityLog.delete_shot_group(current_user)
prompt = dgettext("prompts", "Shot records deleted succesfully") prompt = dgettext("prompts", "Shot records deleted succesfully")
{:noreply, socket |> put_flash(:info, prompt) |> display_shot_records()} {:noreply, socket |> put_flash(:info, prompt) |> display_shot_groups()}
end end
def handle_event( def handle_event(
"toggle_staged", "toggle_staged",
%{"pack_id" => pack_id}, %{"ammo_group_id" => ammo_group_id},
%{assigns: %{current_user: current_user}} = socket %{assigns: %{current_user: current_user}} = socket
) do ) do
pack = Ammo.get_pack!(pack_id, current_user) ammo_group = Ammo.get_ammo_group!(ammo_group_id, current_user)
{:ok, _pack} = pack |> Ammo.update_pack(%{"staged" => !pack.staged}, current_user) {:ok, _ammo_group} =
ammo_group |> Ammo.update_ammo_group(%{"staged" => !ammo_group.staged}, current_user)
prompt = dgettext("prompts", "Ammo unstaged succesfully") prompt = dgettext("prompts", "Ammo unstaged succesfully")
{:noreply, socket |> put_flash(:info, prompt) |> display_shot_records()} {:noreply, socket |> put_flash(:info, prompt) |> display_shot_groups()}
end end
def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do
{:noreply, socket |> push_patch(to: ~p"/range")} {:noreply, socket |> push_patch(to: Routes.range_index_path(Endpoint, :index))}
end end
def handle_event("search", %{"search" => %{"search_term" => search_term}}, socket) do def handle_event("search", %{"search" => %{"search_term" => search_term}}, socket) do
{:noreply, socket |> push_patch(to: ~p"/range/search/#{search_term}")} {:noreply, socket |> push_patch(to: Routes.range_index_path(Endpoint, :search, search_term))}
end end
def handle_event("change_class", %{"type" => %{"class" => "rifle"}}, socket) do @spec display_shot_groups(Socket.t()) :: Socket.t()
{:noreply, socket |> assign(:class, :rifle) |> display_shot_records()} defp display_shot_groups(%{assigns: %{search: search, current_user: current_user}} = socket) do
end shot_groups = ActivityLog.list_shot_groups(search, current_user)
ammo_groups = Ammo.list_staged_ammo_groups(current_user)
def handle_event("change_class", %{"type" => %{"class" => "shotgun"}}, socket) do chart_data = shot_groups |> get_chart_data_for_shot_group()
{:noreply, socket |> assign(:class, :shotgun) |> display_shot_records()} original_counts = ammo_groups |> Ammo.get_original_counts(current_user)
end cprs = ammo_groups |> Ammo.get_cprs(current_user)
last_used_dates = ammo_groups |> ActivityLog.get_last_used_dates(current_user)
def handle_event("change_class", %{"type" => %{"class" => "pistol"}}, socket) do
{:noreply, socket |> assign(:class, :pistol) |> display_shot_records()}
end
def handle_event("change_class", %{"type" => %{"class" => _all}}, socket) do
{:noreply, socket |> assign(:class, :all) |> display_shot_records()}
end
@spec display_shot_records(Socket.t()) :: Socket.t()
defp display_shot_records(
%{assigns: %{class: class, search: search, current_user: current_user}} = socket
) do
shot_records = ActivityLog.list_shot_records(current_user, search: search, class: class)
packs = Ammo.list_packs(current_user, staged: true)
chart_data = shot_records |> get_chart_data_for_shot_record()
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)
shot_record_count = ActivityLog.get_shot_record_count!(current_user)
socket socket
|> assign( |> assign(
packs: packs, ammo_groups: ammo_groups,
original_counts: original_counts, original_counts: original_counts,
cprs: cprs, cprs: cprs,
last_used_dates: last_used_dates, last_used_dates: last_used_dates,
chart_data: chart_data, chart_data: chart_data,
shot_records: shot_records, shot_groups: shot_groups
shot_record_count: shot_record_count
) )
end end
@spec get_chart_data_for_shot_record([ShotRecord.t()]) :: [map()] @spec get_chart_data_for_shot_group([ShotGroup.t()]) :: [map()]
defp get_chart_data_for_shot_record(shot_records) do defp get_chart_data_for_shot_group(shot_groups) do
shot_records shot_groups
|> Enum.group_by(fn %{date: date} -> date end, fn %{count: count} -> count end) |> Enum.group_by(fn %{date: date} -> date end, fn %{count: count} -> count end)
|> Enum.map(fn {date, rounds} -> |> Enum.map(fn {date, rounds} ->
sum = Enum.sum(rounds) sum = Enum.sum(rounds)

View File

@ -3,51 +3,54 @@
<%= gettext("Range day") %> <%= gettext("Range day") %>
</h1> </h1>
<%= if @packs |> Enum.empty?() do %> <%= if @ammo_groups |> Enum.empty?() do %>
<h1 class="title text-xl text-primary-600"> <h1 class="title text-xl text-primary-600">
<%= gettext("No ammo staged") %> <%= gettext("No ammo staged") %>
<%= display_emoji("😔") %> <%= display_emoji("😔") %>
</h1> </h1>
<.link navigate={~p"/ammo"} class="btn btn-primary"> <.link navigate={Routes.ammo_group_index_path(Endpoint, :index)} class="btn btn-primary">
<%= dgettext("actions", "Why not get some ready to shoot?") %> <%= dgettext("actions", "Why not get some ready to shoot?") %>
</.link> </.link>
<% else %> <% else %>
<.link navigate={~p"/ammo"} class="btn btn-primary"> <.link navigate={Routes.ammo_group_index_path(Endpoint, :index)} class="btn btn-primary">
<%= dgettext("actions", "Stage ammo") %> <%= dgettext("actions", "Stage ammo") %>
</.link> </.link>
<div class="w-full flex flex-row flex-wrap justify-center items-stretch"> <div class="w-full flex flex-row flex-wrap justify-center items-stretch">
<.pack_card <.ammo_group_card
:for={%{id: pack_id} = pack <- @packs} :for={%{id: ammo_group_id} = ammo_group <- @ammo_groups}
pack={pack} ammo_group={ammo_group}
original_count={Map.fetch!(@original_counts, pack_id)} original_count={Map.fetch!(@original_counts, ammo_group_id)}
cpr={Map.get(@cprs, pack_id)} cpr={Map.get(@cprs, ammo_group_id)}
last_used_date={Map.get(@last_used_dates, pack_id)} last_used_date={Map.get(@last_used_dates, ammo_group_id)}
current_user={@current_user} current_user={@current_user}
> >
<button <button
type="button" type="button"
class="btn btn-primary" class="btn btn-primary"
phx-click="toggle_staged" phx-click="toggle_staged"
phx-value-pack_id={pack.id} phx-value-ammo_group_id={ammo_group.id}
data-confirm={"#{dgettext("prompts", "Are you sure you want to unstage this ammo?")}"} data-confirm={"#{dgettext("prompts", "Are you sure you want to unstage this ammo?")}"}
> >
<%= if pack.staged, <%= if ammo_group.staged,
do: dgettext("actions", "Unstage from range"), do: dgettext("actions", "Unstage from range"),
else: dgettext("actions", "Stage for range") %> else: dgettext("actions", "Stage for range") %>
</button> </button>
<.link patch={~p"/range/add_shot_record/#{pack}"} class="btn btn-primary"> <.link
patch={Routes.range_index_path(Endpoint, :add_shot_group, ammo_group)}
class="btn btn-primary"
>
<%= dgettext("actions", "Record shots") %> <%= dgettext("actions", "Record shots") %>
</.link> </.link>
</.pack_card> </.ammo_group_card>
</div> </div>
<% end %> <% end %>
<hr class="hr" /> <hr class="hr" />
<%= if @shot_record_count == 0 do %> <%= if @shot_groups |> Enum.empty?() and @search |> is_nil() do %>
<h1 class="title text-xl text-primary-600"> <h1 class="title text-xl text-primary-600">
<%= gettext("No shots recorded") %> <%= gettext("No shots recorded") %>
<%= display_emoji("😔") %> <%= display_emoji("😔") %>
@ -71,71 +74,45 @@
<%= dgettext("errors", "Your browser does not support the canvas element.") %> <%= dgettext("errors", "Your browser does not support the canvas element.") %>
</canvas> </canvas>
<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"> <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={: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>
<.form <.form
:let={f} :let={f}
for={%{}} for={%{}}
as={:search} as={:search}
phx-change="search" phx-change="search"
phx-submit="search" phx-submit="search"
class="grow flex items-center" class="grow self-stretch flex flex-col items-stretch"
> >
<%= text_input(f, :search_term, <%= text_input(f, :search_term,
class: "grow input input-primary", class: "input input-primary",
phx_debounce: 300, value: @search,
placeholder: gettext("Search shot records"),
role: "search", role: "search",
value: @search phx_debounce: 300,
placeholder: gettext("Search shot records")
) %> ) %>
</.form> </.form>
</div> </div>
<%= if @shot_records |> Enum.empty?() do %> <%= if @shot_groups |> Enum.empty?() do %>
<h1 class="title text-xl text-primary-600"> <h1 class="title text-xl text-primary-600">
<%= gettext("No shots recorded") %> <%= gettext("No shots recorded") %>
<%= display_emoji("😔") %> <%= display_emoji("😔") %>
</h1> </h1>
<% else %> <% else %>
<.live_component <.live_component
module={CanneryWeb.Components.ShotRecordTableComponent} module={CanneryWeb.Components.ShotGroupTableComponent}
id="shot-records-index-table" id="shot_groups_index_table"
shot_records={@shot_records} shot_groups={@shot_groups}
current_user={@current_user} current_user={@current_user}
> >
<:actions :let={shot_record}> <:actions :let={shot_group}>
<div class="px-4 py-2 space-x-4 flex justify-center items-center"> <div class="px-4 py-2 space-x-4 flex justify-center items-center">
<.link <.link
patch={~p"/range/edit/#{shot_record}"} patch={Routes.range_index_path(Endpoint, :edit, shot_group)}
class="text-primary-600 link" class="text-primary-600 link"
aria-label={ aria-label={
dgettext("actions", "Edit shot record of %{shot_record_count} shots", dgettext("actions", "Edit shot record of %{shot_group_count} shots",
shot_record_count: shot_record.count shot_group_count: shot_group.count
) )
} }
> >
@ -146,13 +123,13 @@
href="#" href="#"
class="text-primary-600 link" class="text-primary-600 link"
phx-click="delete" phx-click="delete"
phx-value-id={shot_record.id} phx-value-id={shot_group.id}
data-confirm={ data-confirm={
dgettext("prompts", "Are you sure you want to delete this shot record?") dgettext("prompts", "Are you sure you want to delete this shot record?")
} }
aria-label={ aria-label={
dgettext("actions", "Delete shot record of %{shot_record_count} shots", dgettext("actions", "Delete shot record of %{shot_group_count} shots",
shot_record_count: shot_record.count shot_group_count: shot_group.count
) )
} }
> >
@ -167,26 +144,26 @@
<%= case @live_action do %> <%= case @live_action do %>
<% :edit -> %> <% :edit -> %>
<.modal return_to={~p"/range"}> <.modal return_to={Routes.range_index_path(Endpoint, :index)}>
<.live_component <.live_component
module={CanneryWeb.RangeLive.FormComponent} module={CanneryWeb.RangeLive.FormComponent}
id={@shot_record.id} id={@shot_group.id}
title={@page_title} title={@page_title}
action={@live_action} action={@live_action}
shot_record={@shot_record} shot_group={@shot_group}
return_to={~p"/range"} return_to={Routes.range_index_path(Endpoint, :index)}
current_user={@current_user} current_user={@current_user}
/> />
</.modal> </.modal>
<% :add_shot_record -> %> <% :add_shot_group -> %>
<.modal return_to={~p"/range"}> <.modal return_to={Routes.range_index_path(Endpoint, :index)}>
<.live_component <.live_component
module={CanneryWeb.Components.AddShotRecordComponent} module={CanneryWeb.Components.AddShotGroupComponent}
id={:new} id={:new}
title={@page_title} title={@page_title}
action={@live_action} action={@live_action}
pack={@pack} ammo_group={@ammo_group}
return_to={~p"/range"} return_to={Routes.range_index_path(Endpoint, :index)}
current_user={@current_user} current_user={@current_user}
/> />
</.modal> </.modal>

View File

@ -17,7 +17,7 @@ defmodule CanneryWeb.TagLive.FormComponent do
@impl true @impl true
def handle_event("validate", %{"tag" => tag_params}, socket) do def handle_event("validate", %{"tag" => tag_params}, socket) do
{:noreply, socket |> assign_changeset(tag_params, :validate)} {:noreply, socket |> assign_changeset(tag_params)}
end end
def handle_event("save", %{"tag" => tag_params}, %{assigns: %{action: action}} = socket) do def handle_event("save", %{"tag" => tag_params}, %{assigns: %{action: action}} = socket) do
@ -26,9 +26,14 @@ defmodule CanneryWeb.TagLive.FormComponent do
defp assign_changeset( defp assign_changeset(
%{assigns: %{action: action, current_user: user, tag: tag}} = socket, %{assigns: %{action: action, current_user: user, tag: tag}} = socket,
tag_params, tag_params
changeset_action \\ nil
) do ) do
changeset_action =
case action do
:new -> :insert
:edit -> :update
end
changeset = changeset =
case action do case action do
:new -> tag |> Tag.create_changeset(user, tag_params) :new -> tag |> Tag.create_changeset(user, tag_params)
@ -36,14 +41,10 @@ defmodule CanneryWeb.TagLive.FormComponent do
end end
changeset = changeset =
if changeset_action do
case changeset |> Changeset.apply_action(changeset_action) do case changeset |> Changeset.apply_action(changeset_action) do
{:ok, _data} -> changeset {:ok, _data} -> changeset
{:error, changeset} -> changeset {:error, changeset} -> changeset
end end
else
changeset
end
socket |> assign(:changeset, changeset) socket |> assign(:changeset, changeset)
end end

Some files were not shown because too many files have changed in this diff Show More