cash orders

This commit is contained in:
core 2023-06-18 14:01:43 -04:00
parent 43b98d6d0d
commit 8a044653f7
Signed by: core
GPG Key ID: FDBF740DADDCEECF
11 changed files with 537 additions and 17 deletions

17
backend/src/db_orders.rs Normal file
View File

@ -0,0 +1,17 @@
use std::collections::HashMap;
use bonsaidb::core::schema::Collection;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Collection)]
#[collection(name = "orders")]
pub struct Order {
pub order_type: OrderType,
pub total_usd: f64,
pub products: HashMap<u64, u64>
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub enum OrderType {
Cash,
Stripe
}

View File

@ -7,14 +7,18 @@ use bonsaidb::local::AsyncDatabase;
use bonsaidb::local::config::{Builder, StorageConfiguration};
use crate::db_products::Product;
use crate::db_orders::Order;
use crate::route_order::{cash_order, get_orders};
use crate::route_products::{create_product, delete_product, get_product, get_products, update_product};
pub mod db_products;
pub mod route_products;
pub mod error;
pub mod route_order;
pub mod db_orders;
#[derive(Debug, Schema)]
#[schema(name = "lmsposschema", collections = [Product])]
#[schema(name = "lmsposschema", collections = [Product, Order])]
pub struct DbSchema;
pub struct AppState {
@ -41,6 +45,8 @@ async fn main() -> Result<(), Box<dyn Error>> {
.service(get_product)
.service(delete_product)
.service(status)
.service(get_orders)
.service(cash_order)
})
.bind(("127.0.0.1", 8080))?
.run()

121
backend/src/route_order.rs Normal file
View File

@ -0,0 +1,121 @@
use std::collections::HashMap;
use actix_web::{get, HttpResponse, post};
use actix_web::web::{Data, Json};
use bonsaidb::core::schema::SerializedCollection;
use serde::{Deserialize, Serialize};
use crate::AppState;
use crate::db_orders::{Order, OrderType};
use crate::db_products::Product;
use crate::error::APIError;
#[derive(Deserialize)]
pub struct CashOrderRequest {
pub products: HashMap<u64, u64>,
pub adjust_stock: bool
}
#[derive(Serialize)]
pub struct CashOrderResponse {
pub order_id: u64
}
#[post("/cash_order")]
pub async fn cash_order(req: Json<CashOrderRequest>, db: Data<AppState>) -> HttpResponse {
// load product data
let mut products = match Product::get_multiple_async(req.products.keys(), &db.db).await {
Ok(r) => r,
Err(e) => {
return HttpResponse::InternalServerError().json(APIError {
code: "DB_ERROR".to_string(),
message: e.to_string(),
})
}
};
let mut price_total: f64 = 0.0;
for product in &products {
price_total += req.products[&product.header.id] as f64 * product.contents.price_usd;
}
let doc = Order {
order_type: OrderType::Cash,
total_usd: price_total,
products: req.products.clone(),
};
if req.adjust_stock {
for product in &mut products {
if product.contents.stock < req.products[&product.header.id] {
return HttpResponse::BadRequest().json(APIError {
code: "NOT_ENOUGH_STOCK".to_string(),
message: "Cannot sell more items than are in stock backend".to_string(),
});
}
}
}
let order = match doc.push_into_async(&db.db).await {
Ok(r) => r,
Err(e) => {
return HttpResponse::InternalServerError().json(APIError {
code: "DB_ERROR".to_string(),
message: e.to_string(),
})
}
};
if req.adjust_stock {
for product in &mut products {
product.contents.stock -= req.products[&product.header.id];
match product.update_async(&db.db).await {
Ok(_) => (),
Err(e) => {
return HttpResponse::InternalServerError().json(APIError {
code: "DB_ERROR".to_string(),
message: e.to_string(),
})
}
}
}
}
HttpResponse::Ok().json(CashOrderResponse {
order_id: order.header.id,
})
}
#[derive(Serialize)]
pub struct OrdersResponse {
pub orders: Vec<OrderResponse>
}
#[derive(Serialize)]
pub struct OrderResponse {
pub id: u64,
pub order_type: OrderType,
pub total_usd: f64,
pub products: HashMap<u64, u64>
}
#[get("/orders")]
pub async fn get_orders(db: Data<AppState>) -> HttpResponse {
let orders = match Order::list_async(0.., &db.db).await {
Ok(p) => p,
Err(e) => {
return HttpResponse::InternalServerError().json(APIError {
code: "DB_ERROR".to_string(),
message: e.to_string(),
})
}
};
let resp: Vec<OrderResponse> = orders.iter().map(|u| OrderResponse {
id: u.header.id,
order_type: u.contents.order_type.clone(),
total_usd: u.contents.total_usd,
products: u.contents.products.clone(),
}).collect();
HttpResponse::Ok().json(OrdersResponse { orders: resp })
}

View File

@ -16,9 +16,11 @@
"@material/typography": "^14.0.0",
"@smui/button": "^7.0.0-beta.8",
"@smui/card": "^7.0.0-beta.8",
"@smui/checkbox": "^7.0.0-beta.8",
"@smui/circular-progress": "^7.0.0-beta.8",
"@smui/data-table": "^7.0.0-beta.8",
"@smui/dialog": "^7.0.0-beta.8",
"@smui/form-field": "^7.0.0-beta.8",
"@smui/icon-button": "^7.0.0-beta.8",
"@smui/linear-progress": "^7.0.0-beta.8",
"@smui/paper": "^7.0.0-beta.8",

View File

@ -23,6 +23,12 @@
<Label>Manage Env</Label>
</Button>
</Wrapper>
<Wrapper>
<Button on:click={() => {window.location.href = "/manage/orders"}} disabled={selected === "manage/orders"} class={selected === "manage/orders" ? "underline" : ""}>
<Label>Manage Orders</Label>
</Button>
</Wrapper>
</div>
<style>

View File

@ -6,15 +6,22 @@
import Button, {Label as ButtonLabel, Group as ButtonGroup} from "@smui/button";
import DataTable, { Head, Body, Row, Cell } from '@smui/data-table';
import LinearProgress from "@smui/linear-progress";
import Dialog, { Title as DialogTitle, Content as DialogContent, Actions as DialogActions } from "@smui/dialog";
import Textfield from "@smui/textfield";
import HelperText from "@smui/textfield/helper-text";
import Checkbox from "@smui/checkbox";
import FormField from "@smui/form-field";
let products = [];
let productsCounter = {};
let failureSnackbar: Snackbar;
let failureSnackbarText = "";
let snackbar: Snackbar;
let snackbarText = "";
let loaded;
let loadedCashIn = false;
const formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD'});
// load products from the backend
@ -32,14 +39,14 @@
loaded = true;
} else {
console.error(await resp.text());
failureSnackbarText = "There was an error loading the products list. Check the browser console for more information.";
failureSnackbar.open();
snackbarText = "There was an error loading the products list. Check the browser console for more information.";
snackbar.open();
return;
}
} catch (e) {
console.error(e);
failureSnackbarText = "There was an error loading the products list. Check the browser console for more information.";
failureSnackbar.open();
snackbarText = "There was an error loading the products list. Check the browser console for more information.";
snackbar.open();
return;
}
}
@ -57,13 +64,122 @@
onMount(() => {
loadProducts();
})
});
let cashDialogOpen = false;
let cashInDirty = false;
let cashInInvalid = false;
let cashInFocused = false;
let cashIn = 0.0;
function change(total, cashin) {
let value_b = cashin - total;
let value = Math.trunc(Number(Number(value_b).toFixed(2)) * 100);
// 5s, 1s, quarters, dimes, nickels, pennies
let fives = 0;
let ones = 0;
let quarters = 0;
let dimes = 0;
let nickels = 0;
let pennies = 0;
if (value % 500 >= 0) {
const _temp = value - (value % 500);
fives = _temp / 500;
value -= _temp;
}
if (value % 100 >= 0) {
const _temp = value - (value % 100);
ones = _temp / 100;
value -= _temp;
}
if (value % 25 >= 0) {
const _temp = value - (value % 25);
quarters = _temp / 25;
value -= _temp;
}
if (value % 10 >= 0) {
const _temp = value - (value % 10);
dimes = _temp / 10;
value -= _temp;
}
if (value % 5 >= 0) {
const _temp = value - (value % 5);
nickels = _temp / 5;
value -= _temp;
}
pennies = value;
return [fives, ones, quarters, dimes, nickels, pennies];
}
let changeGiven = [];
function reChange() {
changeGiven = change(total, cashIn);
}
$: cashIn, reChange();
$: total, reChange();
let adjustStockOnOrderFinish = true;
let hasProducts = false;
function updateHasProducts() {
for (let i = 0; i < products.length; i++) {
if (productsCounter[products[i].id] !== 0) {
hasProducts = true;
return;
}
}
hasProducts = false;
}
$: productsCounter, updateHasProducts();
async function cashOrder() {
loadedCashIn = false;
try {
let resp = await fetch(`${$serverUrl}/cash_order`, {
method: 'POST',
headers: [
['Content-Type', 'application/json']
],
body: JSON.stringify({
products: productsCounter,
adjust_stock: adjustStockOnOrderFinish
})
});
if (resp.ok) {
let json = await resp.json();
loadedCashIn = true;
snackbarText = "Order complete! Order ID: " + json.order_id;
snackbar.open();
loadProducts();
return;
} else {
console.error(await resp.text());
snackbarText = "There was an error creating the order. Check the browser console for more information.";
snackbar.open();
return;
}
} catch (e) {
console.error(e);
snackbarText = "There was an error creating the order. Check the browser console for more information.";
snackbar.open();
return;
}
}
</script>
<Header selected="pos" />
<Snackbar bind:this={failureSnackbar}>
<SnackbarLabel>{failureSnackbarText}</SnackbarLabel>
<Snackbar bind:this={snackbar}>
<SnackbarLabel>{snackbarText}</SnackbarLabel>
</Snackbar>
<DataTable table$aria-label="Product list" style="width: 100%;">
@ -91,10 +207,10 @@
<Cell>{formatter.format(productsCounter[product.id] * product.price_usd)}</Cell>
<Cell>
<ButtonGroup>
<Button variant="outlined" on:click={() => {productsCounter[product.id]++}}>
<Button disabled={productsCounter[product.id] === product.stock} variant="outlined" on:click={() => {if (productsCounter[product.id] < product.stock) productsCounter[product.id]++}}>
<ButtonLabel>+</ButtonLabel>
</Button>
<Button variant="outlined" on:click={() => {if (productsCounter[product.id] >= 1) {productsCounter[product.id]--}}}>
<Button disabled={productsCounter[product.id] === 0} variant="outlined" on:click={() => {if (productsCounter[product.id] >= 1) {productsCounter[product.id]--}}}>
<ButtonLabel>-</ButtonLabel>
</Button>
<Button variant="outlined" on:click={() => {productsCounter[product.id] = 0}}>
@ -115,10 +231,10 @@
</Cell>
<Cell>
<ButtonGroup>
<Button variant="outlined">
<Button disabled={!hasProducts} variant="outlined" on:click={() => {cashIn = total.toFixed(2); cashDialogOpen = true;}}>
<ButtonLabel>Cash In</ButtonLabel>
</Button>
<Button variant="outlined">
<Button disabled={!hasProducts} variant="outlined">
<ButtonLabel>Stripe</ButtonLabel>
</Button>
<Button variant="unelevated" on:click={() => {for (let i = 0; i < products.length; i++) {productsCounter[products[i].id] = 0}}}>
@ -130,4 +246,51 @@
</Body>
<LinearProgress indeterminate bind:closed={loaded} aria-label="Data is being loaded..." slot="progress" />
</DataTable>
</DataTable>
<Dialog bind:open={cashDialogOpen} aria-labelledby="cash-title" aria-describedby="cash-content">
<DialogTitle id="cash-title">Cash In</DialogTitle>
<DialogContent id="cash-content">
<p>Total Price: {formatter.format(total)}</p>
<Textfield type="number" input$step="0.01" input$min="{total.toFixed(2)}" bind:dirty={cashInDirty} bind:invalid={cashInInvalid} updateInvalid bind:value={cashIn} label="Cash in" on:focus={() => {cashInFocused = true}} on:blur={() => {cashInFocused = false}}>
<HelperText validationMsg slot="helper">
That's not a valid price - must be a number and above {total.toFixed(2)}
</HelperText>
</Textfield>
<p>Change:</p>
<DataTable>
<Head>
<Row>
<Cell>5s</Cell>
<Cell>1s</Cell>
<Cell>Quarters</Cell>
<Cell>Dimes</Cell>
<Cell>Nickels</Cell>
<Cell>Pennies</Cell>
</Row>
<Row>
<Cell>{changeGiven[0]}</Cell>
<Cell>{changeGiven[1]}</Cell>
<Cell>{changeGiven[2]}</Cell>
<Cell>{changeGiven[3]}</Cell>
<Cell>{changeGiven[4]}</Cell>
<Cell>{changeGiven[5]}</Cell>
</Row>
</Head>
</DataTable>
<p>Total Change: {formatter.format(changeGiven[0]*5 + changeGiven[1] + changeGiven[2] * 0.25 + changeGiven[3] * 0.10 + changeGiven[4] * 0.05 + changeGiven[5] * 0.01)}</p>
<p>Total: {formatter.format(changeGiven[0]*5 + changeGiven[1] + changeGiven[2] * 0.25 + changeGiven[3] * 0.10 + changeGiven[4] * 0.05 + changeGiven[5] * 0.01 + total)}</p>
<FormField>
<Checkbox bind:checked={adjustStockOnOrderFinish} />
<span slot="label">Adjust stock?</span>
</FormField>
</DialogContent>
<DialogActions>
<Button bind:disabled={cashInInvalid} on:click={cashOrder}>
<ButtonLabel>Finish</ButtonLabel>
</Button>
<Button>
<ButtonLabel>Cancel</ButtonLabel>
</Button>
</DialogActions>
</Dialog>

