Compare commits
18 Commits
d4da439542
...
master
Author | SHA1 | Date | |
---|---|---|---|
41615303db | |||
06767268de | |||
3c03cc133c | |||
8bbadf36a8 | |||
634f05bc27 | |||
64d317115c | |||
5a583a94ff | |||
8383080395 | |||
2e8ee7ca5c | |||
a0ebf89ef8 | |||
d565497de1 | |||
fa26595797 | |||
ee6dd16be2 | |||
e3fba930d2 | |||
e377af7501 | |||
fb23f73963 | |||
7715672802 | |||
94034fa29f |
31
README.MD
31
README.MD
@ -16,3 +16,34 @@ Funktionen:
|
||||
- Erklärung MP3
|
||||
- Quittierung MP3
|
||||
- Verabschiedung MP3
|
||||
|
||||
|
||||
## API Endpoint planning
|
||||
|
||||
alertContacts (CRUD Fully implemented)
|
||||
alerts -> Only get
|
||||
|
||||
|
||||
actionPlan (CRUD)
|
||||
- select all prios
|
||||
|
||||
priorities (CRUD)
|
||||
- select actionPlan
|
||||
- Only allow changes to priority
|
||||
|
||||
content (CRUD)
|
||||
- Howto handle upload?
|
||||
|
||||
|
||||
|
||||
POST /alert/[:alert_hook]
|
||||
-> Check actionplan if hook exists and select current prios -> Write call request to XYXYX
|
||||
|
||||
|
||||
|
||||
|
||||
1. create one or more alertContacts
|
||||
2. create 4x content (all phases)
|
||||
3. create actionplan with contents
|
||||
4. create one or more priorities
|
||||
|
||||
|
29346
docs/index.html
29346
docs/index.html
File diff suppressed because one or more lines are too long
@ -1,98 +0,0 @@
|
||||
//// ------------------------------------------------------
|
||||
//// THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
|
||||
//// ------------------------------------------------------
|
||||
|
||||
Project "AssetFlow" {
|
||||
database_type: ''
|
||||
Note: ''
|
||||
}
|
||||
|
||||
Table alerts {
|
||||
id Int [pk, increment]
|
||||
type alertType [not null]
|
||||
message String
|
||||
actionplan actionPlan
|
||||
actionplanId Int
|
||||
date DateTime [not null]
|
||||
state alertState [not null]
|
||||
acknowledged_by alertContacts [not null]
|
||||
acknowledged_at DateTime
|
||||
}
|
||||
|
||||
Table alertContacts {
|
||||
id Int [pk, increment]
|
||||
name String [not null]
|
||||
phone String [unique, not null]
|
||||
comment String
|
||||
prios priorities [not null]
|
||||
alerts alerts [not null]
|
||||
}
|
||||
|
||||
Table actionPlan {
|
||||
id Int [pk, increment]
|
||||
name String [not null]
|
||||
comment String
|
||||
alert_hook String [unique, not null]
|
||||
prio priorities [not null]
|
||||
content content [not null]
|
||||
alerts alerts [not null]
|
||||
}
|
||||
|
||||
Table priorities {
|
||||
id Int [pk, increment]
|
||||
Contact alertContacts [not null]
|
||||
contactId Int [not null]
|
||||
priority Int [not null]
|
||||
actionplan actionPlan [not null]
|
||||
actionplanId Int [not null]
|
||||
|
||||
indexes {
|
||||
(priority, actionplanId) [unique]
|
||||
}
|
||||
}
|
||||
|
||||
Table content {
|
||||
id Int [pk, increment]
|
||||
type contentType [not null]
|
||||
name String [not null]
|
||||
filename String [not null]
|
||||
actionplan actionPlan [not null]
|
||||
}
|
||||
|
||||
Table alertContactsToalerts {
|
||||
acknowledged_byId Int [ref: > alertContacts.id]
|
||||
alertsId Int [ref: > alerts.id]
|
||||
}
|
||||
|
||||
Table actionPlanTocontent {
|
||||
contentId Int [ref: > content.id]
|
||||
actionplanId Int [ref: > actionPlan.id]
|
||||
}
|
||||
|
||||
Enum contentType {
|
||||
voice_alarm
|
||||
voice_explainer
|
||||
voice_acknowledgement
|
||||
voice_ending
|
||||
}
|
||||
|
||||
Enum alertType {
|
||||
generic
|
||||
fire
|
||||
fault
|
||||
intrusion
|
||||
clear
|
||||
}
|
||||
|
||||
Enum alertState {
|
||||
incomming
|
||||
running
|
||||
failed
|
||||
acknowledged
|
||||
}
|
||||
|
||||
Ref: alerts.actionplanId > actionPlan.id
|
||||
|
||||
Ref: priorities.contactId > alertContacts.id
|
||||
|
||||
Ref: priorities.actionplanId > actionPlan.id
|
File diff suppressed because one or more lines are too long
3708
package-lock.json
generated
3708
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -33,8 +33,6 @@
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"prisma": "^6.2.1",
|
||||
"prisma-dbml-generator": "^0.12.0",
|
||||
"prisma-docs-generator": "^0.8.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
@ -46,9 +44,11 @@
|
||||
"express": "^4.21.2",
|
||||
"express-fileupload": "^1.5.1",
|
||||
"express-session": "^1.18.1",
|
||||
"helmet": "^8.0.0",
|
||||
"joi": "^17.13.3",
|
||||
"jquery": "^3.7.1",
|
||||
"lodash": "^4.17.21",
|
||||
"minio": "^8.0.4",
|
||||
"passport": "^0.7.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"signale": "^1.4.0",
|
||||
|
@ -13,23 +13,6 @@ datasource db {
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
|
||||
// https://github.com/pantharshit00/prisma-docs-generator
|
||||
generator docs {
|
||||
provider = "node node_modules/prisma-docs-generator"
|
||||
output = "../docs"
|
||||
}
|
||||
|
||||
// https://github.com/notiz-dev/prisma-dbml-generator
|
||||
// Viewer: https://dbdiagram.io/d
|
||||
generator dbml {
|
||||
provider = "prisma-dbml-generator"
|
||||
output = "../docs"
|
||||
outputName = "schema.dbml"
|
||||
projectName = "AssetFlow"
|
||||
}
|
||||
|
||||
|
||||
enum contentType {
|
||||
voice_alarm
|
||||
voice_explainer
|
||||
@ -46,71 +29,74 @@ enum alertType {
|
||||
}
|
||||
|
||||
enum alertState {
|
||||
incomming // Incomming alerts
|
||||
running // Started calling
|
||||
failed // Failed to get acknowledgement of any alertContacts
|
||||
acknowledged // Some user acknowledged alert
|
||||
incoming // Incoming alerts
|
||||
running // Started calling
|
||||
failed // Failed to get acknowledgement of any alertContacts
|
||||
acknowledged // Some user acknowledged alert
|
||||
}
|
||||
|
||||
model alerts {
|
||||
id Int @id @unique @default(autoincrement())
|
||||
type alertType
|
||||
message String?
|
||||
actionplan actionPlan? @relation(fields: [actionplanId], references: [id])
|
||||
actionplanId Int?
|
||||
date DateTime
|
||||
id Int @id @unique @default(autoincrement())
|
||||
type alertType
|
||||
state alertState
|
||||
|
||||
description String?
|
||||
date DateTime
|
||||
|
||||
actionplan actionPlan? @relation(fields: [actionplanId], references: [id])
|
||||
actionplanId Int?
|
||||
|
||||
acknowledged_by alertContacts[]
|
||||
acknowledged_at DateTime?
|
||||
@@fulltext([message])
|
||||
|
||||
@@fulltext([description])
|
||||
}
|
||||
|
||||
|
||||
model alertContacts {
|
||||
id Int @id @unique @default(autoincrement())
|
||||
id Int @id @unique @default(autoincrement())
|
||||
name String
|
||||
phone String @unique
|
||||
phone String @unique
|
||||
comment String?
|
||||
prios priorities[]
|
||||
alerts alerts[]
|
||||
alerts alerts[]
|
||||
|
||||
@@fulltext([name, phone, comment])
|
||||
}
|
||||
|
||||
model actionPlan {
|
||||
id Int @id @unique @default(autoincrement())
|
||||
name String
|
||||
comment String?
|
||||
alert_hook String @unique
|
||||
prio priorities[]
|
||||
content content[] // aka. all voice files
|
||||
id Int @id @unique @default(autoincrement())
|
||||
name String @unique
|
||||
comment String?
|
||||
alerthook String @unique @default(ulid())
|
||||
prio priorities[]
|
||||
content content[] // aka. all voice files
|
||||
|
||||
alerts alerts[]
|
||||
|
||||
@@fulltext([name, comment])
|
||||
}
|
||||
|
||||
model priorities {
|
||||
id Int @id @unique @default(autoincrement())
|
||||
Contact alertContacts @relation(fields: [contactId], references: [id])
|
||||
contactId Int
|
||||
priority Int
|
||||
actionplan actionPlan @relation(fields: [actionplanId], references: [id])
|
||||
actionplanId Int
|
||||
id Int @id @unique @default(autoincrement())
|
||||
Contact alertContacts @relation(fields: [contactId], references: [id])
|
||||
contactId Int
|
||||
priority Int
|
||||
actionplan actionPlan @relation(fields: [actionplanId], references: [id])
|
||||
actionplanId Int
|
||||
|
||||
@@unique([priority, actionplanId])
|
||||
}
|
||||
|
||||
model content {
|
||||
id Int @id @unique @default(autoincrement())
|
||||
type contentType
|
||||
name String
|
||||
filename String
|
||||
actionplan actionPlan[]
|
||||
@@fulltext([name, filename])
|
||||
//id Int @id @unique @default(autoincrement())
|
||||
s3_key String @id @unique
|
||||
name String @unique
|
||||
type contentType
|
||||
actionplan actionPlan[]
|
||||
|
||||
@@fulltext([name])
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// https://spacecdn.de/file/bma_stoe_v1.mp3
|
||||
// https://spacecdn.de/file/quittiert_v1.mp3
|
||||
// https://spacecdn.de/file/angenehmen_tag_v1.mp3
|
||||
|
@ -1,14 +1,23 @@
|
||||
import ConfigManager from '../libs/configManager.js';
|
||||
import __path from "./path.js";
|
||||
import __path from './path.js';
|
||||
import _ from 'lodash';
|
||||
|
||||
import log from './log.js';
|
||||
|
||||
// Create a new config instance.
|
||||
const config = new ConfigManager(__path + '/config.json', true, {
|
||||
db_connection_string: 'mysql://USER:PASSWORD@HOST:3306/DATABASE',
|
||||
http_listen_address: '0.0.0.0',
|
||||
http_port: 3000,
|
||||
debug: true,
|
||||
http_domain: 'example.org',
|
||||
http_enable_hsts: false,
|
||||
devmode: true,
|
||||
s3: {
|
||||
endpoint: 'minio.example.org',
|
||||
port: 443,
|
||||
use_ssl: true,
|
||||
access_key: '',
|
||||
secret_key: ''
|
||||
},
|
||||
auth: {
|
||||
cookie_secret: 'gen',
|
||||
cookie_secure: true,
|
||||
@ -25,9 +34,11 @@ const config = new ConfigManager(__path + '/config.json', true, {
|
||||
// If no local User exists, create the default with a generated password
|
||||
if (_.isEqual(config.global.auth.local.users, {})) {
|
||||
config.global.auth.local.users = {
|
||||
'administrator': 'gen',
|
||||
administrator: 'gen'
|
||||
};
|
||||
config.save_config();
|
||||
}
|
||||
|
||||
!config.global.devmode && log.core.error('devmode active! Do NOT use this in prod!');
|
||||
|
||||
export default config;
|
||||
|
@ -12,75 +12,29 @@ const prisma = new PrismaClient({
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// FIXME: any
|
||||
export function handlePrismaError(errorObj: any, res: Response) {
|
||||
export function handlePrismaError(errorObj: any, res: Response, source: string) {
|
||||
log.db.error(source, errorObj);
|
||||
if (errorObj instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
switch (errorObj.code) {
|
||||
|
||||
log.core.debug(errorObj);
|
||||
res.status(500).json({ status: 'ERROR', meta: errorObj.meta, errorcode: errorObj.code, message: errorObj.message });
|
||||
// P2002 -> "Unique constraint failed on the {constraint}"
|
||||
case 'P2002':
|
||||
res.status(409).json({ status: 'ERROR', errorcode: 'DB_ERROR', message: 'The object needs to be unique', meta: errorObj.meta });
|
||||
break;
|
||||
|
||||
// P2003 -> "Foreign key constraint failed on the field: {field_name}"
|
||||
case 'P2003':
|
||||
res.status(404).json({ status: 'ERROR', errorcode: 'DB_ERROR', message: 'Relation object does not exist', meta: errorObj.meta });
|
||||
break;
|
||||
|
||||
// if(errorObj instanceof Prisma.PrismaClientKnownRequestError)
|
||||
|
||||
// switch (errorObj.code) {
|
||||
// // P2002 -> "Unique constraint failed on the {constraint}"
|
||||
// // https://www.prisma.io/docs/reference/api-reference/error-reference
|
||||
// case 'P2002': //
|
||||
// log.db.error('');
|
||||
// break;
|
||||
|
||||
|
||||
// // P2003 -> "Foreign key constraint failed on the field: {field_name}"
|
||||
// // https://www.prisma.io/docs/reference/api-reference/error-reference
|
||||
// // FIXME: Is this errormessage right?
|
||||
// case 'P2003': //
|
||||
// log.db.error('');
|
||||
// break;
|
||||
// case 'xxx': //
|
||||
// log.db.error('');
|
||||
// break;
|
||||
// case 'xxx':
|
||||
// log.db.error('');
|
||||
// break;
|
||||
// case 'xxx':
|
||||
// log.db.error('');
|
||||
// break;
|
||||
// case 'xxx':
|
||||
// log.db.error('');
|
||||
// break;
|
||||
// case 'xxx':
|
||||
// log.db.error('');
|
||||
// break;
|
||||
// case 'xxx':
|
||||
// log.db.error('');
|
||||
// break;
|
||||
// case 'xxx':
|
||||
// log.db.error('');
|
||||
// break;
|
||||
|
||||
|
||||
// default:
|
||||
// break;
|
||||
// }
|
||||
|
||||
// // Check if an entry already exists.
|
||||
// if (errorcode === 'P2002') {
|
||||
// // P2002 -> "Unique constraint failed on the {constraint}"
|
||||
// // https://www.prisma.io/docs/reference/api-reference/error-reference
|
||||
// res.status(409).json({ status: 'ERROR', errorcode: 'EXISTING', message: 'Item already exists' });
|
||||
// } else if (errorcode == 'P2003') {
|
||||
// // P2003 -> "Foreign key constraint failed on the field: {field_name}"
|
||||
// // https://www.prisma.io/docs/reference/api-reference/error-reference
|
||||
// // FIXME: Is this errormessage right?
|
||||
// res.status(404).json({ status: 'ERROR', errorcode: 'NOT_EXISTING', message: 'Item does not exist' });
|
||||
// } else if (errorcode == 'P2000') {
|
||||
// // P2000 -> "The provided value for the column is too long for the column's type. Column: {column_name}"
|
||||
// // https://www.prisma.io/docs/reference/api-reference/error-reference
|
||||
// res.status(404).json({ status: 'ERROR', errorcode: 'VALIDATION_ERROR', message: 'One or more fields exceed the maximum length restriction' });
|
||||
// } else {
|
||||
// log.db.error(err);
|
||||
// res.status(500).json({ status: 'ERROR', errorcode: 'DB_ERROR', error: err, message: 'An error occurred during the database operation' });
|
||||
// }
|
||||
default:
|
||||
res.status(500).json({ status: 'ERROR', errorcode: 'DB_ERROR', message: 'An error occurred during the database operation' });
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
res.status(500).json({ status: 'ERROR', errorcode: 'DB_ERROR', message: 'If you can read this something went terribly wrong!' });
|
||||
}
|
||||
}
|
||||
|
||||
export default prisma;
|
||||
|
@ -14,6 +14,7 @@ type log = {
|
||||
core: Logger<unknown>
|
||||
db: Logger<unknown>
|
||||
web: Logger<unknown>
|
||||
S3: Logger<unknown>
|
||||
auth: Logger<unknown>
|
||||
api?: Logger<unknown>
|
||||
frontend?: Logger<unknown>
|
||||
@ -24,6 +25,7 @@ let log: log = {
|
||||
core: new Logger(loggerConfig("Core")),
|
||||
db: new Logger(loggerConfig("DB")),
|
||||
web: new Logger(loggerConfig("Web")),
|
||||
S3: new Logger(loggerConfig("S3")),
|
||||
auth: new Logger(loggerConfig("Auth")),
|
||||
// helper: new Logger(loggerConfig("HELPER")),
|
||||
};
|
||||
|
19
src/handlers/s3.ts
Normal file
19
src/handlers/s3.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import * as Minio from 'minio';
|
||||
import log from './log.js';
|
||||
import config from './config.js';
|
||||
|
||||
const minioClient = new Minio.Client({
|
||||
endPoint: config.global.s3.endpoint,
|
||||
port: config.global.s3.port,
|
||||
useSSL: config.global.s3.use_ssl,
|
||||
accessKey: config.global.s3.access_key,
|
||||
secretKey: config.global.s3.secret_key
|
||||
});
|
||||
|
||||
export async function test() {
|
||||
log.S3.debug('GET', await minioClient.presignedGetObject('atas-dev', 'test', 500));
|
||||
|
||||
log.S3.debug('PUT', await minioClient.presignedPutObject('atas-dev', 'test', 500));
|
||||
}
|
||||
|
||||
export default minioClient;
|
@ -12,11 +12,11 @@ export function parseDynamicSortBy(SortField: string, Order: string) {
|
||||
|
||||
/**
|
||||
* Function to parse a string into a number or return undefined if it is not a number
|
||||
*
|
||||
* Deprecated since all empty strings in bodys are now undefined. This happens in api/v1 router
|
||||
* @export
|
||||
* @param {string || any} data
|
||||
* @returns {object}
|
||||
*/
|
||||
export function parseIntOrUndefined(data: any) {
|
||||
return isNaN(parseInt(data)) ? undefined : parseInt(data);
|
||||
}
|
||||
}
|
||||
|
62
src/index.ts
62
src/index.ts
@ -1,17 +1,18 @@
|
||||
// MARK: Imports
|
||||
import path from 'node:path';
|
||||
import __path from "./handlers/path.js";
|
||||
import log from "./handlers/log.js";
|
||||
import db from "./handlers/db.js";
|
||||
import __path from './handlers/path.js';
|
||||
import log from './handlers/log.js';
|
||||
import db from './handlers/db.js';
|
||||
import config from './handlers/config.js';
|
||||
|
||||
// Express & more
|
||||
import express from 'express';
|
||||
import cors from 'cors'
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import session from 'express-session';
|
||||
import fileUpload from 'express-fileupload';
|
||||
import bodyParser, { Options } from 'body-parser';
|
||||
import { Eta } from "eta";
|
||||
import { Eta } from 'eta';
|
||||
import passport from 'passport';
|
||||
|
||||
import ChildProcess from 'child_process';
|
||||
@ -20,29 +21,26 @@ import routes from './routes/index.js';
|
||||
|
||||
import fs from 'node:fs';
|
||||
|
||||
log.core.trace("Running from path: " + __path);
|
||||
db.$disconnect();
|
||||
|
||||
log.core.trace('Running from path: ' + __path);
|
||||
|
||||
// MARK: Express
|
||||
const app = express();
|
||||
|
||||
// Versioning
|
||||
try {
|
||||
const rawPkg = fs.readFileSync("package.json", 'utf8');
|
||||
const rawPkg = fs.readFileSync('package.json', 'utf8');
|
||||
const pkgJson = JSON.parse(rawPkg);
|
||||
app.locals.version = pkgJson.version;
|
||||
} catch (error) {
|
||||
log.core.error("Failed to get version from package.json.");
|
||||
app.locals.version = "0.0.0";
|
||||
log.core.error('Failed to get version from package.json.');
|
||||
app.locals.version = '0.0.0';
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
try {
|
||||
app.locals.versionRevLong = ChildProcess.execSync('git rev-parse HEAD').toString().trim();
|
||||
app.locals.versionRev = app.locals.versionRevLong.substring(0, 7);
|
||||
} catch (error) {
|
||||
log.core.error("Failed to get git revision hash.");
|
||||
log.core.error('Failed to get git revision hash.');
|
||||
app.locals.versionRev = '0';
|
||||
app.locals.versionRevLong = '0';
|
||||
}
|
||||
@ -50,7 +48,7 @@ try {
|
||||
try {
|
||||
app.locals.versionRevLatest = ChildProcess.execSync('git ls-remote --refs -q').toString().trim().split('\t')[0];
|
||||
} catch (error) {
|
||||
log.core.error("Failed to get latest git revision hash.");
|
||||
log.core.error('Failed to get latest git revision hash.');
|
||||
app.locals.versionRevLatest = '0';
|
||||
}
|
||||
|
||||
@ -62,19 +60,31 @@ if (app.locals.versionRevLong === app.locals.versionRevLatest) {
|
||||
app.locals.versionUpdateAvailable = true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ETA Init
|
||||
const eta = new Eta({ views: path.join(__path, "views") })
|
||||
app.engine("eta", buildEtaEngine())
|
||||
app.set("view engine", "eta")
|
||||
|
||||
const eta = new Eta({ views: path.join(__path, 'views') });
|
||||
app.engine('eta', buildEtaEngine());
|
||||
app.set('view engine', 'eta');
|
||||
|
||||
// MARK: Express Middleware & Config
|
||||
app.set('x-powered-by', false);
|
||||
app.set('x-powered-by', false); // helmet does this too. But not in devmode
|
||||
if (!config.global.devmode) {
|
||||
app.use(
|
||||
helmet({
|
||||
strictTransportSecurity: config.global.http_enable_hsts,
|
||||
contentSecurityPolicy: {
|
||||
useDefaults: false,
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", config.global.http_domain],
|
||||
objectSrc: ["'none'"],
|
||||
upgradeInsecureRequests: config.global.devmode ? null : []
|
||||
}
|
||||
}
|
||||
})
|
||||
); // Add headers
|
||||
}
|
||||
|
||||
app.use(fileUpload());
|
||||
//app.use(cors());
|
||||
app.use(bodyParser.urlencoded({ extended: false }));
|
||||
app.use(bodyParser.json());
|
||||
|
||||
@ -91,16 +101,13 @@ app.use(
|
||||
app.use(passport.authenticate('session'));
|
||||
app.use(routes);
|
||||
|
||||
|
||||
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}`);
|
||||
});
|
||||
|
||||
|
||||
|
||||
// MARK: Helper Functions
|
||||
function buildEtaEngine() {
|
||||
return (path:string, opts:Options, callback: CallableFunction) => {
|
||||
return (path: string, opts: Options, callback: CallableFunction) => {
|
||||
try {
|
||||
const fileContent = eta.readFile(path);
|
||||
const renderedTemplate = eta.renderString(fileContent, opts);
|
||||
@ -110,4 +117,3 @@ function buildEtaEngine() {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1,70 +1,101 @@
|
||||
import { Request, Response } from 'express';
|
||||
import db, { handlePrismaError } from '../../../handlers/db.js'; // Database
|
||||
import log from '../../../handlers/log.js';
|
||||
import { parseIntOrUndefined, parseDynamicSortBy } from '../../../helpers/prisma_helpers.js';
|
||||
import { schema_get } from './alertContacts_schema.js';
|
||||
import { parseDynamicSortBy } from '../../../helpers/prisma_helpers.js';
|
||||
import { schema_get, schema_post, schema_patch, schema_del } from './alertContacts_schema.js';
|
||||
|
||||
// MARK: GET AlertContact
|
||||
// MARK: GET alertContact
|
||||
async function get(req: Request, res: Response) {
|
||||
const { error, value } = schema_get.validate(req.query);
|
||||
if (error) {
|
||||
log.api?.debug('Error:', req.query, value);
|
||||
res.status(400).json({ error: error.details[0].message });
|
||||
log.api?.debug('alertContact GET 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('Success:', req.query, value);
|
||||
log.api?.debug('alertContact GET Success:', req.query, value);
|
||||
|
||||
// Query with FullTextSearch
|
||||
const query_fts = {
|
||||
where: {
|
||||
OR: [{ id: parseIntOrUndefined(value.id) }, { name: { search: value.search } }, { phone: { search: value.search } }, { comment: { search: value.search } }]
|
||||
},
|
||||
orderBy: parseDynamicSortBy(value.sort.toString(), value.order.toString())
|
||||
};
|
||||
|
||||
if (value.search !== undefined || value.id !== undefined) {
|
||||
// with FullTextSearch
|
||||
if (!value.count) {
|
||||
// get all entrys
|
||||
log.api?.trace('get all entrys - with FullTextSearch');
|
||||
await db.alertContacts.findMany(query_fts).then((result) => {
|
||||
res.status(200).json(result);
|
||||
});
|
||||
await db.alertContacts
|
||||
.findMany({
|
||||
where: {
|
||||
OR: [{ id: value.id }, { name: { search: value.search } }, { phone: { search: value.search } }, { comment: { search: value.search } }]
|
||||
},
|
||||
orderBy: parseDynamicSortBy(value.sort.toString(), value.order.toString()),
|
||||
skip: value.skip,
|
||||
take: value.take
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.length !== 0) {
|
||||
res.status(200).json(result);
|
||||
} else {
|
||||
res.status(404).json({ status: 'ERROR', errorcode: 'NOT_FOUND', message: 'Could not find specified object' });
|
||||
}
|
||||
}).catch((err) => {
|
||||
handlePrismaError(err, res, 'GET alertContact');
|
||||
});
|
||||
} else {
|
||||
// count all entrys
|
||||
log.api?.trace('count all entrys - with FullTextSearch');
|
||||
await db.alertContacts.count(query_fts).then((result) => {
|
||||
res.status(200).json(result);
|
||||
});
|
||||
await db.alertContacts
|
||||
.count({
|
||||
where: {
|
||||
OR: [{ id: value.id }, { name: { search: value.search } }, { phone: { search: value.search } }, { comment: { search: value.search } }]
|
||||
},
|
||||
orderBy: parseDynamicSortBy(value.sort.toString(), value.order.toString())
|
||||
})
|
||||
.then((result) => {
|
||||
res.status(200).json(result);
|
||||
}).catch((err) => {
|
||||
handlePrismaError(err, res, 'GET alertContact');
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// without FullTextSearch
|
||||
if (!value.count) {
|
||||
// get all entrys
|
||||
log.api?.trace('get all entrys - without FullTextSearch');
|
||||
await db.alertContacts.findMany({ orderBy: parseDynamicSortBy(value.sort.toString(), value.order.toString()) }).then((result) => {
|
||||
res.status(200).json(result);
|
||||
});
|
||||
await db.alertContacts
|
||||
.findMany({ orderBy: parseDynamicSortBy(value.sort.toString(), value.order.toString()), take: value.take, skip: value.skip })
|
||||
.then((result) => {
|
||||
if (result.length !== 0) {
|
||||
res.status(200).json(result);
|
||||
} else {
|
||||
res.status(404).json({ status: 'ERROR', errorcode: 'NOT_FOUND', message: 'Could not find specified object' });
|
||||
}
|
||||
}).catch((err) => {
|
||||
handlePrismaError(err, res, 'GET alertContact');
|
||||
});
|
||||
} else {
|
||||
// count all entrys without FullTextSearch
|
||||
log.api?.trace('count all entrys - without FullTextSearch');
|
||||
await db.alertContacts.count().then((result) => {
|
||||
res.status(200).json(result);
|
||||
}).catch((err) => {
|
||||
handlePrismaError(err, res, 'GET alertContact');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: CREATE AlertContact
|
||||
// MARK: CREATE alertContact
|
||||
async function post(req: Request, res: Response) {
|
||||
// Check if undefined or null
|
||||
if (req.body.name != null && req.body.phone != null) {
|
||||
const { error, value } = schema_post.validate(req.body);
|
||||
if (error) {
|
||||
log.api?.debug('alertContact POST 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('alertContact POST Success:', req.body, value);
|
||||
await db.alertContacts
|
||||
.create({
|
||||
data: {
|
||||
name: req.body.name,
|
||||
phone: req.body.phone,
|
||||
comment: req.body.comment
|
||||
name: value.name,
|
||||
phone: value.phone,
|
||||
comment: value.comment
|
||||
},
|
||||
select: {
|
||||
id: true
|
||||
@ -72,16 +103,62 @@ async function post(req: Request, res: Response) {
|
||||
})
|
||||
.then((result) => {
|
||||
res.status(201).json({ status: 'CREATED', message: 'Successfully created alertContact', id: result.id });
|
||||
}).catch((err) => {
|
||||
handlePrismaError(err, res, 'POST alertContact');
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({ status: 'ERROR', errorcode: 'VALIDATION_ERROR', message: 'One or more required fields are missing or invalid' });
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: UPDATE AlertContact
|
||||
async function patch(req: Request, res: Response) {}
|
||||
// MARK: UPDATE alertContact
|
||||
async function patch(req: Request, res: Response) {
|
||||
const { error, value } = schema_patch.validate(req.body);
|
||||
if (error) {
|
||||
log.api?.debug('alertContact PATCH 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('alertContact PATCH Success:', req.body, value);
|
||||
await db.alertContacts
|
||||
.update({
|
||||
where: {
|
||||
id: value.id
|
||||
},
|
||||
data: {
|
||||
name: value.name,
|
||||
phone: value.phone,
|
||||
comment: value.comment
|
||||
},
|
||||
select: {
|
||||
id: true
|
||||
}
|
||||
})
|
||||
.then((result) => {
|
||||
res.status(200).json({ status: 'UPDATED', message: 'Successfully updated alertContact', id: result.id });
|
||||
}).catch((err) => {
|
||||
handlePrismaError(err, res, 'PATCH alertContact');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: DELETE AlertContact
|
||||
async function del(req: Request, res: Response) {}
|
||||
// MARK: DELETE alertContact
|
||||
async function del(req: Request, res: Response) {
|
||||
const { error, value } = schema_del.validate(req.body);
|
||||
if (error) {
|
||||
log.api?.debug('alertContact DELETE 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('alertContact DELETE Success:', req.body, value);
|
||||
await db.alertContacts
|
||||
.delete({
|
||||
where: {
|
||||
id: value.id
|
||||
}
|
||||
})
|
||||
.then((result) => {
|
||||
res.status(200).json({ status: 'DELETED', message: 'Successfully deleted alertContact', id: result.id });
|
||||
}).catch((err) => {
|
||||
handlePrismaError(err, res, 'DEL alertContact');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default { get, post, patch, del };
|
||||
|
@ -1,52 +1,54 @@
|
||||
import { Request, Response } from 'express';
|
||||
import validator from 'joi'; // DOCS: https://joi.dev/api
|
||||
import log from '../../../handlers/log.js';
|
||||
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
// MARK: GET alertContact
|
||||
const schema_get = validator.object({
|
||||
sort: validator.string().valid('id', 'name', 'phone', 'comment').default('id'),
|
||||
//sort: validator.string().valid('id', 'name', 'phone', 'comment').default('id'),
|
||||
sort: validator.string().valid(...Object.keys(Prisma.AlertContactsScalarFieldEnum)).default('id'),
|
||||
order: validator.string().valid('asc', 'desc').default('asc'),
|
||||
search: validator.string().min(3).max(20), // TODO: Check if * or ** or *** -> Due to crashes..
|
||||
take: validator.number().min(1).max(512),
|
||||
skip: validator.number().min(0),
|
||||
// This regex ensures that the search string does not contain consecutive asterisks (**) and is at least 3 characters long.
|
||||
search: validator.string().min(3).max(20).regex(new RegExp('^(?!.*\\*{2,}).*$')),
|
||||
id: validator.number().positive().precision(0),
|
||||
count: validator.boolean()
|
||||
}).nand('id', 'search'); // Allow id or search. not both.
|
||||
|
||||
// MARK: CREATE alertContact
|
||||
const schema_post = validator.object({
|
||||
name: validator.string().min(1).max(32).required(),
|
||||
phone: validator.string().pattern(new RegExp('^\\+(\\d{1,3})\\s*(?:\\(\\s*(\\d{2,5})\\s*\\)|\\s*(\\d{2,5})\\s*)\\s*(\\d{5,15})$')).required(),
|
||||
comment: validator.string().max(64),
|
||||
})
|
||||
|
||||
// MARK: UPDATE alertContact
|
||||
const schema_patch = validator.object({
|
||||
sort: validator.string().valid('id', 'name', 'phone', 'comment').default('id'),
|
||||
order: validator.string().valid('asc', 'desc').default('asc'),
|
||||
search: validator.string().min(3).max(20), // TODO: Check if * or ** or *** -> Due to crashes..
|
||||
id: validator.number().positive().precision(0),
|
||||
count: validator.boolean()
|
||||
}).nand('id', 'search'); // Allow id or search. not both.
|
||||
id: validator.number().positive().precision(0).required(),
|
||||
name: validator.string().min(1).max(32),
|
||||
phone: validator.string().pattern(new RegExp('^\\+(\\d{1,3})\\s*(?:\\(\\s*(\\d{2,5})\\s*\\)|\\s*(\\d{2,5})\\s*)\\s*(\\d{5,15})$')),
|
||||
comment: validator.string().max(64),
|
||||
}).or('name', 'phone', 'comment')
|
||||
|
||||
// MARK: DELETE alertContact
|
||||
const schema_del = validator.object({
|
||||
sort: validator.string().valid('id', 'name', 'phone', 'comment').default('id'),
|
||||
order: validator.string().valid('asc', 'desc').default('asc'),
|
||||
search: validator.string().min(3).max(20), // TODO: Check if * or ** or *** -> Due to crashes..
|
||||
id: validator.number().positive().precision(0),
|
||||
count: validator.boolean()
|
||||
}).nand('id', 'search'); // Allow id or search. not both.
|
||||
id: validator.number().positive().precision(0).required()
|
||||
})
|
||||
|
||||
// Describe all schemas
|
||||
const schema_get_describe = schema_get.describe();
|
||||
const schema_post_describe = schema_post.describe();
|
||||
const schema_patch_describe = schema_patch.describe();
|
||||
const schema_del_describe = schema_del.describe();
|
||||
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_describe,
|
||||
POST: schema_post_describe,
|
||||
PATCH: schema_patch_describe,
|
||||
DELETE: schema_del_describe
|
||||
GET: schema_get_desc,
|
||||
POST: schema_post_desc,
|
||||
PATCH: schema_patch_desc,
|
||||
DELETE: schema_del_desc
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -12,16 +12,17 @@ import alertContactsRoute_schema from './alertContacts_schema.js';
|
||||
// Router base is '/api/v1'
|
||||
const Router = express.Router({ strict: false });
|
||||
|
||||
// All empty strings are null values.
|
||||
// All empty strings are null values (body)
|
||||
Router.use('*', function (req, res, next) {
|
||||
for (let key in req.body) {
|
||||
if (req.body[key] === '') {
|
||||
req.body[key] = null;
|
||||
req.body[key] = undefined;
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
|
||||
// All api routes lowercase! Yea I know but when strict: true it matters.
|
||||
Router.route('/alertcontacts').get(alertContactsRoute.get).post(alertContactsRoute.post).patch(alertContactsRoute.patch).delete(alertContactsRoute.del);
|
||||
Router.route('/alertcontacts/describe').get(alertContactsRoute_schema);
|
||||
|
7
src/routes/frontend/contact.ts
Normal file
7
src/routes/frontend/contact.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
|
||||
function get(req: Request, res: Response) {
|
||||
res.render("contacts")
|
||||
}
|
||||
|
||||
export default { get };
|
@ -1,11 +1,9 @@
|
||||
import express from 'express';
|
||||
|
||||
// Route imports
|
||||
// import skuRoute from './:id.js';
|
||||
// import skuRouteDash from './itemInfo.js'
|
||||
// import testRoute from './test.js';
|
||||
import dashboardRoute from './dashboard.js';
|
||||
import testRoute from './test.js';
|
||||
import contactRoute from './contact.js';
|
||||
// import itemsRoute from './items.js';
|
||||
// import manage_routes from './manage/index.js';
|
||||
|
||||
@ -22,5 +20,6 @@ const Router = express.Router({ strict: false });
|
||||
|
||||
Router.route('/').get(dashboardRoute.get);
|
||||
Router.route('/dbTest').get(testRoute.get);
|
||||
Router.route('/contact').get(contactRoute.get);
|
||||
|
||||
export default Router;
|
||||
|
@ -21,12 +21,18 @@ let _api = {
|
||||
const response = await fetch(_apiConfig.basePath + path, options);
|
||||
// Handle the response
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch:', response.statusText);
|
||||
_testPageFail(response.statusText);
|
||||
return;
|
||||
}
|
||||
const result = await response.json();
|
||||
// Handle the result, was json valid?
|
||||
if (!result) {
|
||||
// Is it a number instead?
|
||||
if (typeof result === 'number') {
|
||||
return result;
|
||||
}
|
||||
console.error('Invalid JSON response');
|
||||
_testPageFail('Invalid JSON response');
|
||||
return;
|
||||
}
|
||||
@ -54,17 +60,61 @@ let _api = {
|
||||
|
||||
return result;
|
||||
},
|
||||
getAsync: function (path, callback) {
|
||||
delete: async function (path, data) {
|
||||
const options = {
|
||||
headers: new Headers({ 'content-type': 'application/json' })
|
||||
method: 'DELETE',
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
body: JSON.stringify(data)
|
||||
};
|
||||
fetch(_apiConfig.basePath + path, options)
|
||||
.then((response) => response.json())
|
||||
.then((data) => callback(data))
|
||||
.catch((error) => _testPageFail(error));
|
||||
const response = await fetch(_apiConfig.basePath + path, options);
|
||||
// Handle the response
|
||||
if (!response.ok) {
|
||||
_testPageFail(response.statusText);
|
||||
return;
|
||||
}
|
||||
const result = await response.json();
|
||||
// Handle the result, was json valid?
|
||||
if (!result) {
|
||||
_testPageFail('Invalid JSON response');
|
||||
return;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
patch: async function (path, data) {
|
||||
const options = {
|
||||
method: 'PATCH',
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
body: JSON.stringify(data)
|
||||
};
|
||||
const response = await fetch(_apiConfig.basePath + path, options);
|
||||
// Handle the response
|
||||
if (!response.ok) {
|
||||
_testPageFail(response.statusText);
|
||||
return;
|
||||
}
|
||||
const result = await response.json();
|
||||
// Handle the result, was json valid?
|
||||
if (!result) {
|
||||
_testPageFail('Invalid JSON response');
|
||||
return;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
function updateRow(tableName, id, data) {
|
||||
invalidateCache(tableName);
|
||||
return _api.patch(`${tableName}`, { id: id, ...data });
|
||||
}
|
||||
|
||||
function deleteRow(tableName, id) {
|
||||
invalidateCache(tableName);
|
||||
return _api.delete(`${tableName}`, { id: id });
|
||||
}
|
||||
|
||||
function getApiDescriptionByTable(tableName) {
|
||||
const keyDesc = `desc:${tableName}`;
|
||||
const keyTime = `${keyDesc}:time`;
|
||||
@ -113,21 +163,37 @@ function getApiDescriptionByTable(tableName) {
|
||||
}
|
||||
}
|
||||
|
||||
function returnTableDataByTableName(tableName) {
|
||||
return _api.get(tableName);
|
||||
function returnTableDataByTableName(tableName, search="", orderBy="asc", sort="", take=-1, skip=0) {
|
||||
var orderBy = orderBy.toLowerCase();
|
||||
if(orderBy == "") {
|
||||
orderBy = "asc";
|
||||
}
|
||||
var baseString = tableName + "?order=" + orderBy;
|
||||
if(sort && sort.length > 0) {
|
||||
baseString += "&sort=" + sort;
|
||||
}
|
||||
if(take > 0) {
|
||||
baseString += "&take=" + take;
|
||||
}
|
||||
if(skip > 0) {
|
||||
baseString += "&skip=" + skip;
|
||||
}
|
||||
|
||||
if (search && search.length > 0) {
|
||||
return _api.get(baseString + '&search=' + search);
|
||||
} else {
|
||||
return _api.get(baseString);
|
||||
}
|
||||
}
|
||||
|
||||
function returnTableDataByTableNameWithSearch(tableName, search) {
|
||||
return _api.get(tableName + '?search=' + search);
|
||||
}
|
||||
|
||||
function returnTableDataByTableNameAsync(tableName, callback) {
|
||||
_api.getAsync(tableName, callback);
|
||||
}
|
||||
|
||||
async function getCountByTable(tableName) {
|
||||
async function getCountByTable(tableName, search="") {
|
||||
let baseString = tableName + '?count=true';
|
||||
if (search && search.length > 0) {
|
||||
baseString += '&search=' + search;
|
||||
}
|
||||
// Stored in `data:count:${tableName}`
|
||||
let result = await _api.get(tableName + '?count=true');
|
||||
let result = await _api.get(baseString);
|
||||
console.debug('Count result:', result);
|
||||
if (typeof result !== 'number') {
|
||||
_testPageWarn('Count was not a number, was: ' + result);
|
||||
console.warn('Count was not a number, was: ' + result);
|
||||
@ -136,10 +202,6 @@ async function getCountByTable(tableName) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function getRowsByTableAndColumnList(tableName, columnList) {
|
||||
//return _api.get(tableName + '/rows/' + columnList.join(','))
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function _testPageFail(reason) {
|
||||
document.getElementById('heroStatus').classList.remove('is-success');
|
||||
|
@ -26,45 +26,6 @@ var searchFields = document.querySelectorAll('input[data-searchTargetId]');
|
||||
// Find all modalForms
|
||||
var modalForms = document.querySelectorAll('form[data-targetTable]');
|
||||
|
||||
// Iterate over all tables
|
||||
tables.forEach(async (table) => {
|
||||
console.log('Table found: ', table);
|
||||
// Get THEAD and TBODY elements
|
||||
const thead = table.querySelector('thead');
|
||||
const tbody = table.querySelector('tbody');
|
||||
|
||||
// get index per column
|
||||
const columns = thead.querySelectorAll('th');
|
||||
const columnIndices = [];
|
||||
columns.forEach((column, index) => {
|
||||
columnIndices[column.getAttribute('data-dataCol')] = index;
|
||||
});
|
||||
|
||||
// All required cols
|
||||
let requiredCols = [];
|
||||
columns.forEach((column) => {
|
||||
requiredCols.push(column.getAttribute('data-dataCol'));
|
||||
});
|
||||
console.log('Required columns: ', requiredCols);
|
||||
|
||||
// Get data from API
|
||||
//let result = getRowsByTableAndColumnList(table.getAttribute("data-dataSource"), requiredCols);
|
||||
let result = await returnTableDataByTableName(table.getAttribute('data-dataSource'));
|
||||
// for (resultIndex in result) {
|
||||
// const row = result[resultIndex];
|
||||
// const tr = document.createElement("tr");
|
||||
// requiredCols.forEach(column => {
|
||||
// const td = document.createElement("td");
|
||||
// td.innerHTML = row[column];
|
||||
// tr.appendChild(td);
|
||||
// });
|
||||
// tbody.appendChild(tr);
|
||||
// }
|
||||
writeDataToTable(table, result);
|
||||
|
||||
console.log('Column indices: ', columnIndices);
|
||||
});
|
||||
|
||||
console.info('Processing single values');
|
||||
console.info(singleValues);
|
||||
|
||||
@ -73,6 +34,45 @@ singleValues.forEach(async (singleValue) => {
|
||||
writeSingelton(singleValue);
|
||||
});
|
||||
|
||||
// Iterate over all tables
|
||||
tables.forEach(async (table) => {
|
||||
// Get THs and attach onClick event to sort
|
||||
const ths = table.querySelectorAll('th');
|
||||
ths.forEach((th) => {
|
||||
if(th.getAttribute('fnc') == "actions") {
|
||||
return;
|
||||
}
|
||||
th.style.cursor = 'pointer';
|
||||
th.style.userSelect = 'none';
|
||||
th.addEventListener('click', async function () {
|
||||
const table = th.closest('table');
|
||||
const order = th.getAttribute('data-order');
|
||||
// Clear all other order attributes
|
||||
ths.forEach((th) => {
|
||||
if (th != this) {
|
||||
th.removeAttribute('data-order');
|
||||
th.classList.remove("bi-caret-up-fill")
|
||||
th.classList.remove("bi-caret-down-fill")
|
||||
}
|
||||
});
|
||||
if (order == 'ASC') {
|
||||
th.setAttribute('data-order', 'DESC');
|
||||
th.classList.add("bi-caret-down-fill")
|
||||
th.classList.remove("bi-caret-up-fill")
|
||||
} else {
|
||||
th.setAttribute('data-order', 'ASC');
|
||||
th.classList.add("bi-caret-up-fill")
|
||||
th.classList.remove("bi-caret-down-fill")
|
||||
}
|
||||
refreshTable(table);
|
||||
});
|
||||
});
|
||||
refreshTable(table);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
async function writeSingelton(element) {
|
||||
const table = element.getAttribute('data-dataSource');
|
||||
console.log('Table: ', table, ' Action: ', element.getAttribute('data-dataAction'), ' Element: ', element);
|
||||
@ -131,9 +131,6 @@ searchFields.forEach((searchField) => {
|
||||
const column = target.getAttribute('data-dataCol');
|
||||
const value = searchField.value;
|
||||
console.log('Searching for ', value, ' in ', table, ' column ', column);
|
||||
//const result = await returnTableDataByTableNameWithSearch(table, value);
|
||||
//clearTable(target);
|
||||
//writeDataToTable(target, result);
|
||||
refreshTableByName(table);
|
||||
}
|
||||
});
|
||||
@ -152,6 +149,8 @@ modalForms.forEach((modalForm) => {
|
||||
event.target.classList.remove('is-danger');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
getApiDescriptionByTable(modalForm.getAttribute('data-targetTable')).then((desc) => {
|
||||
console.log('Description: ', desc);
|
||||
const keys = desc['POST']['keys'];
|
||||
@ -220,9 +219,17 @@ modalForms.forEach((modalForm) => {
|
||||
jsonData[key] = value;
|
||||
});
|
||||
console.log('JSON Data: ', jsonData);
|
||||
let resp = await createEntry(table, jsonData);
|
||||
let resp = {};
|
||||
if(modalForm.getAttribute('data-action') == 'edit') {
|
||||
Rid = modalForm.getAttribute('data-rid');
|
||||
resp = await updateRow(table, Rid,jsonData);
|
||||
modalForm.setAttribute('data-action', 'create');
|
||||
} else {
|
||||
resp = await createEntry(table, jsonData);
|
||||
}
|
||||
|
||||
console.log('Response: ', resp);
|
||||
if (resp['status'] == 'CREATED') {
|
||||
if (resp['status'] == 'CREATED' || resp['status'] == 'UPDATED') {
|
||||
console.log('Entry created successfully');
|
||||
modalForm.closest('.modal').classList.remove('is-active');
|
||||
modalForm.reset();
|
||||
@ -246,9 +253,6 @@ modalForms.forEach((modalForm) => {
|
||||
// TODO: Show error message
|
||||
}
|
||||
|
||||
// const target = document.getElementById(table);
|
||||
// writeDataToTable(target, result);
|
||||
|
||||
// Find all tables with data-searchTargetId set to table
|
||||
setTimeout(() => {
|
||||
refreshTableByName(table);
|
||||
@ -261,19 +265,73 @@ modalForms.forEach((modalForm) => {
|
||||
async function refreshTable(table) {
|
||||
// Refresh a table while keeping (optionally set) search value
|
||||
const searchField = document.querySelector("input[data-searchTargetId='" + table.id + "']");
|
||||
if (searchField && searchField.value != '') {
|
||||
// Get state of order and sort
|
||||
const ths = table.querySelectorAll('th');
|
||||
const columnIndices = [];
|
||||
ths.forEach((th, index) => {
|
||||
columnIndices[th.getAttribute('data-dataCol')] = index;
|
||||
});
|
||||
let order = '';
|
||||
let column = '';
|
||||
ths.forEach((th) => {
|
||||
if (th.hasAttribute('data-order')) {
|
||||
order = th.getAttribute('data-order');
|
||||
column = th.getAttribute('data-dataCol');
|
||||
}
|
||||
});
|
||||
console.log('Order: ', order, ' Column: ', column);
|
||||
|
||||
const maxLinesPerPage = table.getAttribute('data-pageSize');
|
||||
let currentPage = table.getAttribute('data-currentPage');
|
||||
if(currentPage == null) {
|
||||
table.setAttribute('data-currentPage', 1);
|
||||
currentPage = 1;
|
||||
}
|
||||
|
||||
const start = (currentPage - 1) * maxLinesPerPage;
|
||||
const end = start + maxLinesPerPage;
|
||||
|
||||
let paginationPassOnPre = {
|
||||
'start': start,
|
||||
'end': end,
|
||||
'currentPage': currentPage,
|
||||
'maxLinesPerPage': maxLinesPerPage
|
||||
};
|
||||
|
||||
if (searchField) {
|
||||
const value = searchField.value;
|
||||
const dbTable = table.getAttribute('data-dataSource');
|
||||
const result = await returnTableDataByTableNameWithSearch(dbTable, value);
|
||||
const result = await returnTableDataByTableName(dbTable, value, order, column, take= maxLinesPerPage, skip= start);
|
||||
const totalResultCount = await getCountByTable(dbTable, value);
|
||||
paginationPassOnPre['dataLength'] = totalResultCount;
|
||||
var magMiddl = managePaginationMiddleware(result, paginationPassOnPre);
|
||||
var data = magMiddl[0];
|
||||
var paginationPassOn = magMiddl[1];
|
||||
clearTable(table);
|
||||
writeDataToTable(table, result);
|
||||
writeDataToTable(table, data, paginationPassOn);
|
||||
} else {
|
||||
const result = await returnTableDataByTableName(table.getAttribute('data-dataSource'));
|
||||
const result = await returnTableDataByTableName(table.getAttribute('data-dataSource'), undefined, order, column, take= maxLinesPerPage, skip= start);
|
||||
const resultCount = await getCountByTable(table.getAttribute('data-dataSource'));
|
||||
paginationPassOnPre['dataLength'] = resultCount;
|
||||
var magMiddl = managePaginationMiddleware(result, paginationPassOnPre);
|
||||
var data = magMiddl[0];
|
||||
var paginationPassOn = magMiddl[1];
|
||||
clearTable(table);
|
||||
writeDataToTable(table, result);
|
||||
writeDataToTable(table, data, paginationPassOn);
|
||||
}
|
||||
}
|
||||
|
||||
function managePaginationMiddleware(data, paginationPassOnPre) {
|
||||
const maxLinesPerPage = paginationPassOnPre['maxLinesPerPage'];
|
||||
|
||||
const dataLength = paginationPassOnPre['dataLength'];
|
||||
const maxPages = Math.ceil(dataLength / maxLinesPerPage);
|
||||
paginationPassOn = paginationPassOnPre;
|
||||
paginationPassOn['maxPages'] = maxPages;
|
||||
// paginationPassOn['dataLength'] = dataLength;
|
||||
return [data, paginationPassOn];
|
||||
}
|
||||
|
||||
async function refreshTableByName(name) {
|
||||
const dirtyTables = document.querySelectorAll("table[data-dataSource='" + name + "']");
|
||||
for (dirty of dirtyTables) {
|
||||
@ -293,7 +351,10 @@ function clearTable(table) {
|
||||
tbody.innerHTML = '';
|
||||
}
|
||||
|
||||
function writeDataToTable(table, data) {
|
||||
function writeDataToTable(table, data, paginationPassOn) {
|
||||
if(data == undefined || data == null || data.length == 0) {
|
||||
return;
|
||||
}
|
||||
console.log('Writing data to table: ', table, data);
|
||||
// Get THEAD and TBODY elements
|
||||
const thead = table.querySelector('thead');
|
||||
@ -308,22 +369,212 @@ function writeDataToTable(table, data) {
|
||||
|
||||
// All required cols
|
||||
let requiredCols = [];
|
||||
let actionFields = [];
|
||||
columns.forEach((column) => {
|
||||
// console.log('Column: ', column, ' FNC: ', column.getAttribute('data-fnc'), column.attributes);
|
||||
if(column.getAttribute('data-fnc') == "actions") {
|
||||
console.log('!!! Found actions column !!!');
|
||||
actionFields.push(column);
|
||||
return;
|
||||
}
|
||||
requiredCols.push(column.getAttribute('data-dataCol'));
|
||||
});
|
||||
|
||||
|
||||
// Get paginationPassOn
|
||||
const start = paginationPassOn['start'];
|
||||
const end = paginationPassOn['end'];
|
||||
const currentPage = paginationPassOn['currentPage'];
|
||||
const maxLinesPerPage = paginationPassOn['maxLinesPerPage'];
|
||||
const maxPages = paginationPassOn['maxPages'];
|
||||
const dataLength = paginationPassOn['dataLength'];
|
||||
|
||||
|
||||
// Find nav with class pagination and data-targetTable="table.id"
|
||||
const paginationElement = document.querySelector("nav.pagination[data-targetTable='" + table.id + "']");
|
||||
const paginationList = paginationElement.querySelector('ul.pagination-list');
|
||||
console.log('Data length: ', dataLength, ' Max pages: ', maxPages);
|
||||
|
||||
if(maxPages > 1) {
|
||||
// Clear pagination list
|
||||
paginationList.innerHTML = '';
|
||||
|
||||
for (let i = 1; i <= maxPages; i++) {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = '<a class="pagination-link" aria-label="Goto page ' + i + '" data-page="' + i + '">' + i + '</a>';
|
||||
if(i == currentPage) {
|
||||
li.querySelector('a').classList.add('is-current');
|
||||
}
|
||||
paginationList.appendChild(li);
|
||||
}
|
||||
|
||||
|
||||
// Remove unused pages, only leave first, last, current and 2 neighbors
|
||||
let pages = paginationList.querySelectorAll('li');
|
||||
let friends = []
|
||||
// Always add first and last
|
||||
friends.push(0);
|
||||
friends.push(pages.length - 1);
|
||||
friends.push(currentPage-1);
|
||||
|
||||
// Add direct neighbors
|
||||
// friends.push(currentPage - 2);
|
||||
friends.push(currentPage);
|
||||
friends.push(currentPage - 2);
|
||||
|
||||
|
||||
// Deduplicate friends
|
||||
friends = [...new Set(friends)];
|
||||
// Sort friends
|
||||
friends.sort((a, b) => a - b);
|
||||
// Parse friends (string to int)
|
||||
friends = friends.map((x) => parseInt(x));
|
||||
|
||||
console.log('Friends: ', friends, ' Pages: ', pages.length, ' Current: ', currentPage);
|
||||
|
||||
|
||||
// Remove everyone who is not a friend
|
||||
for(let i = 0; i < pages.length; i++) {
|
||||
if(friends.includes(i)) {
|
||||
continue;
|
||||
}
|
||||
pages[i].remove();
|
||||
}
|
||||
|
||||
// Find all gaps (step size bigger then 1) and add an ellipsis in between the two numbers
|
||||
let last = 0;
|
||||
for(let i = 0; i < friends.length; i++) {
|
||||
if(friends[i] - last > 1) {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = '<span class="pagination-ellipsis">…</span>';
|
||||
paginationList.insertBefore(li, pages[friends[i]]);
|
||||
}
|
||||
last = friends[i];
|
||||
}
|
||||
|
||||
|
||||
// Append on click event to all pagination links
|
||||
paginationList.querySelectorAll('a').forEach((link) => {
|
||||
link.addEventListener('click', async function() {
|
||||
const page = link.getAttribute('data-page');
|
||||
table.setAttribute('data-currentPage', page);
|
||||
refreshTable(table);
|
||||
});
|
||||
});
|
||||
|
||||
paginationElement.classList.remove('is-hidden');
|
||||
} else {
|
||||
paginationElement.classList.add('is-hidden');
|
||||
}
|
||||
|
||||
|
||||
for (resultIndex in data) {
|
||||
const row = data[resultIndex];
|
||||
const tr = document.createElement('tr');
|
||||
requiredCols.forEach((column) => {
|
||||
const td = document.createElement('td');
|
||||
td.innerHTML = row[column];
|
||||
td.innerText = row[column];
|
||||
tr.appendChild(td);
|
||||
});
|
||||
|
||||
// Add action fields
|
||||
actionFields.forEach((actionField) => {
|
||||
const td = document.createElement('td');
|
||||
const actions = actionField.getAttribute('data-actions').split(',');
|
||||
actions.forEach((action) => {
|
||||
const button = document.createElement('button');
|
||||
let icon = '';
|
||||
let color = 'is-primary';
|
||||
switch(action) {
|
||||
case 'edit': {
|
||||
icon = '<i class="bi bi-pencil"></i>';
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
icon = '<i class="bi bi-trash"></i>';
|
||||
color = 'is-danger';
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Add classes
|
||||
button.classList.add('button');
|
||||
button.classList.add('is-small');
|
||||
button.classList.add(color);
|
||||
button.classList.add('is-outlined');
|
||||
button.innerHTML = ` <span class="icon is-small">${icon}</span> `;
|
||||
button.style.marginRight = '5px';
|
||||
|
||||
// Add data-action and data-id
|
||||
button.setAttribute('data-action', action);
|
||||
button.setAttribute("data-id", row["id"]);
|
||||
|
||||
// Add event listener
|
||||
button.addEventListener('click', async function() {
|
||||
const table = actionField.closest('table');
|
||||
const row = button.closest('tr');
|
||||
const columns = table.querySelectorAll('th');
|
||||
const columnIndices = [];
|
||||
columns.forEach((column, index) => {
|
||||
columnIndices[column.getAttribute('data-dataCol')] = index;
|
||||
});
|
||||
const data = [];
|
||||
columns.forEach((column) => {
|
||||
data[column.getAttribute('data-dataCol')] = row.children[columnIndices[column.getAttribute('data-dataCol')]].innerText;
|
||||
});
|
||||
console.log('Data: ', data);
|
||||
switch(action) {
|
||||
case 'edit': {
|
||||
// Open modal with form
|
||||
const form = document.querySelector("form[data-targetTable='" + table.getAttribute('data-dataSource') + "']");
|
||||
const formTitle = form.querySelector('.title');
|
||||
const entryPhase = form.querySelector('.entryPhase');
|
||||
const loadPhase = form.querySelector('.loadPhase');
|
||||
const fields = form.querySelectorAll('input');
|
||||
// Set modal to edit mode
|
||||
form.setAttribute('data-action', 'edit');
|
||||
form.setAttribute('data-rid', button.getAttribute('data-id'));
|
||||
formTitle.innerText = 'Edit entry';
|
||||
fields.forEach((field) => {
|
||||
// Skip for submit button
|
||||
if(field.getAttribute('type') == 'submit') {
|
||||
return;
|
||||
}
|
||||
field.value = data[field.getAttribute('name')];
|
||||
});
|
||||
form.closest('.modal').classList.add('is-active');
|
||||
// TBD
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
// confirm
|
||||
const confirm = window.confirm('Do you really want to delete this entry?');
|
||||
// Delete entry
|
||||
if(confirm) {
|
||||
const table = actionField.closest('table');
|
||||
const id = button.getAttribute('data-id');
|
||||
const resp = await deleteRow(table.getAttribute('data-dataSource'), id);
|
||||
if(resp['status'] == 'DELETED') {
|
||||
refreshTable(table);
|
||||
updateSingeltonsByTableName(table.getAttribute('data-dataSource'));
|
||||
} else {
|
||||
// Show error message
|
||||
// TODO: Show error message
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
td.appendChild(button);
|
||||
});
|
||||
tr.appendChild(td);
|
||||
});
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Handle modal
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Functions to open and close a modal
|
||||
|
@ -96,7 +96,7 @@
|
||||
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
"alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
|
119
views/contacts.eta
Normal file
119
views/contacts.eta
Normal file
@ -0,0 +1,119 @@
|
||||
<%~ include("partials/base_head.eta", {"title": "Kontakte"}) %>
|
||||
<%~ include("partials/nav.eta") %>
|
||||
|
||||
<section class="hero is-primary" id="heroStatus">
|
||||
<div class="hero-body">
|
||||
<p class="title" data-tK="start-hero-header-welcome">Kontaktverwaltung</p>
|
||||
<p class="subtitle" data-tK="start-hero-header-subtitle-default" id="heroExplainer">Erklärungstext</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<nav class="level">
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="heading">Kontakte</p>
|
||||
<p class="title"><span data-dataSource="AlertContacts" data-dataAction="COUNT" class="is-skeleton">Load.</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="heading"><button class="js-modal-trigger button" data-target="modal-js-example">
|
||||
Neuen Konakt anlegen
|
||||
</button></p>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- TODO: Mark required fields as required; add handling for validation -->
|
||||
<div id="modal-js-example" class="modal">
|
||||
<div class="modal-background"></div>
|
||||
|
||||
<div class="modal-content">
|
||||
<div class="box entryPhase is-hidden">
|
||||
<h2 class="title">Neuer Kontakt</h1>
|
||||
|
||||
<i class="bi bi-arrow-clockwise title"></i>
|
||||
</div>
|
||||
<div class="box entryPhase">
|
||||
|
||||
<form data-targetTable="AlertContacts">
|
||||
<h2 class="title">Neuer Kontakt</h1>
|
||||
<div class="field">
|
||||
<label class="label">Name</label>
|
||||
<div class="control has-icons-left">
|
||||
<input class="input" type="text" placeholder="John Doe" 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">Telefonummer</label>
|
||||
<div class="control has-icons-left">
|
||||
<input class="input" type="text" placeholder="+49 1234 5678912" value="" name="phone">
|
||||
<span class="icon is-small is-left">
|
||||
<i class="bi bi-telephone-fill"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Anmerkung</label>
|
||||
<div class="control has-icons-left">
|
||||
<input class="input" type="text" placeholder="" value="" name="comment">
|
||||
<span class="icon is-small is-left">
|
||||
<i class="bi bi-chat-fill"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<input type="submit" class="button is-link" value="Save" data-actionBtn="save">
|
||||
</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>
|
||||
|
||||
|
||||
|
||||
|
||||
<section class="section">
|
||||
<h1 class="title" data-tK="start-recent-header">Kontaktübersicht</h1>
|
||||
<input class="input" type="text" data-searchTargetId="contactTable" placeholder="Search..." />
|
||||
<table class="table is-striped is-fullwidth is-hoverable" data-dataSource="AlertContacts" id="contactTable" data-pageSize="5">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-dataCol = "id"><abbr title="Position">Pos</abbr></th>
|
||||
<th data-dataCol = "name">Name</th>
|
||||
<th data-dataCol = "phone">Telefonnummer</th>
|
||||
<th data-dataCol = "comment">Kommentar</th>
|
||||
<th data-fnc="actions" data-actions="edit,delete">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
<nav class="pagination is-hidden" role="navigation" aria-label="pagination" data-targetTable="contactTable">
|
||||
<ul class="pagination-list">
|
||||
|
||||
</ul>
|
||||
</nav>
|
||||
</section>
|
||||
|
||||
<%~ include("partials/footer.eta") %>
|
||||
<%~ include("partials/base_foot.eta") %>
|
@ -20,6 +20,7 @@
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="/">Home</a>
|
||||
<a class="navbar-item" href="/dbTest">API Integration <span class="tag is-info">Dev</span></a>
|
||||
<a class="navbar-item" href="/contact">Kontakte <span class="tag is-primary">Neu!</span></a>
|
||||
<!--<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link">More</a>
|
||||
<div class="navbar-dropdown">
|
||||
@ -32,7 +33,7 @@
|
||||
</div>-->
|
||||
</div>
|
||||
|
||||
<!--<div class="navbar-end">
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item">
|
||||
<div class="buttons">
|
||||
<a class="button is-primary">
|
||||
@ -41,6 +42,6 @@
|
||||
<a class="button is-light">Log in</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>-->
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
@ -32,6 +32,34 @@
|
||||
</section>
|
||||
|
||||
|
||||
<section class="section">
|
||||
<h1 class="title" data-tK="start-sysinfo-header">File test</h1>
|
||||
<form method="put" action="/api/upload" enctype="multipart/form-data" id="uploadForm">
|
||||
<div class="field">
|
||||
<label class="label">File</label>
|
||||
<div class="control">
|
||||
<input class="input" type="file" name="file" id="file">
|
||||
</div>
|
||||
</div>
|
||||
<!-- URL field -->
|
||||
<div class="field">
|
||||
<label class="label">URL</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="url" id="url">
|
||||
</div>
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<input type="submit" class="button is-link" value="Upload" data-actionBtn="upload">
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById("url").addEventListener("input", function() {
|
||||
document.getElementById("uploadForm").action = document.getElementById("url").value;
|
||||
});
|
||||
</script>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- TODO: Mark required fields as required; add handling for validation -->
|
||||
<div id="modal-js-example" class="modal">
|
||||
<div class="modal-background"></div>
|
||||
@ -43,8 +71,9 @@
|
||||
<i class="bi bi-arrow-clockwise title"></i>
|
||||
</div>
|
||||
<div class="box entryPhase">
|
||||
<h2 class="title">New Contact</h1>
|
||||
|
||||
<form data-targetTable="AlertContacts">
|
||||
<h2 class="title">New Contact</h1>
|
||||
<div class="field">
|
||||
<label class="label">Name</label>
|
||||
<div class="control has-icons-left">
|
||||
@ -101,18 +130,25 @@
|
||||
<section class="section">
|
||||
<h1 class="title" data-tK="start-recent-header">Alarm Kontakte</h1>
|
||||
<input class="input" type="text" data-searchTargetId="contactTable" placeholder="Search..." />
|
||||
<table class="table is-striped is-fullwidth" data-dataSource="AlertContacts" id="contactTable">
|
||||
<table class="table is-striped is-fullwidth is-hoverable" data-dataSource="AlertContacts" id="contactTable" data-pageSize="5">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-dataCol = "id"><abbr title="Position">Pos</abbr></th>
|
||||
<th data-dataCol = "name">Name</th>
|
||||
<th data-dataCol = "phone">Telefon Nummer</th>
|
||||
<th data-dataCol = "comment">Kommentar</th>
|
||||
<th data-fnc="actions" data-actions="edit,delete">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
<nav class="pagination is-hidden" role="navigation" aria-label="pagination" data-targetTable="contactTable">
|
||||
<ul class="pagination-list">
|
||||
|
||||
</ul>
|
||||
</nav>
|
||||
</section>
|
||||
|
||||
<%~ include("partials/footer.eta") %>
|
||||
|
Reference in New Issue
Block a user