parent
dfd7a9b6a8
commit
949b6b0238
@ -0,0 +1,41 @@
|
||||
-- Add migration script here
|
||||
|
||||
CREATE TABLE IF NOT EXISTS User (
|
||||
id INTEGER PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
CHECK(role = 'admin' OR role = 'editor' OR role = 'viewer')
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS InventoryItem (
|
||||
id INTEGER PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
reorder_point INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS PositiveAdjustment (
|
||||
id INTEGER PRIMARY KEY NOT NULL,
|
||||
user INTEGER NOT NULL,
|
||||
create_date TIMESTAMP NOT NULL,
|
||||
target_date TIMESTAMP NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
unit_price INTEGER NOT NULL,
|
||||
FOREIGN KEY(user) REFERENCES User(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS NegativeAdjustment (
|
||||
id INTEGER PRIMARY KEY NOT NULL,
|
||||
user INTEGER NOT NULL,
|
||||
create_date TIMESTAMP NOT NULL,
|
||||
target_date TIMESTAMP NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
reason INTEGER NOT NULL,
|
||||
FOREIGN KEY(user) REFERENCES User(id),
|
||||
FOREIGN KEY(reason) REFERENCES NegativeAdjustmentReason(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS NegativeAdjustmentReason (
|
||||
id INTEGER PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
use askama::Template;
|
||||
use askama_axum::IntoResponse;
|
||||
use axum::extract::{Path, State};
|
||||
use sqlx::SqlitePool;
|
||||
use axum::response::Response;
|
||||
use axum_htmx::HxRequest;
|
||||
use serde::Deserialize;
|
||||
use crate::db::inventory_item::{inventory_item_get_all, inventory_item_get_by_id, inventory_item_get_search, DbInventoryItem};
|
||||
use crate::error::{AppError, QueryExtractor};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "item_list.html")]
|
||||
struct ItemListTemplate {
|
||||
items: Vec<DbInventoryItem>,
|
||||
query: ItemsQueryArgs,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "item_list_fragment.html")]
|
||||
struct ItemListFragmentTemplate {
|
||||
items: Vec<DbInventoryItem>
|
||||
}
|
||||
|
||||
/// Query string response for "authorized" endpoint
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ItemsQueryArgs {
|
||||
#[serde(rename = "q")]
|
||||
pub search: Option<String>,
|
||||
#[serde(alias = "p")]
|
||||
pub page: Option<i64>,
|
||||
#[serde(rename = "size")]
|
||||
pub page_size: Option<i64>,
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
pub async fn item_list(
|
||||
QueryExtractor(query): QueryExtractor<ItemsQueryArgs>,
|
||||
HxRequest(hx_request): HxRequest,
|
||||
State(db): State<SqlitePool>
|
||||
) -> Result<Response, AppError> {
|
||||
let page = query.page.unwrap_or(0);
|
||||
let page_size = query.page_size.unwrap_or(100);
|
||||
|
||||
let items = match query.search.as_ref() {
|
||||
Some(s) => inventory_item_get_search(&db, &s, page_size, page).await?,
|
||||
None => inventory_item_get_all(&db, page_size, page).await?,
|
||||
};
|
||||
|
||||
if hx_request {
|
||||
Ok(ItemListFragmentTemplate { items }.into_response())
|
||||
}
|
||||
else {
|
||||
Ok(ItemListTemplate { items, query }.into_response())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "item.html")]
|
||||
struct ItemTemplate {
|
||||
item: DbInventoryItem
|
||||
}
|
||||
|
||||
pub async fn item(State(db): State<SqlitePool>, Path(id): Path<i64>) -> Result<Response, AppError> {
|
||||
let item = inventory_item_get_by_id(&db, id).await?;
|
||||
|
||||
Ok(ItemTemplate { item }.into_response())
|
||||
}
|
||||
@ -1,27 +1,24 @@
|
||||
use askama::Template;
|
||||
use askama_axum::IntoResponse;
|
||||
use axum::middleware::from_extractor;
|
||||
use axum::Router;
|
||||
use axum::routing::get;
|
||||
use axum_htmx::{AutoVaryLayer};
|
||||
use crate::app::state::AppState;
|
||||
use crate::auth::User;
|
||||
|
||||
pub mod state;
|
||||
pub mod items;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/index.html", get(index))
|
||||
.route("/", get(items::item_list))
|
||||
.route("/index.html", get(items::item_list))
|
||||
.route("/items", get(items::item_list))
|
||||
.route("/items/", get(items::item_list))
|
||||
.route("/item/:item_id", get(items::item))
|
||||
.route("/item/:item_id/", get(items::item))
|
||||
// Ensure that all routes here require an authenticated user
|
||||
// whether explicitly asked or not
|
||||
.route_layer(from_extractor::<User>())
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "index.html")]
|
||||
struct IndexTemplate;
|
||||
|
||||
async fn index() -> impl IntoResponse {
|
||||
IndexTemplate.into_response()
|
||||
.layer(AutoVaryLayer)
|
||||
}
|
||||
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
use sqlx::SqlitePool;
|
||||
use sqlx::sqlite::SqliteConnectOptions;
|
||||
|
||||
pub async fn init(filename: &str) -> anyhow::Result<SqlitePool> {
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(filename)
|
||||
.create_if_missing(true);
|
||||
|
||||
let db = SqlitePool::connect_with(options).await?;
|
||||
|
||||
tracing::info!("Database connected {}", filename);
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
use serde::Serialize;
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct DbInventoryItem {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub reorder_point: i64,
|
||||
}
|
||||
|
||||
|
||||
pub async fn inventory_item_get_all(db: &SqlitePool, page_size: i64, page_num: i64) -> Result<Vec<DbInventoryItem>> {
|
||||
let offset = page_num * page_size;
|
||||
sqlx::query_as!(
|
||||
DbInventoryItem,
|
||||
r#"
|
||||
SELECT
|
||||
id, name, reorder_point
|
||||
FROM
|
||||
InventoryItem
|
||||
LIMIT ? OFFSET ?
|
||||
"#,
|
||||
page_size, offset
|
||||
)
|
||||
.fetch_all(db).await
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
pub async fn inventory_item_get_search(db: &SqlitePool,
|
||||
search_term: &str,
|
||||
page_size: i64,
|
||||
page_num: i64) -> Result<Vec<DbInventoryItem>> {
|
||||
|
||||
let offset = page_num * page_size;
|
||||
let search = String::from("%") + search_term + "%";
|
||||
sqlx::query_as!(
|
||||
DbInventoryItem,
|
||||
r#"
|
||||
SELECT
|
||||
id, name, reorder_point
|
||||
FROM
|
||||
InventoryItem
|
||||
WHERE InventoryItem.name LIKE ?
|
||||
LIMIT ? OFFSET ?
|
||||
"#,
|
||||
search,
|
||||
page_size, offset
|
||||
)
|
||||
.fetch_all(db).await
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
pub async fn inventory_item_get_by_id(db: &SqlitePool, id: i64) -> Result<DbInventoryItem> {
|
||||
sqlx::query_as!(
|
||||
DbInventoryItem,
|
||||
r#"
|
||||
SELECT
|
||||
id, name, reorder_point
|
||||
FROM
|
||||
InventoryItem
|
||||
WHERE id = ?
|
||||
"#,
|
||||
id
|
||||
)
|
||||
.fetch_one(db).await
|
||||
.map_err(From::from)
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
pub mod inventory_item;
|
||||
|
||||
use std::str::FromStr;
|
||||
use anyhow::Context;
|
||||
use sqlx::SqlitePool;
|
||||
use sqlx::sqlite::SqliteConnectOptions;
|
||||
|
||||
pub async fn init() -> anyhow::Result<SqlitePool> {
|
||||
let db_url = std::env::var("DATABASE_URL")
|
||||
.context("DATABASE_URI not set")?;
|
||||
|
||||
let db = connect_db(&db_url).await?;
|
||||
|
||||
sqlx::migrate!().run(&db).await?;
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
pub async fn connect_db(url: &str) -> anyhow::Result<SqlitePool> {
|
||||
|
||||
let options = SqliteConnectOptions::from_str(url)?
|
||||
.create_if_missing(true);
|
||||
|
||||
let db = SqlitePool::connect_with(options).await?;
|
||||
|
||||
tracing::info!("Database connected {}", url);
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
{% extends "main.html" %}
|
||||
|
||||
{% block title %} Inventory App {% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<p>
|
||||
<input name="search"
|
||||
placeholder="Search"
|
||||
aria-label="Search"
|
||||
type="search">
|
||||
</p>
|
||||
|
||||
{% endblock %}
|
||||
@ -0,0 +1,10 @@
|
||||
{% extends "main.html" %}
|
||||
|
||||
{% block title %} Items {% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h3>{{item.name}}</h3>
|
||||
<p>Reorder at: {{item.reorder_point}}</p>
|
||||
|
||||
{% endblock %}
|
||||
@ -0,0 +1,22 @@
|
||||
{% extends "main.html" %}
|
||||
|
||||
{% block title %} Items {% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<p>
|
||||
<input id="search" type="search" name="q"
|
||||
placeholder="Search"
|
||||
aria-label="Search"
|
||||
value='{{ query.search.as_deref().unwrap_or("") }}'
|
||||
hx-get="/items"
|
||||
hx-trigger="search, keyup delay:200ms changed"
|
||||
hx-target="#items"
|
||||
/>
|
||||
</p>
|
||||
|
||||
<div id="items" class="container">
|
||||
{% include "item_list_fragment.html" %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@ -0,0 +1,9 @@
|
||||
|
||||
{% for item in items %}
|
||||
<article>
|
||||
<div class="grid">
|
||||
<div><a href="/item/{{item.id}}/">{{ item.name }}</a></div>
|
||||
<div>Reorder Point: {{ item.reorder_point }}</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
Loading…
Reference in new issue