Compare commits

...

9 Commits

20 changed files with 966 additions and 32760 deletions

View File

@ -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

File diff suppressed because one or more lines are too long

View File

@ -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

3704
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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,7 +29,7 @@ enum alertType {
}
enum alertState {
incomming // Incomming alerts
incoming // Incoming alerts
running // Started calling
failed // Failed to get acknowledgement of any alertContacts
acknowledged // Some user acknowledged alert
@ -55,16 +38,19 @@ enum alertState {
model alerts {
id Int @id @unique @default(autoincrement())
type alertType
message String?
state alertState
description String?
date DateTime
actionplan actionPlan? @relation(fields: [actionplanId], references: [id])
actionplanId Int?
date DateTime
state alertState
acknowledged_by alertContacts[]
acknowledged_at DateTime?
@@fulltext([message])
}
@@fulltext([description])
}
model alertContacts {
id Int @id @unique @default(autoincrement())
@ -73,17 +59,20 @@ model alertContacts {
comment String?
prios priorities[]
alerts alerts[]
@@fulltext([name, phone, comment])
}
model actionPlan {
id Int @id @unique @default(autoincrement())
name String
name String @unique
comment String?
alert_hook String @unique
alerthook String @unique @default(ulid())
prio priorities[]
content content[] // aka. all voice files
alerts alerts[]
@@fulltext([name, comment])
}
@ -99,17 +88,15 @@ model priorities {
}
model content {
id Int @id @unique @default(autoincrement())
//id Int @id @unique @default(autoincrement())
s3_key String @id @unique
name String @unique
type contentType
name String
filename String
actionplan actionPlan[]
@@fulltext([name, filename])
@@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

View File

@ -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;

View File

@ -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
View 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;

View File

@ -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,28 +21,26 @@ import routes from './routes/index.js';
import fs from 'node:fs';
log.core.trace("Running from path: " + __path);
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 {
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';
}
@ -49,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';
}
@ -61,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());
@ -90,13 +101,10 @@ 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) => {
@ -109,4 +117,3 @@ function buildEtaEngine() {
}
};
}

View File

@ -1,9 +1,11 @@
import { Request, Response } from 'express';
import validator from 'joi'; // DOCS: https://joi.dev/api
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'),
take: validator.number().min(1).max(512),
skip: validator.number().min(0),

View File

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

View File

@ -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;

View File

@ -59,8 +59,61 @@ let _api = {
}
return result;
}
},
delete: async function (path, data) {
const options = {
method: 'DELETE',
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;
},
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}`;

View File

@ -39,6 +39,9 @@ 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 () {
@ -216,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();
@ -358,10 +369,18 @@ function writeDataToTable(table, data, paginationPassOn) {
// 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'];
@ -457,6 +476,100 @@ function writeDataToTable(table, data, paginationPassOn) {
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);
}
}

View File

@ -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
View 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") %>

View File

@ -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>

View File

@ -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">
@ -108,6 +137,7 @@
<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>