Compare commits

...

22 commits

Author SHA1 Message Date
Adam Piontek 94637a564c added Dockerfile and entrypoint script 2022-08-14 15:14:14 -04:00
Adam Piontek 10f284da6f don't gzip static files, and always commit them 2022-08-14 15:09:30 -04:00
Adam Piontek 64e310b598 removed accidentally commited old assets dir 2022-08-14 14:33:42 -04:00
Adam Piontek 61796cf985 npm update, remove runtime.exs as not using for now 2022-08-14 13:58:41 -04:00
Adam Piontek dceef941c7 fixed user delete error by correcting foreign key constraint; updated liveview modals to use component directly & removed deprecated @socket parameters 2022-08-14 12:49:25 -04:00
Adam Piontek 68d60c120d removed unnecessary @socket param to live_component in settings; removed now-unused icon_helpers 2022-08-14 11:44:31 -04:00
Adam Piontek 6b787297bb fix EEx tag typo 2022-08-14 11:37:40 -04:00
Adam Piontek 24642d7c67 main shift assign UI updated, with custom shift field fixes & shift template form field fix for time_zone 2022-08-14 11:25:28 -04:00
Adam Piontek 8cd984adc5 updated import-shifts template 2022-08-14 10:43:16 -04:00
Adam Piontek e7d93989d3 fixed csv export view fields required issue 2022-08-14 10:37:55 -04:00
Adam Piontek a99c5eea35 updated shift index template and implemented delete shift as modal instead of js alert 2022-08-14 10:30:57 -04:00
Adam Piontek ada166fb41 updated template management, including time_input: required fix 2022-08-14 09:49:34 -04:00
Adam Piontek 6a5d2346ff cleaned up parentheses in schemas 2022-08-14 09:22:39 -04:00
Adam Piontek f28c85e343 cleaned up parentheses in migrations 2022-08-14 09:16:58 -04:00
Adam Piontek f27df8d676 implemented optional 'allow_registration' config, with first registered user being pre-confirmed Admin, registration unavailable after that point if allow_registration: :false 2022-08-14 09:14:42 -04:00
Adam Piontek ea74a89078 user management new/edit/delete working, with fixed live modal 2022-08-13 09:39:08 -04:00
Adam Piontek ce03eaaf2d user settings templates updated, user management index template updated 2022-08-13 09:30:17 -04:00
Adam Piontek 3eff955672 progress on migrating to heex templates and font-icons 2022-08-13 07:32:36 -04:00
Adam Piontek d43daafdb7 updated Bamboo references to Swoosh; added runtime.exs config file 2022-08-13 06:39:14 -04:00
Adam Piontek 721ba53c15 updated deps & switched from Mix.Config to Config 2022-08-13 06:19:56 -04:00
Adam Piontek 75eb9aa316 modifications to fix basic tests, many still need implementing 2021-04-10 12:23:11 -04:00
Adam Piontek 085f226cfe updated dependencies 2021-04-07 17:23:41 -04:00
172 changed files with 2947 additions and 18233 deletions

2
.gitignore vendored
View file

@ -35,7 +35,7 @@ npm-debug.log
# Since we are building assets from assets/,
# we ignore priv/static. You may want to comment
# this depending on your deployment strategy.
/priv/static/
#/priv/static/
# Files matching config/*.secret.exs pattern contain sensitive
# data and you should not commit them into version control.

31
Dockerfile Normal file
View file

@ -0,0 +1,31 @@
# ./Dockerfile
# Extend from the official Elixir image
FROM elixir:1.13.4-otp-25-alpine
# # install the package postgresql-client to run pg_isready within entrypoint script
# RUN apt-get update && \
# apt-get install -y postgresql-client
# Create app directory and copy the Elixir project into it
RUN mkdir /app
COPY . /app
WORKDIR /app
# Install the build tools we'll need
RUN apk update && \
apk upgrade --no-cache && \
apk add --no-cache \
build-base && \
mix local.rebar --force && \
mix local.hex --force
# The environment to build with
ENV MIX_ENV=prod
# Get deps and compile
RUN mix do deps.get, deps.compile, compile
# Start command
CMD = ["/app/entrypoint.sh"]

132
README.md
View file

@ -6,79 +6,73 @@ Written in Elixir & Phoenix LiveView, with Bootstrap v5.
## TODO
- [ ] Ability to edit shifts?
- [ ] Proper modal to delete shifts?
- [ ] Allow all-day items for notes, or require hours even for sick days?
- [X] ~~*Proper modal to delete shifts?*~~ [2022-08-14]
- [ ] move runtime config out of compile-time config files, to move towards supporting releases
- [ ] probably need to use `def get_app_config` style functions instead of `@module_var` module variables, ([see this](https://stephenbussey.com/2019/01/03/understanding-compile-time-dependencies-in-elixir-a-bug-hunt.html))
- [ ] Update tests, which are probably all way out of date. But I also don't care that much for this project...
## Deploying
### New versions
I'm using a dumb & simple docker approach to deploying this now. Nothing automated, the basic steps are:
When improvements are made, we can update the deployed version like so:
1. ensure latest assets are built, digested, and committed to the repo
```shell
# rebuild static assets:
rm -rf ./priv/static/*
npm --prefix assets run build
MIX_ENV=prod mix phx.digest
# then do a new commit and push...
```
2. on server, build a new container, and run it
### Simple dockerfile
```dockerfile
# ./Dockerfile
# Extend from the official Elixir image
FROM elixir:1.13.4-otp-25-alpine
# # install the package postgresql-client to run pg_isready within entrypoint script
# RUN apt-get update && \
# apt-get install -y postgresql-client
# Copy the entrypoint script
COPY ./entrypoint.sh /entrypoint.sh
# Create app directory and copy the Elixir projects into it
RUN mkdir /app
COPY ./app /app
WORKDIR /app
# Install the build tools we'll need
RUN apk update && \
apk upgrade --no-cache && \
apk add --no-cache \
build-base && \
mix local.rebar --force && \
mix local.hex --force
# The environment to build with
ENV MIX_ENV=prod
# Get deps and compile
RUN mix do deps.get, deps.compile, compile
# Start command
CMD = ["/entrypoint.sh"]
```
### Simple entrypoint script
```shell
cd /opt/shift73k
git pull
mix deps.get --only prod
MIX_ENV=prod mix compile
# might not be needed:
MIX_ENV=prod mix ecto.migrate
# rebuild static assets:
rm -rf priv/static/
npm run deploy --prefix ./assets
MIX_ENV=prod mix phx.digest
MIX_ENV=prod mix release --overwrite
# test starting it:
MIX_ENV=prod _build/prod/rel/shift73k/bin/shift73k start
#!/bin/ash
export MIX_ENV="prod"
cd /app
exec mix ecto.migrate && mix phx.server
```
### systemd unit:
```ini
[Unit]
Description=Shift73k service
After=local-fs.target network.target
[Service]
Type=simple
User=runuser
Group=runuser
WorkingDirectory=/opt/shift73k/_build/prod/rel/shift73k
ExecStart=/opt/shift73k/_build/prod/rel/shift73k/bin/shift73k start
ExecStop=/opt/shift73k/_build/prod/rel/shift73k/bin/shift73k stop
#EnvironmentFile=/etc/default/myApp.env
Environment=LANG=en_US.utf8
Environment=MIX_ENV=prod
#Environment=PORT=4000
LimitNOFILE=65535
UMask=0027
SyslogIdentifier=shift73k
Restart=always
[Install]
WantedBy=multi-user.target
```
### nginx config:
```conf
upstream phoenix {
server 127.0.0.1:4000 max_fails=5 fail_timeout=60s;
}
server {
location / {
allow all;
# Proxy Headers
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-Cluster-Client-Ip $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_redirect off;
# WebSockets
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass http://phoenix;
}
}
```

View file

@ -1,5 +0,0 @@
{
"presets": [
"@babel/preset-env"
]
}

View file

@ -9,6 +9,7 @@
@import "bs-colors";
// Required || Configuration -- CONTINUED
@import "../node_modules/bootstrap/scss/maps";
@import "../node_modules/bootstrap/scss/mixins";
@import "../node_modules/bootstrap/scss/utilities";

View file

@ -9,3 +9,6 @@
// @import "../node_modules/@fontsource/lato/700-italic.css"; /* bold | italic */
// @import "../node_modules/@fontsource/lato/900.css"; /* black | normal */
// @import "../node_modules/@fontsource/lato/900-italic.css"; /* black | italic */
/* Bootstrap Icons Font */
@import "../node_modules/bootstrap-icons/font/bootstrap-icons.css";

View file

@ -32,6 +32,10 @@ $hamburger-active-hover-opacity: $hamburger-hover-opacity !default;
background-color: $navbar-light-color;
}
}
&:focus {
box-shadow: $navbar-light-toggler-border-color 0 0 0 $navbar-toggler-focus-width;
// var(--bs-navbar-toggler-focus-width);
}
}
}
}
@ -51,6 +55,9 @@ $hamburger-active-hover-opacity: $hamburger-hover-opacity !default;
background-color: $navbar-dark-color;
}
}
&:focus {
box-shadow: $navbar-dark-toggler-border-color 0 0 0 $navbar-toggler-focus-width;
}
}
}
}

View file

@ -1,21 +0,0 @@
/*
SVG ICON SYSTEM
per https://blog.prototypr.io/align-svg-icons-to-text-and-say-goodbye-to-font-icons-d44b3d7b26b4
*/
.icon {
display: inline-flex;
align-self: center;
}
.icon svg,
.icon img {
height: 1em;
width: 1em;
fill: currentColor;
}
.icon.baseline svg,
.icon img {
top: 0.125em;
position: relative;
}

View file

@ -4,9 +4,6 @@
/* Load Bootstrap v5 and customizations */
@import "bs-load";
/*SVG ICON SYSTEM*/
@import "svg-icons";
/* LiveView specific CSS */
@import "phx-liveview";
@ -43,7 +40,7 @@
/* style icon */
.inner-addon > .icon {
position: absolute;
padding: 0.5625rem 0.5rem;
padding: 0.25rem 0.5rem;
pointer-events: none;
}
@ -68,3 +65,11 @@
.shift-description p:last-child {
margin-bottom: 0;
}
/*
fix readonly form background
*/
.form-control[readonly] {
background-color: $input-disabled-bg;
}

View file

@ -1,12 +1,14 @@
const togglerBtn = document.getElementById("navbarSupportedContentToggler");
const navbarContent = document.getElementById("navbarSupportedContent");
const togglerBtn = document.getElementById('navbarSupportedContentToggler');
const navbarContent = document.getElementById('navbarSupportedContent');
navbarContent.addEventListener("show.bs.collapse", () => {
console.log("opening navbar content");
togglerBtn.classList.toggle("is-active");
});
navbarContent.addEventListener("hide.bs.collapse", () => {
console.log("closing navbar content");
togglerBtn.classList.toggle("is-active");
});
if (navbarContent != null) {
navbarContent.addEventListener('show.bs.collapse', () => {
console.log('opening navbar content');
togglerBtn.classList.toggle('is-active');
});
navbarContent.addEventListener('hide.bs.collapse', () => {
console.log('closing navbar content');
togglerBtn.classList.toggle('is-active');
});
}

View file

@ -1,84 +1,24 @@
// We need to import the CSS so that webpack will load it.
// The MiniCssExtractPlugin is used to separate it out into
// its own CSS file.
import "../css/app.scss";
// We import the main SCSS file, which performs all other SCSS imports,
// and which vite.js will preprocess with sass.
import '../css/app.scss'
// Import icons for sprite-loader
// navbar brand icon
import "../node_modules/bootstrap-icons/icons/calendar2-week.svg"; // brand
// menus etc
import "../node_modules/bootstrap-icons/icons/person-circle.svg"; // accounts menu
import "../node_modules/bootstrap-icons/icons/person-plus.svg"; // new user / register
import "../node_modules/bootstrap-icons/icons/door-open.svg"; // log in
import "../node_modules/bootstrap-icons/icons/door-closed.svg"; // log out
import "../node_modules/bootstrap-icons/icons/sliders.svg"; // new user / register
// forms etc
import "../node_modules/bootstrap-icons/icons/at.svg"; // email field
import "../node_modules/bootstrap-icons/icons/key.svg"; // new password field
import "../node_modules/bootstrap-icons/icons/key-fill.svg"; // pw confirm field
import "../node_modules/bootstrap-icons/icons/lock.svg"; // current pw field
import "../node_modules/bootstrap-icons/icons/shield.svg"; // role
// live tables
import "../node_modules/bootstrap-icons/icons/arrow-down-up.svg"; // sort
import "../node_modules/bootstrap-icons/icons/funnel.svg"; // filter
import "../node_modules/bootstrap-icons/icons/x-circle-fill.svg"; // clear filter
import "../node_modules/bootstrap-icons/icons/sort-down-alt.svg";
import "../node_modules/bootstrap-icons/icons/sort-up-alt.svg";
import "../node_modules/bootstrap-icons/icons/chevron-left.svg";
import "../node_modules/bootstrap-icons/icons/chevron-right.svg";
import "../node_modules/bootstrap-icons/icons/pencil.svg";
import "../node_modules/bootstrap-icons/icons/trash.svg";
// page headers
import "../node_modules/bootstrap-icons/icons/shield-lock.svg"; // reset password
import "../node_modules/bootstrap-icons/icons/arrow-repeat.svg"; // resend confirmation
import "../node_modules/@mdi/svg/svg/head-question-outline.svg"; // forgot password
import "../node_modules/bootstrap-icons/icons/people.svg"; // users management
// calendar/event icons
import "../node_modules/bootstrap-icons/icons/calendar2.svg";
import "../node_modules/bootstrap-icons/icons/calendar2-plus.svg";
import "../node_modules/bootstrap-icons/icons/calendar2-date.svg";
import "../node_modules/bootstrap-icons/icons/calendar2-event.svg";
import "../node_modules/bootstrap-icons/icons/calendar2-range.svg";
import "../node_modules/bootstrap-icons/icons/clock-history.svg"; // shift template
import "../node_modules/bootstrap-icons/icons/tag.svg";
import "../node_modules/bootstrap-icons/icons/hourglass.svg";
import "../node_modules/bootstrap-icons/icons/map.svg";
import "../node_modules/bootstrap-icons/icons/geo.svg";
import "../node_modules/bootstrap-icons/icons/justify-left.svg";
import "../node_modules/bootstrap-icons/icons/plus-circle-dotted.svg";
import "../node_modules/bootstrap-icons/icons/clipboard-plus.svg";
import "../node_modules/bootstrap-icons/icons/star.svg";
import "../node_modules/bootstrap-icons/icons/star-fill.svg";
import "../node_modules/bootstrap-icons/icons/binoculars.svg";
import "../node_modules/bootstrap-icons/icons/binoculars-fill.svg";
import "../node_modules/bootstrap-icons/icons/eraser.svg";
import "../node_modules/bootstrap-icons/icons/save.svg";
import "../node_modules/bootstrap-icons/icons/asterisk.svg";
import "../node_modules/bootstrap-icons/icons/card-list.svg";
import "../node_modules/bootstrap-icons/icons/file-earmark-spreadsheet.svg";
import "../node_modules/bootstrap-icons/icons/box-arrow-in-left.svg";
import "../node_modules/bootstrap-icons/icons/link.svg";
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
import 'phoenix_html'
// webpack automatically bundles all modules in your
// entry points. Those entry points can be configured
// in "webpack.config.js".
//
// Import deps with the dep name or local files with a relative path, for example:
//
// import {Socket} from "phoenix"
// import socket from "./socket"
//
import "phoenix_html";
// import Socket for Phoenix Channels
import { Socket } from "phoenix";
// import topbar for load progress in live reloading / liveview
import topbar from "topbar";
// import LiveSocket for LiveView
import { LiveSocket } from "phoenix_live_view";
// Bootstrap v5 js imports
import "bootstrap/js/dist/alert";
import "bootstrap/js/dist/collapse";
import "bootstrap/js/dist/dropdown";
import 'bootstrap/js/dist/alert';
import 'bootstrap/js/dist/collapse';
import 'bootstrap/js/dist/dropdown';
// Bootstrap helpers
import "./_hamburger-helper";
import './_hamburger-helper';
import "./_form-validity";
// Bootstrap-liveview helpers
import { AlertRemover } from "./_alert-remover";

17561
assets/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,43 +1,28 @@
{
"repository": {},
"description": " ",
"license": "MIT",
"name": "vanilla",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"deploy": "NODE_ENV=production webpack --mode production",
"watch": "webpack --mode development --watch"
},
"dependencies": {
"@fontsource/lato": "^4.2.1",
"@mdi/svg": "^5.9.55",
"@popperjs/core": "^2.8.4",
"bootstrap": "^5.0.0-beta3",
"bootstrap-icons": "^1.4.0",
"hamburgers": "^1.1.3",
"heroicons": "^0.4.2",
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../deps/phoenix_live_view",
"topbar": "^1.x"
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@babel/core": "^7.x",
"@babel/preset-env": "^7.x",
"autoprefixer": "^10.2.4",
"babel-loader": "^8.x",
"copy-webpack-plugin": "^8.x",
"css-loader": "^5.x",
"css-minimizer-webpack-plugin": "^1.x",
"file-loader": "^6.2.0",
"glob-all": "^3.2.1",
"mini-css-extract-plugin": "^1.x",
"postcss": "^8.2.6",
"postcss-loader": "^5.0.0",
"postcss-scss": "^3.0.4",
"purgecss-webpack-plugin": "^4.0.2",
"sass": "^1.x",
"sass-loader": "^11.x",
"svg-sprite-loader": "^6.x",
"webpack": "^5.x",
"webpack-cli": "^4.x"
"@types/node": "^18.6.5",
"@types/phoenix": "^1.5.4",
"sass": "^1.54.3",
"svg-sprite-generator": "^0.0.7",
"vite": "^3.0.0"
},
"dependencies": {
"@fontsource/lato": "^4.5.9",
"bootstrap": "^5.2.0",
"bootstrap-icons": "^1.9.1",
"hamburgers": "^1.2.1",
"phoenix": "^1.6.11",
"phoenix_html": "^3.2.0",
"phoenix_live_view": "^0.17.11",
"topbar": "^1.x"
}
}

View file

@ -1,5 +0,0 @@
module.exports = {
parser: require("postcss-scss"),
plugins: [require("autoprefixer")],
};

37
assets/vite.config.js Normal file
View file

@ -0,0 +1,37 @@
import { defineConfig } from "vite";
export default defineConfig(({ command }) => {
const isDev = command !== "build";
if (isDev) {
// Terminate the watcher when Phoenix quits
process.stdin.on("close", () => {
process.exit(0);
});
process.stdin.resume();
}
return {
server: {
port: 3000
},
publicDir: "static",
build: {
target: "esnext", // build for recent browsers
outDir: "../priv/static", // emit assets to priv/static
emptyOutDir: true,
sourcemap: isDev, // enable source map in dev build
manifest: false, // do not generate manifest.json
rollupOptions: {
input: {
app: "./js/app.js"
},
output: {
entryFileNames: "assets/[name].js", // remove hash
chunkFileNames: "assets/[name].js",
assetFileNames: "assets/[name][extname]"
}
}
}
};
});

View file

@ -1,105 +0,0 @@
const path = require("path");
const glob = require("glob-all");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const SpriteLoaderPlugin = require("svg-sprite-loader/plugin");
const PurgecssPlugin = require("purgecss-webpack-plugin");
module.exports = (env, options) => {
const devMode = options.mode !== "production";
return {
entry: {
app: glob.sync("./vendor/**/*.js").concat(["./js/app.js"]),
},
output: {
path: path.resolve(__dirname, "../priv/static/js"),
filename: "[name].js",
publicPath: "/js/",
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
},
},
{
test: /\.[s]?css$/,
use: [
MiniCssExtractPlugin.loader,
"css-loader",
"sass-loader",
"postcss-loader",
],
},
{
test: /\.(woff(2)?|ttf|eot)(\?v=\d+\.\d+\.\d+)?$/,
use: [
{
loader: "file-loader",
options: {
esModule: false,
name: "[name].[ext]",
outputPath: "../fonts",
},
},
],
},
{
test: /\.svg$/,
loader: "svg-sprite-loader",
options: {
extract: true,
spriteFilename: "icons.svg",
publicPath: "../images/",
symbolId: (filePath) => {
if (filePath.includes("bootstrap-icons")) {
return `bi-${path.basename(filePath).slice(0, -4)}`;
} else if (filePath.includes("@mdi")) {
return `mdi-${path.basename(filePath).slice(0, -4)}`;
} else if (filePath.includes("heroicons")) {
if (filePath.includes("outline")) {
return `hio-${path.basename(filePath).slice(0, -4)}`;
} else {
return `his-${path.basename(filePath).slice(0, -4)}`;
}
} else {
return `${path.basename(filePath).slice(0, -4)}`;
}
},
},
},
],
},
plugins: [
new MiniCssExtractPlugin({ filename: "../css/app.css" }),
new SpriteLoaderPlugin({ plainSprite: true }),
new CopyWebpackPlugin({
patterns: [{ from: "static/", to: "../" }],
}),
].concat(
devMode
? []
: [
new PurgecssPlugin({
paths: glob.sync([
"../**/*.html.leex",
"../**/*.html.eex",
"../**/views/**/*.ex",
"../**/live/**/*.ex",
"./js/**/*.js",
]),
safelist: [/phx/, /topbar/],
}),
]
),
optimization: {
minimizer: ["...", new CssMinimizerPlugin()],
},
devtool: devMode ? "source-map" : undefined,
};
};

