Add graphs on stats page, suggestions improvements, only fetch on focus
This commit is contained in:
@@ -24,10 +24,4 @@ Then run the app in production mode:
|
||||
npm start
|
||||
```
|
||||
|
||||
## Todo
|
||||
- Filtering
|
||||
- Easy inventory updating for admin
|
||||
- Mobile styling frontpage
|
||||
- Undo checkout for admin
|
||||
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ function DrinkPage(arg:Arguments) {
|
||||
containers.map((container) => {
|
||||
totalinventory += container.inventory;
|
||||
|
||||
if((container.portions || 0) > 1){
|
||||
if((container.portions || 0) > 1 && container.inventory > 0){
|
||||
containerWithMultiplePortions = true;
|
||||
}
|
||||
})
|
||||
@@ -227,13 +227,13 @@ function DrinkPage(arg:Arguments) {
|
||||
) : ""}
|
||||
</Row>
|
||||
<Row className="mt-3 mb-3">
|
||||
<Col col={12}>
|
||||
<h3>Checkouts</h3>
|
||||
<ClientOnly fallback={<Fallback />}>
|
||||
{() => <Chart options={chartOptions} data={arg.chartData} type={"bar"} />}
|
||||
</ClientOnly>
|
||||
</Col>
|
||||
</Row>
|
||||
<Col col={12}>
|
||||
<h3>Checkouts</h3>
|
||||
<ClientOnly fallback={<Fallback />}>
|
||||
{() => <Chart options={chartOptions} data={arg.chartData} type={"bar"} />}
|
||||
</ClientOnly>
|
||||
</Col>
|
||||
</Row>
|
||||
{arg.recommendations.length > 0 ? (
|
||||
<>
|
||||
<Row className="mt-3 mb-3">
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function generateDrinksChartData(id: number){
|
||||
// History of specific drink
|
||||
const containersWithHistory = await dbe.container.findMany({
|
||||
where: {drink_id: id},
|
||||
include: {checkouts: {orderBy: {checkoutAt: "asc"}}}
|
||||
include: {checkouts: {orderBy: {checkoutAt: "asc"}, where: {checkoutAt: {gte: dates[0]}}}}
|
||||
})
|
||||
|
||||
var datasets : any = [];
|
||||
@@ -88,21 +88,22 @@ export async function generateDrinksChartData(id: number){
|
||||
inventoryAfter[i] = totalInventory;
|
||||
}
|
||||
|
||||
let color = random_rgba();
|
||||
let [color1, color2] = random_rgba(2);
|
||||
|
||||
datasets.push({
|
||||
type: "line",
|
||||
label: 'Inventory ' + volume(container.volume),
|
||||
data: inventoryAfter,
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
borderColor: color1,
|
||||
backgroundColor: color1,
|
||||
tension: 0.1
|
||||
});
|
||||
datasets.push({
|
||||
type: 'bar',
|
||||
label: 'Checkouts ' + volume(container.volume),
|
||||
data: checkouts,
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
borderColor: color2,
|
||||
backgroundColor: color2,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { enhance } from "@zenstackhq/runtime";
|
||||
import { random_rgba } from "~/utils/color";
|
||||
import timeAgo from "~/utils/datetime";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { randomRange } from "~/utils/random";
|
||||
|
||||
export async function generateMonthChartData(){
|
||||
const dbe = enhance(db);
|
||||
|
||||
// Get the previous month in dates
|
||||
var dates = [];
|
||||
|
||||
const now = Date.now();
|
||||
const subtracter = (1000 * 60 * 60 * 24);
|
||||
|
||||
for(let i = 0; i <= 30; i++){
|
||||
let date = now - (subtracter * i);
|
||||
dates.push(new Date(date));
|
||||
}
|
||||
|
||||
dates.reverse();
|
||||
|
||||
// Generate labels
|
||||
const labels = dates.map((date)=> timeAgo(date));
|
||||
|
||||
// History of specific drink
|
||||
const containersWithHistory = await dbe.container.findMany({
|
||||
include: {drink: true, checkouts: {orderBy: {checkoutAt: "asc"}, where: {checkoutAt: {gte: dates[0]}}}}
|
||||
})
|
||||
|
||||
var datasets : any = [];
|
||||
|
||||
for(let container of containersWithHistory){
|
||||
|
||||
if(container.checkouts.length == 0) continue;
|
||||
|
||||
var history = container.checkouts;
|
||||
var totalInventory = container.inventory;
|
||||
|
||||
// Generate arrays of data
|
||||
var checkouts = [];
|
||||
var inventoryAfter = [];
|
||||
|
||||
var lastInventoryAfter = history.length > 0 ? history[0].inventoryAfter + 1 : 0;
|
||||
var previousCheckoutInventoryAfter = history.length > 0 ? history[0].inventoryAfter : 0;
|
||||
|
||||
var lastChange = 0;
|
||||
var previousLastChange = 0;
|
||||
|
||||
for(let i = 0; i <= dates.length - 1; i++){
|
||||
let startDate = dates[i];
|
||||
let endDate = dates[i+1];
|
||||
|
||||
let totalCheckouts = 0;
|
||||
|
||||
for(let entry of history){
|
||||
if(entry.checkoutAt > startDate && entry.checkoutAt < endDate){
|
||||
totalCheckouts++;
|
||||
lastInventoryAfter = entry.inventoryAfter;
|
||||
lastChange = i;
|
||||
}
|
||||
}
|
||||
|
||||
// There was inventory added
|
||||
if((lastInventoryAfter + totalCheckouts) > previousCheckoutInventoryAfter){
|
||||
if(i > 0 && checkouts[i-1] > 0){
|
||||
// Special case, there were also checkouts the previous day, do nothing
|
||||
} else if(i > 0) {
|
||||
let index = randomRange(previousLastChange + 1, i - 1);
|
||||
for(let j = index; j <= i - 1; j++){
|
||||
inventoryAfter[j] = lastInventoryAfter + totalCheckouts;
|
||||
}
|
||||
}
|
||||
}
|
||||
previousCheckoutInventoryAfter = lastInventoryAfter;
|
||||
previousLastChange = lastChange;
|
||||
|
||||
checkouts[i] = totalCheckouts;
|
||||
inventoryAfter[i] = lastInventoryAfter;
|
||||
}
|
||||
|
||||
// Force the remaining inventory left values after the last checkout to be equal to
|
||||
// the actual remaining inventory
|
||||
let index = randomRange(lastChange + 1, dates.length - 1);
|
||||
for(let i = index; i <= dates.length - 1; i++){
|
||||
inventoryAfter[i] = totalInventory;
|
||||
}
|
||||
|
||||
let [color1] = random_rgba(1);
|
||||
|
||||
datasets.push({
|
||||
type: 'bar',
|
||||
label: 'Checkouts ' + container.drink.name,
|
||||
data: checkouts,
|
||||
borderColor: color1,
|
||||
backgroundColor: color1,
|
||||
});
|
||||
}
|
||||
|
||||
const data = {
|
||||
labels,
|
||||
datasets: datasets
|
||||
};
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function generate24HChartData(){
|
||||
const dbe = enhance(db);
|
||||
|
||||
// Get the previous month in hours
|
||||
var hours = [];
|
||||
|
||||
const now = Date.now();
|
||||
const subtracter = (1000 * 60 * 60);
|
||||
|
||||
for(let i = 0; i <= 24; i++){
|
||||
let date = now - (subtracter * i);
|
||||
hours.push(new Date(date));
|
||||
}
|
||||
|
||||
hours.reverse();
|
||||
|
||||
// Generate labels
|
||||
const labels = hours.map((date)=> timeAgo(date));
|
||||
|
||||
// History of specific drink
|
||||
const containersWithHistory = await dbe.container.findMany({
|
||||
include: {drink: true, checkouts: {orderBy: {checkoutAt: "asc"}, where: {checkoutAt: {gte: hours[0]}}}}
|
||||
})
|
||||
|
||||
var datasets : any = [];
|
||||
|
||||
for(let container of containersWithHistory){
|
||||
|
||||
if(container.checkouts.length == 0) continue;
|
||||
|
||||
var history = container.checkouts;
|
||||
var totalInventory = container.inventory;
|
||||
|
||||
// Generate arrays of data
|
||||
var checkouts = [];
|
||||
var inventoryAfter = [];
|
||||
|
||||
var lastInventoryAfter = history.length > 0 ? history[0].inventoryAfter + 1 : 0;
|
||||
var previousCheckoutInventoryAfter = history.length > 0 ? history[0].inventoryAfter : 0;
|
||||
|
||||
var lastChange = 0;
|
||||
var previousLastChange = 0;
|
||||
|
||||
for(let i = 0; i <= hours.length - 1; i++){
|
||||
let startDate = hours[i];
|
||||
let endDate = hours[i+1];
|
||||
|
||||
let totalCheckouts = 0;
|
||||
|
||||
for(let entry of history){
|
||||
if(entry.checkoutAt > startDate && entry.checkoutAt < endDate){
|
||||
totalCheckouts++;
|
||||
lastInventoryAfter = entry.inventoryAfter;
|
||||
lastChange = i;
|
||||
}
|
||||
}
|
||||
|
||||
// There was inventory added
|
||||
if((lastInventoryAfter + totalCheckouts) > previousCheckoutInventoryAfter){
|
||||
if(i > 0 && checkouts[i-1] > 0){
|
||||
// Special case, there were also checkouts the previous day, do nothing
|
||||
} else if(i > 0) {
|
||||
let index = randomRange(previousLastChange + 1, i - 1);
|
||||
for(let j = index; j <= i - 1; j++){
|
||||
inventoryAfter[j] = lastInventoryAfter + totalCheckouts;
|
||||
}
|
||||
}
|
||||
}
|
||||
previousCheckoutInventoryAfter = lastInventoryAfter;
|
||||
previousLastChange = lastChange;
|
||||
|
||||
checkouts[i] = totalCheckouts;
|
||||
inventoryAfter[i] = lastInventoryAfter;
|
||||
}
|
||||
|
||||
// Force the remaining inventory left values after the last checkout to be equal to
|
||||
// the actual remaining inventory
|
||||
let index = randomRange(lastChange + 1, hours.length - 1);
|
||||
for(let i = index; i <= hours.length - 1; i++){
|
||||
inventoryAfter[i] = totalInventory;
|
||||
}
|
||||
|
||||
let [color1] = random_rgba(1);
|
||||
|
||||
datasets.push({
|
||||
type: 'bar',
|
||||
label: 'Checkouts ' + container.drink.name,
|
||||
data: checkouts,
|
||||
borderColor: color1,
|
||||
backgroundColor: color1,
|
||||
});
|
||||
}
|
||||
|
||||
const data = {
|
||||
labels,
|
||||
datasets: datasets
|
||||
};
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { DrinkType } from "@prisma/client";
|
||||
import { enhance } from "@zenstackhq/runtime";
|
||||
import { hexToRGB, random_rgba } from "~/utils/color";
|
||||
import timeAgo from "~/utils/datetime";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { randomRange } from "~/utils/random";
|
||||
|
||||
export async function generateTypeInventoryChartData(){
|
||||
const dbe = enhance(db);
|
||||
|
||||
let labels : string[] = [];
|
||||
let colors : string[] = [];
|
||||
let numset : number[] = [];
|
||||
let priceset : number[] = [];
|
||||
|
||||
|
||||
for(let type in DrinkType){
|
||||
if(type == DrinkType.Drink) continue;
|
||||
let typeT = DrinkType[type];
|
||||
|
||||
labels.push(type);
|
||||
colors.push(random_rgba(1)[0]);
|
||||
const aggregate = await dbe.container.aggregate({_sum: {inventory: true}, where: {drink: {type: typeT}}});
|
||||
numset.push(aggregate._sum.inventory ?? 0);
|
||||
|
||||
const containers = await dbe.container.findMany({select: {price: true, inventory: true}, where: {drink: {type: typeT}}});
|
||||
|
||||
let reduced = containers.reduce((sum, val) => {return {inventory: sum.inventory + val.inventory, price: sum.price + (val.price * val.inventory)}});
|
||||
|
||||
priceset.push(reduced.price);
|
||||
}
|
||||
|
||||
const data = {
|
||||
labels,
|
||||
datasets: [{
|
||||
type: 'doughnut',
|
||||
label: 'Inventory',
|
||||
data: numset,
|
||||
backgroundColor: colors,
|
||||
},
|
||||
{
|
||||
type: 'doughnut',
|
||||
label: 'Price',
|
||||
data: priceset,
|
||||
backgroundColor: colors,
|
||||
}]
|
||||
};
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function generateBeerInventoryChartData(){
|
||||
const dbe = enhance(db);
|
||||
|
||||
let labels : string[] = [];
|
||||
let colors : string[] = [];
|
||||
let numset : number[] = [];
|
||||
|
||||
const beerstyle = await dbe.beerStyle.findMany({});
|
||||
|
||||
for(let style of beerstyle){
|
||||
labels.push(style.name);
|
||||
colors.push(hexToRGB("#"+style.color, 1));
|
||||
|
||||
const containers = await dbe.beer.findMany({where: {style_id: style.id}, select: {containers: {select: {inventory: true}}}});
|
||||
const reduced = containers.reduce((sum, val) => {return sum + val.containers.reduce((sum2, val2)=>{return sum2 + val2.inventory}, 0)}, 0);
|
||||
numset.push(reduced);
|
||||
}
|
||||
|
||||
const data = {
|
||||
labels,
|
||||
datasets: [{
|
||||
type: 'doughnut',
|
||||
label: 'Inventory',
|
||||
data: numset,
|
||||
backgroundColor: colors,
|
||||
}]
|
||||
};
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function generateTypeHistoryChartData(){
|
||||
const dbe = enhance(db);
|
||||
|
||||
let labels : string[] = [];
|
||||
let colors : string[] = [];
|
||||
let numset : number[] = [];
|
||||
let priceset : number[] = [];
|
||||
|
||||
for(let type in DrinkType){
|
||||
if(type == DrinkType.Drink) continue;
|
||||
let typeT = DrinkType[type];
|
||||
|
||||
labels.push(type);
|
||||
colors.push(random_rgba(1)[0]);
|
||||
const aggregate = await dbe.history.aggregate({_count: {_all: true}, where: {container: {drink: {type: typeT}}}});
|
||||
numset.push(aggregate._count._all ?? 0);
|
||||
|
||||
const history = await dbe.history.findMany({select: {container: {select: {price: true}}}, where: {container: {drink: {type: typeT}}}});
|
||||
|
||||
let reduced = history.reduce((sum, val) => {return {container:{ price: sum.container.price + val.container.price}}}, {container: {price: 0}});
|
||||
|
||||
priceset.push(reduced.container.price);
|
||||
}
|
||||
|
||||
const data = {
|
||||
labels,
|
||||
datasets: [{
|
||||
type: 'doughnut',
|
||||
label: 'Checked out',
|
||||
data: numset,
|
||||
backgroundColor: colors,
|
||||
},
|
||||
{
|
||||
type: 'doughnut',
|
||||
label: 'Price',
|
||||
data: priceset,
|
||||
backgroundColor: colors,
|
||||
}]
|
||||
};
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function generateBeerHistoryChartData(){
|
||||
const dbe = enhance(db);
|
||||
|
||||
let labels : string[] = [];
|
||||
let colors : string[] = [];
|
||||
let numset : number[] = [];
|
||||
|
||||
const beerstyle = await dbe.beerStyle.findMany({});
|
||||
|
||||
for(let style of beerstyle){
|
||||
labels.push(style.name);
|
||||
colors.push(hexToRGB("#"+style.color, 1));
|
||||
|
||||
const containers = await dbe.beer.findMany({where: {style_id: style.id}, select: {containers: {select: {checkouts: true}}}});
|
||||
const reduced = containers.reduce((sum, val) => {return sum + val.containers.reduce((sum2, val2)=>{return sum2 + val2.checkouts.length}, 0)}, 0);
|
||||
numset.push(reduced);
|
||||
}
|
||||
|
||||
const data = {
|
||||
labels,
|
||||
datasets: [{
|
||||
type: 'doughnut',
|
||||
label: 'Checked out',
|
||||
data: numset,
|
||||
backgroundColor: colors,
|
||||
}]
|
||||
};
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { LoaderFunctionArgs, redirect } from "@remix-run/node";
|
||||
|
||||
import { enhance } from "~/utils/db.server";
|
||||
|
||||
export const loader = async ({
|
||||
request,
|
||||
params
|
||||
} : LoaderFunctionArgs) => {
|
||||
const { dbe } = await enhance(request);
|
||||
|
||||
const id = Number(params.id);
|
||||
|
||||
await dbe.suggestion.update({where: {id: id}, data: {resolved: true}});
|
||||
return redirect("/inventory/suggestions");
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useRouteError } from "@remix-run/react";
|
||||
|
||||
import DrinkPage from "~/components/cards/drink.page";
|
||||
import { generateDrinksChartData } from "~/models/charts.server";
|
||||
import { generateDrinksChartData } from "~/models/drinks.charts.server";
|
||||
|
||||
|
||||
import { findIdFromSlug, findRecommendations, getMostCommonSection } from "~/models/drinks.server";
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useRouteError } from "@remix-run/react";
|
||||
|
||||
import DrinkPage from "~/components/cards/drink.page";
|
||||
import { generateDrinksChartData } from "~/models/charts.server";
|
||||
import { generateDrinksChartData } from "~/models/drinks.charts.server";
|
||||
|
||||
import { findIdFromSlug, findRecommendations, getMostCommonSection } from "~/models/drinks.server";
|
||||
import { enhance } from "~/utils/db.server";
|
||||
|
||||
@@ -31,25 +31,50 @@ export default function InventoryLayout() {
|
||||
const [checkouts, setCheckouts] = useState(data.checkouts);
|
||||
let fetcher = useFetcher();
|
||||
|
||||
let shouldFetch = true;
|
||||
|
||||
// User has switched back to the tab
|
||||
const onFocus = () => {
|
||||
shouldFetch = true;
|
||||
};
|
||||
|
||||
// User has switched away from the tab (AKA tab is hidden)
|
||||
const onBlur = () => {
|
||||
shouldFetch = false;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("focus", onFocus);
|
||||
window.addEventListener("blur", onBlur);
|
||||
// Calls onFocus when the window first loads
|
||||
onFocus();
|
||||
// Specify how to clean up after this effect:
|
||||
return () => {
|
||||
window.removeEventListener("focus", onFocus);
|
||||
window.removeEventListener("blur", onBlur);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
|
||||
fetcher.load("/resource/checkouts");
|
||||
if(shouldFetch){
|
||||
fetcher.load("/resource/checkouts");
|
||||
|
||||
if(fetcher.state === "idle"){
|
||||
if(!fetcher.data) return;
|
||||
if(fetcher.state === "idle"){
|
||||
if(!fetcher.data) return;
|
||||
|
||||
let newCheckouts = Number(fetcher.data.checkouts);
|
||||
if(newCheckouts != checkouts){
|
||||
let newCheckouts = Number(fetcher.data.checkouts);
|
||||
if(newCheckouts != checkouts){
|
||||
|
||||
setCheckouts(newCheckouts);
|
||||
setCheckouts(newCheckouts);
|
||||
|
||||
if (revalidator.state === "idle") {
|
||||
revalidator.revalidate();
|
||||
if (revalidator.state === "idle") {
|
||||
revalidator.revalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}, 5000);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LoaderFunctionArgs, json } from "@remix-run/node";
|
||||
import { useLoaderData, useSearchParams } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs, json, redirect } from "@remix-run/node";
|
||||
import { Link, useLoaderData, useNavigate, useSearchParams } from "@remix-run/react";
|
||||
import { enhance } from "@zenstackhq/runtime";
|
||||
import { useState } from "react";
|
||||
import { Accordion, Button, Col, Container, Offcanvas, Row, useAccordionButton } from "react-bootstrap";
|
||||
@@ -11,6 +11,7 @@ import { findManufacturersFromSearch } from "~/models/drinks.server";
|
||||
import { sortDrinksFromSearch } from "~/models/drinks.sort.server";
|
||||
|
||||
import { db } from "~/utils/db.server";
|
||||
import { randomRange } from "~/utils/random";
|
||||
|
||||
export const loader = async ({request} : LoaderFunctionArgs) => {
|
||||
const dbe = enhance(db);
|
||||
@@ -30,6 +31,8 @@ export const loader = async ({request} : LoaderFunctionArgs) => {
|
||||
export default function BeersRoute() {
|
||||
const loadData = useLoaderData<typeof loader>();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [show, setShow] = useState(false);
|
||||
@@ -39,6 +42,12 @@ export default function BeersRoute() {
|
||||
|
||||
const numResults = loadData.drinkResults.length;
|
||||
|
||||
let randomDrink = function(){
|
||||
let index = randomRange(0, numResults - 1);
|
||||
let drink_id = loadData.drinkResults[index].id;
|
||||
navigate("/inventory/drink/" + drink_id);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Row className="mt-3">
|
||||
@@ -77,6 +86,15 @@ export default function BeersRoute() {
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col key="pick">
|
||||
<Row className="my-5">
|
||||
<Col className="text-center">
|
||||
<Button size="lg" onClick={randomDrink} >Pick one for me!</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useRouteError } from "@remix-run/react";
|
||||
|
||||
import DrinkPage from "~/components/cards/drink.page";
|
||||
import { generateDrinksChartData } from "~/models/charts.server";
|
||||
import { generateDrinksChartData } from "~/models/drinks.charts.server";
|
||||
|
||||
import { findIdFromSlug, findRecommendations, getMostCommonSection } from "~/models/drinks.server";
|
||||
import { enhance } from "~/utils/db.server";
|
||||
|
||||
@@ -2,14 +2,18 @@ import { LoaderFunctionArgs, json } from "@remix-run/node";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import { enhance } from "@zenstackhq/runtime";
|
||||
import { Badge, Button, Col, Container, Nav, Row, Tab, Table, Tabs } from "react-bootstrap";
|
||||
import { Line } from "react-chartjs-2";
|
||||
import { Chart, Line } from "react-chartjs-2";
|
||||
import { LinkContainer } from "react-router-bootstrap";
|
||||
import { ClientOnly } from "remix-utils/client-only";
|
||||
import { generate24HChartData, generateMonthChartData } from "~/models/history.chart.server";
|
||||
import { ContainerWithDrink, containerTypeToString } from "~/models/types";
|
||||
import { volume } from "~/utils/conversions";
|
||||
import timeAgo from "~/utils/datetime";
|
||||
import 'chart.js/auto';
|
||||
|
||||
|
||||
import { db } from "~/utils/db.server";
|
||||
import { generateBeerHistoryChartData, generateBeerInventoryChartData, generateTypeHistoryChartData, generateTypeInventoryChartData } from "~/models/type.chart.server";
|
||||
|
||||
function statsForContainers(containers : ContainerWithDrink[]){
|
||||
var drinksInventory = 0;
|
||||
@@ -56,7 +60,6 @@ function statsForContainers(containers : ContainerWithDrink[]){
|
||||
totalPrice = Math.round(totalPrice * 100) / 100;
|
||||
averageAlcoholPercentage = Math.round(averageAlcoholPercentage * 100) / 100;
|
||||
|
||||
|
||||
return {drinksInventory, beersInventory, wineInventory, sodaInventory, cocktailInventory, totalLiterAlcohol, averageAlcoholPercentage, totalPrice};
|
||||
}
|
||||
|
||||
@@ -80,6 +83,7 @@ export const loader = async ({request} : LoaderFunctionArgs) => {
|
||||
});
|
||||
|
||||
const statsLast24H = statsForContainers(containersLast24);
|
||||
const chartData24H = await generate24HChartData();
|
||||
|
||||
// Checkout 24H statistics
|
||||
let lastMonthDate = Date.now() - (31 * 24 * 60 * 60 * 1000);
|
||||
@@ -93,10 +97,12 @@ export const loader = async ({request} : LoaderFunctionArgs) => {
|
||||
containersLastMonth.push(entry.container);
|
||||
});
|
||||
|
||||
const statsLastMonth = statsForContainers(containersLastMonth);
|
||||
|
||||
let statsLastMonth = statsForContainers(containersLastMonth);
|
||||
const chartDataMonth = await generateMonthChartData();
|
||||
|
||||
const historyTotal = await dbe.history.findMany({include: {container: {include: {drink: true}}}});
|
||||
const chartDataHistoryTotal = await generateTypeHistoryChartData();
|
||||
const chartDataBeerHistoryTotal = await generateBeerHistoryChartData();
|
||||
|
||||
let containersTotal : ContainerWithDrink[] = [];
|
||||
historyTotal.forEach((entry) => {
|
||||
@@ -105,15 +111,83 @@ export const loader = async ({request} : LoaderFunctionArgs) => {
|
||||
});
|
||||
|
||||
const statsTotal = statsForContainers(containersTotal);
|
||||
const chartDataTotal = await generateTypeInventoryChartData();
|
||||
const chartDataBeerTotal = await generateBeerInventoryChartData();
|
||||
|
||||
const history = await dbe.history.findMany({select: {checkoutAt: true, inventoryAfter: true, container: {select: {volume: true, type: true, drink: true}}}, take: 10, orderBy: {checkoutAt: "desc"}});
|
||||
|
||||
return json({total, statsLast24H, statsLastMonth, statsTotal, history});
|
||||
return json({total, chartDataTotal, chartDataBeerTotal, statsLast24H, chartData24H, statsLastMonth, chartDataMonth, statsTotal, chartDataHistoryTotal, chartDataBeerHistoryTotal, history});
|
||||
};
|
||||
|
||||
export default function StatsRoute() {
|
||||
const loadData = useLoaderData<typeof loader>();
|
||||
|
||||
const barChartOptions = {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
title: {
|
||||
display: false
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'History'
|
||||
},
|
||||
stacked: true
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Amount'
|
||||
},
|
||||
min: 0,
|
||||
ticks: {
|
||||
// forces step size to be 50 units
|
||||
stepSize: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const doughnutChartOptions = {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
title: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
let label = context.dataset.label || '';
|
||||
|
||||
if (label) {
|
||||
label += ': ';
|
||||
}
|
||||
|
||||
if(label != "Price: ") return label + context.parsed;
|
||||
|
||||
if (context.parsed !== null) {
|
||||
label += new Intl.NumberFormat('nl-NL', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(context.parsed);
|
||||
}
|
||||
return label;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Row className="mt-3">
|
||||
@@ -171,6 +245,18 @@ export default function StatsRoute() {
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
<Row>
|
||||
<Col xs={12} md={4}>
|
||||
<ClientOnly fallback={<Fallback />}>
|
||||
{() => <Chart options={doughnutChartOptions} data={loadData.chartDataTotal} type={"doughnut"} />}
|
||||
</ClientOnly>
|
||||
</Col>
|
||||
<Col xs={12} md={4}>
|
||||
<ClientOnly fallback={<Fallback />}>
|
||||
{() => <Chart options={doughnutChartOptions} data={loadData.chartDataBeerTotal} type={"doughnut"} />}
|
||||
</ClientOnly>
|
||||
</Col>
|
||||
</Row>
|
||||
</Tab>
|
||||
<Tab eventKey="totalh" title="Total history">
|
||||
<h2 className="mt-3">Total checkouts</h2>
|
||||
@@ -224,6 +310,18 @@ export default function StatsRoute() {
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
<Row>
|
||||
<Col xs={12} md={4}>
|
||||
<ClientOnly fallback={<Fallback />}>
|
||||
{() => <Chart options={doughnutChartOptions} data={loadData.chartDataHistoryTotal} type={"doughnut"} />}
|
||||
</ClientOnly>
|
||||
</Col>
|
||||
<Col xs={12} md={4}>
|
||||
<ClientOnly fallback={<Fallback />}>
|
||||
{() => <Chart options={doughnutChartOptions} data={loadData.chartDataBeerHistoryTotal} type={"doughnut"} />}
|
||||
</ClientOnly>
|
||||
</Col>
|
||||
</Row>
|
||||
</Tab>
|
||||
<Tab eventKey="month" title="Last Month">
|
||||
<h2 className="mt-3">Last Month checkouts</h2>
|
||||
@@ -277,6 +375,9 @@ export default function StatsRoute() {
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
<ClientOnly fallback={<Fallback />}>
|
||||
{() => <Chart options={barChartOptions} data={loadData.chartDataMonth} type={"bar"} />}
|
||||
</ClientOnly>
|
||||
</Tab>
|
||||
<Tab eventKey="24h" title="Last 24H">
|
||||
<h2 className="mt-3">Last 24H checkouts</h2>
|
||||
@@ -330,6 +431,9 @@ export default function StatsRoute() {
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
<ClientOnly fallback={<Fallback />}>
|
||||
{() => <Chart options={barChartOptions} data={loadData.chartData24H} type={"bar"} />}
|
||||
</ClientOnly>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Col>
|
||||
@@ -361,4 +465,8 @@ export default function StatsRoute() {
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function Fallback() {
|
||||
return <div>Generating Chart</div>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/node";
|
||||
import { ActionFunctionArgs, EntryContext, LoaderFunctionArgs, json } from "@remix-run/node";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import { Button, Col, Container, Form, Row, Table } from "react-bootstrap";
|
||||
import { LinkContainer } from "react-router-bootstrap";
|
||||
@@ -94,7 +94,10 @@ export default function SuggestionsRoute() {
|
||||
<th>Date</th>
|
||||
<th>Suggestions</th>
|
||||
{ loadData.isAdmin ? (
|
||||
<th>Delete</th>
|
||||
<>
|
||||
<th>Resolve</th>
|
||||
<th>Delete</th>
|
||||
</>
|
||||
) : ""}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -103,15 +106,26 @@ export default function SuggestionsRoute() {
|
||||
<tr key={entry.id}>
|
||||
<td>{entry.name ? (<>{entry.name}</>) : ( <><i>No name</i></> ) }</td>
|
||||
<td>{timeAgo(new Date(entry.createdAt))}</td>
|
||||
<td>{entry.content}</td>
|
||||
<td>{entry.resolved ? (<><s className="text-muted">{entry.content}</s></>) : (<>{entry.content}</>)}</td>
|
||||
{ loadData.isAdmin ? (
|
||||
<>
|
||||
<td>
|
||||
{!entry.resolved ? (
|
||||
<LinkContainer to={"/admin/resolve/suggestion/" + entry.id}>
|
||||
<Button variant="success">
|
||||
Resolve
|
||||
</Button>
|
||||
</LinkContainer>
|
||||
): ""}
|
||||
</td>
|
||||
<td>
|
||||
<LinkContainer to={"/admin/remove/suggestion/" + entry.id}>
|
||||
<Button variant="danger">
|
||||
Remove
|
||||
</Button>
|
||||
</LinkContainer>
|
||||
</td>
|
||||
</td>
|
||||
</>
|
||||
) : ""}
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useRouteError } from "@remix-run/react";
|
||||
|
||||
import DrinkPage from "~/components/cards/drink.page";
|
||||
import { generateDrinksChartData } from "~/models/charts.server";
|
||||
import { generateDrinksChartData } from "~/models/drinks.charts.server";
|
||||
|
||||
import { findIdFromSlug, findRecommendations, getMostCommonSection } from "~/models/drinks.server";
|
||||
import { enhance } from "~/utils/db.server";
|
||||
|
||||
+26
-3
@@ -1,4 +1,27 @@
|
||||
export function random_rgba() {
|
||||
var o = Math.round, r = Math.random, s = 205, m=50;
|
||||
return 'rgba(' + o(r()*s + m) + ',' + o(r()*s + m) + ',' + o(r()*s + m) + ',' + 1 + ')';
|
||||
export function random_rgba(numShades : number) {
|
||||
let o = Math.round, r = Math.random, s = 155, m = 100;
|
||||
|
||||
let red = o(r()*s + m), green = o(r()*s + m), blue = o(r()*s + m);
|
||||
|
||||
let result = [];
|
||||
|
||||
numShades = Math.min(numShades, 3);
|
||||
|
||||
for(let i = 0; i < numShades; ++i){
|
||||
result.push('rgba(' + (red - i*50) + ',' + (green - i*50) + ',' + (blue - i*50) + ',' + 0.8 + ')');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function hexToRGB(hex : string, alpha : number) {
|
||||
var r = parseInt(hex.slice(1, 3), 16),
|
||||
g = parseInt(hex.slice(3, 5), 16),
|
||||
b = parseInt(hex.slice(5, 7), 16);
|
||||
|
||||
if (alpha) {
|
||||
return "rgba(" + r + ", " + g + ", " + b + ", " + alpha + ")";
|
||||
} else {
|
||||
return "rgb(" + r + ", " + g + ", " + b + ")";
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,10 @@ export default function timeAgo(previous: Date) {
|
||||
const now = new Date();
|
||||
const elapsed : number = (now.getTime() - previous.getTime());
|
||||
|
||||
if(elapsed < 0){
|
||||
return 'recently';
|
||||
}
|
||||
|
||||
if (elapsed < msPerMinute) {
|
||||
return Math.round(elapsed/1000) + ' seconds ago';
|
||||
}
|
||||
|
||||
+1
-2
@@ -1,4 +1,3 @@
|
||||
export function randomRange(min:number, max:number) {
|
||||
return Math.round(Math.random() * (max - min) + min);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -212,4 +212,5 @@ model Suggestion {
|
||||
createdAt DateTime @default(now())
|
||||
name String?
|
||||
content String
|
||||
resolved Boolean @default(false)
|
||||
}
|
||||
|
||||
@@ -228,6 +228,7 @@ model Suggestion {
|
||||
createdAt DateTime @default(now())
|
||||
name String?
|
||||
content String
|
||||
resolved Boolean @default(false)
|
||||
|
||||
@@allow('all', true)
|
||||
}
|
||||
@@ -110,6 +110,7 @@ export default defineConfig({
|
||||
route("remove/checkouts/:containerid", "routes/admin/edit/remove/checkouts.$containerid.tsx");
|
||||
|
||||
route("remove/suggestion/:id", "routes/admin/edit/remove/suggestion.$id.tsx");
|
||||
route("resolve/suggestion/:id", "routes/admin/edit/suggestion.$id.tsx");
|
||||
|
||||
route("manage/inventory", "routes/admin/manage/inventory.tsx");
|
||||
route("manage/checkout", "routes/admin/manage/checkout.tsx");
|
||||
|
||||
Reference in New Issue
Block a user