View File

@ -0,0 +1,173 @@
<script lang="ts">
import Header from "$lib/components/Header.svelte";
import DataTable, { Head, Body, Row, Cell } from '@smui/data-table';
import LinearProgress from '@smui/linear-progress';
import Snackbar, {Label as SnackbarLabel} from "@smui/snackbar";
import { onMount } from 'svelte';
import {serverUrl} from "$lib/stores/ServerStore";
import Paper, { Title as PaperTitle, Content as PaperContent } from "@smui/paper";
let loaded = false;
let orders = [];
let failureSnackbar: Snackbar;
let failureSnackbarText = "";
const formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD'});
async function loadOrders() {
loaded = false;
try {
let resp = await fetch(`${$serverUrl}/orders`);
if (resp.ok) {
let json = await resp.json();
console.log(json);
orders = json.orders;
loaded = true;
} else {
console.error(await resp.text());
failureSnackbarText = "There was an error loading the orders list. Check the browser console for more information.";
failureSnackbar.open();
loaded = true;
return;
}
} catch (e) {
console.error(e);
failureSnackbarText = "There was an error loading the orders list. Check the browser console for more information.";
failureSnackbar.open();
loaded = true;
return;
}
}
let products = [];
async function loadProducts() {
loaded = false;
try {
let resp = await fetch(`${$serverUrl}/products`);
if (resp.ok) {
let json = await resp.json();
console.log(json);
products = json.products;
loaded = true;
} else {
console.error(await resp.text());
failureSnackbarText = "There was an error loading the products list. Check the browser console for more information.";
failureSnackbar.open();
loaded = true;
return;
}
} catch (e) {
console.error(e);
failureSnackbarText = "There was an error loading the products list. Check the browser console for more information.";
failureSnackbar.open();
loaded = true;
return;
}
}
onMount(async () => {
await loadProducts();
await loadOrders();
});
function getProduct(id) {
for (let i = 0; i < products.length; i++) {
if (products[i].id == id) {
return products[i];
}
}
return undefined;
}
function buildProductsString(order) {
let resp = [];
let iter = Object.entries(order.products);
for (let i = 0; i < iter.length; i++) {
if (iter[i][1] === 0) continue;
resp.push(`${iter[i][1]}x ${getProduct(iter[i][0]).name} (#${iter[i][0]})`);
}
return resp.join(", ");
}
let total = 0.0;
function updateTotal() {
total = 0.0;
for (let i = 0; i < orders.length; i++) {
total += orders[i].total_usd;
}
}
$: orders, updateTotal();
let stripe_orders = 0;
let cash_orders = 0;
function updateStats() {
for (let i = 0; i < orders.length; i++) {
if (orders[i].order_type === "Cash") {
cash_orders += 1;
}
if (orders[i].order_type === "Stripe") {
stripe_orders += 1;
}
}
}
$: orders, updateStats();
</script>
<Header selected="manage/orders"></Header>
<DataTable table$aria-label="Order list" style="width: 100%;">
<Head>
<Row>
<Cell numeric>ID</Cell>
<Cell style="width: 70%;">Products</Cell>
<Cell>Order Type</Cell>
<Cell>Revenue</Cell>
</Row>
</Head>
<Body>
{#each orders as order (order.id)}
<Row>
<Cell numeric>{order.id}</Cell>
<Cell>
{buildProductsString(order)}
</Cell>
<Cell>{order.order_type}</Cell>
<Cell>{formatter.format(order.total_usd)}</Cell>
</Row>
{/each}
<Row>
<Cell numeric></Cell>
<Cell></Cell>
<Cell><b>Total Revenue</b></Cell>
<Cell>{formatter.format(total.toFixed(2))}</Cell>
</Row>
</Body>
<LinearProgress indeterminate bind:closed={loaded} aria-label="Data is being loaded..." slot="progress" />
</DataTable>
<Paper>
<PaperTitle>Statistics</PaperTitle>
<PaperContent>
<p>Total number of orders: {orders.length}</p>
<p>order_type = CASH: {cash_orders}</p>
<p>order_type = STRIPE: {stripe_orders}</p>
<p>Percentage of orders using CASH: {(cash_orders / orders.length) * 100}%</p>
<p>Percentage of orders using STRIPE: {(stripe_orders / orders.length) * 100}%</p>
</PaperContent>
</Paper>
<Snackbar bind:this={failureSnackbar}>
<SnackbarLabel>{failureSnackbarText}</SnackbarLabel>
</Snackbar>

View File

@ -0,0 +1,8 @@
@use '@material/typography/mdc-typography';
.btn {
float: right;
display: inline-block;
vertical-align: middle;
margin-bottom: 15px;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -313,6 +313,19 @@
"@material/feature-targeting" "^14.0.0"
"@material/rtl" "^14.0.0"
"@material/form-field@^14.0.0":
version "14.0.0"
resolved "https://registry.yarnpkg.com/@material/form-field/-/form-field-14.0.0.tgz#a1d773f0d8d25cc7c59201ff62d2c11e5cfb7122"
integrity sha512-k1GNBj6Sp8A7Xsn5lTMp5DkUkg60HX7YkQIRyFz1qCDCKJRWh/ou7Z45GMMgKmG3aF6LfjIavc7SjyCl8e5yVg==
dependencies:
"@material/base" "^14.0.0"
"@material/feature-targeting" "^14.0.0"
"@material/ripple" "^14.0.0"
"@material/rtl" "^14.0.0"
"@material/theme" "^14.0.0"
"@material/typography" "^14.0.0"
tslib "^2.1.0"
"@material/icon-button@^14.0.0":
version "14.0.0"
resolved "https://registry.yarnpkg.com/@material/icon-button/-/icon-button-14.0.0.tgz#cdfc7e7b967abe81d537fd7db916c9113c3a09b7"
@ -681,6 +694,17 @@
"@smui/common" "^7.0.0-beta.8"
svelte2tsx "^0.6.10"
"@smui/form-field@^7.0.0-beta.8":
version "7.0.0-beta.8"
resolved "https://registry.yarnpkg.com/@smui/form-field/-/form-field-7.0.0-beta.8.tgz#485e905ae15e19992b3216cc4433d586a5e233a5"
integrity sha512-B+8QJulCY13qiWiwLn9Fso3ZzYLCRBpq6MHE0o/4ZI0g5tI4yzyU1YNSXA3zWg6FJx7XukqOqoOA2sNCd00cjA==
dependencies:
"@material/feature-targeting" "^14.0.0"
"@material/form-field" "^14.0.0"
"@material/rtl" "^14.0.0"
"@smui/common" "^7.0.0-beta.8"
svelte2tsx "^0.6.10"
"@smui/icon-button@^7.0.0-beta.8":
version "7.0.0-beta.8"
resolved "https://registry.yarnpkg.com/@smui/icon-button/-/icon-button-7.0.0-beta.8.tgz#27a3015597710e655df5e2c9f309557cd288c254"