View file

@ -5,7 +5,7 @@
# is restricted to this project.
# General application configuration
use Mix.Config
import Config
config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase
@ -16,7 +16,8 @@ config :shift73k,
config :shift73k, :app_global_vars,
time_zone: "America/New_York",
mailer_reply_to: "reply_to@example.com",
mailer_from: "app_name@example.com"
mailer_from: "app_name@example.com",
allow_registration: :true
# Configures the endpoint
config :shift73k, Shift73kWeb.Endpoint,
@ -26,6 +27,18 @@ config :shift73k, Shift73kWeb.Endpoint,
pubsub_server: Shift73k.PubSub,
live_view: [signing_salt: "2D4GC4ac"]
# Configures the mailer
#
# By default it uses the "Local" adapter which stores the emails
# locally. You can see the emails in your browser, at "/dev/mailbox".
#
# For production it's recommended to configure a different adapter
# at the `config/runtime.exs`.
config :shift73k, Shift73k.Mailer, adapter: Swoosh.Adapters.Local
# Swoosh API client is needed for adapters other than SMTP.
config :swoosh, :api_client, false
# Configures Elixir's Logger
config :logger, :console,
format: "$time $metadata[$level] $message\n",

View file

@ -1,11 +1,12 @@
use Mix.Config
import Config
# Configure your database
config :shift73k, Shift73k.Repo,
username: "postgres",
password: "postgres",
socket_dir: "/srv/dck/postgres/sock/postgres",
database: "shift73k_dev",
hostname: "localhost",
stacktrace: true,
show_sensitive_data_on_connection_error: true,
pool_size: 10
@ -22,11 +23,7 @@ config :shift73k, Shift73kWeb.Endpoint,
check_origin: false,
watchers: [
node: [
"node_modules/webpack/bin/webpack.js",
"--mode",
"development",
"--watch",
"--watch-options-stdin",
"node_modules/vite/bin/vite.js",
cd: Path.expand("../assets", __DIR__)
]
]

View file

@ -1,4 +1,4 @@
use Mix.Config
import Config
# For production, don't forget to configure the url host
# to something meaningful, Phoenix uses this information

View file

@ -1,4 +1,4 @@
use Mix.Config
import Config
# Only in tests, remove the complexity from the password hashing algorithm
config :bcrypt_elixir, :log_rounds, 1
@ -13,7 +13,8 @@ config :shift73k, Shift73k.Repo,
password: "postgres",
database: "shift73k_test#{System.get_env("MIX_TEST_PARTITION")}",
hostname: "localhost",
pool: Ecto.Adapters.SQL.Sandbox
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: 10
# We don't run a server during test. If one is required,
# you can enable the server option below.
@ -24,8 +25,8 @@ config :shift73k, Shift73kWeb.Endpoint,
# Print only warnings and errors during test
config :logger, level: :warn
# Bamboo test mailer config
config :shift73k, Shift73k.Mailer, adapter: Bamboo.TestAdapter
# Swoosh test mailer config
config :shift73k, Shift73k.Mailer, adapter: Swoosh.Adapters.Test
# Import secret config
import_config "test.secret.exs"
# import_config "test.secret.exs"

7
entrypoint.sh Executable file
View file

@ -0,0 +1,7 @@
#!/bin/ash
export MIX_ENV="prod"
cd /app
mix ecto.migrate
exec mix phx.server

View file

@ -108,6 +108,13 @@ defmodule Shift73k.Accounts do
"""
def register_user(attrs) do
# If attrs has atom keys, convert to string
# If attrs don't include role, put default role
attrs =
attrs
|> Map.new(fn {k, v} -> {to_string(k), v} end)
|> Map.put_new("role", registration_role())
%User{}
|> User.registration_changeset(attrs)
|> Repo.insert()

View file

@ -20,19 +20,19 @@ defmodule Shift73k.Accounts.User do
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users" do
field(:email, :string)
field(:password, :string, virtual: true)
field(:hashed_password, :string)
field(:confirmed_at, :naive_datetime)
field(:calendar_slug, :string, default: Ecto.UUID.generate())
field :email, :string
field :password, :string, virtual: true
field :hashed_password, :string
field :confirmed_at, :naive_datetime
field :calendar_slug, :string, default: Ecto.UUID.generate()
field(:role, Ecto.Enum, values: Keyword.keys(@roles), default: :user)
field(:week_start_at, Ecto.Enum, values: weekdays(), default: :monday)
field :role, Ecto.Enum, values: Keyword.keys(@roles), default: :user
field :week_start_at, Ecto.Enum, values: weekdays(), default: :monday
has_many(:shift_templates, ShiftTemplate)
belongs_to(:fave_shift_template, ShiftTemplate)
has_many :shift_templates, ShiftTemplate
belongs_to :fave_shift_template, ShiftTemplate
has_many(:shifts, Shift)
has_many :shifts, Shift
timestamps()
end

View file

@ -2,12 +2,19 @@ defmodule Shift73k.Accounts.UserNotifier do
alias Shift73k.Mailer
alias Shift73k.Mailer.UserEmail
def deliver(user_email, subject, body) do
%Swoosh.Email{} = email = UserEmail.compose(user_email, subject, body)
case Mailer.deliver(email) do
{:ok, msg} -> {:ok, msg, email}
err -> err
end
end
@doc """
Deliver instructions to confirm account.
"""
def deliver_confirmation_instructions(user, url) do
user
|> UserEmail.compose("Confirm Your Account", """
deliver(user.email, "Confirmation instructions", """
==============================
@ -21,15 +28,13 @@ defmodule Shift73k.Accounts.UserNotifier do
==============================
""")
|> Mailer.deliver_later()
end
@doc """
Deliver instructions to reset a user password.
"""
def deliver_reset_password_instructions(user, url) do
user
|> UserEmail.compose("Reset Your Password", """
deliver(user.email, "Reset password instructions", """
==============================
@ -43,15 +48,13 @@ defmodule Shift73k.Accounts.UserNotifier do
==============================
""")
|> Mailer.deliver_later()
end
@doc """
Deliver instructions to update a user email.
"""
def deliver_update_email_instructions(user, url) do
user
|> UserEmail.compose("Change Your E-mail", """
deliver(user.email, "Update email instructions", """
==============================
@ -65,6 +68,5 @@ defmodule Shift73k.Accounts.UserNotifier do
==============================
""")
|> Mailer.deliver_later()
end
end

View file

@ -15,10 +15,10 @@ defmodule Shift73k.Accounts.UserToken do
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users_tokens" do
field(:token, :binary)
field(:context, :string)
field(:sent_to, :string)
belongs_to(:user, Shift73k.Accounts.User)
field :token, :binary
field :context, :string
field :sent_to, :string
belongs_to :user, Shift73k.Accounts.User
timestamps(updated_at: false)
end

View file

@ -1,3 +1,3 @@
defmodule Shift73k.Mailer do
use Bamboo.Mailer, otp_app: :shift73k
use Swoosh.Mailer, otp_app: :shift73k
end

View file

@ -1,16 +1,16 @@
defmodule Shift73k.Mailer.UserEmail do
import Bamboo.Email
import Swoosh.Email
@mailer_vars Application.compile_env(:shift73k, :app_global_vars,
mailer_reply_to: "admin@example.com",
mailer_from: {"Shift73k", "shift73k@example.com"}
)
def compose(user, subject, body_text) do
new_email()
def compose(user_email, subject, body_text) do
new()
|> from(@mailer_vars[:mailer_from])
|> to(user.email)
|> put_header("Reply-To", @mailer_vars[:mailer_reply_to])
|> to(user_email)
|> header("Reply-To", @mailer_vars[:mailer_reply_to])
|> subject(subject)
|> text_body(body_text)
end

View file

@ -86,8 +86,12 @@ defmodule Shift73k.Shifts do
** (Ecto.NoResultsError)
"""
def get_shift!(nil), do: nil
def get_shift!(id), do: Repo.get!(Shift, id)
def get_shift(nil), do: nil
def get_shift(id), do: Repo.get(Shift, id)
@doc """
Creates a shift.

View file

@ -13,7 +13,7 @@ defmodule Shift73k.Shifts.Shift do
field :time_start, :time
field :time_end, :time
belongs_to(:user, Shift73k.Accounts.User)
belongs_to :user, Shift73k.Accounts.User
timestamps()
end

View file

@ -16,8 +16,8 @@ defmodule Shift73k.Shifts.Templates.ShiftTemplate do
field :time_start, :time, default: ~T[09:00:00]
field :time_end, :time, default: ~T[17:00:00]
belongs_to(:user, Shift73k.Accounts.User)
has_one(:is_fave_of_user, Shift73k.Accounts.User, foreign_key: :fave_shift_template_id)
belongs_to :user, Shift73k.Accounts.User
has_one :is_fave_of_user, Shift73k.Accounts.User, foreign_key: :fave_shift_template_id
timestamps()
end
@ -57,6 +57,7 @@ defmodule Shift73k.Shifts.Templates.ShiftTemplate do
[]
end
end)
|> validate_not_nil([:time_zone])
|> validate_inclusion(:time_zone, Tzdata.zone_list(),
message: "must be a valid IANA tz database time zone"
)
@ -72,4 +73,14 @@ defmodule Shift73k.Shifts.Templates.ShiftTemplate do
|> Map.from_struct()
|> Map.drop([:__meta__, :id, :inserted_at, :updated_at, :user, :is_fave_of_user])
end
def validate_not_nil(changeset, fields) do
Enum.reduce(fields, changeset, fn field, changeset ->
if get_field(changeset, field) == nil do
add_error(changeset, field, "nil")
else
changeset
end
end)
end
end

View file

@ -105,9 +105,6 @@ defmodule Shift73kWeb do
# Import basic rendering functionality (render, render_layout, etc)
import Phoenix.View
# Import SVG Icon helper
import Shift73kWeb.IconHelpers
import Shift73kWeb.ErrorHelpers
import Shift73kWeb.Gettext
alias Shift73kWeb.Router.Helpers, as: Routes

View file

@ -10,12 +10,11 @@ defmodule Shift73kWeb.Endpoint do
signing_salt: "9CKxo0VJ"
]
socket("/socket", Shift73kWeb.UserSocket,
socket "/socket", Shift73kWeb.UserSocket,
websocket: true,
longpoll: false
)
socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]])
socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
# Serve at "/" the static files from "priv/static" directory.
#
@ -23,38 +22,54 @@ defmodule Shift73kWeb.Endpoint do
# when deploying your static files in production.
#
# file list generated by simple ls -1 assets/static/ - then copy/paste here
plug(Plug.Static,
plug Plug.Static,
at: "/",
from: :shift73k,
gzip: (Mix.env() not in [:dev, :test]),
only: "priv/static" |> Path.expand() |> File.ls!()
)
gzip: false,
only: ~w(assets
android-chrome-192x192.png
android-chrome-512x512.png
apple-touch-icon.png
browserconfig.xml
favicon-16x16.png
favicon-32x32.png
favicon.ico
mstile-144x144.png
mstile-150x150.png
mstile-310x150.png
mstile-310x310.png
mstile-70x70.png
robots.txt
safari-pinned-tab.svg
site.webmanifest)
# For using vite.js in dev, we need to instruct Phoenix to serve files at assets/src over the usual endpoint. This is only necessary in development.
if Mix.env() == :dev do
plug Plug.Static,
at: "/",
from: "assets",
gzip: false
end
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do
socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket)
plug(Phoenix.LiveReloader)
plug(Phoenix.CodeReloader)
plug(Phoenix.Ecto.CheckRepoStatus, otp_app: :shift73k)
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
plug Phoenix.LiveReloader
plug Phoenix.CodeReloader
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :shift73k
end
plug(Phoenix.LiveDashboard.RequestLogger,
param_key: "request_logger",
cookie_key: "request_logger"
)
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug(Plug.RequestId)
plug(Plug.Telemetry, event_prefix: [:phoenix, :endpoint])
plug(Plug.Parsers,
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
)
plug(Plug.MethodOverride)
plug(Plug.Head)
plug(Plug.Session, @session_options)
plug(Shift73kWeb.Router)
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug Shift73kWeb.Router
end

View file

