Compare commits

..

31 Commits

Author SHA1 Message Date
2eb80e0da9 added url, apikey to lockscreen 2025-05-03 15:12:58 +02:00
dbcdce5296 set new gallery url 2025-05-03 15:07:08 +02:00
7482c329ed Nav fix 2025-05-03 14:36:48 +02:00
aafaf4dd9e Minor fixes with lockscreen 2025-05-03 14:34:09 +02:00
7b08d6e03f switch logger to db 2025-04-21 21:44:19 +02:00
fe5cbabd46 Fix spelling 2025-04-21 21:31:38 +02:00
141f75717b Fix burgernav icon 2025-04-21 21:25:24 +02:00
c38be00f73 Reintroduce burger nav 2025-04-21 21:24:52 +02:00
ccbcb94449 make user_select buttons dark 2025-04-21 21:08:39 +02:00
86b9595665 Add navlink to user_select 2025-04-21 21:04:10 +02:00
c6e441dc26 Refactor navbar to dynamically inject buttons 2025-04-21 21:02:11 +02:00
c89eb37361 Implement kiosk mode functionality and update external link handling in footer and credits 2025-04-21 21:01:43 +02:00
5cfd8b2319 Change user_select style 2025-04-21 18:18:33 +02:00
d44900435f Fix lockscreen for different screen sizes 2025-04-21 18:17:21 +02:00
1e4ebc2a3c Move foother to bottom 2025-04-21 17:36:26 +02:00
5ce521c8a7 Remove invalid unsplash api key - Never commit such keys! / Remove unsplash branding from credits 2025-04-21 17:35:13 +02:00
ef16f045f7 Implement AbortError class and enhance transaction handling with stock and existence checks 2025-04-21 17:03:32 +02:00
bf561f8c7f Rename "Bild hinterlegt" to "Bild" 2025-04-21 14:33:27 +02:00
5da8060857 Frontend Products -> Change Upload text to Icon 2025-04-21 14:30:45 +02:00
b7d12d18d4 Prisma schema-> Increase the decimal places for total and price from 5,2 to 8,2 2025-04-21 14:20:49 +02:00
16ee092b35 Migrate config to subobj (http and mysql) / .env fort prisma is written on startup / Add http.enable_csp config option 2025-04-21 00:26:01 +02:00
366f3297da Streamline some variable names 2025-04-20 21:20:57 +02:00
475690ca2b Minor bugfixes 2025-03-19 23:16:11 +01:00
03fec1ebd7 reports view 2025-03-19 23:15:28 +01:00
8cd011fc01 added old transactions to payup view 2025-03-19 23:15:12 +01:00
9066397cd4 fixed layout 2025-03-19 21:37:14 +01:00
cf7bd8da9c fixed lockscreen up a little 2025-03-19 21:36:47 +01:00
14cf8af14b restock mode 2025-03-19 20:45:05 +01:00
d491033c29 pay up implementation 2025-03-19 20:44:26 +01:00
fd7d1ffd47 products -> removes id from POST 2025-03-09 23:12:54 +01:00
fe04ad9ce3 custom validation -> parse arrays from string / Rough transaction implementaion 2025-03-09 23:11:59 +01:00
38 changed files with 1107 additions and 179 deletions