@ -1,6 +1,5 @@
defmodule Shift73kWeb.LiveHelpers do
import Phoenix.LiveView
import Phoenix.LiveView.Helpers
alias Shift73k.Accounts
alias Shift73k.Accounts.User
@ -19,27 +18,6 @@ defmodule Shift73kWeb.LiveHelpers do
"""
def live_okreply(socket), do: {:ok, socket}
@doc """
Renders a component inside the `Shift73kWeb.ModalComponent` component.
The rendered modal receives a `:return_to` option to properly update
the URL when the modal is closed.
## Examples
<%= live_modal @socket, Shift73kWeb.PropertyLive.FormComponent,
id: @property.id || :new,
action: @live_action,
property: @property,
return_to: Routes.property_index_path(@socket, :index) %>
"""
def live_modal(socket, component, opts) do
modal_opts = [id: :modal, component: component, opts: opts]
# dirty little workaround for elixir complaining about socket being unused
_socket = socket
live_component(socket, Shift73kWeb.ModalComponent, modal_opts)
end
@doc """
Loads default assigns for liveviews
"""

View file

@ -3,12 +3,12 @@ defmodule Shift73kWeb.ModalComponent do
@impl true
def render(assigns) do
~L"""
<div id="<%= @id %>" class="modal fade"
~H"""
<div id={@id} class="modal fade"
phx-hook="BsModal"
phx-window-keydown="hide"
phx-key="escape"
phx-target="#<%= @id %>"
phx-target={"#" <> to_string(@id)}
phx-page-loading>
<div class="modal-dialog modal-dialog-centered">
@ -16,10 +16,10 @@ defmodule Shift73kWeb.ModalComponent do
<div class="modal-header">
<h5 class="modal-title"><%= Keyword.get(@opts, :title, "Modal title") %></h5>
<button type="button" class="btn-close" phx-click="hide" phx-target="<%= @myself %>" aria-label="Close"></button>
<button type="button" class="btn-close" phx-click="hide" phx-target={@myself} aria-label="Close"></button>
</div>
<%= live_component @socket, @component, Keyword.put(@opts, :modal_id, @id) %>
<%= live_component @component, Keyword.put(@opts, :modal_id, @id) %>
</div>
</div>

View file

@ -0,0 +1,35 @@
<div>
<div class="modal-body">
<p>Are you sure you want to delete all assigned shifts from the selected days?</p>
<%= for {y, data} <- @date_map do %>
<dt><%= y %></dt>
<% months = Map.keys(data) %>
<dd>
<%= for {m, i} <- Enum.with_index(months, 1) do %>
<%= data |> Map.get(m) |> hd() |> Calendar.strftime("%b") %>:
<% days = Map.get(data, m) %>
<%= for {d, i} <- Enum.with_index(days, 1) do %>
<%= d.day %><%= if i < length(days) do %>,<% end %>
<% end %>
<%= if i < length(months) do %><br /><% end %>
<% end %>
</dd>
<% end %>
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= link "Confirm Delete", to: "#",
class: "btn btn-danger",
phx_click: "confirm-delete-days-shifts",
phx_target: @myself
%>
</div>
</div>

View file

@ -1,31 +0,0 @@
<div class="modal-body">
<p>Are you sure you want to delete all assigned shifts from the selected days?</p>
<%= for {y, data} <- @date_map do %>
<dt><%= y %></dt>
<% months = Map.keys(data) %>
<dd>
<%= for {m, i} <- Enum.with_index(months, 1) do %>
<%= data |> Map.get(m) |> hd() |> Calendar.strftime("%b") %>:
<% days = Map.get(data, m) %>
<%= for {d, i} <- Enum.with_index(days, 1) do %>
<%= d.day %><%= if i < length(days) do %>,<% end %>
<% end %>
<%= if i < length(months) do %><br /><% end %>
<% end %>
</dd>
<% end %>
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= link "Confirm Delete", to: "#",
class: "btn btn-danger",
phx_click: "confirm-delete-days-shifts",
phx_target: @myself
%>
</div>

View file

@ -1,5 +1,6 @@
defmodule Shift73kWeb.ShiftAssignLive.Index do
use Shift73kWeb, :live_view
import Shift73k, only: [app_time_zone: 0]
alias Shift73k.Repo
alias Shift73k.Shifts

View file

@ -1,16 +1,20 @@
<%= if @delete_days_shifts do %>
<%= live_modal @socket, Shift73kWeb.ShiftAssignLive.DeleteComponent,
<.live_component
module={Shift73kWeb.ModalComponent}
id="modal"
component={Shift73kWeb.ShiftAssignLive.DeleteComponent}
opts={[
id: "delete-days-shifts-#{@current_user.id}",
title: "Delete Shifts From Selected Days",
delete_days_shifts: @delete_days_shifts,
current_user: @current_user
%>
]}
/>
<% end %>
<h2 class="mb-3 mb-sm-0">
<%= icon_div @socket, "bi-calendar2-plus", [class: "icon baseline"] %>
Schedule Shifts
<i class="bi bi-calendar2-plus me-1"></i> Schedule Shifts
</h2>
<div class="row justify-content-center mt-4">
@ -24,14 +28,19 @@
%>
<% end %>
<button type="button" class="ms-2 btn btn-primary text-nowrap <%= if @show_template_btn_active, do: "active" %>" id="#templateDetailsBtn" phx-click="toggle-template-details" phx-value-target_id="#templateDetailsCol">
<%= icon_div @socket, (@show_template_btn_active && "bi-binoculars-fill" || "bi-binoculars"), [class: "icon baseline"] %>
<% details_button_class = "ms-2 btn btn-primary text-nowrap"
details_button_class = if @show_template_btn_active, do: "#{details_button_class} active", else: details_button_class %>
<button type="button" class={details_button_class} id="#templateDetailsBtn" phx-click="toggle-template-details" phx-value-target_id="#templateDetailsCol">
<i class={@show_template_btn_active && "bi bi-binoculars-fill me-sm-1" || "bi bi-binoculars me-sm-1"}></i>
<span class="d-none d-sm-inline">Details</span>
</button>
</div>
<div class="col-12 col-lg-9 col-xl-8 col-xxl-7 <%= @show_template_details && "collapse show" || "collapse" %>" id="#templateDetailsCol" phx-hook="BsCollapse">
<% template_details_div_class = "col-12 col-lg-9 col-xl-8 col-xxl-7 collapse"
template_details_div_class = if @show_template_details, do: "#{template_details_div_class} show", else: template_details_div_class %>
<div class={template_details_div_class} id="#templateDetailsCol" phx-hook="BsCollapse">
<div class="card mt-4">
<div class="card-body">
@ -41,8 +50,8 @@
<div class="col-12 col-md-6">
<%= label stf, :subject, "Subject/Title", class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(stf, :subject) %>">
<%= icon_div @socket, "bi-tag", [class: "icon is-left text-muted fs-5"] %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(stf, :subject)}>
<i class="bi bi-tag icon is-left text-muted fs-5"></i>
<%= text_input stf, :subject,
value: input_value(stf, :subject),
class: input_class(stf, :subject, "form-control"),
@ -57,25 +66,27 @@
<div class="col-12 col-md-6 mb-3">
<div class="row gx-2 gx-sm-3">
<div class="col-6" phx-feedback-for="<%= input_id(stf, :time_start) %>">
<div class="col-6" phx-feedback-for={input_id(stf, :time_start)}>
<%= label stf, :time_start, "Start", class: "form-label" %>
<%= time_input stf, :time_start,
precision: :minute,
value: input_value(stf, :time_start),
class: input_class(stf, :time_start, "form-control"),
disabled: @shift_template.id != @custom_shift.id,
aria_describedby: error_ids(stf, :time_start)
aria_describedby: error_ids(stf, :time_start),
required: true
%>
</div>
<div class="col-6" phx-feedback-for="<%= input_id(stf, :time_end) %>">
<div class="col-6" phx-feedback-for={input_id(stf, :time_end)}>
<%= label stf, :time_end, "End", class: "form-label" %>
<%= time_input stf, :time_end,
precision: :minute,
value: input_value(stf, :time_end),
class: input_class(stf, :time_end, "form-control"),
disabled: @shift_template.id != @custom_shift.id,
aria_describedby: error_ids(stf, :time_end)
aria_describedby: error_ids(stf, :time_end),
required: true
%>
</div>
@ -83,18 +94,18 @@
<div class="valid-feedback d-block text-primary">Shift length: <%= @shift_length %></div>
<div class="phx-orphaned-feedback" phx-feedback-for="<%= input_id(stf, :time_start) %>">
<div class="phx-orphaned-feedback" phx-feedback-for={input_id(stf, :time_start)}>
<%= error_tag stf, :time_start %>
</div>
<div class="phx-orphaned-feedback" phx-feedback-for="<%= input_id(stf, :time_end) %>">
<div class="phx-orphaned-feedback" phx-feedback-for={input_id(stf, :time_end)}>
<%= error_tag stf, :time_end %>
</div>
</div>
<div class="col-12 col-md-6">
<%= label stf, :location, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(stf, :location) %>">
<%= icon_div @socket, "bi-geo", [class: "icon is-left text-muted fs-5"] %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(stf, :location)}>
<i class="bi bi-geo icon is-left text-muted fs-5"></i>
<%= text_input stf, :location,
value: input_value(stf, :location),
class: input_class(stf, :location, "form-control"),
@ -108,18 +119,19 @@
<div class="col-12 col-md-6">
<%= label stf, :time_zone, class: "form-label" %>
<div class="inner-addon left-addon mb-3 mb-md-0" phx-feedback-for="<%= input_id(stf, :time_zone) %>">
<%= icon_div @socket, "bi-map", [class: "icon is-left text-muted fs-5"] %>
<div class="inner-addon left-addon mb-3 mb-md-0" phx-feedback-for={input_id(stf, :time_zone)}>
<i class="bi bi-map icon is-left text-muted fs-5"></i>
<%= text_input stf, :time_zone,
value: input_value(stf, :time_zone),
class: input_class(stf, :time_zone, "form-control"),
disabled: @shift_template.id != @custom_shift.id,
phx_debounce: 250,
list: "tz_list"
list: "tz_list",
placeholder: "Default: #{app_time_zone()}"
%>
<datalist id="tz_list">
<%= for tz_name <- Tzdata.zone_list() do %>
<option value="<%= tz_name %>"></option>
<option value={tz_name}></option>
<% end %>
end
</datalist>
@ -132,7 +144,7 @@
<div class="col-12">
<%= label stf, :description, class: "form-label" %>
<div phx-feedback-for="<%= input_id(stf, :description) %>">
<div phx-feedback-for={input_id(stf, :description)}>
<%= textarea stf, :description,
value: input_value(stf, :description),
@ -174,17 +186,17 @@
<%= Calendar.strftime(@cursor_date, "%B %Y") %>
</h3>
<div>
<button type="button" phx-click="month-nav" phx-value-month="now" class="btn btn-info text-white" <%= if Map.get(@cursor_date, :month) == Map.get(Date.utc_today(), :month), do: "disabled" %>>
<%= icon_div @socket, "bi-asterisk", [class: "icon baseline"] %>
<button type="button" phx-click="month-nav" phx-value-month="now" class="btn btn-info text-white" disabled={if Map.get(@cursor_date, :month) == Map.get(Date.utc_today(), :month), do: :true, else: :false}>
<i class="bi bi-asterisk me-sm-1"></i>
<span class="d-none d-sm-inline">Today</span>
</button>
<button type="button" phx-click="month-nav" phx-value-month="prev" class="btn btn-primary">
<%= icon_div @socket, "bi-chevron-left", [class: "icon baseline"] %>
<i class="bi bi-chevron-left me-sm-1"></i>
<span class="d-none d-sm-inline">Prev</span>
</button>
<button type="button" phx-click="month-nav" phx-value-month="next" class="btn btn-primary">
<span class="d-none d-sm-inline">Next</span>
<%= icon_div @socket, "bi-chevron-right", [class: "icon baseline", style: "margin-left:0.125rem;"] %>
<i class="bi bi-chevron-right ms-sm-1"></i>
</button>
</div>
</div>
@ -204,7 +216,7 @@
<%= for week <- @week_rows do %>
<tr>
<%= for day <- week do %>
<td class="<%= day_color(day, @current_date, @cursor_date, @selected_days) %>" phx-click="select-day" phx-value-day="<%= day %>">
<td class={day_color(day, @current_date, @cursor_date, @selected_days)} phx-click="select-day" phx-value-day={day}>
<%= Calendar.strftime(day, "%d") %><%= if day.month != @cursor_date.month, do: "-#{Calendar.strftime(day, "%b")}" %>
@ -236,19 +248,16 @@
<div class="row justify-content-center justify-content-lg-end my-5">
<div class="col-12 col-sm-10 col-md-8 col-lg-auto d-flex flex-column-reverse flex-lg-row">
<button class="btn btn-outline-danger mb-1 mb-lg-0 me-lg-1" phx-click="delete-days-shifts" <%= if Enum.empty?(@selected_days), do: "disabled" %>>
<%= icon_div @socket, "bi-trash", [class: "icon baseline"] %>
Delete shifts from selected days
<button class="btn btn-outline-danger mb-1 mb-lg-0 me-lg-1" phx-click="delete-days-shifts" disabled={if Enum.empty?(@selected_days), do: :true, else: :false}>
<i class="bi bi-trash me-1"></i> Delete shifts from selected days
</button>
<button class="btn btn-outline-dark mb-1 mb-lg-0 me-lg-1" phx-click="clear-days" <%= if Enum.empty?(@selected_days), do: "disabled" %>>
<%= icon_div @socket, "bi-eraser", [class: "icon baseline"] %>
De-select all selected
<button class="btn btn-outline-dark mb-1 mb-lg-0 me-lg-1" phx-click="clear-days" disabled={if Enum.empty?(@selected_days), do: :true, else: :false}>
<i class="bi bi-eraser me-1"></i> De-select all selected
</button>
<button class="btn btn-primary mb-1 mb-lg-0" phx-click="save-days" <%= if (!@shift_template_changeset.valid? || Enum.empty?(@selected_days)), do: "disabled" %>>
<%= icon_div @socket, "bi-save", [class: "icon baseline"] %>
Assign shifts to selected days
<button class="btn btn-primary mb-1 mb-lg-0" phx-click="save-days" disabled={if (!@shift_template_changeset.valid? || Enum.empty?(@selected_days)), do: :true, else: :false}>
<i class="bi bi-save me-1"></i> Assign shifts to selected days
</button>
</div>

View file

@ -2,10 +2,8 @@
<div class="col-12 col-md-10 col-xl-8">
<h2>
<%= icon_div @socket, "bi-box-arrow-in-left", [class: "icon baseline"] %>
Import Shifts
<i class="bi bi-box-arrow-in-left me-1"></i> Import Shifts
</h2>
<p class="lead">If you have an iCal/ics formatted calendar hosted elsewhere, provide its URL here to import its events.</p>
<div class="row justify-content-center">
@ -17,7 +15,7 @@
<% valid_class = @url_validated && "is-valid" || "" %>
<%= label iimf, :ics_url, "iCal/ics URL", class: "form-label" %>
<div class="inner-addon left-addon mb-3">
<%= icon_div @socket, "bi-link", [class: "icon is-left text-muted fs-5"] %>
<i class="bi bi-link icon is-left text-muted fs-5"></i>
<%= url_input iimf, :ics_url,
class: show_url_error && "form-control is-invalid" || "form-control #{valid_class}",
phx_debounce: 500,
@ -33,7 +31,7 @@
<%= label iimf, :time_zone, class: "form-label" %>
<div class="inner-addon left-addon mb-3">
<%= icon_div @socket, "bi-map", [class: "icon is-left text-muted fs-5"] %>
<i class="bi bi-map icon is-left text-muted fs-5"></i>
<%= text_input iimf, :time_zone,
value: Shift73k.app_time_zone(),
class: @tz_valid && "form-control" || "form-control is-invalid",
@ -43,7 +41,7 @@
%>
<datalist id="tz_list">
<%= for tz_name <- Tzdata.zone_list() do %>
<option value="<%= tz_name %>"></option>
<option value={tz_name}></option>
<% end %>
</datalist>
<div class="valid-feedback d-block text-primary">Type to search & select from list of known <%= link "IANA tz database", to: "https://en.wikipedia.org/wiki/List_of_tz_database_time_zones", target: "_blank" %> time zones</div>

View file

@ -0,0 +1,46 @@
defmodule Shift73kWeb.ShiftLive.DeleteComponent do
use Shift73kWeb, :live_component
alias Shift73k.Shifts
@impl true
def update(assigns, socket) do
socket
|> assign(assigns)
|> live_okreply()
end
@impl true
def handle_event("confirm", %{"id" => id, "subject" => subject, "datetime" => datetime}, socket) do
shift = Shifts.get_shift(id)
if (shift) do
shift
|> Shifts.delete_shift()
|> case do
{:ok, _} ->
flash = {:info, "Shift deleted successfully: \"#{subject}\""}
send(self(), {:put_flash_message, flash})
socket
|> push_event("modal-please-hide", %{})
|> live_noreply()
{:error, _} ->
handle_error(socket, subject, datetime)
end
end
end
defp handle_error(socket, subject, datetime) do
flash =
{:error,
"Some error trying to delete shift \"#{subject} (#{datetime})\". Possibly already deleted? Reloading list..."}
send(self(), {:put_flash_message, flash})
socket
|> push_event("modal-please-hide", %{})
|> live_noreply()
end
end

View file

@ -0,0 +1,22 @@
<div>
<div class="modal-body">
<% shift_datetime = "#{Calendar.strftime(@delete_shift.date, "%A, %b %-d")}, #{format_shift_time(@delete_shift.time_start)} — #{format_shift_time(@delete_shift.time_end)}" %>
Are you sure you want to delete "<%= @delete_shift.subject %> (<%= shift_datetime %>)?"
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= link "Confirm Delete", to: "#",
class: "btn btn-danger",
phx_click: "confirm",
phx_target: @myself,
phx_value_id: @delete_shift.id,
phx_value_subject: @delete_shift.subject,
phx_value_datetime: shift_datetime %>
</div>
</div>

View file

@ -22,6 +22,7 @@ defmodule Shift73kWeb.ShiftLive.Index do
socket
|> init_today(Date.utc_today())
|> update_agenda()
|> assign_modal_close_handlers()
|> assign(:delete_shift, nil)
|> apply_action(socket.assigns.live_action, params)
|> live_noreply()
@ -33,6 +34,11 @@ defmodule Shift73kWeb.ShiftLive.Index do
end
end
defp assign_modal_close_handlers(socket) do
to = Routes.shift_index_path(socket, :index)
assign(socket, modal_return_to: to, modal_close_action: :return)
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "My Shifts")
@ -76,6 +82,14 @@ defmodule Shift73kWeb.ShiftLive.Index do
|> assign_known_shifts()
end
@impl true
def handle_event("delete-modal", %{"id" => id}, socket) do
socket
|> assign(:modal_close_action, :delete_shift)
|> assign(:delete_shift, Shifts.get_shift!(id))
|> live_noreply()
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
shift = Shifts.get_shift!(id)
@ -94,6 +108,28 @@ defmodule Shift73kWeb.ShiftLive.Index do
|> live_noreply()
end
@impl true
def handle_info({:close_modal, _}, %{assigns: %{modal_close_action: :return}} = socket) do
socket
|> copy_flash()
|> push_patch(to: socket.assigns.modal_return_to)
|> live_noreply()
end
@impl true
def handle_info({:close_modal, _}, %{assigns: %{modal_close_action: assign_key}} = socket) do
socket
|> assign(assign_key, nil)
|> assign_modal_close_handlers()
|> assign_known_shifts()
|> live_noreply()
end
@impl true
def handle_info({:put_flash_message, {flash_type, msg}}, socket) do
socket |> put_flash(flash_type, msg) |> live_noreply()
end
defp new_nav_cursor("now", _cursor_date), do: Date.utc_today()
defp new_nav_cursor(nav, cursor_date) do

View file

@ -1,40 +1,48 @@
<%= if @delete_shift do %>
<.live_component
module={Shift73kWeb.ModalComponent}
id="modal"
component={Shift73kWeb.ShiftLive.DeleteComponent}
opts={[
id: @delete_shift.id,
title: "Delete Shift Template",
delete_shift: @delete_shift
]}
/>
<% end %>
<div class="row justify-content-start justify-content-sm-center">
<div class="col-md-10 col-xl-10">
<h2 class="mb-3 mb-sm-0">
<%= icon_div @socket, "bi-card-list", [class: "icon baseline"] %>
My Shifts
<i class="bi bi-card-list me-1"></i> My Shifts
</h2>
<div class="row justify-content-start justify-content-sm-center">
<div class="col-md-10 col-xl-10">
<%# month navigation %>
<div class="d-flex justify-content-between align-items-end my-4">
<h3 class="text-muted mb-0">
<%= Calendar.strftime(@cursor_date, "%B %Y") %>
</h3>
<div>
<button type="button" phx-click="month-nav" phx-value-month="now" class="btn btn-info text-white" <%= if Map.get(@cursor_date, :month) == Map.get(Date.utc_today(), :month), do: "disabled" %>>
<%= icon_div @socket, "bi-asterisk", [class: "icon baseline"] %>
<button type="button" phx-click="month-nav" phx-value-month="now" class="btn btn-info text-white" disabled={if Map.get(@cursor_date, :month) == Map.get(Date.utc_today(), :month), do: :true, else: :false}>
<i class="bi bi-asterisk me-sm-1"></i>
<span class="d-none d-sm-inline">Today</span>
</button>
<button type="button" phx-click="month-nav" phx-value-month="prev" class="btn btn-primary">
<%= icon_div @socket, "bi-chevron-left", [class: "icon baseline"] %>
<i class="bi bi-chevron-left me-sm-1"></i>
<span class="d-none d-sm-inline">Prev</span>
</button>
<button type="button" phx-click="month-nav" phx-value-month="next" class="btn btn-primary">
<span class="d-none d-sm-inline">Next</span>
<%= icon_div @socket, "bi-chevron-right", [class: "icon baseline", style: "margin-left:0.125rem;"] %>
<i class="bi bi-chevron-right ms-sm-1"></i>
</button>
</div>
</div>
<%= for day <- Enum.to_list(@date_range) do %>
<%= if Date.day_of_week(day, @current_user.week_start_at) == 1 do %>
<div class="border-top mt-4 mb-4"></div>
@ -45,24 +53,21 @@
<% day_shifts = Enum.filter(@shifts, fn s -> s.date == day end) %>
<%= if !Enum.empty?(day_shifts) do %>
<%= for shift <- day_shifts do %>
<div class="card mt-2 mb-4 col-12 ms-sm-3 ms-md-4 col-lg-10 ms-lg-5 col-xxl-8" id="shift-<%= shift.id %>">
<div class="card mt-2 mb-4 col-12 ms-sm-3 ms-md-4 col-lg-10 ms-lg-5 col-xxl-8" id={"shift-#{shift.id}"}>
<div class="card-body">
<h5 class="card-title">
<%= icon_div @socket, "bi-tag", [class: "icon baseline text-muted me-1"] %>
<i class="bi bi-tag text-muted me-1"></i>
<%= shift.subject %>
</h5>
<table class="table table-borderless table-nonfluid table-sm">
<tbody>
<tr>
<th scope="row" class="text-end">
<%= icon_div @socket, "bi-hourglass", [class: "icon baseline text-muted"] %>
<i class="bi bi-hourglass text-muted"></i>
<span class="visually-hidden">Hours:</span>
</th>
<td>
@ -79,7 +84,7 @@
<tr>
<th scope="row" class="text-end">
<%= icon_div @socket, "bi-geo", [class: "icon baseline text-muted"] %>
<i class="bi bi-geo text-muted"></i>
<span class="visually-hidden">Location:</span>
</th>
<td>
@ -92,7 +97,7 @@
</tr>
<tr>
<th scope="row" class="text-end">
<%= icon_div @socket, "bi-justify-left", [class: "icon baseline text-muted"] %>
<i class="bi bi-justify-left text-muted"></i>
<span class="visually-hidden">Description:</span>
</th>
<td class="shift-description">
@ -106,51 +111,22 @@
</tbody>
</table>
<%#= if Roles.can?(@current_user, template, :edit) do %>
<%#= live_patch to: Routes.shift_template_index_path(@socket, :edit, template), class: "btn btn-primary btn-sm text-nowrap" do %>
<%#= icon_div @socket, "bi-pencil", [class: "icon baseline"] %>
<%# Edit %>
<%# end %>
<%# end %>
<%#= if Roles.can?(@current_user, template, :delete) do %>
<%# <button class="btn btn-outline-danger btn-sm text-nowrap" phx-click="delete-modal" phx-value-id=" %>
<%#= shift.id %>
<%# "> %>
<%#= icon_div @socket, "bi-trash", [class: "icon baseline"] %>
<%# Delete %>
<%# </button> %>
<%# end %>
<%= button to: "#", phx_click: "delete", phx_value_id: shift.id, data: [confirm: "Are you sure?"], class: "btn btn-outline-danger btn-sm text-nowrap" do %>
<%= icon_div @socket, "bi-trash", [class: "icon baseline"] %>
Delete
<% end %>
<button class="btn btn-outline-danger btn-sm text-nowrap" phx-click="delete-modal" phx-value-id={shift.id}>
<i class="bi bi-trash me-1"></i> Delete
</button>
</div>
</div>
<% end %>
<% else %>
<p class="text-muted"><em>Nothing scheduled</em></p>
<% end %>
<% end %>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,23 @@
<div>
<div class="modal-body">
Are you sure you want to delete "<%= @delete_shift_template.subject %>
(<%= format_shift_time(@delete_shift_template.time_start) %>
&mdash;
<%= format_shift_time(@delete_shift_template.time_end) %>)"?
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= link "Confirm Delete", to: "#",
class: "btn btn-danger",
phx_click: "confirm",
phx_target: @myself,
phx_value_id: @delete_shift_template.id,
phx_value_subject: @delete_shift_template.subject %>
</div>
</div>

View file

@ -1,19 +0,0 @@
<div class="modal-body">
Are you sure you want to delete "<%= @delete_shift_template.subject %>
(<%= format_shift_time(@delete_shift_template.time_start) %>
&mdash;
<%= format_shift_time(@delete_shift_template.time_end) %>)"?
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= link "Confirm Delete", to: "#",
class: "btn btn-danger",
phx_click: "confirm",
phx_target: @myself,
phx_value_id: @delete_shift_template.id,
phx_value_subject: @delete_shift_template.subject %>
</div>

View file

@ -1,5 +1,6 @@
defmodule Shift73kWeb.ShiftTemplateLive.FormComponent do
use Shift73kWeb, :live_component
import Shift73k, only: [app_time_zone: 0]
alias Shift73k.Shifts.Templates
alias Shift73k.Shifts.Templates.ShiftTemplate

View file

@ -0,0 +1,119 @@
<div>
<.form let={f} for={@changeset} phx-change="validate" phx-submit="save" phx-target={@myself} id="shift_template-form">
<div class="modal-body">
<%= label f, :subject, "Subject/Title", class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :subject)}>
<i class="bi bi-tag icon is-left text-muted fs-5"></i>
<%= text_input f, :subject,
value: input_value(f, :subject),
class: input_class(f, :subject, "form-control"),
autofocus: true,
phx_debounce: 250,
aria_describedby: error_ids(f, :subject)
%>
<%= error_tag f, :subject %>
</div>
<div class="row gx-2 gx-sm-3">
<div class="col-6" phx-feedback-for={input_id(f, :time_start)}>
<%= label f, :time_start, "Start", class: "form-label" %>
<%= time_input f, :time_start,
precision: :minute,
value: input_value(f, :time_start),
class: input_class(f, :time_start, "form-control"),
aria_describedby: error_ids(f, :time_start),
required: true
%>
</div>
<div class="col-6" phx-feedback-for={input_id(f, :time_end)}>
<%= label f, :time_end, "End", class: "form-label" %>
<%= time_input f, :time_end,
precision: :minute,
value: input_value(f, :time_end),
class: input_class(f, :time_end, "form-control"),
aria_describedby: error_ids(f, :time_end),
required: true
%>
</div>
</div>
<div class="valid-feedback d-block text-primary">Shift length: <%= @shift_length %></div>
<div class="phx-orphaned-feedback" phx-feedback-for={input_id(f, :time_start)}>
<%= error_tag f, :time_start %>
</div>
<div class="phx-orphaned-feedback" phx-feedback-for={input_id(f, :time_end)}>
<%= error_tag f, :time_end %>
</div>
<%= label f, :location, class: "form-label mt-3" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :location)}>
<i class="bi bi-geo icon is-left text-muted fs-5"></i>
<%= text_input f, :location,
value: input_value(f, :location),
class: input_class(f, :location, "form-control"),
phx_debounce: 250,
aria_describedby: error_ids(f, :location)
%>
<%= error_tag f, :location %>
</div>
<%= label f, :description, class: "form-label" %>
<div class="mb-3" phx-feedback-for={input_id(f, :description)}>
<%= textarea f, :description,
value: input_value(f, :description),
class: input_class(f, :description, "form-control"),
phx_debounce: 250,
aria_describedby: error_ids(f, :description)
%>
<%= error_tag f, :description %>
</div>
<%= label f, :time_zone, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :time_zone)}>
<i class="bi bi-map icon is-left text-muted fs-5"></i>
<%= text_input f, :time_zone,
value: input_value(f, :time_zone),
class: input_class(f, :time_zone, "form-control"),
phx_debounce: 250,
list: "tz_list",
placeholder: "Default: #{app_time_zone()}"
%>
<datalist id="tz_list">
<%= for tz_name <- Tzdata.zone_list() do %>
<option value={tz_name}></option>
<% end %>
</datalist>
<div class="valid-feedback d-block text-primary">Type to search & select from list of known <%= link "IANA tz database", to: "https://en.wikipedia.org/wiki/List_of_tz_database_time_zones", target: "_blank" %> time zones</div>
<%= error_tag f, :time_zone %>
</div>
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= submit "Save",
class: "btn btn-primary ",
disabled: !@changeset.valid?,
aria_disabled: !@changeset.valid? && "true" || false,
phx_disable_with: "Saving..."
%>
</div>
</.form>
</div>

View file

@ -1,116 +0,0 @@
<%= f = form_for @changeset, "#",
id: "shift_template-form",
phx_target: @myself,
phx_change: "validate",
phx_submit: "save" %>
<div class="modal-body">
<%= label f, :subject, "Subject/Title", class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :subject) %>">
<%= icon_div @socket, "bi-tag", [class: "icon is-left text-muted fs-5"] %>
<%= text_input f, :subject,
value: input_value(f, :subject),
class: input_class(f, :subject, "form-control"),
autofocus: true,
phx_debounce: 250,
aria_describedby: error_ids(f, :subject)
%>
<%= error_tag f, :subject %>
</div>
<div class="row gx-2 gx-sm-3">
<div class="col-6" phx-feedback-for="<%= input_id(f, :time_start) %>">
<%= label f, :time_start, "Start", class: "form-label" %>
<%= time_input f, :time_start,
precision: :minute,
value: input_value(f, :time_start),
class: input_class(f, :time_start, "form-control"),
aria_describedby: error_ids(f, :time_start)
%>
</div>
<div class="col-6" phx-feedback-for="<%= input_id(f, :time_end) %>">
<%= label f, :time_end, "End", class: "form-label" %>
<%= time_input f, :time_end,
precision: :minute,
value: input_value(f, :time_end),
class: input_class(f, :time_end, "form-control"),
aria_describedby: error_ids(f, :time_end)
%>
</div>
</div>
<div class="valid-feedback d-block text-primary">Shift length: <%= @shift_length %></div>
<div class="phx-orphaned-feedback" phx-feedback-for="<%= input_id(f, :time_start) %>">
<%= error_tag f, :time_start %>
</div>
<div class="phx-orphaned-feedback" phx-feedback-for="<%= input_id(f, :time_end) %>">
<%= error_tag f, :time_end %>
</div>
<%= label f, :location, class: "form-label mt-3" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :location) %>">
<%= icon_div @socket, "bi-geo", [class: "icon is-left text-muted fs-5"] %>
<%= text_input f, :location,
value: input_value(f, :location),
class: input_class(f, :location, "form-control"),
phx_debounce: 250,
aria_describedby: error_ids(f, :location)
%>
<%= error_tag f, :location %>
</div>
<%= label f, :description, class: "form-label" %>
<div class="mb-3" phx-feedback-for="<%= input_id(f, :description) %>">
<%= textarea f, :description,
value: input_value(f, :description),
class: input_class(f, :description, "form-control"),
phx_debounce: 250,
aria_describedby: error_ids(f, :description)
%>
<%= error_tag f, :description %>
</div>
<%= label f, :time_zone, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :time_zone) %>">
<%= icon_div @socket, "bi-map", [class: "icon is-left text-muted fs-5"] %>
<%= text_input f, :time_zone,
value: input_value(f, :time_zone),
class: input_class(f, :time_zone, "form-control"),
phx_debounce: 250,
list: "tz_list"
%>
<datalist id="tz_list">
<%= for tz_name <- Tzdata.zone_list() do %>
<option value="<%= tz_name %>"></option>
<% end %>
</datalist>
<div class="valid-feedback d-block text-primary">Type to search & select from list of known <%= link "IANA tz database", to: "https://en.wikipedia.org/wiki/List_of_tz_database_time_zones", target: "_blank" %> time zones</div>
<%= error_tag f, :time_zone %>
</div>
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= submit "Save",
class: "btn btn-primary ",
disabled: !@changeset.valid?,
aria_disabled: !@changeset.valid? && "true" || false,
phx_disable_with: "Saving..."
%>
</div>
</form>

View file

@ -1,17 +1,29 @@
<%= if @live_action in [:new, :edit, :clone] do %>
<%= live_modal @socket, Shift73kWeb.ShiftTemplateLive.FormComponent,
id: @shift_template.id || :new,
title: @page_title,
action: @live_action,
shift_template: @shift_template,
current_user: @current_user %>
<.live_component
module={Shift73kWeb.ModalComponent}
id="modal"
component={Shift73kWeb.ShiftTemplateLive.FormComponent}
opts={[
id: @shift_template.id || :new,
title: @page_title,
action: @live_action,
shift_template: @shift_template,
current_user: @current_user
]}
/>
<% end %>
<%= if @delete_shift_template do %>
<%= live_modal @socket, Shift73kWeb.ShiftTemplateLive.DeleteComponent,
<.live_component
module={Shift73kWeb.ModalComponent}
id="modal"
component={Shift73kWeb.ShiftTemplateLive.DeleteComponent}
opts={[
id: @delete_shift_template.id,
title: "Delete Shift Template",
delete_shift_template: @delete_shift_template %>
delete_shift_template: @delete_shift_template
]}
/>
<% end %>
@ -20,12 +32,10 @@
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-start align-items-sm-center">
<h2 class="mb-3 mb-sm-0">
<%= icon_div @socket, "bi-clock-history", [class: "icon baseline"] %>
My Shift Templates
<i class="bi bi-clock-history me-1"></i> My Shift Templates
</h2>
<%= live_patch to: Routes.shift_template_index_path(@socket, :new), class: "btn btn-primary" do %>
<%= icon_div @socket, "bi-plus-circle-dotted", [class: "icon baseline"] %>
New Shift Template
<i class="bi bi-plus-circle-dotted me-1"></i> New Shift Template
<% end %>
</div>
@ -39,13 +49,13 @@
<div class="card mt-4">
<h5 class="card-header d-flex justify-content-between align-items-center">
<span class="visually-hidden">Subject:</span>
<%= icon_div @socket, "bi-tag", [class: "icon baseline me-1"] %>
<i class="bi bi-tag me-1"></i>
<div class="w-100"><%= template.subject %></div>
<%= if template.id == @current_user.fave_shift_template_id do %>
<%= icon_div @socket, "bi-star-fill", [class: "icon baseline text-primary align-self-start ms-2"], [role: "img", aria_hidden: false, aria_label: "Unset as favorite", phx_click: "unset-user-fave-shift-template", class: "cursor-pointer"] %>
<% else %>
<%= icon_div @socket, "bi-star", [class: "icon baseline text-primary align-self-start ms-2"], [role: "img", aria_hidden: false, aria_label: "Set as favorite", phx_click: "set-user-fave-shift-template", phx_value_id: template.id, class: "cursor-pointer"] %>
<% end %>
<% fav_icon_data = if template.id == @current_user.fave_shift_template_id, do:
{"bi-star-fill", "Unset as favorite", "unset-user-fave-shift-template"}, else:
{"bi-star", "Set as favorite", "set-user-fave-shift-template"}
%>
<i class={"bi #{elem(fav_icon_data, 0)} text-primary align-self-start ms-2 cursor-pointer"} role="img" aria-hidden="false" aria-label={elem(fav_icon_data, 1)} phx-click={elem(fav_icon_data, 2)} phx-value-id={template.id}></i>
</h5>
<div class="card-body">
@ -53,7 +63,7 @@
<tbody>
<tr>
<th scope="row" class="text-end">
<%= icon_div @socket, "bi-hourglass", [class: "icon baseline text-muted"] %>
<i class="bi bi-hourglass text-muted"></i>
<span class="visually-hidden">Hours:</span>
</th>
<td>
@ -70,7 +80,7 @@
<tr>
<th scope="row" class="text-end">
<%= icon_div @socket, "bi-geo", [class: "icon baseline text-muted"] %>
<i class="bi bi-geo text-muted"></i>
<span class="visually-hidden">Location:</span>
</th>
<td>
@ -83,7 +93,7 @@
</tr>
<tr>
<th scope="row" class="text-end">
<%= icon_div @socket, "bi-justify-left", [class: "icon baseline text-muted"] %>
<i class="bi bi-justify-left text-muted"></i>
<span class="visually-hidden">Description:</span>
</th>
<td>
@ -99,24 +109,19 @@
<%= if Roles.can?(@current_user, template, :edit) do %>
<%= live_patch to: Routes.shift_template_index_path(@socket, :edit, template), class: "btn btn-primary btn-sm text-nowrap" do %>
<%= icon_div @socket, "bi-pencil", [class: "icon baseline"] %>
Edit
<i class="bi bi-pencil me-1"></i> Edit
<% end %>
<% end %>
<%= if Roles.can?(@current_user, template, :clone) do %>
<%= live_patch to: Routes.shift_template_index_path(@socket, :clone, template), class: "btn btn-outline-primary btn-sm text-nowrap" do %>
<%= icon_div @socket, "bi-clipboard-plus", [class: "icon baseline"] %>
Clone
<i class="bi bi-clipboard-plus me-1"></i> Clone
<% end %>
<% end %>
<%#= button "" %>
<%= if Roles.can?(@current_user, template, :delete) do %>
<button class="btn btn-outline-danger btn-sm text-nowrap" phx-click="delete-modal" phx-value-id="<%= template.id %>">
<%= icon_div @socket, "bi-trash", [class: "icon baseline"] %>
Delete
<button class="btn btn-outline-danger btn-sm text-nowrap" phx-click="delete-modal" phx-value-id={template.id}>
<i class="bi bi-trash me-1"></i> Delete
</button>
<% end %>

View file

@ -1,6 +1,6 @@
defmodule Shift73kWeb.UserLive.Registration do
use Shift73kWeb, :live_view
alias Shift73k.Repo
alias Shift73k.Accounts
alias Shift73k.Accounts.User
@ -20,9 +20,7 @@ defmodule Shift73kWeb.UserLive.Registration do
user_id: nil,
user_return_to: Map.get(session, "user_return_to", "/"),
messages: [
success: "Welcome! Your new account has been created, and you've been logged in.",
info:
"Some features may be unavailable until you confirm your email address. Check your inbox for instructions."
success: "Welcome! Your new account has been created, and you've been logged in."
]
}
end
@ -35,19 +33,33 @@ defmodule Shift73kWeb.UserLive.Registration do
@impl true
def handle_event("save", %{"user" => user_params}, socket) do
is_first_user = !Repo.exists?(User)
user_params
|> Map.put("role", Accounts.registration_role())
|> Accounts.register_user()
|> case do
{:ok, user} ->
{:ok, %Bamboo.Email{}} =
Accounts.deliver_user_confirmation_instructions(
user,
&Routes.user_confirmation_url(socket, :confirm, &1)
)
# If this is the first user, we just confirm them
if is_first_user do
user |> User.confirm_changeset() |> Repo.update()
else
# Otherwise, all new users require email confirmation so we wend instructions
{:ok, _, %Swoosh.Email{} = _captured_email} =
Accounts.deliver_user_confirmation_instructions(
user,
&Routes.user_confirmation_url(socket, :confirm, &1)
)
end
login_params =
if is_first_user do
socket.assigns.login_params
else
put_in(socket.assigns.login_params, [:messages, :info], "Some features may be unavailable until you confirm your email address. Check your inbox for instructions.")
end
|> put_in([:user_id], user.id)
socket
|> assign(login_params: %{socket.assigns.login_params | user_id: user.id})
|> assign(login_params: login_params)
|> assign(trigger_submit: true)
|> live_noreply()

View file

@ -2,16 +2,15 @@
<div class="col-11 col-sm-8 col-md-6 col-lg-5 col-xl-4 col-xxl-3">
<h2>
<%= icon_div @socket, "bi-person-plus", [class: "icon baseline"] %>
Register
<i class="bi bi-journal-plus me-1"></i> Register
</h2>
<p class="lead">Create an account to manage your work shifts with us.</p>
<%= form_for @changeset, "#", [phx_change: :validate, phx_submit: :save, novalidate: true, id: "reg_form"], fn f -> %>
<.form let={f} for={@changeset} phx-change="validate" phx-submit="save" novalidate id="reg_form">
<%= label f, :email, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :email) %>">
<%= icon_div @socket, "bi-at", [class: "icon is-left text-muted fs-5"] %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :email)}>
<i class="bi bi-at icon is-left text-muted fs-5"></i>
<%= email_input f, :email,
value: input_value(f, :email),
class: input_class(f, :email, "form-control"),
@ -25,8 +24,8 @@
</div>
<%= label f, :password, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :password) %>">
<%= icon_div @socket, "bi-key", [class: "icon is-left text-muted fs-5"] %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :password)}>
<i class="bi bi-key icon is-left text-muted fs-5"></i>
<%= password_input f, :password,
value: input_value(f, :password),
class: input_class(f, :password, "form-control"),
@ -46,7 +45,7 @@
%>
</div>
<% end %>
</.form>
<p>
<%= link "Log in", to: Routes.user_session_path(@socket, :new) %> |

View file

@ -4,6 +4,9 @@ defmodule Shift73kWeb.UserLive.ResetPassword do
alias Shift73k.Accounts
alias Shift73k.Accounts.User
@app_vars Application.compile_env(:shift73k, :app_global_vars, allow_registration: :true)
@app_allow_registration @app_vars[:allow_registration]
@impl true
def mount(_params, session, socket) do
user = Accounts.get_user!(session["user_id"])
@ -37,4 +40,6 @@ defmodule Shift73kWeb.UserLive.ResetPassword do
|> assign(changeset: changeset)}
end
end
def allow_registration, do: @app_allow_registration
end

View file

@ -2,16 +2,15 @@
<div class="col-11 col-sm-8 col-md-6 col-lg-5 col-xl-4 col-xxl-3">
<h2>
<%= icon_div @socket, "bi-shield-lock", [class: "icon baseline"] %>
Reset password
<i class="bi bi-shield-lock me-1"></i> Reset password
</h2>
<p class="lead">Hi <%= @user.email %> &mdash; tell us your new password, please.</p>
<%= form_for @changeset, "#", [phx_change: :validate, phx_submit: :save, novalidate: true, id: "pw_reset_form"], fn f -> %>
<.form let={f} for={@changeset} phx-change="validate" phx-submit="save" novalidate id="pw_reset_form">
<%= label f, :password, "New password", class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :password) %>">
<%= icon_div @socket, "bi-key", [class: "icon is-left text-muted fs-5"] %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :password)}>
<i class="bi bi-key icon is-left text-muted fs-5"></i>
<%= password_input f, :password,
value: input_value(f, :password),
class: input_class(f, :password, "form-control"),
@ -23,8 +22,8 @@
</div>
<%= label f, :password_confirmation, "Confirm new password", class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :password_confirmation) %>">
<%= icon_div @socket, "bi-key-fill", [class: "icon is-left text-muted fs-5"] %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :password_confirmation)}>
<i class="bi bi-key-fill icon is-left text-muted fs-5"></i>
<%= password_input f, :password_confirmation,
value: input_value(f, :password_confirmation),
class: input_class(f, :password_confirmation, "form-control"),
@ -43,10 +42,12 @@
%>
</div>
<% end %>
</.form>
<p class="mt-3 is-pulled-right">
<%= link "Register", to: Routes.user_registration_path(@socket, :new) %> |
<%= if allow_registration() do %>
<%= link "Register", to: Routes.user_registration_path(@socket, :new) %> |
<% end %>
<%= link "Log in", to: Routes.user_session_path(@socket, :new) %>
</p>

View file

@ -0,0 +1,16 @@
<div class="row justify-content-center">
<div class="col-11">
<h2 class="mb-3">
<i class="bi bi-sliders me-1"></i> User Settings
</h2>
<div class="row justify-content-center justify-content-md-start">
<.live_component module={Shift73kWeb.UserLive.Settings.Email} id={"email-#{@current_user.id}"} current_user={@current_user} />
<.live_component module={Shift73kWeb.UserLive.Settings.Password} id={"password-#{@current_user.id}"} current_user={@current_user} />
<.live_component module={Shift73kWeb.UserLive.Settings.WeekStart} id={"week_start-#{@current_user.id}"} current_user={@current_user} />
<.live_component module={Shift73kWeb.UserLive.Settings.CalendarUrl} id={"calendar_url-#{@current_user.id}"} current_user={@current_user} />
</div>
</div>
</div>

View file

@ -1,17 +0,0 @@
<div class="row justify-content-center">
<div class="col-11">
<h2 class="mb-3">
<%= icon_div @socket, "bi-sliders", [class: "icon baseline"] %>
User Settings
</h2>
<div class="row justify-content-center justify-content-md-start">
<%= live_component @socket, Shift73kWeb.UserLive.Settings.Email, id: "email-#{@current_user.id}", current_user: @current_user %>
<%= live_component @socket, Shift73kWeb.UserLive.Settings.Password, id: "password-#{@current_user.id}", current_user: @current_user %>
<%= live_component @socket, Shift73kWeb.UserLive.Settings.WeekStart, id: "week_start-#{@current_user.id}", current_user: @current_user %>
<%= live_component @socket, Shift73kWeb.UserLive.Settings.CalendarUrl, id: "calendar_url-#{@current_user.id}", current_user: @current_user %>
</div>
</div>
</div>

View file

@ -1,11 +1,11 @@
<div id="<%= @id %>" class="col-12 col-sm-10 col-md-9 col-lg-8 col-xl-7 col-xxl-6 mt-1">
<div id={@id} class="col-12 col-sm-10 col-md-9 col-lg-8 col-xl-7 col-xxl-6 mt-1">
<h3>iCal Subscribe URL</h3>
<div class="row">
<div class="col mb-3">
<label class="form-label">Use this URL to subscribe in calendar software</label>
<input type="text" class="form-control" value="<%= Routes.user_shifts_ics_url(@socket, :index, @current_user.calendar_slug) %>" readonly onclick="this.focus();this.select()" />
<input type="text" class="form-control" value={Routes.user_shifts_ics_url(@socket, :index, @current_user.calendar_slug)} readonly onclick="this.focus();this.select()" />
</div>
</div>

View file

@ -1,12 +1,12 @@
<div id="<%= @id %>" class="col-12 col-sm-10 col-md-6 col-lg-5 col-xl-4 col-xxl-3 mt-1">
<div id={@id} class="col-12 col-sm-10 col-md-6 col-lg-5 col-xl-4 col-xxl-3 mt-1">
<h3>Change email</h3>
<%= form_for @changeset, "#", [phx_change: :validate, phx_submit: :save, phx_target: @myself], fn f -> %>
<%= label f, :email, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :email) %>">
<%= icon_div @socket, "bi-at", [class: "icon is-left text-muted fs-5"] %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :email)}>
<i class="bi bi-at icon is-left text-muted fs-5"></i>
<%= email_input f, :email,
value: input_value(f, :email),
class: input_class(f, :email, "form-control"),
@ -20,7 +20,7 @@
<%= label f, :current_password, class: "form-label" %>
<div class="inner-addon left-addon mb-3">
<%= icon_div @socket, "bi-lock", [class: "icon is-left text-muted fs-5"] %>
<i class="bi bi-lock icon is-left text-muted fs-5"></i>
<%= password_input f, :current_password,
value: input_value(f, :current_password),
id: "user_email_current_password",

View file

@ -1,12 +1,12 @@
<div id="<%= @id %>" class="col-12 col-sm-10 col-md-6 col-lg-5 col-xl-4 col-xxl-3 mt-1">
<div id={@id} class="col-12 col-sm-10 col-md-6 col-lg-5 col-xl-4 col-xxl-3 mt-1">
<h3>Change password</h3>
<%= form_for @changeset, "#", [phx_change: :validate, phx_submit: :save, phx_target: @myself], fn f -> %>
<%= label f, :password, "New password", class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :password) %>">
<%= icon_div @socket, "bi-key", [class: "icon is-left text-muted fs-5"] %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :password)}>
<i class="bi bi-key icon is-left text-muted fs-5"></i>
<%= password_input f, :password,
value: input_value(f, :password),
class: input_class(f, :password, "form-control"),
@ -18,8 +18,8 @@
</div>
<%= label f, :password_confirmation, "Confirm new password", class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :password_confirmation) %>">
<%= icon_div @socket, "bi-key-fill", [class: "icon is-left text-muted fs-5"] %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :password_confirmation)}>
<i class="bi bi-key-fill icon is-left text-muted fs-5"></i>
<%= password_input f, :password_confirmation,
value: input_value(f, :password_confirmation),
class: input_class(f, :password_confirmation, "form-control"),
@ -31,7 +31,7 @@
<%= label f, :current_password, class: "form-label" %>
<div class="inner-addon left-addon mb-3">
<%= icon_div @socket, "bi-lock", [class: "icon is-left text-muted fs-5"] %>
<i class="bi bi-lock icon is-left text-muted fs-5"></i>
<%= password_input f, :current_password,
value: input_value(f, :current_password),
id: "user_password_current_password",

View file

@ -1,4 +1,4 @@
<div id="<%= @id %>" class="col-12 col-sm-10 col-md-6 col-lg-5 col-xl-4 col-xxl-3 mt-1">
<div id={@id} class="col-12 col-sm-10 col-md-6 col-lg-5 col-xl-4 col-xxl-3 mt-1">
<h3>Calendar view</h3>
@ -6,7 +6,7 @@
<%= label cvf, :week_start_at, "Week starts at", class: "form-label" %>
<div class="inner-addon left-addon mb-3">
<%= icon_div @socket, "bi-calendar2-range", [class: "icon is-left text-muted fs-5"] %>
<i class="bi bi-calendar2-range icon is-left text-muted fs-5"></i>
<%= select cvf, :week_start_at, week_start_options(),
value: @current_user.week_start_at,
class: "form-select"

View file

@ -9,9 +9,13 @@ defmodule Shift73kWeb.UserManagement.DeleteComponent do
end
@impl true
def handle_event("confirm", %{"id" => id, "email" => email}, socket) do
id
|> Accounts.get_user()
def handle_event("confirm", %{"id" => id, "email" => email} = params, socket) do
IO.inspect(params)
user = Accounts.get_user(id)
IO.inspect(user)
user
|> Accounts.delete_user()
|> case do
{:ok, _} ->

View file

@ -0,0 +1,20 @@
<div>
<div class="modal-body">
Are you sure you want to delete "<%= @delete_user.email %>"?
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= link "Confirm Delete", to: "#",
class: "btn btn-danger",
phx_click: "confirm",
phx_target: @myself,
phx_value_id: @delete_user.id,
phx_value_email: @delete_user.email %>
</div>
</div>

View file

@ -1,16 +0,0 @@
<div class="modal-body">
Are you sure you want to delete "<%= @delete_user.email %>"?
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= link "Confirm Delete", to: "#",
class: "btn btn-danger",
phx_click: "confirm",
phx_target: @myself,
phx_value_id: @delete_user.id,
phx_value_email: @delete_user.email %>
</div>

View file

@ -33,7 +33,7 @@ defmodule Shift73kWeb.UserManagement.FormComponent do
defp save_user(%{assigns: %{action: :new}} = socket, user_params) do
case Accounts.register_user(user_params) do
{:ok, user} ->
{:ok, %Bamboo.Email{}} =
{:ok, _, %Swoosh.Email{} = _captured_email} =
Accounts.deliver_user_confirmation_instructions(
user,
&Routes.user_confirmation_url(socket, :confirm, &1)

View file

@ -0,0 +1,66 @@
<div>
<%= form_for @changeset, "#", [
phx_target: @myself,
phx_change: "validate",
phx_submit: "save"
], fn f -> %>
<div class="modal-body">
<%= label f, :email, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :email)}>
<i class="bi bi-at icon is-left text-muted fs-5"></i>
<%= email_input f, :email,
value: input_value(f, :email),
class: input_class(f, :email, "form-control"),
placeholder: "e.g., babka@73k.us",
maxlength: User.max_email,
autofocus: true,
phx_debounce: "250",
aria_describedby: error_ids(f, :email)
%>
<%= error_tag f, :email %>
</div>
<%= label f, :password, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for={input_id(f, :password)}>
<i class="bi bi-key icon is-left text-muted fs-5"></i>
<%= password_input f, :password,
value: input_value(f, :password),
class: input_class(f, :password, "form-control"),
maxlength: User.max_password,
aria_describedby: error_ids(f, :password)
%>
<%= error_tag f, :password %>
</div>
<%= if Roles.can?(@current_user, %User{}, :edit_role) do %>
<%= label f, :role, class: "form-label" %>
<div class="inner-addon left-addon mb-3">
<i class="bi bi-shield icon is-left text-muted fs-5"></i>
<%= select f, :role, Enum.map(User.roles(), fn {k, _v} -> {String.capitalize(Atom.to_string(k)), k} end), class: "form-select" %>
<span class="valid-feedback text-primary" style="display: block;">
<%= role_description(input_value(f, :role)) %>
</span>
</div>
<% else %>
<%= hidden_input f, :role, value: input_value(f, :role) %>
<% end %>
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= submit "Save",
class: "btn btn-primary ",
disabled: !@changeset.valid?,
aria_disabled: !@changeset.valid? && "true" || false,
phx_disable_with: "Saving..."
%>
</div>
<% end %>
</div>

View file

@ -1,62 +0,0 @@
<%= form_for @changeset, "#", [
phx_target: @myself,
phx_change: "validate",
phx_submit: "save"
], fn f -> %>
<div class="modal-body">
<%= label f, :email, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :email) %>">
<%= icon_div @socket, "bi-at", [class: "icon is-left text-muted fs-5"] %>
<%= email_input f, :email,
value: input_value(f, :email),
class: input_class(f, :email, "form-control"),
placeholder: "e.g., babka@73k.us",
maxlength: User.max_email,
autofocus: true,
phx_debounce: "250",
aria_describedby: error_ids(f, :email)
%>
<%= error_tag f, :email %>
</div>
<%= label f, :password, class: "form-label" %>
<div class="inner-addon left-addon mb-3" phx-feedback-for="<%= input_id(f, :password) %>">
<%= icon_div @socket, "bi-key", [class: "icon is-left text-muted fs-5"] %>
<%= password_input f, :password,
value: input_value(f, :password),
class: input_class(f, :password, "form-control"),
maxlength: User.max_password,
aria_describedby: error_ids(f, :password)
%>
<%= error_tag f, :password %>
</div>
<%= if Roles.can?(@current_user, %User{}, :edit_role) do %>
<%= label f, :role, class: "form-label" %>
<div class="inner-addon left-addon mb-3">
<%= icon_div @socket, "bi-shield", [class: "icon is-left text-muted fs-5"] %>
<%= select f, :role, Enum.map(User.roles(), fn {k, _v} -> {String.capitalize(Atom.to_string(k)), k} end), class: "form-select" %>
<span class="valid-feedback text-primary" style="display: block;">
<%= role_description(input_value(f, :role)) %>
</span>
</div>
<% else %>
<%= hidden_input f, :role, value: input_value(f, :role) %>
<% end %>
</div>
<div class="modal-footer">
<%= link "Cancel", to: "#", class: "btn btn-outline-dark", phx_click: "hide", phx_target: "##{@modal_id}" %>
<%= submit "Save",
class: "btn btn-primary ",
disabled: !@changeset.valid?,
aria_disabled: !@changeset.valid? && "true" || false,
phx_disable_with: "Saving..."
%>
</div>
<% end %>

View file

@ -1,24 +1,34 @@
<%= if @live_action in [:new, :edit] do %>
<%= live_modal @socket, Shift73kWeb.UserManagement.FormComponent,
<.live_component
module={Shift73kWeb.ModalComponent}
id="modal"
component={Shift73kWeb.UserManagement.FormComponent}
opts={[
id: @user.id || :new,
title: @page_title,
action: @live_action,
user: @user,
current_user: @current_user %>
current_user: @current_user
]}
/>
<% end %>
<%= if @delete_user do %>
<%= live_modal @socket, Shift73kWeb.UserManagement.DeleteComponent,
<.live_component
module={Shift73kWeb.ModalComponent}
id="modal"
component={Shift73kWeb.UserManagement.DeleteComponent}
opts={[
id: @delete_user.id,
title: "Delete User",
delete_user: @delete_user
%>
]}
/>
<% end %>
<h2 class="mb-3">
<%= icon_div @socket, "bi-people", [class: "icon baseline"] %>
Listing Users
<i class="bi bi-people me-1"></i> Listing Users
</h2>
<%# filtering and new item creation %>
@ -27,8 +37,7 @@
<div class="col-12 col-md-6 col-lg-4 col-xl-3">
<%= live_patch to: Routes.user_management_index_path(@socket, :new, Enum.into(@query, [])),
class: "btn btn-primary mb-3 mb-md-0" do %>
<%= icon_div @socket, "bi-person-plus", [class: "icon baseline me-1"] %>
New User
<i class="bi bi-person-plus me-1"></i> New User
<% end %>
</div>
@ -36,10 +45,10 @@
<%= form_for :sort, "#", [phx_change: "sort-by-change"], fn srt -> %>
<%= label srt, :sort_by, class: "visually-hidden" %>
<div class="input-group inner-addon left-addon mb-3 mb-md-0">
<%= icon_div @socket, "bi-arrow-down-up", [class: "icon is-left text-muted fs-5", style: "z-index:1001;"], [style: "padding: 1px;"] %>
<%= Phoenix.HTML.Form.select srt, :sort_by, ["Email": "email", "Role": "role", "Created at": "inserted_at"], value: @query.sort_by, class: "form-select" %>
<i class="bi bi-arrow-down-up icon is-left text-muted fs-5" style="z-index:1001;"></i>
<%= Phoenix.HTML.Form.select srt, :sort_by, ["Email": "email", "Role": "role", "Created at": "inserted_at"], value: @query.sort_by, class: "form-select rounded-start" %>
<button class="btn btn-primary" type="button" aria-label="Change sort order" phx-click="sort-order-change">
<%= icon_div @socket, (@query.sort_order == "desc" && "bi-sort-up-alt" || "bi-sort-down-alt"), [class: "icon baseline"] %>
<i class={if @query.sort_order == "desc", do: "bi bi-sort-up-alt", else: "bi bi-sort-down-alt"}></i>
</button>
</div>
<% end %>
@ -49,9 +58,9 @@
<%= form_for :filter, "#", [phx_change: "filter-change"], fn flt -> %>
<%= label flt, :filter, class: "visually-hidden" %>
<div class="inner-addon left-addon right-addon">
<%= icon_div @socket, "bi-funnel", [class: "icon is-left text-muted fs-5"] %>
<i class="bi bi-funnel icon is-left text-muted fs-5"></i>
<%= if @query.filter != "" do %>
<%= icon_div @socket, "bi-x-circle-fill", [class: "icon is-right text-primary fs-5"], [role: "img", aria_hidden: false, aria_label: "Clear filter", class: "cursor-pointer pe-auto", phx_click: "filter-clear"] %>
<i class="bi bi-x-circle-fill icon is-right text-primary fs-5 cursor-pointer pe-auto" role="img" aria-hidden="false" aria-label="Clear filter" phx-click="filter-clear"></i>
<% end %>
<%= text_input flt, :filter,
name: "filter",
@ -92,21 +101,19 @@
</dt>
<dd class="d-inline d-sm-block col-auto">
<span class="visually-hidden"><%= user.confirmed_at && "Yes" || "No" %></span>
<input type="checkbox" class="form-check-input" aria-hidden="true" <%= user.confirmed_at && "checked" || "" %> disabled>
<input type="checkbox" class="form-check-input" aria-hidden="true" checked={user.confirmed_at && :true || :false} disabled>
</dd>
</dl>
<%= if Roles.can?(@current_user, user, :edit) do %>
<%= live_patch to: Routes.user_management_index_path(@socket, :edit, user.id, Enum.into(@query, [])), class: "btn btn-primary btn-sm text-nowrap" do %>
<%= icon_div @socket, "bi-pencil", [class: "icon baseline"] %>
Edit
<i class="bi bi-pencil me-1"></i> Edit
<% end %>
<% end %>
<%= if Roles.can?(@current_user, user, :delete) do %>
<button class="btn btn-outline-danger btn-sm text-nowrap" phx-click="delete-modal" phx-value-id="<%= user.id %>">
<%= icon_div @socket, "bi-trash", [class: "icon baseline"] %>
Delete
<button class="btn btn-outline-danger btn-sm text-nowrap" phx-click="delete-modal" phx-value-id={user.id}>
<i class="bi bi-trash me-1"></i> Delete
</button>
<% end %>
@ -126,28 +133,25 @@
<thead>
<tr>
<th scope="col" phx-click="sort-change" phx-value-sort_by="email" class="cursor-pointer">
<th scope="col" style="white-space: nowrap;" phx-click="sort-change" phx-value-sort_by="email" class="cursor-pointer">
Email
<%= if @query.sort_by == "email", do: icon_div @socket,
(@query.sort_order == "desc" && "bi-sort-up-alt" || "bi-sort-down-alt"),
[class: "icon baseline ms-1"]
%>
<%= if @query.sort_by == "email" do %>
<i class={@query.sort_order == "desc" && "bi bi-sort-up-alt ms-1" || "bi bi-sort-down-alt ms-1"}></i>
<% end %>
</th>
<th scope="col" phx-click="sort-change" phx-value-sort_by="role" class="cursor-pointer">
<th scope="col" style="white-space: nowrap;" phx-click="sort-change" phx-value-sort_by="role" class="cursor-pointer">
Role
<%= if @query.sort_by == "role", do: icon_div @socket,
(@query.sort_order == "desc" && "bi-sort-up-alt" || "bi-sort-down-alt"),
[class: "icon baseline ms-1"]
%>
<%= if @query.sort_by == "role" do %>
<i class={@query.sort_order == "desc" && "bi bi-sort-up-alt ms-1" || "bi bi-sort-down-alt ms-1"}></i>
<% end %>
</th>
<th scope="col" phx-click="sort-change" phx-value-sort_by="inserted_at" class="cursor-pointer">
<th scope="col" style="white-space: nowrap;" phx-click="sort-change" phx-value-sort_by="inserted_at" class="cursor-pointer">
Created at
<%= if @query.sort_by == "inserted_at", do: icon_div @socket,
(@query.sort_order == "desc" && "bi-sort-up-alt" || "bi-sort-down-alt"),
[class: "icon baseline ms-1"]
%>
<%= if @query.sort_by == "inserted_at" do %>
<i class={@query.sort_order == "desc" && "bi bi-sort-up-alt ms-1" || "bi bi-sort-down-alt ms-1"}></i>
<% end %>
</th>
<th scope="col">Confirmed?</th>
@ -168,27 +172,25 @@
</tr>
<% else %>
<%= for user <- @page.entries do %>
<tr id="user-<%= user.id %>">
<tr id={"user-#{user.id}"}>
<td class="align-middle"><%= user.email %></td>
<td class="align-middle"><%= user.role |> Atom.to_string() |> String.capitalize() %></td>
<td class="align-middle" style="white-space: nowrap;"><%= dt_out(user.inserted_at) %></td>
<td class="align-middle">
<span class="visually-hidden"><%= user.confirmed_at && "Confirmed" || "Not confirmed" %></span>
<input type="checkbox" class="form-check-input" aria-hidden="true" <%= user.confirmed_at && "checked" || "" %> disabled>
<input type="checkbox" class="form-check-input" aria-hidden="true" checked={user.confirmed_at && :true || :false} disabled>
</td>
<td class="align-middle text-end text-nowrap">
<%= if Roles.can?(@current_user, user, :edit) do %>
<%= live_patch to: Routes.user_management_index_path(@socket, :edit, user.id, Enum.into(@query, [])), class: "btn btn-outline-primary btn-sm text-nowrap" do %>
<%= icon_div @socket, "bi-pencil", [class: "icon baseline"] %>
Edit
<i class="bi bi-pencil me-1"></i> Edit
<% end %>
<% end %>
<%= if Roles.can?(@current_user, user, :delete) do %>
<button class="btn btn-outline-danger btn-sm text-nowrap" phx-click="delete-modal" phx-value-id="<%= user.id %>">
<%= icon_div @socket, "bi-trash", [class: "icon baseline"] %>
Delete
<button class="btn btn-outline-danger btn-sm text-nowrap" phx-click="delete-modal" phx-value-id={user.id}>
<i class="bi bi-trash me-1"></i> Delete
</button>
<% end %>
@ -227,16 +229,16 @@
<ul class="pagination mb-0">
<%# previous page button %>
<% icon = icon_div @socket, "bi-chevron-left", [class: "icon baseline"] %>
<%= if @page.page_number == 1 do %>
<li class="page-item disabled">
<span class="page-link" aria-hidden="true"><%= icon %></span>
<span class="page-link" aria-hidden="true"><i class="bi bi-chevron-left"></i></span>
<span class="visually-hidden">Previous</span>
</li>
<% else %>
<li class="page-item">
<a class="page-link" href="#" aria-label="Previous" phx-value-page_number="<%= @page.page_number - 1 %>" phx-click="page-change"><%= icon %></a>
<% end %>
<a class="page-link" href="#" aria-label="Previous" phx-value-page_number={@page.page_number - 1} phx-click="page-change"><i class="bi bi-chevron-left"></i></a>
</li>
<% end %>
<%# page buttons %>
<%= for page_num <- generate_page_list(@page.page_number, @page.total_pages) do %>
@ -248,26 +250,26 @@
</li>
<% page_num == @page.page_number -> %>
<li class="page-item active" aria-current="page">
<span class="page-link"><%= page_num %></a>
<span class="page-link"><%= page_num %></span>
</li>
<% true -> %>
<li class="page-item">
<a class="page-link" href="#" phx-value-page_number="<%= page_num %>" phx-click="page-change"><%= page_num %></a>
<a class="page-link" href="#" phx-value-page_number={page_num} phx-click="page-change"><%= page_num %></a>
</li>
<% end %>
<% end %>
<%# next page button %>
<% icon = icon_div @socket, "bi-chevron-right", [class: "icon baseline"] %>
<%= if @page.page_number == @page.total_pages do %>
<li class="page-item disabled">
<span class="page-link" aria-hidden="true"><%= icon %></span>
<span class="page-link" aria-hidden="true"><i class="bi bi-chevron-right"></i></span>
<span class="visually-hidden">Next</span>
</li>
<% else %>
<li class="page-item">
<a class="page-link" href="#" aria-label="Next" phx-value-page_number="<%= @page.page_number + 1 %>" phx-click="page-change"><%= icon %></a>
<% end %>
<a class="page-link" href="#" aria-label="Next" phx-value-page_number={@page.page_number + 1} phx-click="page-change"><i class="bi bi-chevron-right"></i></a>
</li>
<% end %>
</ul>
</nav>

View file

@ -0,0 +1,35 @@
defmodule Shift73kWeb.EnsureAllowRegistrationPlug do
@moduledoc """
This plug ensures that there is at least one known User.
"""
import Plug.Conn
import Phoenix.Controller
alias Shift73k.Repo
alias Shift73k.Accounts.User
@app_vars Application.compile_env(:shift73k, :app_global_vars, allow_registration: :true)
@app_allow_registration @app_vars[:allow_registration]
@doc false
@spec init(any()) :: any()
def init(config), do: config
@doc false
@spec call(Conn.t(), atom() | [atom()]) :: Conn.t()
def call(conn, _opts) do
# If there aren't even any users, or registration is allowed
if !Repo.exists?(User) || @app_allow_registration do
# We will allow registration
conn
else
# Otherwise,
# if app is configured to not allow registration,
# and there is a user,
# then we redirect to root URL
conn
|> redirect(to: "/")
|> halt()
end
end
end

View file

@ -27,8 +27,7 @@ defmodule Shift73kWeb.EnsureRolePlug do
def call(conn, roles) do
user_token = get_session(conn, :user_token)
(user_token &&
Accounts.get_user_by_session_token(user_token))
(user_token && Accounts.get_user_by_session_token(user_token))
|> has_role?(roles)
|> maybe_halt(conn)
end

View file

@ -0,0 +1,30 @@
defmodule Shift73kWeb.EnsureUserExistPlug do
@moduledoc """
This plug ensures that there is at least one known User.
"""
import Plug.Conn
import Phoenix.Controller
alias Shift73k.Repo
alias Shift73k.Accounts.User
alias Shift73kWeb.Router.Helpers, as: Routes
@doc false
@spec init(any()) :: any()
def init(config), do: config
@doc false
@spec call(Conn.t(), atom() | [atom()]) :: Conn.t()
def call(conn, _opts) do
# If there aren't even any users,
if !Repo.exists?(User) do
# We're just going to redirect to registration
conn
|> redirect(to: Routes.user_registration_path(conn, :new))
|> halt()
else
# Otherwise we proceed as normal
conn
end
end
end

View file

@ -2,98 +2,100 @@ defmodule Shift73kWeb.Router do
use Shift73kWeb, :router
import Shift73kWeb.UserAuth
alias Shift73kWeb.EnsureRolePlug
alias Shift73kWeb.EnsureUserExistPlug
alias Shift73kWeb.EnsureAllowRegistrationPlug
pipeline :browser do
plug(:accepts, ["html"])
plug(:fetch_session)
plug(:fetch_live_flash)
plug(:put_root_layout, {Shift73kWeb.LayoutView, :root})
plug(:protect_from_forgery)
plug(:put_secure_browser_headers)
plug(:fetch_current_user)
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, {Shift73kWeb.LayoutView, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_user
end
pipeline :api do
plug(:accepts, ["json"])
pipeline :ensure_role_user do
plug EnsureRolePlug, [:admin, :manager, :user]
end
pipeline :user do
plug(EnsureRolePlug, [:admin, :manager, :user])
pipeline :ensure_user_exist do
plug EnsureUserExistPlug
end
pipeline :manager do
plug(EnsureRolePlug, [:admin, :manager])
pipeline :ensure_allow_registration do
plug EnsureAllowRegistrationPlug
end
pipeline :admin do
plug(EnsureRolePlug, :admin)
pipeline :ensure_role_manager do
plug EnsureRolePlug, [:admin, :manager]
end
scope "/", Shift73kWeb do
pipe_through([:browser])
get("/", Redirector, to: "/assign")
pipeline :ensure_role_admin do
plug EnsureRolePlug, :admin
end
# Other scopes may use custom stacks.
# scope "/api", Shift73kWeb do
# pipe_through :api
# end
# Enables LiveDashboard only for development
# Enables the Swoosh mailbox preview in development.
#
# If you want to use the LiveDashboard in production, you should put
# it behind authentication and allow only admins to access it.
# If your application does not have an admins-only section yet,
# you can use Plug.BasicAuth to set up some basic authentication
# as long as you are also using SSL (which you should anyway).
if Mix.env() in [:dev, :test] do
import Phoenix.LiveDashboard.Router
# Note that preview only shows emails that were sent by the same
# node running the Phoenix server.
if Mix.env() == :dev do
scope "/dev" do
pipe_through :browser
scope "/" do
pipe_through(:browser)
live_dashboard("/dashboard", metrics: Shift73kWeb.Telemetry)
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
scope "/", Shift73kWeb do
pipe_through([:browser, :redirect_if_user_is_authenticated])
pipe_through([:browser, :ensure_user_exist])
get "/", Redirector, to: "/assign"
end
scope "/", Shift73kWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated, :ensure_allow_registration]
get "/users/register", UserRegistrationController, :new
end
scope "/", Shift73kWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated, :ensure_user_exist]
# session routes, irrelevant if user is authenticated
get("/users/register", UserRegistrationController, :new)
get("/users/log_in", UserSessionController, :new)
post("/users/log_in", UserSessionController, :create)
get("/users/reset_password", UserResetPasswordController, :new)
post("/users/reset_password", UserResetPasswordController, :create)
get("/users/reset_password/:token", UserResetPasswordController, :edit)
get "/users/log_in", UserSessionController, :new
post "/users/log_in", UserSessionController, :create
get "/users/reset_password", UserResetPasswordController, :new
post "/users/reset_password", UserResetPasswordController, :create
get "/users/reset_password/:token", UserResetPasswordController, :edit
end
scope "/", Shift73kWeb do
pipe_through([:browser, :require_authenticated_user])
pipe_through [:browser, :require_authenticated_user]
# user settings (change email, password, calendar week start, etc)
live("/users/settings", UserLive.Settings, :edit)
live "/users/settings", UserLive.Settings, :edit
# confirm email by token
get("/users/settings/confirm_email/:token", UserSettingsController, :confirm_email)
get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email
end
scope "/", Shift73kWeb do
pipe_through([:browser])
pipe_through [:browser, :ensure_user_exist]
# session paths
delete("/users/log_out", UserSessionController, :delete)
get("/users/force_logout", UserSessionController, :force_logout)
get("/users/confirm", UserConfirmationController, :new)
post("/users/confirm", UserConfirmationController, :create)
get("/users/confirm/:token", UserConfirmationController, :confirm)
delete "/users/log_out", UserSessionController, :delete
get "/users/force_logout", UserSessionController, :force_logout
get "/users/confirm", UserConfirmationController, :new
post "/users/confirm", UserConfirmationController, :create
get "/users/confirm/:token", UserConfirmationController, :confirm
# ics/ical route for user's shifts
get("/ics/:slug", UserShiftsIcsController, :index)
get "/ics/:slug", UserShiftsIcsController, :index
end
scope "/", Shift73kWeb do
pipe_through([:browser, :require_authenticated_user, :user])
pipe_through [:browser, :require_authenticated_user, :ensure_role_user]
live "/templates", ShiftTemplateLive.Index, :index
live "/templates/new", ShiftTemplateLive.Index, :new
@ -111,16 +113,16 @@ defmodule Shift73kWeb.Router do
end
# scope "/", Shift73kWeb do
# pipe_through([:browser, :require_authenticated_user, :admin])
# pipe_through([:browser, :require_authenticated_user, :ensure_role_admin])
# end
# Users Management
scope "/users", Shift73kWeb do
pipe_through([:browser, :require_authenticated_user, :manager, :require_email_confirmed])
pipe_through [:browser, :require_authenticated_user, :ensure_role_manager, :require_email_confirmed]
live("/", UserManagementLive.Index, :index)
live("/new", UserManagementLive.Index, :new)
live("/edit/:id", UserManagementLive.Index, :edit)
live "/", UserManagementLive.Index, :index
live "/new", UserManagementLive.Index, :new
live "/edit/:id", UserManagementLive.Index, :edit
# resources "/", UserManagementController, only: [:new, :create, :edit, :update]
end
end

View file

@ -1,88 +0,0 @@
<nav class="navbar navbar-expand-lg navbar-dark bg-secondary mb-4">
<div class="container">
<h1 class="fs-4 my-0 py-0 lh-base">
<%= link to: "/", class: "navbar-brand fs-4" do %>
<%= icon_div @conn, "bi-calendar2-week", [class: "icon baseline me-1"] %>
<span class="fw-light">Shift73k</span>
<% end %>
</h1>
<%= if @current_user do %>
<button class="hamburger hamburger--squeeze collapsed navbar-toggler" id="navbarSupportedContentToggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="hamburger-box d-flex">
<span class="hamburger-inner"></span>
</span>
</button>
<% else %>
<%= link nav_link_opts(@conn, to: Routes.user_session_path(@conn, :new), class: "btn btn-outline-light d-block d-lg-none") do %>
<%= icon_div @conn, "bi-door-open", [class: "icon baseline"] %>
Log in
<% end %>
<% end %>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<%# nav LEFT items %>
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<%#= if @current_user do %>
<%# <li class="nav-item"> %>
<%#= link nav_link_opts(@conn, to: Routes.shift_template_index_path(@conn, :index), class: "nav-link") do %>
<%#= icon_div @conn, "bi-clock-history", [class: "icon baseline me-1"] %>
<%# Templates %>
<%# end %>
<%# </li> %>
<%# end %>
<%# normal navbar link example %>
<%# <li class="nav-item"> %>
<%#= link "Properties", nav_link_opts(@conn, to: Routes.property_index_path(@conn, :index), class: "nav-link") %>
<%# </li> %>
<%# ACTIVE page link example %>
<%# <li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">Home</a>
</li> %>
<%# DISABLED page link example %>
<%# <li class="nav-item">
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Disabled</a>
</li> %>
<%# normal dropdown menu example %>
<%# <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownExample" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</a>
<ul class="dropdown-menu" aria-labelledby="navbarDropdownExample">
<li><a class="dropdown-item" href="#">Action</a></li>
<li><a class="dropdown-item" href="#">Another action</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#">Something else here</a></li>
</ul>
</li> %>
</ul>
<%# nav RIGHT items %>
<ul class="navbar-nav">
<%= if @current_user do %>
<%= render "navbar/_shifts_menu.html", assigns %>
<%= render "navbar/_user_menu.html", assigns %>
<% else %>
<%= link nav_link_opts(@conn, to: Routes.user_session_path(@conn, :new), class: "btn btn-outline-light d-none d-lg-block") do %>
<%= icon_div @conn, "bi-door-open", [class: "icon baseline"] %>
Log in
<% end %>
<% end %>
</ul>
</div>
</div>
</nav>

View file

@ -0,0 +1,70 @@
<nav class="navbar navbar-expand-lg navbar-dark bg-secondary mb-4">
<div class="container">
<h1 class="fs-4 my-0 py-0 lh-base">
<%= link to: "/", class: "navbar-brand fs-4" do %>
<i class="bi bi-calendar2-week me-1"></i>
<span class="fw-light">Shift73k</span>
<% end %>
</h1>
<%# If there's a current user,
OR if there are users & we allow registration,
THEN we will show a full menu configuration %>
<%= if @current_user || (Repo.exists?(User) && allow_registration()) do %>
<button class="hamburger hamburger--squeeze collapsed navbar-toggler" id="navbarSupportedContentToggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="hamburger-box d-flex">
<span class="hamburger-inner"></span>
</span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<%# nav LEFT items %>
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
</ul>
<%# nav RIGHT items %>
<ul class="navbar-nav">
<%= if @current_user do %>
<%= render "navbar/_shifts_menu.html", assigns %>
<%= render "navbar/_user_menu.html", assigns %>
<% else %>
<%= render "navbar/_nouser_menu.html", assigns %>
<% end %>
</ul>
</div>
<%# If there's no current user,
AND:
There are no users -- [REGISTER]
OR no registration allowed -- [LOG IN] %>
<% else %>
<%= if !Repo.exists?(User) || allow_registration() do %>
<%= link nav_link_opts(@conn, to: Routes.user_registration_path(@conn, :new), class: "btn btn-outline-light") do %>
<i class="bi bi-journal-plus"></i> Register
<% end %>
<% else %>
<%= link nav_link_opts(@conn, to: Routes.user_session_path(@conn, :new), class: "btn btn-outline-light") do %>
<i class="bi bi-door-open"></i> Log in
<% end %>
<% end %>
<% end %>
</div>
</nav>

View file

@ -0,0 +1,10 @@
<%= if dev_env?() do %>
<script type="module" src="http://localhost:3000/@vite/client"></script>
<script type="module" src="http://localhost:3000/js/app.js"></script>
<% else %>
<link rel="preload" href={Routes.static_path(@conn, "/assets/lato-latin-300-normal.woff2")} as="font" type="font/woff2" crossorigin="anonymous">
<link rel="preload" href={Routes.static_path(@conn, "/assets/lato-latin-400-normal.woff2")} as="font" type="font/woff2" crossorigin="anonymous">
<link rel="preload" href={Routes.static_path(@conn, "/assets/lato-latin-700-normal.woff2")} as="font" type="font/woff2" crossorigin="anonymous">
<link phx-track-static rel="stylesheet" href={Routes.static_path(@conn, "/assets/app.css")}/>
<script defer phx-track-static type="text/javascript" src={Routes.static_path(@conn, "/assets/app.js")}></script>
<% end %>

View file

@ -5,7 +5,7 @@
<div class="col-md-12 col-lg-10 col-xxl-8 ">
<%= for {kind, class} <- alert_kinds() do %>
<%= if flash_content = get_flash(@conn, kind) do %>
<div class="alert <%= class %> alert-dismissible fade show" role="alert">
<div class={"alert #{class} alert-dismissible fade show"} role="alert">
<%= flash_content %>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>

View file

@ -5,7 +5,7 @@
<div class="col-md-12 col-lg-10 col-xxl-8 ">
<%= for {kind, class} <- alert_kinds() do %>
<%= if flash_content = live_flash(@flash, kind) do %>
<div class="alert <%= class %> alert-dismissible fade show" role="alert" id="lv-alert-<%= kind %>" phx-hook="AlertRemover" data-key="<%= kind %>">
<div class={"alert #{class} alert-dismissible fade show"} role="alert" id={"lv-alert-#{kind}"} phx-hook="AlertRemover" data-key={"#{kind}"}>
<%= flash_content %>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>

View file

@ -0,0 +1,23 @@
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" id="navbarDropdownNoUserMenu" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-person-circle me-1"></i> Hello?
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdownNoUserMenu">
<li>
<%= link nav_link_opts(@conn, to: Routes.user_registration_path(@conn, :new), class: "dropdown-item") do %>
<i class="bi bi-journal-plus me-1"></i> Register
<% end %>
</li>
<li>
<%= link nav_link_opts(@conn, to: Routes.user_session_path(@conn, :new), class: "dropdown-item") do %>
<i class="bi bi-door-open me-1"></i> Log in
<% end %>
</li>
</ul>
</li>

View file

@ -1,28 +1,24 @@
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" id="navbarDropdownUserMenu" data-bs-toggle="dropdown" aria-expanded="false">
<%= icon_div @conn, "bi-calendar2", [class: "icon baseline me-1"] %>
Shifts
<a href="#" class="nav-link dropdown-toggle" id="navbarDropdownShiftsMenu" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-calendar2 me-1"></i> Shifts
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdownUserMenu">
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdownShiftsMenu">
<li>
<%= link nav_link_opts(@conn, to: Routes.shift_assign_index_path(@conn, :index), class: "dropdown-item") do %>
<%= icon_div @conn, "bi-calendar2-plus", [class: "icon baseline me-1"] %>
Schedule Shifts
<i class="bi bi-calendar2-plus me-1"></i> Schedule Shifts
<% end %>
</li>
<li>
<%= link nav_link_opts(@conn, to: Routes.shift_index_path(@conn, :index), class: "dropdown-item") do %>
<%= icon_div @conn, "bi-card-list", [class: "icon baseline me-1"] %>
My Scheduled Shifts
<i class="bi bi-card-list me-1"></i> My Scheduled Shifts
<% end %>
</li>
<li>
<%= link nav_link_opts(@conn, to: Routes.shift_template_index_path(@conn, :index), class: "dropdown-item") do %>
<%= icon_div @conn, "bi-clock-history", [class: "icon baseline me-1"] %>
My Shift Templates
<i class="bi bi-clock-history me-1"></i> My Shift Templates
<% end %>
</li>
@ -30,14 +26,12 @@
<%# user_shifts_csv_path %>
<li>
<%= link nav_link_opts(@conn, to: Routes.user_shifts_csv_path(@conn, :new), class: "dropdown-item") do %>
<%= icon_div @conn, "bi-file-earmark-spreadsheet", [class: "icon baseline me-1"] %>
CSV Export
<i class="bi bi-file-earmark-spreadsheet me-1"></i> CSV Export
<% end %>
</li>
<li>
<%= link nav_link_opts(@conn, to: Routes.shift_import_index_path(@conn, :index), class: "dropdown-item") do %>
<%= icon_div @conn, "bi-box-arrow-in-left", [class: "icon baseline me-1"] %>
iCal Import
<i class="bi bi-box-arrow-in-left me-1"></i> iCal Import
<% end %>
</li>

View file

@ -1,8 +1,7 @@
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" id="navbarDropdownUserMenu" data-bs-toggle="dropdown" aria-expanded="false">
<%= icon_div @conn, "bi-person-circle", [class: "icon baseline me-1"] %>
<%= @current_user && "Hello!" || "Hello?" %>
<i class="bi bi-person-circle me-1"></i> Hello!
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdownUserMenu">
@ -12,22 +11,19 @@
<%= if Roles.can?(@current_user, %User{}, :index) do %>
<li>
<%= link nav_link_opts(@conn, to: Routes.user_management_index_path(@conn, :index), class: "dropdown-item") do %>
<%= icon_div @conn, "bi-people", [class: "icon baseline me-1"] %>
Users
<i class="bi bi-people me-1"></i> Users
<% end %>
</li>
<li><hr class="dropdown-divider"></li>
<% end %>
<li>
<%= link nav_link_opts(@conn, to: Routes.user_settings_path(@conn, :edit), class: "dropdown-item") do %>
<%= icon_div @conn, "bi-sliders", [class: "icon baseline me-1"] %>
Settings
<i class="bi bi-sliders me-1"></i> Settings
<% end %>
</li>
<li>
<%= link nav_link_opts(@conn, to: Routes.user_session_path(@conn, :delete), method: :delete, class: "dropdown-item") do %>
<%= icon_div @conn, "bi-door-closed", [class: "icon baseline me-1"] %>
Log out
<i class="bi bi-door-closed me-1"></i> Log out
<% end %>
</li>

View file

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<%= csrf_meta_tag() %>
<%= live_title_tag assigns[:page_title] || "", suffix: assigns[:page_title] && " · Shift73k" || "Shift73k" %>
<%= render "_preamble.html", assigns %>
<link rel="apple-touch-icon" sizes="180x180" href={Routes.static_path(@conn, "/apple-touch-icon.png")}>
<link rel="icon" type="image/png" sizes="32x32" href={Routes.static_path(@conn, "/favicon-32x32.png")}>
<link rel="icon" type="image/png" sizes="16x16" href={Routes.static_path(@conn, "/favicon-16x16.png")}>
<link rel="manifest" href={Routes.static_path(@conn, "/site.webmanifest")}>
<link rel="mask-icon" href={Routes.static_path(@conn, "/safari-pinned-tab.svg")} color="#78868a">
<meta name="apple-mobile-web-app-title" content="Shift73k">
<meta name="application-name" content="Shift73k">
<meta name="msapplication-TileColor" content="#ee6c4d">
<meta name="theme-color" content="#ffffff">
<link rel="icon" href="favicon.ico">
</head>
<body>
<%= render "_navbar.html", assigns %>
<%= @inner_content %>
</body>
</html>

View file

@ -1,32 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<%= csrf_meta_tag() %>
<%= live_title_tag assigns[:page_title] || "", suffix: assigns[:page_title] && " · Shift73k" || "Shift73k" %>
<link rel="preload" href="<%= Routes.static_path(@conn, "/fonts/lato-latin-300-normal.woff2") %>" as="font" type="font/woff2" crossorigin="anonymous">
<link rel="preload" href="<%= Routes.static_path(@conn, "/fonts/lato-latin-400-normal.woff2") %>" as="font" type="font/woff2" crossorigin="anonymous">
<link rel="preload" href="<%= Routes.static_path(@conn, "/fonts/lato-latin-700-normal.woff2") %>" as="font" type="font/woff2" crossorigin="anonymous">
<link rel="apple-touch-icon" sizes="180x180" href="<%= Routes.static_path(@conn, "/apple-touch-icon.png") %>">
<link rel="icon" type="image/png" sizes="32x32" href="<%= Routes.static_path(@conn, "/favicon-32x32.png") %>">
<link rel="icon" type="image/png" sizes="16x16" href="<%= Routes.static_path(@conn, "/favicon-16x16.png") %>">
<link rel="manifest" href="<%= Routes.static_path(@conn, "/site.webmanifest") %>">
<link rel="mask-icon" href="<%= Routes.static_path(@conn, "/safari-pinned-tab.svg") %>" color="#78868a">
<meta name="apple-mobile-web-app-title" content="Shift73k">
<meta name="application-name" content="Shift73k">
<meta name="msapplication-TileColor" content="#ee6c4d">
<meta name="theme-color" content="#ffffff">
<link rel="icon" href="favicon.ico">
<link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
<script defer phx-track-static type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
</head>
<body>
<%= render "_navbar.html", assigns %>
<%= @inner_content %>
</body>
</html>

View file

@ -2,8 +2,7 @@
<div class="col-11 col-sm-10 col-md-8 col-lg-7 col-xl-6 col-xxl-5">
<h2>
<%= icon_div @conn, "bi-arrow-repeat", [class: "icon baseline"] %>
Resend confirmation instructions
<i class="bi bi-arrow-repeat me-1"></i> Resend confirmation instructions
</h2>
<p class="lead">We'll send you another email with instructions to confirm your email address.</p>
@ -11,7 +10,7 @@
<%= label f, :email, class: "form-label" %>
<div class="inner-addon left-addon mb-3">
<%= icon_div @conn, "bi-at", [class: "icon is-left text-muted fs-5"] %>
<i class="bi bi-at icon is-left text-muted fs-5"></i>
<%= email_input f, :email,
value: @current_user && @current_user.email || "",
placeholder: "e.g., babka@73k.us",
@ -30,6 +29,9 @@
<% end %>
<p>
<%= if allow_registration() do %>
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
<% end %>
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
<%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
</p>

View file

@ -2,8 +2,7 @@
<div class="col-11 col-sm-10 col-md-8 col-lg-7 col-xl-6 col-xxl-5">
<h2>
<%= icon_div @conn, "mdi-head-question-outline", [class: "icon baseline"] %>
Forgot your password?
<i class="bi bi-patch-question me-1"></i> Forgot your password?
</h2>
<p class="lead">We'll send you an email with instructions to reset your password.</p>
@ -11,7 +10,7 @@
<%= label f, :email, class: "form-label" %>
<div class="inner-addon left-addon mb-3">
<%= icon_div @conn, "bi-at", [class: "icon is-left text-muted fs-5"] %>
<i class="bi bi-at icon is-left text-muted fs-5"></i>
<%= email_input f, :email,
placeholder: "e.g., babka@73k.us",
class: "form-control",
@ -28,7 +27,9 @@
<% end %>
<p>
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
<%= if allow_registration() do %>
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
<% end %>
<%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
</p>

View file

@ -2,14 +2,13 @@
<div class="col-11 col-sm-8 col-md-6 col-lg-5 col-xl-4 col-xxl-3">
<h2>
<%= icon_div @conn, "bi-door-open", [class: "icon baseline"] %>
Log in
<i class="bi bi-door-open me-1"></i> Log in
</h2>
<p class="lead">Who goes there?</p>
<%= form_for @conn, Routes.user_session_path(@conn, :create), [as: :user, class: "needs-validation", novalidate: true], fn f -> %>
<%= if @error_message do %>
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<div class="alert alert-danger alert-dismissible fade show mt-4" role="alert">
<%= @error_message %>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
@ -17,11 +16,12 @@
<%= label f, :email, class: "form-label" %>
<div class="inner-addon left-addon mb-3">
<%= icon_div @conn, "bi-at", [class: "icon is-left text-muted fs-5"] %>
<i class="bi bi-at icon is-left text-muted fs-5"></i>
<%= email_input f, :email,
class: "form-control",
placeholder: "e.g., babka@73k.us",
maxlength: User.max_email,
autofocus: true,
required: true
%>
<span class="invalid-feedback">must be a valid email address</span>
@ -29,7 +29,7 @@
<%= label f, :password, class: "form-label" %>
<div class="inner-addon left-addon mb-3">
<%= icon_div @conn, "bi-lock", [class: "icon is-left text-muted fs-5"] %>
<i class="bi bi-lock icon is-left text-muted fs-5"></i>
<%= password_input f, :password,
class: "form-control",
required: true
@ -49,7 +49,9 @@
<% end %>
<p>
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
<%= if allow_registration() do %>
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
<% end %>
<%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %>
</p>

View file

@ -2,8 +2,7 @@
<div class="col-12 col-md-10 col-xl-8">
<h2>
<%= icon_div @conn, "bi-file-earmark-spreadsheet", [class: "icon baseline"] %>
CSV Export
<i class="bi bi-file-earmark-spreadsheet me-1"></i> CSV Export
</h2>
<p class="lead">Select a date range for which to export a CSV of your scheduled shifts, or click "Export All" to export everything.</p>
@ -27,6 +26,7 @@
value: Date.beginning_of_month(today),
min: min_date,
max: max_date,
required: true,
class: "form-control"
%>
</div>
@ -37,6 +37,7 @@
value: Date.end_of_month(today),
min: min_date,
max: max_date,
required: true,
class: "form-control"
%>
</div>

View file

@ -2,8 +2,7 @@
<div class="col-12 col-md-10 col-xl-8">
<h2>
<%= icon_div @conn, "bi-calendar2", [class: "icon baseline"] %>
User Shifts ICS
<i class="bi bi-calendar2 me-1"></i> User Shifts ICS
</h2>
<p class="lead">Shifts for user: <%= @user.email %></p>
<p>Calendar slug: <%= @slug %></p>

View file

@ -1,38 +0,0 @@
defmodule Shift73kWeb.IconHelpers do
@moduledoc """
Generate SVG sprite use tags for SVG icons
"""
use Phoenix.HTML
alias Shift73kWeb.Router.Helpers, as: Routes
def icon_div(conn, name, div_opts \\ [], svg_opts \\ []) do
content_tag(:div, tag_opts(name, div_opts)) do
icon_svg(conn, name, svg_opts)
end
end
def icon_svg(conn, name, opts \\ []) do
opts = aria_hidden?(opts)
content_tag(:svg, tag_opts(name, opts)) do
~E"""
<%= if title = Keyword.get(opts, :aria_label), do: content_tag(:title, title) %>
<%= tag(:use, "xlink:href": Routes.static_path(conn, "/images/icons.svg##{name}")) %>
"""
end
end
defp tag_opts(name, opts) do
Keyword.update(opts, :class, name, fn c -> "#{c} #{name}" end)
end
defp aria_hidden?(opts) do
case Keyword.get(opts, :aria_hidden) do
"false" -> Keyword.drop(opts, [:aria_hidden])
false -> Keyword.drop(opts, [:aria_hidden])
"true" -> opts
_ -> Keyword.put(opts, :aria_hidden, "true")
end
end
end

View file

@ -1,9 +1,21 @@
defmodule Shift73kWeb.LayoutView do
use Shift73kWeb, :view
alias Shift73k.Repo
alias Shift73k.Accounts.User
alias Shift73kWeb.Roles
@app_vars Application.compile_env(:shift73k, :app_global_vars, allow_registration: :true)
@app_allow_registration @app_vars[:allow_registration]
# With a Vite.js-based workflow, we will import different asset files in development
# and in production builds. Therefore, we will need a way to conditionally render
# <script> tags based on Mix environment. However, since Mix is not available in
# releases, we need to cache the Mix environment at compile time. To this end:
@env Mix.env() # remember value at compile time
def dev_env?, do: @env == :dev
def allow_registration, do: @app_allow_registration
def nav_link_opts(conn, opts) do
case Keyword.get(opts, :to) == Phoenix.Controller.current_path(conn) do
false -> opts

View file

@ -1,4 +1,9 @@
defmodule Shift73kWeb.UserConfirmationView do
use Shift73kWeb, :view
alias Shift73k.Accounts.User
@app_vars Application.compile_env(:shift73k, :app_global_vars, allow_registration: :true)
@app_allow_registration @app_vars[:allow_registration]
def allow_registration, do: @app_allow_registration
end

View file

@ -1,4 +1,9 @@
defmodule Shift73kWeb.UserResetPasswordView do
use Shift73kWeb, :view
alias Shift73k.Accounts.User
@app_vars Application.compile_env(:shift73k, :app_global_vars, allow_registration: :true)
@app_allow_registration @app_vars[:allow_registration]
def allow_registration, do: @app_allow_registration
end

View file

@ -1,4 +1,9 @@
defmodule Shift73kWeb.UserSessionView do
use Shift73kWeb, :view
alias Shift73k.Accounts.User
@app_vars Application.compile_env(:shift73k, :app_global_vars, allow_registration: :true)
@app_allow_registration @app_vars[:allow_registration]
def allow_registration, do: @app_allow_registration
end

37
mix.exs
View file

@ -4,10 +4,10 @@ defmodule Shift73k.MixProject do
def project do
[
app: :shift73k,
version: "0.1.0",
elixir: "~> 1.7",
version: "0.1.1",
elixir: "~> 1.12",
elixirc_paths: elixirc_paths(Mix.env()),
compilers: [:phoenix, :gettext] ++ Mix.compilers(),
compilers: Mix.compilers(),
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps()
@ -33,23 +33,21 @@ defmodule Shift73k.MixProject do
# Type `mix help deps` for examples and options.
defp deps do
[
{:bcrypt_elixir, "~> 2.0"},
{:phoenix, "~> 1.5.8"},
{:phoenix_ecto, "~> 4.1"},
{:ecto_sql, "~> 3.4"},
{:bcrypt_elixir, "~> 3.0"},
{:phoenix, "~> 1.6.11"},
{:phoenix_ecto, "~> 4.4"},
{:ecto_sql, "~> 3.6"},
{:postgrex, ">= 0.0.0"},
{:phoenix_live_view, "~> 0.15.0"},
{:floki, ">= 0.27.0", only: :test},
{:phoenix_html, "~> 2.11"},
{:phoenix_html, "~> 3.0"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_dashboard, "~> 0.4"},
{:telemetry_metrics, "~> 0.4"},
{:telemetry_poller, "~> 0.4"},
{:gettext, "~> 0.11"},
{:jason, "~> 1.0"},
{:plug_cowboy, "~> 2.0"},
{:bamboo, "~> 2.0"},
{:bamboo_smtp, "~> 4.0"},
{:phoenix_live_view, "~> 0.17.5"},
{:floki, ">= 0.30.0", only: :test},
{:swoosh, "~> 1.7"},
{:gen_smtp, "~> 1.2"},
{:telemetry_metrics, "~> 0.6"},
{:telemetry_poller, "~> 1.0"},
{:jason, "~> 1.2"},
{:plug_cowboy, "~> 2.5"},
{:scrivener_ecto, "~> 2.0"},
{:tzdata, "~> 1.1"},
{:nimble_csv, "~> 1.0"},
@ -58,8 +56,7 @@ defmodule Shift73k.MixProject do
# Additional packages
{:credo, "~> 1.5", only: [:dev, :test], runtime: false},
{:sobelow, "~> 0.8", only: :dev}
{:credo, "~> 1.5", only: [:dev, :test], runtime: false}
]
end

View file

@ -1,60 +1,54 @@
%{
"bamboo": {:hex, :bamboo, "2.0.2", "0e2914d2bea0de3b1743384c24ffbe20fbb58094376a49f1cf5d9ed9959abd82", [:mix], [{:hackney, ">= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.4", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "058d57cf4fcdac19413aa72732eb43c88954fb151a1cb6a382014e0cddbf6314"},
"bamboo_smtp": {:hex, :bamboo_smtp, "4.0.0", "0cc7df161d5d440d280a6d2eb20bf80bc45ea77161728a229e5ab339dcd087cd", [:mix], [{:bamboo, "~> 2.0.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:gen_smtp, "~> 1.1.0", [hex: :gen_smtp, repo: "hexpm", optional: false]}], "hexpm", "2412015092121b9f24f3f2e654bcd98e5c5f9afb323a94f8defa22e70ba8f23d"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "2.3.0", "6cb662d5c1b0a8858801cf20997bd006e7016aa8c52959c9ef80e0f34fb60b7a", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "2c81d61d4f6ed0e5cf7bf27a9109b791ff216a1034b3d541327484f46dd43769"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.0.1", "9be815469e6bfefec40fa74658ecbbe6897acfb57614df1416eeccd4903f602c", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "486bb95efb645d1efc6794c1ddd776a186a9a713abf06f45708a6ce324fb96cf"},
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"},
"certifi": {:hex, :certifi, "2.6.1", "dbab8e5e155a0763eea978c913ca280a6b544bfa115633fa20249c3d396d9493", [:rebar3], [], "hexpm", "524c97b4991b3849dd5c17a631223896272c6b0af446778ba4675a1dff53bb7e"},
"certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
"comeonin": {:hex, :comeonin, "5.3.2", "5c2f893d05c56ae3f5e24c1b983c2d5dfb88c6d979c9287a76a7feb1e1d8d646", [:mix], [], "hexpm", "d0993402844c49539aeadb3fe46a3c9bd190f1ecf86b6f9ebd71957534c95f04"},
"comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"},
"connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"},
"cowboy": {:hex, :cowboy, "2.8.0", "f3dc62e35797ecd9ac1b50db74611193c29815401e53bac9a5c0577bd7bc667d", [:rebar3], [{:cowlib, "~> 2.9.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "4643e4fba74ac96d4d152c75803de6fad0b3fa5df354c71afdd6cbeeb15fac8a"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.3.1", "ebd1a1d7aff97f27c66654e78ece187abdc646992714164380d8a041eda16754", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a6efd3366130eab84ca372cbd4a7d3c3a97bdfcfb4911233b035d117063f0af"},
"cowlib": {:hex, :cowlib, "2.9.1", "61a6c7c50cf07fdd24b2f45b89500bb93b6686579b069a89f88cb211e1125c78", [:rebar3], [], "hexpm", "e4175dc240a70d996156160891e1c62238ede1729e45740bdd38064dad476170"},
"credo": {:hex, :credo, "1.5.5", "e8f422026f553bc3bebb81c8e8bf1932f498ca03339856c7fec63d3faac8424b", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "dd8623ab7091956a855dc9f3062486add9c52d310dfd62748779c4315d8247de"},
"db_connection": {:hex, :db_connection, "2.3.1", "4c9f3ed1ef37471cbdd2762d6655be11e38193904d9c5c1c9389f1b891a3088e", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "abaab61780dde30301d840417890bd9f74131041afd02174cf4e10635b3a63f5"},
"cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},
"credo": {:hex, :credo, "1.6.6", "f51f8d45db1af3b2e2f7bee3e6d3c871737bda4a91bff00c5eec276517d1a19c", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "625520ce0984ee0f9f1f198165cd46fa73c1e59a17ebc520038b8fce056a5bdc"},
"db_connection": {:hex, :db_connection, "2.4.2", "f92e79aff2375299a16bcb069a14ee8615c3414863a6fef93156aee8e86c2ff3", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4fe53ca91b99f55ea249693a0229356a08f4d1a7931d8ffa79289b145fe83668"},
"decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
"ecto": {:hex, :ecto, "3.5.8", "8ebf12be6016cb99313348ba7bb4612f4114b9a506d6da79a2adc7ef449340bc", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ea0be182ea8922eb7742e3ae8e71b67ee00ae177de1bf76210299a5f16ba4c77"},
"ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"},
"ecto_sql": {:hex, :ecto_sql, "3.5.4", "a9e292c40bd79fff88885f95f1ecd7b2516e09aa99c7dd0201aa84c54d2358e4", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.5.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1fff1a28a898d7bbef263f1f3ea425b04ba9f33816d843238c84eff883347343"},
"elixir_make": {:hex, :elixir_make, "0.6.2", "7dffacd77dec4c37b39af867cedaabb0b59f6a871f89722c25b28fcd4bd70530", [:mix], [], "hexpm", "03e49eadda22526a7e5279d53321d1cced6552f344ba4e03e619063de75348d9"},
"ecto": {:hex, :ecto, "3.8.4", "e06b8b87e62b27fea17fd2ff6041572ddd10339fd16cdf58446e402c6c90a74b", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f9244288b8d42db40515463a008cf3f4e0e564bb9c249fe87bf28a6d79fe82d4"},
"ecto_sql": {:hex, :ecto_sql, "3.8.3", "a7d22c624202546a39d615ed7a6b784580391e65723f2d24f65941b4dd73d471", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.8.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "348cb17fb9e6daf6f251a87049eafcb57805e2892e5e6a0f5dea0985d367329b"},
"elixir_make": {:hex, :elixir_make, "0.6.3", "bc07d53221216838d79e03a8019d0839786703129599e9619f4ab74c8c096eac", [:mix], [], "hexpm", "f5cbd651c5678bcaabdbb7857658ee106b12509cd976c2c2fca99688e1daf716"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"floki": {:hex, :floki, "0.30.1", "75d35526d3a1459920b6e87fdbc2e0b8a3670f965dd0903708d2b267e0904c55", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "e9c03524447d1c4cbfccd672d739b8c18453eee377846b119d4fd71b1a176bb8"},
"gen_smtp": {:hex, :gen_smtp, "1.1.0", "b0c92138f69e2f73e1eb791075e93e952efcbc231a536740749b02a1a57155a3", [:rebar3], [{:hut, "1.3.0", [hex: :hut, repo: "hexpm", optional: false]}, {:ranch, ">= 1.7.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e53a13c4bb662331bdf4aa47f00982ef49ce2b4e5c703240542cb7b28f23546a"},
"gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"},
"hackney": {:hex, :hackney, "1.17.4", "99da4674592504d3fb0cfef0db84c3ba02b4508bae2dff8c0108baa0d6e0977c", [:rebar3], [{:certifi, "~>2.6.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "de16ff4996556c8548d512f4dbe22dd58a587bf3332e7fd362430a7ef3986b16"},
"floki": {:hex, :floki, "0.33.1", "f20f1eb471e726342b45ccb68edb9486729e7df94da403936ea94a794f072781", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "461035fd125f13fdf30f243c85a0b1e50afbec876cbf1ceefe6fddd2e6d712c6"},
"gen_smtp": {:hex, :gen_smtp, "1.2.0", "9cfc75c72a8821588b9b9fe947ae5ab2aed95a052b81237e0928633a13276fd3", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "5ee0375680bca8f20c4d85f58c2894441443a743355430ff33a783fe03296779"},
"gettext": {:hex, :gettext, "0.20.0", "75ad71de05f2ef56991dbae224d35c68b098dd0e26918def5bb45591d5c8d429", [:mix], [], "hexpm", "1c03b177435e93a47441d7f681a7040bd2a816ece9e2666d1c9001035121eb3d"},
"hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"},
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
"httpoison": {:hex, :httpoison, "1.8.0", "6b85dea15820b7804ef607ff78406ab449dd78bed923a49c7160e1886e987a3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "28089eaa98cf90c66265b6b5ad87c59a3729bea2e74e9d08f9b51eb9729b3c3a"},
"hut": {:hex, :hut, "1.3.0", "71f2f054e657c03f959cf1acc43f436ea87580696528ca2a55c8afb1b06c85e7", [:"erlang.mk", :rebar, :rebar3], [], "hexpm", "7e15d28555d8a1f2b5a3a931ec120af0753e4853a4c66053db354f35bf9ab563"},
"icalendar": {:hex, :icalendar, "1.1.0", "898a8640abb32d161d990e419999004718a7a4b48be31f48db248f90ca33fa6e", [:mix], [{:timex, "~> 3.4", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm", "a131f45fbabd2ee5a22e6bc49ea91e81131158394e7169274cee866263640dca"},
"httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"},
"icalendar": {:hex, :icalendar, "1.1.2", "5d0afff5d0143c5bd43f18ae32a777bf0fb9a724543ab05229a460d368f0a5e7", [:mix], [{:timex, "~> 3.4", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm", "2060f8e353fdf3047e95a3f012583dc3c0bbd7ca1010e32ed9e9fc5760ad4292"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"},
"jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "1.5.0", "203ef35ef3389aae6d361918bf3f952fa17a09e8e43b5aa592b93eba05d0fb8d", [:mix], [], "hexpm", "55a94c0f552249fc1a3dd9cd2d3ab9de9d3c89b559c2bd01121f824834f24746"},
"mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
"nimble_csv": {:hex, :nimble_csv, "1.1.0", "b1dba4a86be9e03065c9de829050468e591f569100332db949e7ce71be0afc25", [:mix], [], "hexpm", "e986755bc302832cac429be6deda0fc9d82d3c82b47abefb68b3c17c9d949a3f"},
"nimble_csv": {:hex, :nimble_csv, "1.2.0", "4e26385d260c61eba9d4412c71cea34421f296d5353f914afe3f2e71cce97722", [:mix], [], "hexpm", "d0628117fcc2148178b034044c55359b26966c6eaa8e2ce15777be3bbc91b12a"},
"parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"},
"phoenix": {:hex, :phoenix, "1.5.8", "71cfa7a9bb9a37af4df98939790642f210e35f696b935ca6d9d9c55a884621a4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "35ded0a32f4836168c7ab6c33b88822eccd201bcd9492125a9bea4c54332d955"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.2.1", "13f124cf0a3ce0f1948cf24654c7b9f2347169ff75c1123f44674afee6af3b03", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 2.15", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "478a1bae899cac0a6e02be1deec7e2944b7754c04e7d4107fc5a517f877743c0"},
"phoenix_html": {:hex, :phoenix_html, "2.14.3", "51f720d0d543e4e157ff06b65de38e13303d5778a7919bcc696599e5934271b8", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "efd697a7fff35a13eeeb6b43db884705cba353a1a41d127d118fda5f90c8e80f"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.4.0", "87990e68b60213d7487e65814046f9a2bed4a67886c943270125913499b3e5c3", [:mix], [{:ecto_psql_extras, "~> 0.4.1 or ~> 0.5", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.14.1 or ~> 2.15", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.15.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.4.0 or ~> 0.5.0 or ~> 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "8d52149e58188e9e4497cc0d8900ab94d9b66f96998ec38c47c7a4f8f4f50e57"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.0", "f35f61c3f959c9a01b36defaa1f0624edd55b87e236b606664a556d6f72fd2e7", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "02c1007ae393f2b76ec61c1a869b1e617179877984678babde131d716f95b582"},
"phoenix_live_view": {:hex, :phoenix_live_view, "0.15.4", "86908dc9603cc81c07e84725ee42349b5325cb250c9c20d3533856ff18dbb7dc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 0.5", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "35d78f3c35fe10a995dca5f4ab50165b7a90cbe02e23de245381558f821e9462"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"},
"phx_gen_auth": {:hex, :phx_gen_auth, "0.6.0", "4ffbfa5b34ad8178c3dfcb996fed776df425903595cbc8d56a9ae5bc53136810", [:mix], [{:phoenix, "~> 1.5.2", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9a801c0f0bc251d8d91d62cecba0ebb6a90b8580fa8843029d931d15164e6ad9"},
"plug": {:hex, :plug, "1.11.1", "f2992bac66fdae679453c9e86134a4201f6f43a687d8ff1cd1b2862d53c80259", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "23524e4fefbb587c11f0833b3910bfb414bf2e2534d61928e920f54e3a1b881f"},
"plug_cowboy": {:hex, :plug_cowboy, "2.4.1", "779ba386c0915027f22e14a48919a9545714f849505fa15af2631a0d298abf0f", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d72113b6dff7b37a7d9b2a5b68892808e3a9a752f2bf7e503240945385b70507"},
"phoenix": {:hex, :phoenix, "1.6.11", "29f3c0fd12fa1fc4d4b05e341578e55bc78d96ea83a022587a7e276884d397e4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1664e34f80c25ea4918fbadd957f491225ef601c0e00b4e644b1a772864bfbc2"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"},
"phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.3", "3a53772a6118d5679bf50fc1670505a290e32a1d195df9e069d8c53ab040c054", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "766796676e5f558dbae5d1bdb066849673e956005e3730dfd5affd7a6da4abac"},
"phoenix_live_view": {:hex, :phoenix_live_view, "0.17.11", "205f6aa5405648c76f2abcd57716f42fc07d8f21dd8ea7b262dd12b324b50c95", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7177791944b7f90ed18f5935a6a5f07f760b36f7b3bdfb9d28c57440a3c43f99"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"},
"phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"},
"plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"},
"plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"},
"plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"},
"postgrex": {:hex, :postgrex, "0.15.8", "f5e782bbe5e8fa178d5e3cd1999c857dc48eda95f0a4d7f7bd92a50e84a0d491", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "698fbfacea34c4cf22c8281abeb5cf68d99628d541874f085520ab3b53d356fe"},
"ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"},
"scrivener": {:hex, :scrivener, "2.7.0", "fa94cdea21fad0649921d8066b1833d18d296217bfdf4a5389a2f45ee857b773", [:mix], [], "hexpm", "30da36a427f2519cf75993271fb7c5aad1759682a70f90d880a85c3d743d2c57"},
"postgrex": {:hex, :postgrex, "0.16.4", "26d998467b4a22252285e728a29d341e08403d084e44674784975bb1cd00d2cb", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "3234d1a70cb7b1e0c95d2e242785ec2a7a94a092bbcef4472320b950cfd64c5f"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"scrivener": {:hex, :scrivener, "2.7.2", "1d913c965ec352650a7f864ad7fd8d80462f76a32f33d57d1e48bc5e9d40aba2", [:mix], [], "hexpm", "7866a0ec4d40274efbee1db8bead13a995ea4926ecd8203345af8f90d2b620d9"},
"scrivener_ecto": {:hex, :scrivener_ecto, "2.7.0", "cf64b8cb8a96cd131cdbcecf64e7fd395e21aaa1cb0236c42a7c2e34b0dca580", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:scrivener, "~> 2.4", [hex: :scrivener, repo: "hexpm", optional: false]}], "hexpm", "e809f171687806b0031129034352f5ae44849720c48dd839200adeaf0ac3e260"},
"sobelow": {:hex, :sobelow, "0.11.1", "23438964486f8112b41e743bbfd402da3e5b296fdc9eacab29914b79c48916dd", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "9897363a7eff96f4809304a90aad819e2ad5e5d24db547af502885146746a53c"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
"swoosh": {:hex, :swoosh, "1.3.2", "608819a638d527d0fd6e8892db431edd3f93c0452499880f94a80bbbcf8377e6", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "2c77c2eef273de37283bdc09c6506d466928f897600ea258fbb4765bbffd6ab2"},
"telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"},
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.0", "da9d49ee7e6bb1c259d36ce6539cd45ae14d81247a2b0c90edf55e2b50507f7b", [:mix], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5cfe67ad464b243835512aa44321cee91faed6ea868d7fb761d7016e02915c3d"},
"telemetry_poller": {:hex, :telemetry_poller, "0.5.1", "21071cc2e536810bac5628b935521ff3e28f0303e770951158c73eaaa01e962a", [:rebar3], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4cab72069210bc6e7a080cec9afffad1b33370149ed5d379b81c7c5f0c663fd4"},
"timex": {:hex, :timex, "3.7.3", "df8a2ea814749d700d6878ab9eacac9fdb498ecee2f507cb0002ec172bc24d0f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8691c1d86ca3a7bc14a156e2199dc8927be95d1a8f0e3b69e4bb2d6262c53ac6"},
"tzdata": {:hex, :tzdata, "1.1.0", "72f5babaa9390d0f131465c8702fa76da0919e37ba32baa90d93c583301a8359", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "18f453739b48d3dc5bcf0e8906d2dc112bb40baafe2c707596d89f3c8dd14034"},
"swoosh": {:hex, :swoosh, "1.7.4", "f967d9b2659e81bab241b96267aae1001d35c2beea2df9c03dcf47b007bf566f", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1553d994b4cf069162965e63de1e1c53d8236e127118d21e56ce2abeaa3f25b4"},
"telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"},
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"},
"telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"},
"timex": {:hex, :timex, "3.7.9", "790cdfc4acfce434e442f98c02ea6d84d0239073bfd668968f82ac63e9a6788d", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "64691582e5bb87130f721fc709acfb70f24405833998fabf35be968984860ce1"},
"tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
}

View file

@ -5,28 +5,28 @@ defmodule Shift73k.Repo.Migrations.CreateUsersAuthTables do
execute("CREATE EXTENSION IF NOT EXISTS citext", "")
create table(:users, primary_key: false) do
add(:id, :binary_id, primary_key: true)
add(:email, :citext, null: false)
add(:hashed_password, :string, null: false)
add(:role, :string, null: false)
add(:confirmed_at, :naive_datetime)
add(:week_start_at, :string, null: false)
add(:calendar_slug, :string, null: false)
add :id, :binary_id, primary_key: true
add :email, :citext, null: false
add :hashed_password, :string, null: false
add :role, :string, null: false
add :confirmed_at, :naive_datetime
add :week_start_at, :string, null: false
add :calendar_slug, :string, null: false
timestamps()
end
create(unique_index(:users, [:email, :calendar_slug]))
create unique_index(:users, [:email, :calendar_slug])
create table(:users_tokens, primary_key: false) do
add(:id, :binary_id, primary_key: true)
add(:user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false)
add(:token, :binary, null: false)
add(:context, :string, null: false)
add(:sent_to, :string)
add :id, :binary_id, primary_key: true
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
add :token, :binary, null: false
add :context, :string, null: false
add :sent_to, :string
timestamps(updated_at: false)
end
create(index(:users_tokens, [:user_id]))
create(unique_index(:users_tokens, [:context, :token]))
create index(:users_tokens, [:user_id])
create unique_index(:users_tokens, [:context, :token])
end
end

View file

@ -3,7 +3,7 @@ defmodule Shift73k.Repo.Migrations.AddUserDefaultShiftColumn do
def change do
alter table(:users) do
add(:fave_shift_template_id, references(:shift_templates, type: :binary_id, on_delete: :nilify_all))
add :fave_shift_template_id, references(:shift_templates, type: :binary_id, on_delete: :nilify_all)
end
end
end

View file

@ -0,0 +1,19 @@
defmodule Shift73k.Repo.Migrations.FixShiftsUserIdReference do
use Ecto.Migration
def up do
execute("ALTER TABLE shifts DROP CONSTRAINT shifts_user_id_fkey")
alter table(:shifts) do
modify :user_id, references(:users, on_delete: :delete_all, type: :binary_id)
end
end
def down do
execute("ALTER TABLE shifts DROP CONSTRAINT shifts_user_id_fkey")
alter table(:shifts) do
modify :user_id, references(:users, on_delete: :nothing, type: :binary_id)
end
end
end

View file

@ -14,139 +14,150 @@ alias Shift73k.Repo
alias Shift73k.Accounts
alias Shift73k.Accounts.User
############################################################################
## INSERTING MOCK USER DATA
{:ok, _admin} =
Accounts.register_user(%{
email: "admin@company.com",
password: "123456789abC",
password_confirmation: "123456789abC",
role: Accounts.registration_role()
})
if Mix.env() == :dev do
{:ok, _user_1} =
Accounts.register_user(%{
email: "user1@company.com",
password: "123456789abC",
password_confirmation: "123456789abC",
role: Accounts.registration_role()
})
if System.get_env("ECTO_SEED_DB") do
{:ok, _user_2} =
Accounts.register_user(%{
email: "user2@company.com",
password: "123456789abC",
password_confirmation: "123456789abC",
role: Accounts.registration_role()
})
############################################################################
## INSERTING MOCK USER DATA
# if Mix.env() == :dev do
this_path = Path.dirname(__ENV__.file)
users_json = Path.join(this_path, "MOCK_DATA_users.json")
{:ok, _admin} =
Accounts.register_user(%{
email: "admin@company.com",
password: "123456789abC",
password_confirmation: "123456789abC",
role: Accounts.registration_role()
})
count_to_take = 15
{:ok, _user_1} =
Accounts.register_user(%{
email: "user1@company.com",
password: "123456789abC",
password_confirmation: "123456789abC",
role: Accounts.registration_role()
})
mock_users = users_json |> File.read!() |> Jason.decode!() |> Enum.take_random(count_to_take)
{:ok, _user_2} =
Accounts.register_user(%{
email: "user2@company.com",
password: "123456789abC",
password_confirmation: "123456789abC",
role: Accounts.registration_role()
})
extra_mock_users = ~s([
{"email":"adam@73k.us","password":"adamadamA1","role":"admin","inserted_at":"2018-12-14T01:01:01Z","confirmed_at":true},
{"email":"karen@73k.us","password":"karenkarenA1","role":"manager","inserted_at":"2018-12-14T01:06:01Z","confirmed_at":true},
{"email":"kat@73k.us","password":"katkatA1","role":"manager","inserted_at":"2018-12-14T01:06:01Z","confirmed_at":true}
])
# if Mix.env() == :dev do
this_path = Path.dirname(__ENV__.file)
users_json = Path.join(this_path, "MOCK_DATA_users.json")
# for random week_start_at values
[head | tail] = Shift73k.weekdays()
week_starts = [head | Enum.drop(tail, 4)]
count_to_take = 15
mock_users =
extra_mock_users
|> Jason.decode!()
|> Stream.concat(mock_users)
|> Enum.map(fn e ->
add_dt = NaiveDateTime.from_iso8601!(e["inserted_at"])
mock_users = users_json |> File.read!() |> Jason.decode!() |> Enum.take_random(count_to_take)
%{
email: e["email"],
role: String.to_existing_atom(e["role"]),
hashed_password: Bcrypt.hash_pwd_salt(e["password"]),
week_start_at: Enum.at(week_starts, Enum.random(0..2)),
calendar_slug: Ecto.UUID.generate(),
inserted_at: add_dt,
updated_at: add_dt,
confirmed_at: (e["confirmed_at"] && NaiveDateTime.add(add_dt, 300, :second)) || nil
}
end)
extra_mock_users = ~s([
{"email":"adam@73k.us","password":"adamadamA1","role":"admin","inserted_at":"2018-12-14T01:01:01Z","confirmed_at":true},
{"email":"kat@73k.us","password":"katkatA1","role":"manager","inserted_at":"2018-12-14T01:06:01Z","confirmed_at":true},
{"email":"babka@73k.us","password":"Babka2020","role":"user","inserted_at":"2018-12-14T01:06:01Z","confirmed_at":false},
{"email":"malcolm@73k.us","password":"Malc2018","role":"user","inserted_at":"2018-12-14T01:06:01Z","confirmed_at":false},
{"email":"casio@73k.us","password":"Casio2011","role":"user","inserted_at":"2018-12-14T01:06:01Z","confirmed_at":false}
])
Repo.insert_all(User, mock_users)
# end
# for random week_start_at values
[head | tail] = Shift73k.weekdays()
week_starts = [head | Enum.drop(tail, 4)]
#####
# shift tepmlates
alias Shift73k.Shifts.Templates.ShiftTemplate
mock_users =
extra_mock_users
|> Jason.decode!()
|> Stream.concat(mock_users)
|> Enum.map(fn e ->
add_dt = NaiveDateTime.from_iso8601!(e["inserted_at"])
shifts_json = Path.join(this_path, "MOCK_DATA_shift-templates.json")
mock_shifts = shifts_json |> File.read!() |> Jason.decode!()
%{
email: e["email"],
role: String.to_existing_atom(e["role"]),
hashed_password: Bcrypt.hash_pwd_salt(e["password"]),
week_start_at: Enum.at(week_starts, Enum.random(0..2)),
calendar_slug: Ecto.UUID.generate(),
inserted_at: add_dt,
updated_at: add_dt,
confirmed_at: (e["confirmed_at"] && NaiveDateTime.add(add_dt, 300, :second)) || nil
}
end)
Repo.insert_all(User, mock_users)
# end
#####
# shift tepmlates
alias Shift73k.Shifts.Templates.ShiftTemplate
shifts_json = Path.join(this_path, "MOCK_DATA_shift-templates.json")
mock_shifts = shifts_json |> File.read!() |> Jason.decode!()
time_from_mock = fn mock_time ->
case String.length(mock_time) do
4 -> Time.from_iso8601!("T0#{mock_time}:00")
5 -> Time.from_iso8601!("T#{mock_time}:00")
end
end
seconds_day = 86_400
seconds_days_14 = seconds_day * 14
seconds_half_day = Integer.floor_div(seconds_day, 2)
for user <- Accounts.list_users() do
user_shifts =
mock_shifts
|> Enum.take_random(:rand.uniform(15) + 5)
|> Enum.map(fn e ->
seconds_to_add = :rand.uniform(seconds_days_14) + seconds_half_day
add_dt = NaiveDateTime.add(user.inserted_at, seconds_to_add)
time_start = time_from_mock.(e["time_start"])
shift_len_min = e["length_minutes"] || 0
shift_length = e["length_hours"] * 60 * 60 + shift_len_min * 60
time_end = Time.add(time_start, shift_length) |> Time.truncate(:second)
%{
subject: e["subject"],
description: e["description"],
location: e["location"],
time_zone: Tzdata.zone_list() |> Enum.random(),
time_start: time_start,
time_end: time_end,
user_id: user.id,
inserted_at: add_dt,
updated_at: add_dt
}
end)
Repo.insert_all(ShiftTemplate, user_shifts)
end
#####
# insert shifts for each user?
alias Shift73k.Shifts
alias Shift73k.Shifts.Templates
for user <- Accounts.list_users() do
# build a date range for the time from 120 days ago to 120 days from now
today = Date.utc_today()
date_range = Date.range(Date.add(today, -120), Date.add(today, 120))
# get 3 random shift templates for user
st_list = Templates.list_shift_templates_by_user(user.id) |> Enum.take_random(3)
for st <- st_list do
days_to_schedule = Enum.take_random(date_range, 47)
shift_data = ShiftTemplate.attrs(st)
days_to_schedule
|> Stream.map(&Map.put(shift_data, :date, &1))
|> Enum.map(&Repo.timestamp/1)
|> Shifts.create_multiple()
end
end
time_from_mock = fn mock_time ->
case String.length(mock_time) do
4 -> Time.from_iso8601!("T0#{mock_time}:00")
5 -> Time.from_iso8601!("T#{mock_time}:00")
end
end
seconds_day = 86_400
seconds_days_14 = seconds_day * 14
seconds_half_day = Integer.floor_div(seconds_day, 2)
for user <- Accounts.list_users() do
user_shifts =
mock_shifts
|> Enum.take_random(:rand.uniform(15) + 5)
|> Enum.map(fn e ->
seconds_to_add = :rand.uniform(seconds_days_14) + seconds_half_day
add_dt = NaiveDateTime.add(user.inserted_at, seconds_to_add)
time_start = time_from_mock.(e["time_start"])
shift_len_min = e["length_minutes"] || 0
shift_length = e["length_hours"] * 60 * 60 + shift_len_min * 60
time_end = Time.add(time_start, shift_length) |> Time.truncate(:second)
%{
subject: e["subject"],
description: e["description"],
location: e["location"],
time_zone: Tzdata.zone_list() |> Enum.random(),
time_start: time_start,
time_end: time_end,
user_id: user.id,
inserted_at: add_dt,
updated_at: add_dt
}
end)
Repo.insert_all(ShiftTemplate, user_shifts)
end
#####
# insert shifts for each user?
alias Shift73k.Shifts
alias Shift73k.Shifts.Templates
for user <- Accounts.list_users() do
# build a date range for the time from 120 days ago to 120 days from now
today = Date.utc_today()
date_range = Date.range(Date.add(today, -120), Date.add(today, 120))
# get 3 random shift templates for user
st_list = Templates.list_shift_templates_by_user(user.id) |> Enum.take_random(3)
for st <- st_list do
days_to_schedule = Enum.take_random(date_range, 47)
shift_data = ShiftTemplate.attrs(st)
days_to_schedule
|> Stream.map(&Map.put(shift_data, :date, &1))
|> Enum.map(&Repo.timestamp/1)
|> Shifts.create_multiple()
end
end

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

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