7
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"@hapi/bourne": "^3.0.0",
"@prisma/client": "^6.4.1", "@prisma/client": "^6.4.1",
"bootstrap-icons": "^1.11.3", "bootstrap-icons": "^1.11.3",
"bulma": "^1.0.3", "bulma": "^1.0.3",
@ -587,6 +588,12 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@hapi/bourne": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-3.0.0.tgz",
"integrity": "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==",
"license": "BSD-3-Clause"
},
"node_modules/@hapi/hoek": { "node_modules/@hapi/hoek": {
"version": "9.3.0", "version": "9.3.0",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",

View File

@ -33,6 +33,7 @@
"typescript": "^5.8.2" "typescript": "^5.8.2"
}, },
"dependencies": { "dependencies": {
"@hapi/bourne": "^3.0.0",
"@prisma/client": "^6.4.1", "@prisma/client": "^6.4.1",
"bootstrap-icons": "^1.11.3", "bootstrap-icons": "^1.11.3",
"bulma": "^1.0.3", "bulma": "^1.0.3",

View File

@ -33,7 +33,7 @@ model transactions {
user user @relation(fields: [userId], references: [id]) user user @relation(fields: [userId], references: [id])
userId Int userId Int
total Decimal @db.Decimal(5,2) total Decimal @db.Decimal(8,2)
paid Boolean @default(false) paid Boolean @default(false)
paidAt DateTime? paidAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@ -55,7 +55,7 @@ model products {
id Int @id @unique @default(autoincrement()) id Int @id @unique @default(autoincrement())
gtin String @unique // Dont try to use BigInt -> https://github.com/prisma/studio/issues/614 gtin String @unique // Dont try to use BigInt -> https://github.com/prisma/studio/issues/614
name String @unique name String @unique
price Decimal @db.Decimal(5,2) price Decimal @db.Decimal(8,2)
stock Int stock Int
visible Boolean @default(true) visible Boolean @default(true)

View File

@ -1,17 +1,30 @@
import log from './log.js';
import ConfigManager from '../libs/configManager.js'; import ConfigManager from '../libs/configManager.js';
import __path from './path.js'; import __path from './path.js';
import _ from 'lodash'; import _ from 'lodash';
// Create a new config instance. // Create a new config instance.
const config = new ConfigManager(__path + '/config.json', true, { const config = new ConfigManager(__path + '/config.json', true, {
db_connection_string: 'mysql://USER:PASSWORD@HOST:3306/DATABASE', // db_connection_string: 'mysql://USER:PASSWORD@HOST:3306/DATABASE',
http_listen_address: '0.0.0.0', http: {
http_port: 3000, listen_address: '0.0.0.0',
http_domain: 'example.org', port: 3000,
http_enable_hsts: false, domain: 'example.org',
devmode: true, enable_hsts: false,
devmode_fileupload: true enable_csp: false
},
mysql: {
host: '',
port: 3306,
user: '',
password: '',
database: 'hydrationhub'
},
devmode: false,
devmode_fileupload: false,
galleryApiKey: '',
});//, log.core); // Disabled due to Cyclic dependencies with log handler (specifically-> devmode for loglevel) });//, log.core); // Disabled due to Cyclic dependencies with log handler (specifically-> devmode for loglevel)
export default config; export default config;

View File

@ -1,13 +1,27 @@
import { PrismaClient, Prisma } from '@prisma/client'; // Database import { PrismaClient, Prisma } from '@prisma/client'; // Database
import { Response } from 'express'; import { Response } from 'express';
import config from './config.js'; import config from './config.js';
import __path from './path.js';
import log from './log.js'; import log from './log.js';
import fs from 'fs';
import path from 'path';
// Generate .env file for Prisma commands
const dotEnvPath = path.join(__path, '/.env')
const dotEnvExist = !fs.existsSync(dotEnvPath);
fs.writeFileSync(dotEnvPath, `DATABASE_URL="mysql://${config.global.mysql.user}:${config.global.mysql.password}@${config.global.mysql.host}:${config.global.mysql.port}/${config.global.mysql.database}"`);
log.core.info('Generated .env file for Prisma.');
if (dotEnvExist) {
log.db.error('Please run "npx prisma db push" to synchronize the database.');
process.exit(1);
}
// TODO: Add errorhandling with some sort of message. // TODO: Add errorhandling with some sort of message.
const prisma = new PrismaClient({ const prisma = new PrismaClient({
datasources: { datasources: {
db: { db: {
url: config.global.db_connection_string url: `mysql://${config.global.mysql.user}:${config.global.mysql.password}@${config.global.mysql.host}:${config.global.mysql.port}/${config.global.mysql.database}`
} }
} }
}); });
@ -44,3 +58,10 @@ export function handlePrismaError(errorObj: any, res: Response, source: string)
export default prisma; export default prisma;
//FIXME: https://www.prisma.io/docs/orm/prisma-client/special-fields-and-types/null-and-undefined //FIXME: https://www.prisma.io/docs/orm/prisma-client/special-fields-and-types/null-and-undefined
// // Simulate a Prisma error for testing purposes
// throw new Prisma.PrismaClientKnownRequestError(
// 'Simulated Prisma error for testing',
// { code: 'P2000', clientVersion: 'unknown' } // Example error parameters
// );

View File

@ -0,0 +1,23 @@
import Bourne from '@hapi/bourne';
import Joi from 'joi';
const validator = Joi.extend((joi) => ({
type: 'array',
base: Joi.array(),
coerce: {
from: 'string',
method(value, helpers) {
if (typeof value !== 'string' || (value[0] !== '[' && !/^\s*\[/.test(value))) {
return { value };
}
try {
return { value: Bourne.parse(value) };
} catch (ignoreErr) {
return { value };
}
}
}
}));
export default validator;

View File

@ -61,15 +61,15 @@ app.set('view engine', 'eta');
// MARK: Express Middleware & Config // MARK: Express Middleware & Config
app.set('x-powered-by', false); // helmet does this too. But not in devmode app.set('x-powered-by', false); // helmet does this too. But not in devmode
if (!config.global.devmode) { if (!config.global.devmode && config.global.http.enable_csp) {
app.use( app.use(
helmet({ helmet({
strictTransportSecurity: config.global.http_enable_hsts, strictTransportSecurity: config.global.http.enable_hsts,
contentSecurityPolicy: { contentSecurityPolicy: {
useDefaults: false, useDefaults: false,
directives: { directives: {
defaultSrc: ["'self'"], defaultSrc: ["'self'"],
scriptSrc: ["'self'", config.global.http_domain], scriptSrc: ["'self'", config.global.http.domain],
objectSrc: ["'none'"], objectSrc: ["'none'"],
upgradeInsecureRequests: config.global.devmode ? null : [] upgradeInsecureRequests: config.global.devmode ? null : []
} }
@ -85,8 +85,8 @@ app.use(bodyParser.json());
app.use(routes); app.use(routes);
// TODO: Remove hardcoded http // TODO: Remove hardcoded http
app.listen(config.global.http_port, config.global.http_listen_address, () => { app.listen(config.global.http.port, config.global.http.listen_address, () => {
log.web.info(`Listening at http://${config.global.http_listen_address}:${config.global.http_port}`); log.web.info(`Listening at http://${config.global.http.listen_address}:${config.global.http.port}`);
}); });
log.core.trace('Running from path: ' + __path); log.core.trace('Running from path: ' + __path);

View File

@ -1,11 +1,11 @@
import express from 'express'; import express from 'express';
// Route imports // Route imports
import v1_routes from './v1/index.js'; import v1_router from './v1/index.js';
// Router base is '/api' // Router base is '/api'
const Router = express.Router({ strict: false }); const Router = express.Router({ strict: false });
Router.use('/v1', v1_routes); Router.use('/v1', v1_router);
export default Router; export default Router;

View File

@ -1,8 +1,8 @@
import express from 'express'; import express from 'express';
// Route imports // Route imports
import testRoute from './test.js'; import test_route from './test.js';
import versionRoute from './version.js'; import version_route from './version.js';
import user_route from './user/user.js'; import user_route from './user/user.js';
import user_schema from './user/user_schema.js'; import user_schema from './user/user_schema.js';
@ -16,6 +16,9 @@ import products_schema from './products/products_schema.js';
import image_route from './image/image.js'; import image_route from './image/image.js';
import image_schema from './image/image_schema.js'; import image_schema from './image/image_schema.js';
import transaction_route from './transaction/transaction.js';
import transaction_schema from './transaction/transaction_schema.js';
// Router base is '/api/v1' // Router base is '/api/v1'
const Router = express.Router({ strict: false }); const Router = express.Router({ strict: false });
@ -52,7 +55,10 @@ Router.route('/products/describe').get(products_schema);
Router.route('/image').get(image_route.get).post(image_route.post).patch(image_route.post).delete(image_route.del); // POST and PATCH are handled in 'image_route.post' Router.route('/image').get(image_route.get).post(image_route.post).patch(image_route.post).delete(image_route.del); // POST and PATCH are handled in 'image_route.post'
Router.route('/image/describe').get(image_schema); Router.route('/image/describe').get(image_schema);
Router.route('/version').get(versionRoute.get); Router.route('/transaction').get(transaction_route.get).post(transaction_route.post).patch(transaction_route.patch).delete(transaction_route.del);
Router.route('/test').get(testRoute.get); Router.route('/transaction/describe').get(transaction_schema);
Router.route('/version').get(version_route.get);
Router.route('/test').get(test_route.get);
export default Router; export default Router;

View File

@ -86,7 +86,6 @@ async function post(req: Request, res: Response) {
await db.products await db.products
.create({ .create({
data: { data: {
id: value.id,
gtin: value.gtin, gtin: value.gtin,
name: value.name, name: value.name,
price: value.price, price: value.price,

View File

@ -0,0 +1,265 @@
import { Request, Response } from 'express';
import db, { handlePrismaError } from '../../../../handlers/db.js'; // Database
import log from '../../../../handlers/log.js';
import { parseDynamicSortBy } from '../../../../helpers/prisma_helpers.js';
import { schema_get, schema_post, schema_patch, schema_del } from './transaction_schema.js';
import { Prisma } from '@prisma/client';
class AbortError extends Error {
constructor(public http_status: number, public status: string, public errorcode: string, public message: string, public details?: any) {
super(message);
this.name = 'AbortError';
}
}
// MARK: GET transaction
async function get(req: Request, res: Response) {
const { error, value } = schema_get.validate(req.query);
if (error) {
log.api?.debug('GET transaction Error:', req.query, value, error.details[0].message);
res.status(400).json({ status: 'ERROR', errorcode: 'VALIDATION_ERROR', message: error.details[0].message });
} else {
log.api?.debug('GET transaction Success:', req.query, value);
if (value.id !== undefined || value.user_id !== undefined) {
// get by id or user_id
await db
.$transaction([
// Same query for count and findMany
db.transactions.count({
where: {
OR: [{ id: value.id }, { userId: value.user_id }],
paid: value.paid
},
orderBy: parseDynamicSortBy(value.sort.toString(), value.order.toString()),
skip: value.skip,
take: value.take
}),
db.transactions.findMany({
where: {
OR: [{ id: value.id }, { userId: value.user_id }],
paid: value.paid
},
orderBy: parseDynamicSortBy(value.sort.toString(), value.order.toString()),
skip: value.skip,
take: value.take
})
])
.then(([count, result]) => {
if (result.length !== 0) {
res.status(200).json({ count, result });
} else {
res.status(404).json({ status: 'ERROR', errorcode: 'NOT_FOUND', message: 'Could not find specified transaction' });
}
})
.catch((err) => {
handlePrismaError(err, res, 'GET transaction');
});
} else {
// get all
await db
.$transaction([
// Same query for count and findMany
db.transactions.count({
where: {
paid: value.paid
},
orderBy: parseDynamicSortBy(value.sort.toString(), value.order.toString()),
skip: value.skip,
take: value.take
}),
db.transactions.findMany({
where: {
paid: value.paid
},
orderBy: parseDynamicSortBy(value.sort.toString(), value.order.toString()),
skip: value.skip,
take: value.take
})
])
.then(([count, result]) => {
if (result.length !== 0) {
res.status(200).json({ count, result });
} else {
res.status(404).json({ status: 'ERROR', errorcode: 'NOT_FOUND', message: 'Could not find specified transaction' });
}
})
.catch((err) => {
handlePrismaError(err, res, 'GET transaction');
});
}
}
}
// MARK: CREATE transaction
async function post(req: Request, res: Response) {
const { error, value } = schema_post.validate(req.body);
if (error) {
log.api?.debug('POST transaction Error:', req.body, value, error.details[0].message);
res.status(400).json({ status: 'ERROR', errorcode: 'VALIDATION_ERROR', message: error.details[0].message });
} else {
log.api?.debug('POST transaction Success:', req.body, value);
const products: Array<number> = value.products;
const outOfStockProducts: { id: number; name: string }[] = [];
const notFoundProducts: { id: number; name: string }[] = [];
const salesData: { productId: number; price: number }[] = [];
let total = new Prisma.Decimal(0);
// Start Prisma transaction
await db
.$transaction(async (prisma) => {
// Iterate over all products for this transaction(not prisma)
for (let i = 0; i < products.length; i++) {
log.api?.debug('Product:', i + 1, 'of', products.length, '(Loop)');
// Get product (price, stock, name)
const product = await prisma.products.findUnique({
where: { id: products[i] },
select: { price: true, stock: true, name: true }
});
// Check if product exists
if (product) {
log.api?.debug('Price:', product.price, '[Name:' + product.name + ']', '[ID:' + products[i] + ']');
if (product.stock > 0) {
// Add price of current product to total
total = total.add(product.price);
// Add product to salesData -> Later generate sales entry for each product
salesData.push({
productId: products[i],
price: Number(product.price)
});
// Reduce stock by 1
await prisma.products.update({
where: { id: products[i] },
data: { stock: { decrement: 1 } }
});
} else {
// Product is out of stock
outOfStockProducts.push({ id: products[i], name: product.name });
}
} else {
// Product not found
notFoundProducts.push({ id: products[i], name: 'unknown' });
}
}
log.api?.debug('Total:', total.toFixed(2));
// Abort the Prisma transaction if there are not existing products
if (notFoundProducts.length > 0) {
log.api?.debug('Aborting. missing products:', notFoundProducts);
throw new AbortError(
400, // http_status
'ERROR', // status
'NOT_FOUND', // errorcode
'Some of the products included in the transaction do not exist, therefore the transaction has not been processed.', // message
notFoundProducts // details
);
}
// Abort the Prisma transaction if there are products with insufficient stock
if (outOfStockProducts.length > 0) {
log.api?.debug('Aborting. out of stock products:', outOfStockProducts);
throw new AbortError(
400, // http_status
'ERROR', // status
'OUT_OF_STOCK', // errorcode
'Some of the products included in the transaction are out of stock, therefore the transaction has not been processed.', // message
outOfStockProducts // details
);
}
// Create transaction with salesData
const transaction = await prisma.transactions.create({
data: {
userId: value.user_id,
total: total,
paid: false,
sales: {
create: salesData
}
},
select: {
id: true
}
});
// Everything went well
res.status(201).json({ status: 'CREATED', message: 'Successfully created transaction', id: transaction.id });
})
.catch((err) => {
if (err instanceof AbortError) {
res.status(err.http_status).json({
status: err.status,
errorcode: err.errorcode,
message: err.message,
details: err.details
});
} else {
handlePrismaError(err, res, 'POST transaction');
}
});
}
}
// MARK: UPDATE transaction
async function patch(req: Request, res: Response) {
const { error, value } = schema_patch.validate(req.body);
if (error) {
log.api?.debug('PATCH transaction Error:', req.body, value, error.details[0].message);
res.status(400).json({ status: 'ERROR', errorcode: 'VALIDATION_ERROR', message: error.details[0].message });
} else {
log.api?.debug('PATCH transaction Success:', req.body, value);
await db.transactions
.update({
where: {
id: value.id
},
data: {
userId: value.user_id,
paid: value.paid,
paidAt: value.paid ? new Date() : undefined
},
select: {
id: true
}
})
.then((result) => {
res.status(200).json({ status: 'UPDATED', message: 'Successfully updated transaction', id: result.id });
})
.catch((err) => {
handlePrismaError(err, res, 'PATCH transaction');
});
}
}
// MARK: DELETE transaction
async function del(req: Request, res: Response) {
const { error, value } = schema_del.validate(req.body);
if (error) {
log.api?.debug('DEL transaction Error:', req.body, value, error.details[0].message);
res.status(400).json({ status: 'ERROR', errorcode: 'VALIDATION_ERROR', message: error.details[0].message });
} else {
log.api?.debug('DEL transaction Success:', req.body, value);
await db.transactions
.delete({
where: {
id: value.id
}
})
.then((result) => {
res.status(200).json({ status: 'DELETED', message: 'Successfully deleted transaction', id: result.id });
})
.catch((err) => {
handlePrismaError(err, res, 'DEL transaction');
});
}
}
export default { get, post, patch, del };

View File

@ -0,0 +1,59 @@
import { Request, Response } from 'express';
//import validator from 'joi'; // DOCS: https://joi.dev/api
import validator from '../../../../handlers/validation.js';
import { Prisma } from '@prisma/client';
// MARK: GET transaction
const schema_get = validator
.object({
sort: validator
.string()
.valid(...Object.keys(Prisma.TransactionsScalarFieldEnum))
.default('id'),
order: validator.string().valid('asc', 'desc').default('asc'),
take: validator.number().min(1).max(512),
skip: validator.number().min(0),
id: validator.number().positive().precision(0),
user_id: validator.number().positive().precision(0),
paid: validator.boolean().note('true-> Only paid / false-> Only unpaid / undefined-> both')
})
.nand('id', 'user_id'); // Allow id or user_id. not both.
// MARK: CREATE transaction
const schema_post = validator.object({
products: validator.array().items(validator.number().positive().precision(0)).required(),
user_id: validator.number().positive().precision(0).required(),
paid: validator.boolean().default(false)
});
// MARK: UPDATE transaction
const schema_patch = validator.object({
id: validator.number().positive().precision(0).required(),
user_id: validator.number().positive().precision(0).required(),
paid: validator.boolean().default(false)
});
// MARK: DELETE transaction
const schema_del = validator.object({
id: validator.number().positive().precision(0).required()
});
// Describe all schemas
const schema_get_desc = schema_get.describe();
const schema_post_desc = schema_post.describe();
const schema_patch_desc = schema_patch.describe();
const schema_del_desc = schema_del.describe();
// GET route
export default async function get(req: Request, res: Response) {
res.status(200).json({
GET: schema_get_desc,
POST: schema_post_desc,
PATCH: schema_patch_desc,
DELETE: schema_del_desc
});
}
export { schema_get, schema_post, schema_patch, schema_del };

View File

@ -1,18 +1,17 @@
import express from 'express'; import express from 'express';
// Route imports // Route imports
import dashboard_Route from './dashboard.js'; import dashboard_route from './dashboard.js';
import users from './users.js'; import users_route from './users.js';
import products from './products.js'; import products_route from './products.js';
import report_route from './report.js';
// Router base is '/admin' // Router base is '/admin'
const Router = express.Router({ strict: false }); const Router = express.Router({ strict: false });
Router.route('/').get(dashboard_Route.get); Router.route('/').get(dashboard_route.get);
Router.route('/users').get(users.get); Router.route('/users').get(users_route.get);
Router.route('/products').get(products.get); Router.route('/products').get(products_route.get);
// Router.route('/user_select').get(user_select_Route.get); Router.route('/report').get(report_route.get);
// Router.route('/product_select').get(product_select_Route.get);
// Router.route('/pay_up').get(pay_up_Route.get);
export default Router; export default Router;

View File

@ -0,0 +1,7 @@
import express, { Request, Response } from 'express';
function get(req: Request, res: Response) {
res.render("admin/reports")
}
export default { get };

View File

@ -2,10 +2,10 @@ import express from 'express';
import config from '../../handlers/config.js'; import config from '../../handlers/config.js';
// Route imports // Route imports
import screensaver_Route from './screensaver.js'; import screensaver_route from './screensaver.js';
import user_select_Route from './user_select.js'; import user_select_route from './user_select.js';
import product_select_Route from './product_select.js'; import product_select_route from './product_select.js';
import pay_up_Route from './pay_up.js'; import pay_up_route from './pay_up.js';
import test_Route from './test.js'; import test_Route from './test.js';
import adminRouter from './admin/index.js'; import adminRouter from './admin/index.js';
@ -13,10 +13,10 @@ import adminRouter from './admin/index.js';
// Router base is '/' // Router base is '/'
const Router = express.Router({ strict: false }); const Router = express.Router({ strict: false });
Router.route('/').get(screensaver_Route.get); Router.route('/').get(screensaver_route.get);
Router.route('/user_select').get(user_select_Route.get); Router.route('/user_select').get(user_select_route.get);
Router.route('/product_select').get(product_select_Route.get); Router.route('/product_select').get(product_select_route.get);
Router.route('/pay_up').get(pay_up_Route.get); Router.route('/pay_up').get(pay_up_route.get);
Router.use('/admin', adminRouter); Router.use('/admin', adminRouter);

View File

@ -1,7 +1,8 @@
import express, { Request, Response } from 'express'; import express, { Request, Response } from 'express';
import config from '../../handlers/config.js';
function get(req: Request, res: Response) { function get(req: Request, res: Response) {
res.render("screensaver") res.render("screensaver", { apikey: config.global.galleryApiKey })
} }
export default { get }; export default { get };

View File

@ -4,8 +4,8 @@ import __path from '../handlers/path.js';
import log from '../handlers/log.js'; import log from '../handlers/log.js';
// Route imports // Route imports
import frontend_routes from './frontend/index.js'; import frontend_router from './frontend/index.js';
import api_routes from './api/index.js'; import api_router from './api/index.js';
const Router = express.Router({ strict: false }); const Router = express.Router({ strict: false });
@ -16,8 +16,8 @@ Router.use('/libs/jquery', express.static(path.join(__path, 'node_modules', 'jqu
Router.use('/libs/bootstrap-icons', express.static(path.join(__path, 'node_modules', 'bootstrap-icons'))); Router.use('/libs/bootstrap-icons', express.static(path.join(__path, 'node_modules', 'bootstrap-icons')));
// Other routers // Other routers
Router.use('/api', api_routes); Router.use('/api', api_router);
Router.use('/', frontend_routes); Router.use('/', frontend_router);
// Default route. // Default route.
Router.all('*', function (req, res) { Router.all('*', function (req, res) {

View File

@ -100,13 +100,13 @@ let _api = {
// Handle the response // Handle the response
if (!response.ok) { if (!response.ok) {
_testPageFail(response.statusText); _testPageFail(response.statusText);
return; return -1;
} }
const result = await response.json(); const result = await response.json();
// Handle the result, was json valid? // Handle the result, was json valid?
if (!result) { if (!result) {
_testPageFail('Invalid JSON response'); _testPageFail('Invalid JSON response');
return; return -1;
} }
return result; return result;
@ -172,7 +172,7 @@ function getApiDescriptionByTable(tableName) {
} }
} }
function returnTableDataByTableName(tableName, search="", orderBy="asc", sort="", take=-1, skip=0) { function returnTableDataByTableName(tableName, search="", orderBy="asc", sort="", take=-1, skip=0, filters=[]) {
var orderBy = orderBy.toLowerCase(); var orderBy = orderBy.toLowerCase();
if(orderBy == "") { if(orderBy == "") {
orderBy = "asc"; orderBy = "asc";
@ -187,6 +187,10 @@ function returnTableDataByTableName(tableName, search="", orderBy="asc", sort=""
if(skip > 0) { if(skip > 0) {
baseString += "&skip=" + skip; baseString += "&skip=" + skip;
} }
filterKeys = Object.keys(filters);
for(var i = 0; i < filterKeys.length; i++) {
baseString += "&" + filterKeys[i] + "=" + filters[filterKeys[i]];
}
if (search && search.length > 0) { if (search && search.length > 0) {
return _api.get(baseString + '&search=' + search); return _api.get(baseString + '&search=' + search);
@ -214,6 +218,7 @@ async function getCountByTable(tableName, search="") {
function _testPageFail(reason) { function _testPageFail(reason) {
return;
document.getElementById('heroStatus').classList.remove('is-success'); document.getElementById('heroStatus').classList.remove('is-success');
document.getElementById('heroStatus').classList.add('is-danger'); document.getElementById('heroStatus').classList.add('is-danger');

View File

@ -1,26 +1,26 @@
#clock { #clock {
font-size: 120px; font-size: 120px;
font-weight: 100; font-weight: 100;
color: #fff; color: #fff;
text-align: center; text-align: center;
bottom: 10%; bottom: 10%;
right: 5%; right: 5%; /* Verschiebt die Flexbox weiter nach links */
z-index: 900010; z-index: 900010;
position: absolute; position: absolute;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
width: 40%;
width: 30%; margin: 0 auto; /* Stellt sicher, dass die Box zentriert bleibt */
} }
#time { #time {
margin-bottom: 0px; margin-bottom: 0px;
padding-bottom: 0px; padding-bottom: 0px;
text-align: center; text-align: center;
width: 95%; width: 140%;
vertical-align: middle; vertical-align: middle;
font-family: monospace; font-family: monospace;
} }
@ -32,18 +32,28 @@ flex-wrap: wrap;
/* HTML: <div class="loader"></div> */ /* HTML: <div class="loader"></div> */
.loader { .loader {
height: 200px; height: 200px;
aspect-ratio: 2/3; aspect-ratio: 2/3;
--c:no-repeat linear-gradient(#fff 0 0); --c:no-repeat linear-gradient(#fff 0 0);
background: var(--c), var(--c), var(--c), var(--c); background: var(--c), var(--c), var(--c), var(--c);
background-size: 50% 33.4%; background-size: 50% 33.4%;
animation: l8 1.5s infinite linear; animation: l8 1.5s infinite linear;
} }
@keyframes l8 { @keyframes l8 {
0%, 0%,
5% {background-position:0 25%,100% 25%,0 75%,100% 75%} 5% {background-position:0 25%,100% 25%,0 75%,100% 75%}
33% {background-position:0 50%,100% 0,0 100%,100% 50%} 33% {background-position:0 50%,100% 0,0 100%,100% 50%}
66% {background-position:0 50%,0 0,100% 100%,100% 50%} 66% {background-position:0 50%,0 0,100% 100%,100% 50%}
95%, 95%,
100% {background-position:0 75%,0 25%,100% 75%,100% 25%} 100% {background-position:0 75%,0 25%,100% 75%,100% 25%}
}
#credits {
position: absolute;
bottom: 1px;
left: 1px;
mix-blend-mode: difference;
color: white;
font-weight: bold;
} }

13
static/js/kiosk_mode.js Normal file
View File

@ -0,0 +1,13 @@
document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => {
// TODO: How to start kiosk mode?
if (true) {
console.info('Kiosk mode -> Disabled all external links');
document.querySelectorAll('a').forEach((link) => {
if (link.classList.contains('external-link')) {
link.style.pointerEvents = 'none';
}
});
}
}, 1000);
});

View File

@ -1,8 +1,4 @@
// Image Handler // Image Handler
const baseUrl = 'https://api.unsplash.com/photos/random?client_id=[KEY]&orientation=landscape&topics=nature';
const apiKey = 'tYOt7Jo94U7dunVcP5gt-kDKDMjWFOGQNsHuhLDLV8k'; // Take from config
const fullUrl = baseUrl.replace('[KEY]', apiKey);
const showModeImage = '/static/media/showModeLockscreen.jpg'; const showModeImage = '/static/media/showModeLockscreen.jpg';
let credits = document.getElementById('credits'); let credits = document.getElementById('credits');
@ -16,9 +12,27 @@ document.body.addEventListener('click', () => {
// Lock screen or show mode // Lock screen or show mode
let screenState = 'lock'; let screenState = 'lock';
let cookieScreen = getCookie('screen');
if (cookieScreen) {
screenState = cookieScreen;
}
function handleImage() { function handleImage() {
if (screenState === 'lock') { if (screenState === 'lock') {
fetch('https://staging.thegreydiamond.de/projects/photoPortfolio/api/getRand.php?uuid=01919dec-b2cd-7adc-8ca2-a071d1169cbc&unsplash=true&orientation=landscape') const apiParams = {
// default galery; spring awakens
uuid: '01919dec-b2cd-7adc-8ca2-a071d1169cbc;01953de0-3aa7-71f1-bfff-cbf9488efa64',
unsplash: true,
orientation: 'landscape',
height: window.screen.availHeight,
width: window.screen.availWidth,
cropCenteringMode: 'sm',
apikey: apiKey
};
const apiUrl = `https://photo.thegreydiamond.de/api/images/random.php?${new URLSearchParams(apiParams).toString()}`;
fetch(apiUrl)
.then((response) => response.json()) .then((response) => response.json())
.then((data) => { .then((data) => {
// data = { // data = {
@ -66,7 +80,7 @@ function handleImage() {
}, 1000); }, 1000);
// Set the credits // Set the credits
credits.innerHTML = `Photo by <a href="${data.user.links.html}" target="_blank">${data.user.name}</a> on <a href="https://unsplash.com" target="_blank">Unsplash</a>`; credits.innerHTML = `"${data.title}" by <a href="${data.user.links.html}" class="external-link" target="_blank">${data.user.name}</a>`;
credits.style.zIndex = 300000; credits.style.zIndex = 300000;
} }
}) })

View File

@ -1,3 +1,10 @@
html, body {
height: 100%;
margin: 0;
display: flex;
flex-direction: column;
}
body { body {
min-height: 100vh; min-height: 100vh;
} }
@ -13,3 +20,8 @@ hidden {
z-index: 1000; z-index: 1000;
margin: 20px; margin: 20px;
} }
footer {
margin-top: auto;
padding: 1rem !important;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -74,6 +74,9 @@ tables.forEach(async (table) => {
refreshTable(table); refreshTable(table);
}); });
}); });
if(table.getAttribute("data-loadmode") == "manual") {
return;
}
refreshTable(table); refreshTable(table);
}); });
@ -240,6 +243,7 @@ modalForms.forEach((modalForm) => {
console.log('Response: ', resp); console.log('Response: ', resp);
if (resp['status'] == 'CREATED' || resp['status'] == 'UPDATED') { if (resp['status'] == 'CREATED' || resp['status'] == 'UPDATED') {
console.log('Entry created successfully'); console.log('Entry created successfully');
createTemporaryNotification('Eintrag erfolgreich aktualisiert', 'is-success');
modalForm.closest('.modal').classList.remove('is-active'); modalForm.closest('.modal').classList.remove('is-active');
modalForm.reset(); modalForm.reset();
// Hide loadPhase // Hide loadPhase
@ -260,12 +264,18 @@ modalForms.forEach((modalForm) => {
entryPhase.classList.remove('is-hidden'); entryPhase.classList.remove('is-hidden');
} }
// TODO: Show error message // TODO: Show error message
createTemporaryNotification('Error while creating entry', 'is-danger');
} }
// Find all tables with data-searchTargetId set to table // Find all tables with data-searchTargetId set to table
setTimeout(() => { setTimeout(() => {
refreshTableByName(table); if(modalForm.getAttribute('data-extTable') != null) {
updateSingeltonsByTableName(table); refreshTableByName(table);
updateSingeltonsByTableName(table);
} else {
refreshTableByName(document.getElementById(modalForm.getAttribute('data-extTable')));
}
}, 500); }, 500);
}); });
}); });
@ -282,6 +292,7 @@ async function refreshTable(table) {
}); });
let order = ''; let order = '';
let column = ''; let column = '';
let filters = JSON.parse(table.getAttribute('data-filters')) || {};
ths.forEach((th) => { ths.forEach((th) => {
if (th.hasAttribute('data-order')) { if (th.hasAttribute('data-order')) {
order = th.getAttribute('data-order'); order = th.getAttribute('data-order');
@ -310,7 +321,7 @@ async function refreshTable(table) {
if (searchField) { if (searchField) {
const value = searchField.value; const value = searchField.value;
const dbTable = table.getAttribute('data-dataSource'); const dbTable = table.getAttribute('data-dataSource');
const result = await returnTableDataByTableName(dbTable, value, order, column, take=maxLinesPerPage, skip= start); const result = await returnTableDataByTableName(dbTable, value, order, column, take=maxLinesPerPage, skip=start, filters);
const totalResultCount = await getCountByTable(dbTable, value); const totalResultCount = await getCountByTable(dbTable, value);
paginationPassOnPre['dataLength'] = totalResultCount; paginationPassOnPre['dataLength'] = totalResultCount;
var magMiddl = managePaginationMiddleware(result, paginationPassOnPre); var magMiddl = managePaginationMiddleware(result, paginationPassOnPre);
@ -319,7 +330,7 @@ async function refreshTable(table) {
clearTable(table); clearTable(table);
writeDataToTable(table, data, paginationPassOn); writeDataToTable(table, data, paginationPassOn);
} else { } else {
const result = await returnTableDataByTableName(table.getAttribute('data-dataSource'), undefined, order, column, take= maxLinesPerPage, skip= start); const result = await returnTableDataByTableName(table.getAttribute('data-dataSource'), undefined, order, column, take= maxLinesPerPage, skip= start, filters);
const resultCount = await getCountByTable(table.getAttribute('data-dataSource')); const resultCount = await getCountByTable(table.getAttribute('data-dataSource'));
paginationPassOnPre['dataLength'] = resultCount; paginationPassOnPre['dataLength'] = resultCount;
var magMiddl = managePaginationMiddleware(result, paginationPassOnPre); var magMiddl = managePaginationMiddleware(result, paginationPassOnPre);
@ -504,7 +515,14 @@ function writeDataToTable(table, data, paginationPassOn) {
if(header.getAttribute('data-type') == "bool") { if(header.getAttribute('data-type') == "bool") {
td.innerHTML = row[column] ? '<i class="bi bi-check"></i>' : '<i class="bi bi-x"></i>'; td.innerHTML = row[column] ? '<i class="bi bi-check"></i>' : '<i class="bi bi-x"></i>';
} else { } else if(header.getAttribute('data-type') == "datetime"){
if(row[column] == null) {
td.innerHTML = "";
} else {
td.innerHTML = formatTimestamp(row[column]);
}
}
else {
td.innerHTML = row[column]; td.innerHTML = row[column];
} }
tr.appendChild(td); tr.appendChild(td);
@ -681,3 +699,40 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
}); });
}); });
function setCookie(name, value, days) {
let expires = "";
if(days) {
let date = new Date();
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
expires = "; expires=" + date.toUTCString();
}
document.cookie = name + "=" + value + expires + "; path=/";
}
function getCookie(name) {
let value = "; " + document.cookie;
let parts = value.split("; " + name + "=");
if(parts.length == 2) {
return parts.pop().split(";").shift();
}
}
function eraseCookie(name) {
document.cookie = name + '=; Max-Age=-99999999;';
}
function errorIfAnyUndefined(inp) {
console.log(inp)
for(var i = 0; i < inp.length; i++) {
if(inp[i] == undefined) {
console.error("Missing element!")
createTemporaryNotification("Beim Laden der Seite ist ein Fehler aufgetreten", "is-danger", 90000)
}
}
}
function formatTimestamp(timestamp) {
const date = new Date(timestamp);
return date.toLocaleString();
}

View File

@ -1,6 +1,26 @@
let uploadFileInput = document.getElementById('imgUpload'); let uploadFileInput = document.getElementById('imgUpload');
let fileName = document.getElementById('fileName'); let fileName = document.getElementById('fileName');
let imgUploadForm = document.getElementById('imgUploadForm'); let imgUploadForm = document.getElementById('imgUploadForm');
let scannerField = document.getElementById('scannerField');
let btn_restock = document.getElementById('btn_restock');
let btn_save_2 = document.getElementById('btn_save_2');
let form_gtin = document.getElementById('form_gtin');
let modal_stage_1 = document.getElementById('modal-stage-1');
let modal_stage_2 = document.getElementById('modal-stage-2');
let modal_stage_3 = document.getElementById('modal-stage-3');
let modal_stage_2_result = document.getElementById("stage-2-result");
let modal_stage_2_amount = document.getElementById("stage-2-amount");
let globalData;
waitingForScan = false;
let currentRestockProduct = null;
function handleImagePresence(row) { function handleImagePresence(row) {
// Check if /api/v1/image?id=row&check returns true // Check if /api/v1/image?id=row&check returns true
@ -21,7 +41,7 @@ function handleImagePresence(row) {
} }
let pretty = isThere ? '<i class="bi bi-check"></i>' : '<i class="bi bi-x"></i></a>'; let pretty = isThere ? '<i class="bi bi-check"></i>' : '<i class="bi bi-x"></i></a>';
const template = `<a href="/api/v1/image?id=${row.id}" target="_blank">${pretty}</a> <i class="bi bi-dot"></i> <button class="btn btn-primary" onclick="uploadImage(${row.id})">Upload</button>`; const template = `<a href="/api/v1/image?id=${row.id}" target="_blank">${pretty}</a> <i class="bi bi-dot"></i> <button class="btn btn-primary" onclick="uploadImage(${row.id})"><i class="bi bi-upload"></i></button>`;
return template; return template;
} }
@ -55,6 +75,10 @@ function silentFormSubmit() {
}; };
} }
function enableScanner() {
waitingForScan = true;
scannerField.focus();
}
uploadFileInput.addEventListener('change', function() { uploadFileInput.addEventListener('change', function() {
fileName.innerHTML = this.files[0].name; fileName.innerHTML = this.files[0].name;
@ -63,3 +87,109 @@ uploadFileInput.addEventListener('change', function() {
refreshTableByName('products'); refreshTableByName('products');
}, 1000); }, 1000);
}); });
scannerField.style.fontSize = '1px';
scannerField.style.height = '1px';
scannerField.style.width = '1px';
scannerField.style.opacity = '0';
scannerField.style.position = 'relative';
// Make sure text fields is always centerd vertically
window.addEventListener('scroll', function(event) {
if(!waitingForScan) {
return;
}
scannerField.y = document.documentElement.scrollTop + 20;
scannerField.style.top = document.documentElement.scrollTop + 20 + "px";
});
setInterval(() => {
if(!waitingForScan) {
return;
}
scannerField.focus();
}, 1000);
btn_restock.addEventListener('click', function() {
modal_stage_1.classList.remove('is-hidden');
modal_stage_2.classList.add('is-hidden');
modal_stage_3.classList.add('is-hidden');
waitingForScan = true;
});
// Handle barcode scanner input
scannerField.addEventListener('keydown', async function(event) {
if(event.key != 'Enter') {
return;
}
let barcode = scannerField.value;
console.log('Barcode scanned:', barcode);
scannerField.value = "";
// createTemporaryNotification(`Barcode ${barcode} gescannt`, 'is-info');
waitingForScan = false;
// Check if barcode is in the database
let product = globalData.find(p => p.gtin == barcode);
if(product) {
console.log('Product found:', product);
currentRestockProduct = product;
modal_stage_2_amount.innerHTML = "Aktuelle Menge: " + product.stock;
modal_stage_1.classList.add('is-hidden');
modal_stage_2.classList.remove('is-hidden');
modal_stage_3.classList.add('is-hidden');
createTemporaryNotification(`<i class="bi bi-upc-scan"></i> Barcode scan: GTIN ${barcode} gefunden`, 'is-success');
} else {
modal_stage_1.classList.add('is-hidden');
modal_stage_2.classList.add('is-hidden');
modal_stage_3.classList.remove('is-hidden');
form_gtin.value = barcode;
}
// modal_stage_2_result.innerHTML = product ? `<i class="bi bi-check"></i> Produkt gefunden: ${product.name}` : `<i class="bi bi-x"></i> Produkt nicht gefunden`;
// let product = globalData.find(p => p.gtin == barcode);
// if(product) {
// let event = new Event('click');
// createTemporaryNotification(`<i class="bi bi-upc-scan"></i> Barcode scan: GTIN ${barcode} gefunden`, 'is-success');
// document.getElementById(`product_${product.id}`).dispatchEvent(event);
// } else {
// createTemporaryNotification( `<i class="bi bi-upc-scan"></i> Barcode scan: GTIN ${barcode} nicht gefunden`, 'is-danger');
// }
});
function restock(amount) {
currentRestockProduct.stock += amount;
modal_stage_2_amount.innerHTML = "Aktuelle Menge: " + currentRestockProduct.stock;
}
function applyStock() {
let result = _api.patch('products', {
"id": currentRestockProduct.id,
"stock": currentRestockProduct.stock
})
if(result) {
createTemporaryNotification('Bestand erfolgreich aktualisiert', 'is-success');
modal_stage_2.classList.add('is-hidden');
modal_stage_1.classList.remove('is-hidden');
modal_stage_3.classList.add('is-hidden');
enableScanner();
} else {
createTemporaryNotification('Fehler beim Aktualisieren des Bestands', 'is-danger');
}
}
document.addEventListener('DOMContentLoaded', async function() {
let data = await returnTableDataByTableName('products');
console.info(`Found ${data.count} products`);
const result = data.result;
globalData = result;
});
// btn_save_2.addEventListener('click', async function() {
// // Assume submission is valid
// // Get the form data
// // reload table
// // close modal
// });

View File

@ -1,4 +1,82 @@
const tableContent = document.querySelector('.table-content'); const tableContent = document.querySelector('.table-content');
const tableSum = document.querySelector('.table-sum'); // HTML Elements
const isEmptyAlert = document.getElementById("noBalance");
const tableDiv = document.getElementById("balanceSheet");
const payTable = document.getElementById("payTable");
const tableCnt = document.getElementById("table-content");
const tableSum = document.getElementById("table-sum");
const modal_sum = document.getElementById("ModalSum");
const confirmModal = document.getElementById("confirmModal");
const btn_paynow = document.getElementById("paynow");
const btn_confirm = document.getElementById("confirmCheckout");
const btn_logout = document.getElementById("logout");
alert("NYI: Endpoint is not yet implemented. This demo ends here."); const table_old = document.getElementById("alltransactions");
errorIfAnyUndefined([isEmptyAlert, tableDiv, payTable, tableCnt, tableSum, modal_sum])
// Current user
let cookieUser = getCookie('user');
if(cookieUser == undefined) {
createTemporaryNotification('Fehler: Nutzer nicht angemeldet.', 'is-danger');
window.location.href = '/user_select';
}
table_old.setAttribute('data-filters', `{"user_id": ${cookieUser}}`);
refreshTable(table_old);
console.log("Table refreshed");
let transactionIds = [];
// Request outstanding transactions by user
async function pullData() {
let data = await _api.get("transaction?user_id=" + parseInt(cookieUser) + "&paid=false");
console.log(data)
if(data.count == 0) {
isEmptyAlert.classList.remove("is-hidden");
tableDiv.classList.add("is-hidden");
return;
}
// Write data to table
const result = data.result;
let priceSum = 0;
for(var i = 0; i < data.count; i++) {
const row = result[i];
const newRow = tableCnt.insertRow();
newRow.id = `row_${row.id}`;
newRow.innerHTML = `
<td>${formatTimestamp(row.createdAt)}</td>
<td>${parseFloat(row.total).toFixed(2)} €</td>
`;
priceSum += parseFloat(row.total);
transactionIds.push(row.id);
}
tableSum.innerText = priceSum.toFixed(2) + " €";
modal_sum.innerText = priceSum.toFixed(2) + " €";
}
btn_paynow.onclick = () => {
confirmModal.classList.add("is-active");
}
btn_confirm.onclick = () => {
for(let i = 0; i < transactionIds.length; i++) {
let res = _api.patch(`transaction`, {paid: true, id: transactionIds[i], user_id: parseInt(cookieUser)});
console.log(res);
if(res == -1 || res == undefined) {
createTemporaryNotification('Fehler: Zahlung fehlgeschlagen.', 'is-danger');
return;
}
}
createTemporaryNotification('Zahlung erfolgreich.', 'is-success');
setTimeout(() => {
window.location.href = '/user_select';
}, 1000);
}
btn_logout.onclick = () => {
eraseCookie('user');
window.location.href = '/user_select';
}
pullData()

View File

@ -186,13 +186,7 @@ function confirmedCart() {
}); });
} }
function getCookie(name) {
let value = "; " + document.cookie;
let parts = value.split("; " + name + "=");
if(parts.length == 2) {
return parts.pop().split(";").shift();
}
}
// Handle barcode scanner // Handle barcode scanner
// Force the cursor to the scanner field // Force the cursor to the scanner field
@ -222,7 +216,7 @@ scannerField.addEventListener('keydown', async function(event) {
let product = globalData.find(p => p.gtin == barcode); let product = globalData.find(p => p.gtin == barcode);
if(product) { if(product) {
let event = new Event('click'); let event = new Event('click');
createTemporaryNotification(`<i class="bi bi-upc-scan"></i> Barcode scan: GTIN ${barcode} gefunden`, 'is-success'); createTemporaryNotification(`<i class="bi bi-upc-scan"></i> Barcode scan: GTIN ${barcode} gefunden`, 'is-success', 2000);
document.getElementById(`product_${product.id}`).dispatchEvent(event); document.getElementById(`product_${product.id}`).dispatchEvent(event);
} else { } else {
createTemporaryNotification( `<i class="bi bi-upc-scan"></i> Barcode scan: GTIN ${barcode} nicht gefunden`, 'is-danger'); createTemporaryNotification( `<i class="bi bi-upc-scan"></i> Barcode scan: GTIN ${barcode} nicht gefunden`, 'is-danger');

View File

@ -12,8 +12,22 @@ let pinInput4 = document.getElementById('pinInput4');
let pinError = document.getElementById('pinError'); let pinError = document.getElementById('pinError');
let lastActivity = new Date().getTime();
let lastActivityTimeout = 1000 * 60 * 5; // 5 minutes
let currentUser = null; let currentUser = null;
document.addEventListener('click', function() {
lastActivity = new Date().getTime();
});
setInterval(function() {
let now = new Date().getTime();
if(now - lastActivity > lastActivityTimeout) {
window.location.href = '/';
}
}, 1000);
// Attach event listeners to all numpad buttons // Attach event listeners to all numpad buttons
let numpadButtons = numpad.getElementsByTagName('button'); let numpadButtons = numpad.getElementsByTagName('button');
for(let i = 0; i < numpadButtons.length; i++) { for(let i = 0; i < numpadButtons.length; i++) {

View File

@ -13,7 +13,7 @@
<a href="/admin/users" class="button is-large is-fullwidth is-primary">Benutzer</a> <a href="/admin/users" class="button is-large is-fullwidth is-primary">Benutzer</a>
</div> </div>
<div class="column is-4"> <div class="column is-4">
<a href="/admin/reports" class="button is-large is-fullwidth is-primary">Berichte</a> <a href="/admin/report" class="button is-large is-fullwidth is-primary">Berichte</a>
</div> </div>
</div> </div>

View File

@ -1,23 +1,26 @@
<%~ include("partials/base_head.eta", {"title": "Admin - Benutzer"}) %> <%~ include("partials/base_head.eta", {"title": "Admin - Benutzer"}) %>
<%~ include("partials/nav.eta") %> <%~ include("partials/nav.eta") %>
<input id="scannerField" type="text"/>
<section class="section container" id="mainSelect"> <section class="section container" id="mainSelect">
<h1 class="title">Produktverwaltung</h1> <h1 class="title">Produktverwaltung</h1>
<p class="heading"><button class="js-modal-trigger button" data-target="modal-js-example"> <p class="heading buttons">
<button class="js-modal-trigger button" data-target="modal-js-example">
Neues Produkt anlegen Neues Produkt anlegen
</button></p> </button><button class="js-modal-trigger button" data-target="modal-restock" id="btn_restock">
Lager nachfüllen / Anpassen
</button><br></p>
<input class="input" type="text" data-searchTargetId="productTable" placeholder="Nach Produkt suchen.." /> <input class="input" type="text" data-searchTargetId="productTable" placeholder="Nach Produkt suchen.." />
<table class="table is-striped is-fullwidth is-hoverable" data-dataSource="products" id="productTable" data-pageSize="10"> <table class="table is-striped is-fullwidth is-hoverable" data-dataSource="products" id="productTable" data-pageSize="10">
<thead> <thead>
<tr> <tr>
<th data-dataCol = "id">Id</th> <th data-dataCol = "id">ID</th>
<th data-dataCol = "name">Name</th> <th data-dataCol = "name">Name</th>
<th data-dataCol = "gtin">GTIN</th> <th data-dataCol = "gtin">GTIN</th>
<th data-dataCol = "price">Preis</th> <th data-dataCol = "price">Preis</th>
<th data-dataCol = "stock">Lagermenge</th> <th data-dataCol = "stock">Lagermenge</th>
<th data-dataCol = "visible" data-type="bool">Sichtbarkeit</th> <th data-dataCol = "visible" data-type="bool">Sichtbarkeit</th>
<th data-dataCol = "FUNC:INLINE" data-ColHandler=handleImagePresence>Bild hinterlegt</th> <th data-dataCol = "FUNC:INLINE" data-ColHandler=handleImagePresence>Bild</th>
<th data-fnc="actions" data-actions="edit,delete">Aktionen</th> <th data-fnc="actions" data-actions="edit,delete">Aktionen</th>
</tr> </tr>
</thead> </thead>
@ -148,6 +151,86 @@
<button class="modal-close is-large" aria-label="close"></button> <button class="modal-close is-large" aria-label="close"></button>
</div> </div>
<div id="modal-restock" class="modal">
<div class="modal-background"></div>
<div class="modal-content">
<div class="box" id="modal-stage-1">
<h2 class="title">Nachfüllen</h1>
<center><h1 class="title"><i class="bi bi-upc-scan"></i></h1></center>
Warten auf Scan....
</div>
<div class="box" id="modal-stage-2">
<h2 class="title">Scan erfolgreich - Produktmenge eingeben</h1>
<h3 class="subtitle" id="stage-2-amount">Aktuelle Menge: 0</h3>
<div class="buttons">
<button class="button is-info" onclick="restock(-1)">-1</button>
<button class="button is-info" onclick="restock(1)">+1</button>
<button class="button is-info" onclick="restock(6)">+6</button>
<button class="button is-info" onclick="restock(10)">+10</button>
<button class="button is-info" onclick="restock(12)">+12</button>
</div>
<button class="button is-success" onclick="applyStock()">Änderungen speichern</button>
<div id="stage-2-result"></div>
</div>
<div class="box" id="modal-stage-3">
<h2 class="title">Scan erfolgreich - Produkt erstellen</h1>
<form data-targetTable="products">
<div class="field">
<label class="label">Bezeichner</label>
<div class="control has-icons-left">
<input class="input" type="text" placeholder="Schokolade" value="" name="name">
<span class="icon is-small is-left">
<i class="bi bi-file-earmark-person-fill"></i>
</span>
</div>
</div>
<div class="field">
<label class="label">GTIN</label>
<div class="control has-icons-left">
<input id="form_gtin" class="input" type="number" placeholder="" value="" name="gtin" readonly>
<span class="icon is-small is-left">
<i class="bi bi-upc"></i>
</span>
</div>
</div>
<div class="field">
<label class="label">Lagermenge</label>
<div class="control has-icons-left">
<input class="input" type="number" placeholder="" value="" name="stock">
<span class="icon is-small is-left">
<i class="bi bi-archive-fill"></i>
</span>
</div>
</div>
<div class="field">
<label class="label">Preis</label>
<div class="control has-icons-left">
<input class="input" type="number" placeholder="" value="" step=0.01 name="price">
<span class="icon is-small is-left">
<i class="bi bi-currency-euro"></i>
</span>
</div>
</div>
<div class="field is-grouped">
<div class="control">
<input type="submit" class="button is-link" value="Save" data-actionBtn="save" data-extTable="productTable" id="btn_save_2">
</div>
<!--<div class="control">
<button type="button" class="button is-link is-light" data-actionBtn="cancel">Cancel</button>
</div>-->
</div>
</form>
</div>
</div>
<button class="modal-close is-large" aria-label="close"></button>
</div>
<script src="/static/pages/admin_products.js"></script> <script src="/static/pages/admin_products.js"></script>
<%~ include("partials/footer.eta") %> <%~ include("partials/footer.eta") %>
<%~ include("partials/base_foot.eta") %> <%~ include("partials/base_foot.eta") %>

36
views/admin/reports.eta Normal file
View File

@ -0,0 +1,36 @@
<%~ include("partials/base_head.eta", {"title": "Dashboard"}) %>
<%~ include("partials/nav.eta") %>
<section class="section container" id="mainSelect">
<h1 class="title">Berichte</h1>
<!-- Big buttons linking to the different admin pages (Produkte, Benutzer, Bericht) -->
<nav class="level">
<div class="level-item has-text-centered">
<div>
<p class="heading">Benutzer</p>
<p class="title"><span data-dataSource="user" data-dataAction="COUNT" class="is-skeleton">Load.</span></p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading">Transaktionen</p>
<p class="title"><span data-dataSource="transaction" data-dataAction="COUNT" class="is-skeleton">Load.</span></p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading">Produkte</p>
<p class="title"><span data-dataSource="products" data-dataAction="COUNT" class="is-skeleton">Load.</span></p>
</div>
</div>
</nav>
<div class="columns is-centered">
</div>
</section>
<%~ include("partials/footer.eta") %>
<!-- <script src="/static/pages/admin_.js"></script>-->
<%~ include("partials/base_foot.eta") %>

View File

@ -4,7 +4,7 @@
<section class="section container" id="mainSelect"> <section class="section container" id="mainSelect">
<h1 class="title">Benutzerverwaltung</h1> <h1 class="title">Benutzerverwaltung</h1>
<p class="heading"><button class="js-modal-trigger button" data-target="modal-js-example"> <p class="heading"><button class="js-modal-trigger button" data-target="modal-js-example">
Neuen Konakt anlegen Benutzer anlegen
</button></p> </button></p>
<table class="table is-striped is-fullwidth is-hoverable" data-dataSource="user" id="userTable" data-pageSize="10"> <table class="table is-striped is-fullwidth is-hoverable" data-dataSource="user" id="userTable" data-pageSize="10">
<thead> <thead>

View File

@ -2,10 +2,11 @@
<div class="content has-text-centered"> <div class="content has-text-centered">
<p> <p>
<i class="bi bi-cup-straw"></i> <i class="bi bi-cup-straw"></i>
<strong>HydrationHUB</strong> by <a target="_blank" rel="noopener noreferrer" href="https://pnh.fyi">[Project-name-here]</a>.<br> <strong>HydrationHUB</strong> by <a target="_blank" rel="noopener noreferrer" href="https://pnh.fyi" class="external-link">[Project-name-here]</a>.<br>
Running Version <span data-dataSource="version" data-dataAction="SPECIAL" class="is-skeleton">Load.</span> Running Version <span data-dataSource="version" data-dataAction="SPECIAL" class="is-skeleton">Load.</span>
</p> </p>
</div> </div>
</footer> </footer>
<script src="/static/apiWrapper.js"></script> <script src="/static/apiWrapper.js"></script>
<script src="/static/pageDriver.js"></script> <script src="/static/pageDriver.js"></script>
<script src="/static/js/kiosk_mode.js"></script>

View File

@ -2,8 +2,9 @@
<div class="navbar-brand"> <div class="navbar-brand">
<a class="navbar-item primary" href="/"> <a class="navbar-item primary" href="/">
<i class="bi bi-cup-straw"></i> <i class="bi bi-cup-straw"></i>
</a>
<a class="navbar-item primary is-hidden" id="nav_username" href="/">
<strong>Hey, <span id="nav_usernameContent"></span></strong>
</a> </a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample"> <a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
@ -15,61 +16,83 @@
</div> </div>
<div id="navbarBasicExample" class="navbar-menu"> <div id="navbarBasicExample" class="navbar-menu">
<div class="navbar-start"> <div class="navbar-end">
<a class="navbar-item" href="/">Screensaver</a> <div class="navbar-item" id="dynamic-navbar-buttons">
<a class="navbar-item" href="/user_select">user_select</a> <!-- Buttons will be dynamically injected here -->
<a class="navbar-item" href="/product_select">product_select</a>
<a class="navbar-item" href="/test">Test <span class="tag is-info">Dev</span></a>
<!--<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">More</a>
<div class="navbar-dropdown">
<a class="navbar-item">About</a>
<a class="navbar-item is-selected">Jobs</a>
<a class="navbar-item">Contact</a>
<hr class="navbar-divider">
<a class="navbar-item">Report an issue</a>
</div>
</div>-->
</div>
<div class="navbar-end">
<div class="navbar-item is-hidden" id="showOnLogin">
<strong>Hey, <span id="nav_username"></span></strong>
<button class="button" onclick="window.location='/pay_up'" >Zur Abrechnung</button>
</div>
<div class="navbar-item is-hidden" id="onlyShowRoot">
<button class="button" onclick="window.location='/admin/'" >Zur Administration</button>
</div>
<div class="navbar-item is-hidden" id="onlyShowAdmin">
<button class="button" onclick="window.location='/admin/'" >Zur Administration</button>
<button class="button" onclick="window.location='/'" >Abmelden</button>
</div> </div>
</div> </div>
<script>
// Check if ?user is set
if (window.location.search.includes('user')) {
// Show the sign up button
document.querySelector('#showOnLogin').classList.remove('is-hidden');
// Get the username from the cookie
username = document.cookie.split('; ').find(row => row.startsWith('name')).split('=')[1];
// Set the username in the nav
document.getElementById('nav_username').innerText = username;
}
// Check if /user_select is the current page
if (window.location.pathname == '/user_select') {
// Show the sign up button
document.querySelector('#onlyShowRoot').classList.remove('is-hidden');
}
// If admin is contained in url
if (window.location.pathname.includes('admin')) {
// Show the sign up button
document.querySelector('#onlyShowAdmin').classList.remove('is-hidden');
}
</script>
</div> </div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const navbarButtons = document.getElementById('dynamic-navbar-buttons');
const currentPath = window.location.pathname;
const queryParams = new URLSearchParams(window.location.search);
const buttonsConfig = {
'/user_select': [
{ text: '', icon: 'bi bi-gear', link: '/admin' },
{ text: '', icon: 'bi bi-house', link: '/user_select' }
],
'/product_select': [
{ text: 'Zur Abrechnung', link: '/pay_up' },
{ text: '', icon: 'bi bi-gear', link: '/admin' },
{ text: '', icon: 'bi bi-box-arrow-right', link: '/user_select' }
],
'/pay_up': [
{ text: '', icon: 'bi bi-gear', link: '/admin' },
{ text: '', icon: 'bi bi-box-arrow-right', link: '/user_select' }
],
'/admin': [
{ text: '', icon: 'bi bi-house', link: '/user_select' }
],
'/admin/products': [
{ text: '', icon: 'bi bi-arrow-return-left', link: '/admin' },
{ text: '', icon: 'bi bi-house', link: '/user_select' }
],
'/admin/users': [
{ text: '', icon: 'bi bi-arrow-return-left', link: '/admin' },
{ text: '', icon: 'bi bi-house', link: '/user_select' }
],
'/admin/report': [
{ text: '', icon: 'bi bi-arrow-return-left', link: '/admin' },
{ text: '', icon: 'bi bi-house', link: '/user_select' }
]
};
if (currentPath === '/product_select' && queryParams.has('user')) {
const username = document.cookie.split('; ').find(row => row.startsWith('name'))?.split('=')[1];
if (username) {
document.getElementById('nav_usernameContent').innerText = username; // Set greeting
document.getElementById('nav_username').classList.remove('is-hidden'); // Show greeting
}
}
const buttons = buttonsConfig[currentPath] || [];
buttons.forEach(button => {
const btn = document.createElement('button');
btn.className = 'button';
btn.onclick = () => window.location = button.link;
if (button.icon) {
const icon = document.createElement('i');
icon.className = button.icon;
btn.appendChild(icon);
}
if (button.text) {
btn.appendChild(document.createTextNode(button.text));
}
navbarButtons.appendChild(btn);
});
// Burger menu toggle
const burger = document.querySelector('.navbar-burger');
const menu = document.querySelector('.navbar-menu');
if (burger && menu) {
burger.addEventListener('click', () => {
burger.classList.toggle('is-active');
menu.classList.toggle('is-active');
});
}
});
</script>
</nav> </nav>

View File

@ -5,28 +5,82 @@
<h1 class="title">Abrechnung</h1> <h1 class="title">Abrechnung</h1>
<h2 class="subtitle">Ausstehend</h2> <h2 class="subtitle">Ausstehend</h2>
<table class="table"> <div class="notification is-info is-light is-hidden" id="noBalance">
Für diesen Benutzer stehen keine Transaktionen aus. <strong>Es gibt nichts zu bezahlen.</strong>
<br>
<button class="button is-info is-large" id="logout">Abmelden</button>
</div>
<div id="balanceSheet">
<table class="table is-striped is-hoverable" id="payTable">
<thead> <thead>
<tr> <tr>
<th><abbr title="Bezeichner">Bez.</abbr></th> <th>Austellungsdatum</th>
<th>Preis</th> <th>Preis</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tfoot> <tfoot>
<tr> <tr>
<th></th> <th></th>
<th id="table-sum"></th> <th id="table-sum"></th>
<th></th>
</tr> </tr>
</tfoot> </tfoot>
<tbody id="table-content"> <tbody id="table-content">
</tbody> </tbody>
</table> </table>
<button class="button is-success is-large" id="paynow">Jetzt bezahlen <i class="bi bi-wallet2"></i></button>
</div>
<details>
<summary>Alle Transaktionen</summary>
<table class="table is-striped is-fullwidth is-hoverable" data-dataSource="transaction" data-pageSize="10" data-filters='{"user_id":-1}' data-loadmode="manual" id="alltransactions">
<thead>
<tr>
<th data-dataCol = "id">Id</th>
<th data-dataCol = "total">Name</th>
<th data-dataCol = "paid" data-type="bool">Bezahlt</th>
<th data-dataCol = "createdAt" data-type="datetime">Ausgestellt am</th>
<th data-dataCol = "paidAt" data-type="datetime" data-order="DESC">Bezahlt am</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<nav class="pagination is-hidden" role="navigation" aria-label="pagination" data-targetTable="alltransactions">
<ul class="pagination-list">
</ul>
</nav>
</details>
</section> </section>
<!-- Confirmation modal -->
<div class="modal" id="confirmModal">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Bezahlung bestätigen</p>
<button class="delete" aria-label="close"></button>
</header>
<section class="modal-card-body">
<div class="content">
<p>
Wurde der Betrag in die Kasse eingezahlt?
</p>
<h2 class="title is-2" id="ModalSum"></h2>
</div>
</section>
<footer class="modal-card-foot buttons">
<button class="button is-success" id="confirmCheckout">Bestätigen</button>
<button class="button" id="cancelCheckout">Abbrechen</button>
</footer>
</div>
</div>
<%~ include("partials/footer.eta") %> <%~ include("partials/footer.eta") %>
<script src="/static/pages/payup.js"></script> <script src="/static/pages/payup.js"></script>
<%~ include("partials/base_foot.eta") %> <%~ include("partials/base_foot.eta") %>

View File

@ -11,7 +11,7 @@
</div> </div>
<!-- Empty sidebar on the right --> <!-- Empty sidebar on the right -->
<div class="column is-one-quarter"> <div class="column is-one-quarter">
<h2 class="title is-4" >Ausgewählte Produkte</h2> <h2 class="title is-4">Ausgewählte Produkte</h2>
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>

View File

@ -10,7 +10,12 @@
<div id="date"></div> <div id="date"></div>
</div> </div>
<script>
const apiKey = "<%= it.apikey %>";
</script>
<script src="/static/apiWrapper.js"></script>
<script src="/static/pageDriver.js"></script>
<script src="/static/js/lockscreenBgHandler.js"></script> <script src="/static/js/lockscreenBgHandler.js"></script>
<script src="/static/js/kiosk_mode.js"></script>
<%~ include("partials/base_foot.eta") %> <%~ include("partials/base_foot.eta") %>

View File

@ -1,12 +1,12 @@
<%~ include("partials/base_head.eta", {"title": "Dashboard"}) %> <%~ include("partials/base_head.eta", {"title": "Dashboard"}) %>
<%~ include("partials/nav.eta") %> <%~ include("partials/nav.eta") %>
<section class="section buttons container" id="mainSelect"> <section class="section buttons container is-fluid is-centered" id="mainSelect">
</section> </section>
<hidden> <hidden>
<!-- Base Button --> <!-- Base Button -->
<button class="button is-link is-large m-2" id="baseStruct">Username</button> <button class="button is-dark is-medium m-2" id="baseStruct">Username</button>
</hidden> </hidden>
<div class="modal" id="pinPadModal"> <div class="modal" id="pinPadModal">
<div class="modal-background"></div> <div class="modal-background"></div>