75 Commits
Author SHA1 Message Date
TorstenHettstedt 12ed91a6da Merge pull request 'womtfix/negative-werte' (#12) from womtfix/negative-werte into master
Reviewed-on: #12
2021-04-15 12:25:33 +02:00
Torstem_JetBrain cc9941d1d3 Negative Werte werden deutlich dargestellt. 2021-04-15 12:23:22 +02:00
Torstem_JetBrain 2b2753e009 Die Darstellung von negativen Werten erfolgt richtig. 2021-04-15 11:34:50 +02:00
Torstem_JetBrain 735b195c06 Die Tests hatten nicht die richtige Darstellung von negativen Werten getestet. 2021-04-15 11:34:04 +02:00
Torstem_JetBrain 0bd8932fa1 Es wurde sich auf Functions-Tests verlassen. 2021-04-15 11:28:47 +02:00
TorstenHettstedt 114e31029c Merge pull request 'feature/css-datei' (#11) from feature/css-datei into master
Reviewed-on: #11
2021-04-15 10:55:54 +02:00
Torstem_JetBrain de0535be32 Bereinigung von Altlasten, da die Texte von woanders übernommen wurden. 2021-04-14 17:23:31 +02:00
Torstem_JetBrain b74e9bbd69 Es wurde an die Darstellung auf einen iPhone bzw. ähnlichen Geräten angepasst. 2021-04-14 17:10:01 +02:00
Torstem_JetBrain 338c3ce5e4 Die Definition der Gestaltung wird durch LESS übernommen. Alle Geräte bis zum iPad und größer werden beachtet. 2021-04-14 14:13:12 +02:00
Torstem_JetBrain 1b4983a06f Definitionen erweitert, um die Gestaltung besser zu ermöglichen. 2021-04-14 14:11:20 +02:00
Torstem_JetBrain 74193f3b46 Die Abfragen werden erfolgreich vom Server verarbeitet. 2021-04-13 15:18:51 +02:00
Torstem_JetBrain 2e0e6db55a Die Tasten werden sonst als 'Submit'-Tasten erkannt. 2021-04-13 15:18:06 +02:00
Torstem_JetBrain c528944746 Das Pattern wird im 'Safari' nicht als solches erkannt.
Es wurde erkannt als `\d4-\d2-\d2`
2021-04-13 15:16:29 +02:00
TorstenHettstedt 05d46ec407 Merge pull request 'Die ausgegebene Reihenfolge ist von der Reihenfolge in der Datenbank unabhängig.' (#9) from bugfix/sortierung into master
Reviewed-on: #9
2021-04-13 13:02:09 +02:00
Torstem_JetBrain 7b48e7e9e2 Die ausgegebene Reihenfolge ist von der Reihenfolge in der Datenbank unabhängig. 2021-04-13 13:00:58 +02:00
TorstenHettstedt 343f12b605 Merge pull request 'bugfixes/cors-problem' (#7) from bugfixes/cors-problem into master
Reviewed-on: #7
2021-04-13 12:44:21 +02:00
Torstem_JetBrain 6203f98e93 Alle notwendigen Header werden mitgeliefert. 2021-04-13 12:43:31 +02:00
Torstem_JetBrain 87f3bb3f0e Es sollte auf die notwendigen Header geachtet werden (https://developer.mozilla.org/de/docs/Web/HTTP/CORS) 2021-04-13 12:34:25 +02:00
Torstem_JetBrain 8d00e0c104 zu viele Zeilen 2021-04-13 12:29:27 +02:00
Torstem_JetBrain 3fe73f3b10 Preflight- und CORS-Abfragen werden verarbeitet. 2021-04-12 18:47:48 +02:00
Torstem_JetBrain b5af0c5a54 Vorbereitung für Preflight- und CORS-Abfragen. 2021-04-12 18:46:37 +02:00
Torstem_JetBrain e29a6dcab9 Der Regex ist nun vollständig. 2021-04-12 18:45:16 +02:00
Torstem_JetBrain 8ee9666250 Die Tests wurden um die Preflight-Tests erweitert. 2021-04-12 18:26:57 +02:00
Torstem_JetBrain edcb80421b Der Browser sendet auch das Protokoll mit. 2021-04-12 18:26:12 +02:00
Torstem_JetBrain 16e05ce819 Die API-Tests wurden um eine Preflight-Überprüfung erweitert, um CORS-Probleme zu vermindern. 2021-04-12 15:56:35 +02:00
TorstenHettstedt 5c15580738 Merge pull request 'js-rest-aktionen' (#6) from js-rest-aktionen into master
Reviewed-on: #6
2021-04-12 14:11:31 +02:00
Torstem_JetBrain 0a5e40d7cb Es gibt ein Objekt 'Controller', dass die Funktionen für die Tasten-Aktionen sammelt.
Es gibt nun eine Initialisierung-Funktion.
Wenn eine Bearbeitung abgebrochen wird, wird nicht jedes mal die Liste neu geladen.
2021-04-12 14:11:00 +02:00
Torstem_JetBrain ae7491f002 Die Eintragungen wurden wo möglich mit den Defaultwerten gesetzt. Ging aber nur bei der Angabe der Zeit. 2021-04-12 12:29:00 +02:00
Torstem_JetBrain 8296a2a281 Die Test-Objekte sind nicht mehr notwendig. 2021-04-10 15:42:21 +02:00
Torstem_JetBrain c711e0ae24 Es ist möglich einen neuen Eintrag zu generieren. 2021-04-10 11:26:34 +02:00
Torstem_JetBrain 74fd96cf25 Ein Eintrag kann verändert werden. 2021-04-09 21:56:08 +02:00
Torstem_JetBrain 9686a8505e Das löschen eines Eintrages wird nicht unterstützt. 2021-04-09 21:53:52 +02:00
Torstem_JetBrain bc83fc8dd1 Die Einträge werden aufgelistet und angezeigt. 2021-04-09 21:19:19 +02:00
Torstem_JetBrain 9d5af9a0fb Die Daten für die Ansichten werden von einer API abgefragt. 2021-04-09 18:20:47 +02:00
Torstem_JetBrain 7f8a50799f Die Web-App kann per *dotEnv* konfiguriert werden. 2021-04-09 18:19:42 +02:00
TorstenHettstedt 365c2f041c Merge pull request 'html-sites' (#3) from html-sites into master
Reviewed-on: #3
2021-04-08 20:15:59 +02:00
Torstem_JetBrain 89ad394773 Die gewünschte HTML-Struktur wurde übernommen. 2021-04-08 19:09:44 +02:00
Torstem_JetBrain 881e7362f8 Großschreibung ist gefordert. 2021-04-08 19:08:50 +02:00
Torstem_JetBrain c8d9bc040e Die HTML-Dateien für die Anzeige und Bearbeitung der Daten sind vorhanden. 2021-04-08 18:12:05 +02:00
Torstem_JetBrain e3ef53b119 Übernahme der Namen aus der OpenAPI-Datei. 2021-04-08 13:29:31 +02:00
Torstem_JetBrain f78fdfbbd5 Definition der Beispieldaten für jede Liste. 2021-04-08 13:20:08 +02:00
Torstem_JetBrain 02771828c9 Das Fav-Icon wurde hinzugefügt. 2021-04-08 12:49:54 +02:00
Torstem_JetBrain 5a88ff3c6f Die Links müssen bleiben. 2021-04-08 12:44:26 +02:00
Torstem_JetBrain f36324bfbd Die grundlegenden HTML-Dateien sind vorhanden. 2021-04-08 12:30:36 +02:00
Torstem_JetBrain 9b99c6a4dc Für die Svelte-Oberfläche sind alle notwendigen Dateien vorhanden. 2021-04-08 12:28:26 +02:00
Torstem_JetBrain fe3a4f69bf Die Struktur der Oberfläche für die Eintragungen wurde skizziert. 2021-04-07 21:28:10 +02:00
TorstenHettstedt 31a3f4cc01 Merge pull request 'api-erstellen' (#1) from api-erstellen into master
Reviewed-on: #1
2021-04-07 13:00:46 +02:00
Torstem_JetBrain 0a59cb60db Die Gemeinsamkeiten der Controller wurde in eine abstrakte Klasse gepackt. 2021-04-07 12:59:51 +02:00
Torstem_JetBrain 9430f3c74c Eine fehlende Datenbank löst schon in der Erstellung des Objektes einen Fehler aus.
Es wurden noch kleinere Fehler beseitigt.
2021-04-07 12:45:27 +02:00
Torstem_JetBrain 2b31e17e14 Eine fehlende Datenbank löst schon in der Erstellung des Objektes einen Fehler aus. 2021-04-07 12:29:46 +02:00
Torstem_JetBrain 9e35ab3b07 Eine fehlende Datenbank sollt schon in der Erstellung des Objektes einen Fehler auslösen. 2021-04-07 12:20:14 +02:00
Torstem_JetBrain 056bf82c7b Die Eigenschaft wird nicht genutzt. 2021-04-07 12:16:47 +02:00
Torstem_JetBrain be54a659c9 Das Logger-Objekt wird in einer speziellen Methode überprüft. 2021-04-07 12:15:00 +02:00
Torstem_JetBrain 8086b86eb2 Bessere Name. 2021-04-07 12:11:01 +02:00
Torstem_JetBrain 3e2865dbe7 Der Fehler kann abgefangen werden, ähnlich read() 2021-04-07 12:08:37 +02:00
Torstem_JetBrain 60eb1f92e8 Der Container wird nur bei der Initialisierung benötigt. 2021-04-07 12:07:55 +02:00
Torstem_JetBrain 9a542c9108 Die Definition der Typen war nicht ausreichend. 2021-04-07 11:58:15 +02:00
Torstem_JetBrain a035ea9648 Kann weg. 2021-04-07 11:55:38 +02:00
Torstem_JetBrain 46f3a6491d Diese Datei hat nichts im Repo verloren 2021-04-07 11:50:35 +02:00
Torstem_JetBrain e070db18b5 Test erweitert um die Coverage zu erhöhen. 2021-04-06 23:21:44 +02:00
Torstem_JetBrain a6675605e4 Kleine Code-Verbesserungen. 2021-04-06 21:33:33 +02:00
Torstem_JetBrain 520db45d6d Vereinfachung der 'index.php' und Entfernung nicht benötigter Klassen. 2021-04-06 21:26:35 +02:00
Torstem_JetBrain 2d3a1074f6 Die Error-Middleware wurde in eine Klasse verpackt. 2021-04-06 21:21:47 +02:00
Torstem_JetBrain a1e8b1aa51 Es wird die geforderte Error-JSON benutzt. 2021-04-06 21:02:29 +02:00
Torstem_JetBrain f25c9d8994 FIXING: Die Filter sind in *Codception* nicht definiert. 2021-04-06 21:00:01 +02:00
Torstem_JetBrain 3854737622 Die Abfragen erzeugen die richtigen HTTP-Codes. Ist die Anfrage richtig, sind auch die Rückgaben richtig. 2021-04-06 20:01:15 +02:00
Torstem_JetBrain d16a4e427d Copy & Past Fehler. 2021-04-06 19:57:29 +02:00
Torstem_JetBrain 4871ff14dc Die Namen der Eigenschaften waren nicht perfekt. 2021-04-06 19:56:41 +02:00
Torstem_JetBrain ac99e079ee Für die API-Tests wird eine andere Datenbank-Verbindung benötigt. Sonst werden die vorhandenen Daten verändert. 2021-04-06 16:52:51 +02:00
Torstem_JetBrain 09ffadc596 Die Anzeige der Ansicht-Tabellen ist erfolgreich. 2021-04-06 16:28:04 +02:00
Torstem_JetBrain 4da5c1242c Die Verbindung zur Datenbank ist eingebaut. 2021-04-06 16:26:43 +02:00
Torstem_JetBrain e0b8deae8b Es war nicht ganz klar, wir der Aufbau für *Regexe* war und das die Stunden deutlich größer als 60 sein können. 2021-04-06 16:23:30 +02:00
Torstem_JetBrain d3df8bdc7e Tests für die API sind vorhanden. 2021-04-06 13:49:42 +02:00
Torstem_JetBrain b53b1cdd3c FIXING: Falsches Format für die Eingabe der Arbeitszeit. 2021-04-06 13:13:35 +02:00
Torstem_JetBrain 0054ab5afc Die UI-Dateien für Swagger werden doch in das Repo aufgenommen. 2021-04-06 11:44:25 +02:00
80 changed files with 3344 additions and 73 deletions
+10
View File
@@ -0,0 +1,10 @@
# http://editorconfig.org
root = true
[*]
indent_size = 4
indent_style = space
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = false
+1 -1
View File
@@ -105,4 +105,4 @@ Temporary Items
/api/vendor/ /api/vendor/
/api/tests/_* /api/tests/_*
/api/tests/*.suite.yml /api/tests/*.suite.yml
/api/html/swagger/ /api/.env
+8
View File
@@ -0,0 +1,8 @@
## Datenbank-Einstellungen
DATABASES_HOST=psql.domain.tld
DATABASES_NAME=db
DATABASES_USER=user
DATABASES_PASS=1234
## CORS Einstellungen
CORS_ALLOW_ORIGIN_REGEX=/https?:\/\/localhost:\d+/
+3
View File
@@ -1,5 +1,8 @@
FROM php:8.0-apache FROM php:8.0-apache
RUN apt update && apt install -y libpq-dev
RUN docker-php-ext-install -j$(nproc) pdo pdo_pgsql
WORKDIR /var/www WORKDIR /var/www
RUN a2enmod rewrite RUN a2enmod rewrite
+6 -2
View File
@@ -19,6 +19,7 @@
"slim/slim": "^4.7.1", "slim/slim": "^4.7.1",
"vlucas/phpdotenv": "^4.2", "vlucas/phpdotenv": "^4.2",
"slim/psr7": "^1.3", "slim/psr7": "^1.3",
"php-di/slim-bridge": "^3.1.0",
"jetbrains/phpstorm-attributes": "^1.0.0", "jetbrains/phpstorm-attributes": "^1.0.0",
"myclabs/php-enum": "^1.8.0" "myclabs/php-enum": "^1.8.0"
}, },
@@ -27,7 +28,8 @@
"codeception/codeception": "^4.1.18", "codeception/codeception": "^4.1.18",
"codeception/module-phpbrowser": "^1.0.0", "codeception/module-phpbrowser": "^1.0.0",
"codeception/module-asserts": "^1.0.0", "codeception/module-asserts": "^1.0.0",
"codeception/module-db": "^1.1.0" "codeception/module-db": "^1.1.0",
"codeception/module-rest": "^1.2.8"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@@ -36,7 +38,9 @@
}, },
"autoload-dev": { "autoload-dev": {
"psr-4": { "psr-4": {
"TorstenHettstedt\\TimekeepingApi\\Tests\\Unit\\": "tests/unit" "TorstenHettstedt\\TimekeepingApi\\Tests\\Unit\\": "tests/unit",
"TorstenHettstedt\\TimekeepingApi\\Tests\\Api\\": "tests/api",
"Helper\\": "tests/_support/Helper"
} }
} }
} }
+1
View File
@@ -15,6 +15,7 @@
# absolute physical path to the directory that contains this htaccess file. # absolute physical path to the directory that contains this htaccess file.
# RewriteBase / # RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [QSA,L] RewriteRule ^ index.php [QSA,L]
</IfModule> </IfModule>
+44 -2
View File
@@ -1,12 +1,54 @@
<?php <?php
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
$dotenv->load();
use DI\Container;
use Slim\Factory\AppFactory; use Slim\Factory\AppFactory;
use Slim\Routing\RouteCollectorProxy;
use TorstenHettstedt\TimekeepingApi\Controller\PreflightController;
use TorstenHettstedt\TimekeepingApi\Controller\WorkingHoursController;
use TorstenHettstedt\TimekeepingApi\Controller\WorkingHoursViewController;
use TorstenHettstedt\TimekeepingApi\Middleware\ErrorHandler;
use TorstenHettstedt\TimekeepingApi\Middleware\JsonBodyParserMiddleware;
use TorstenHettstedt\TimekeepingApi\Middleware\OriginAccessControlHandler;
$container = new Container();
$container->set('databases', function () {
$dsn = "pgsql:host=${_ENV['DATABASES_HOST']};port=5432;dbname=${_ENV['DATABASES_NAME']}";
return new PDO($dsn, $_ENV['DATABASES_USER'], $_ENV['DATABASES_PASS']);
});
AppFactory::setContainer($container);
$app = AppFactory::create(); $app = AppFactory::create();
$app->add(new JsonBodyParserMiddleware());
$app->addBodyParsingMiddleware();
$app->add(new OriginAccessControlHandler());
$app->addRoutingMiddleware();
$app->get('/', 'TorstenHettstedt\TimekeepingApi\Controller\HelloWorldController:read'); $app->group('/views/working-hours', function (RouteCollectorProxy $group) {
$group->options('/weekly', PreflightController::class . ':preflight');
$group->get('/weekly', WorkingHoursViewController::class . ':browseWeekly');
$group->options('/monthly', PreflightController::class . ':preflight');
$group->get('/monthly', WorkingHoursViewController::class . ':browseMonthly');
$group->options('/yearly', PreflightController::class . ':preflight');
$group->get('/yearly', WorkingHoursViewController::class . ':browseYearly');
});
$app->addErrorMiddleware(true, true, true); $app->group('/working-hours', function (RouteCollectorProxy $group) {
$group->options('', PreflightController::class . ':preflight');
$group->get('', WorkingHoursController::class . ':browse');
$group->post('', WorkingHoursController::class . ':creat');
$group->group('/{id:\d\d\d\d-\d\d-\d\d}', function (RouteCollectorProxy $group) {
$group->options('', PreflightController::class . ':preflight');
$group->get('', WorkingHoursController::class . ':read');
$group->put('', WorkingHoursController::class . ':update');
});
});
$errorMiddleware = $app->addErrorMiddleware(true, true, true);
$errorMiddleware->setDefaultErrorHandler(new ErrorHandler($app));
$app->run(); $app->run();
+8 -4
View File
@@ -82,6 +82,7 @@
"tags": [ "tags": [
"Eintragungen Arbeitszeiten" "Eintragungen Arbeitszeiten"
], ],
"description": "Angabe des Datum im Request-Body werden ignoriert",
"parameters": [ "parameters": [
{ {
"$ref": "#/components/parameters/date" "$ref": "#/components/parameters/date"
@@ -167,14 +168,17 @@
"description": "Eintrag der Arbeitszeit für einen Tag", "description": "Eintrag der Arbeitszeit für einen Tag",
"type": "object", "type": "object",
"properties": { "properties": {
"date": { "workingDay": {
"description": "Datum eines Arbeitstages", "description": "Datum eines Arbeitstages",
"type": "string", "type": "string",
"format": "date" "format": "date",
"required": false
}, },
"workingHours": { "workingTime": {
"description": "Arbeitszeit des Tages", "description": "Arbeitszeit des Tages",
"type": "integer" "type": "string",
"pattern": "[0-5]\\d:[0-5]\\d:[0-5]\\d",
"example": "08:01:00"
} }
} }
}, },
Binary file not shown.

After

Width:  |  Height:  |  Size: 665 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 B

+60
View File
@@ -0,0 +1,60 @@
<!-- HTML for static distribution bundle build -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Swagger UI</title>
<link rel="stylesheet" type="text/css" href="swagger-ui.css" >
<link rel="icon" type="image/png" href="favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="favicon-16x16.png" sizes="16x16" />
<style>
html
{
box-sizing: border-box;
overflow: -moz-scrollbars-vertical;
overflow-y: scroll;
}
*,
*:before,
*:after
{
box-sizing: inherit;
}
body
{
margin:0;
background: #fafafa;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="swagger-ui-bundle.js"> </script>
<script src="swagger-ui-standalone-preset.js"> </script>
<script>
window.onload = function() {
// Begin Swagger UI call region
const ui = SwaggerUIBundle({
url: "../openapi.json",
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout"
})
// End Swagger UI call region
window.ui = ui
}
</script>
</body>
</html>
+68
View File
@@ -0,0 +1,68 @@
<!doctype html>
<html lang="en-US">
<title>Swagger UI: OAuth2 Redirect</title>
<body onload="run()">
</body>
</html>
<script>
'use strict';
function run () {
var oauth2 = window.opener.swaggerUIRedirectOauth2;
var sentState = oauth2.state;
var redirectUrl = oauth2.redirectUrl;
var isValid, qp, arr;
if (/code|token|error/.test(window.location.hash)) {
qp = window.location.hash.substring(1);
} else {
qp = location.search.substring(1);
}
arr = qp.split("&")
arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';})
qp = qp ? JSON.parse('{' + arr.join() + '}',
function (key, value) {
return key === "" ? value : decodeURIComponent(value)
}
) : {}
isValid = qp.state === sentState
if ((
oauth2.auth.schema.get("flow") === "accessCode"||
oauth2.auth.schema.get("flow") === "authorizationCode"
) && !oauth2.auth.code) {
if (!isValid) {
oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "warning",
message: "Authorization may be unsafe, passed state was changed in server Passed state wasn't returned from auth server"
});
}
if (qp.code) {
delete oauth2.state;
oauth2.auth.code = qp.code;
oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
} else {
let oauthErrorMsg
if (qp.error) {
oauthErrorMsg = "["+qp.error+"]: " +
(qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
(qp.error_uri ? "More info: "+qp.error_uri : "");
}
oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "error",
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server"
});
}
} else {
oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
}
window.close();
}
</script>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace TorstenHettstedt\TimekeepingApi\Controller;
use JsonSerializable;
use PDO;
use Psr\Container\ContainerInterface;
use Slim\Psr7\Response;
abstract class AbstractController
{
protected PDO $databases;
/**
* WorkingHoursController constructor.
*
* @param ContainerInterface $container
*
* @throws NotDatabasesException
*/
public function __construct(ContainerInterface $container)
{
if ($container->has('databases') === false) {
throw new NotDatabasesException('Datenbank ist nicht Vorhanden');
}
$this->databases = $container->get('databases');
}
/**
* @param Response $response
* @param JsonSerializable|JsonSerializable[] $data
* @param int $status_code
*
* @return Response
*/
protected function printResponse(Response $response, mixed $data, int $status_code): Response {
$payload = json_encode($data);
$response->getBody()->write($payload);
return $response
->withHeader('Content-Type', 'application/json')
->withStatus($status_code);
}
}
@@ -1,30 +0,0 @@
<?php /** @noinspection PhpUnused */
namespace TorstenHettstedt\TimekeepingApi\Controller;
use Slim\Psr7\Response;
use Slim\Psr7\Request;
class HelloWorldController
{
/**
* @param Request $request
* @param Response $response
* @param mixed[] $args
*
* @return Response
*
* @noinspection PhpUnusedParameterInspection
*/
public function read(Request $request, Response $response, array $args): Response
{
$payload = json_encode('Hallo World');
$response->getBody()->write($payload);
return $response
->withHeader('Content-Type', 'application/json')
->withStatus(200);
}
}
@@ -0,0 +1,20 @@
<?php /** @noinspection PhpMissingFieldTypeInspection */
namespace TorstenHettstedt\TimekeepingApi\Controller;
use Slim\Exception\HttpSpecializedException;
class HttpConflictRequestException extends HttpSpecializedException
{
/** @var int */
protected $code = 409;
/** @var string */
protected $message = 'Conflict.';
/** @var string */
protected $title = '409 Conflict';
/** @var string */
protected $description = 'The 409 (Conflict) status code indicates that the request could not be completed due to a conflict with the current state of the target resource.';
}
@@ -0,0 +1,11 @@
<?php
namespace TorstenHettstedt\TimekeepingApi\Controller;
use Exception;
class NotDatabasesException extends Exception
{
}
@@ -0,0 +1,18 @@
<?php
namespace TorstenHettstedt\TimekeepingApi\Controller;
use Psr\Http\Message\ResponseInterface;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
use TorstenHettstedt\TimekeepingApi\Middleware\OriginAccessControlHandler;
class PreflightController
{
public function preflight(Request $request, Response $response): ResponseInterface
{
return (new OriginAccessControlHandler())->originAccessControl($request, $response);
}
}
@@ -0,0 +1,137 @@
<?php
namespace TorstenHettstedt\TimekeepingApi\Controller;
use DateInterval;
use DateTime;
use Exception;
use Fig\Http\Message\StatusCodeInterface;
use Slim\Exception\HttpBadRequestException;
use Slim\Exception\HttpInternalServerErrorException;
use Slim\Exception\HttpNotFoundException;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
use TorstenHettstedt\TimekeepingApi\Models\WorkingHours;
use TorstenHettstedt\TimekeepingApi\Repositories\RepositoryRecordAlreadyExistException;
use TorstenHettstedt\TimekeepingApi\Repositories\RepositoryRecordNotFoundException;
use TorstenHettstedt\TimekeepingApi\Repositories\WorkingHoursRepository;
class WorkingHoursController extends AbstractController
{
/**
* @param Request $request
* @param Response $response
* @param mixed[] $args
*
* @return Response
*
* @throws HttpBadRequestException
* @throws HttpNotFoundException
*/
public function update(Request $request, Response $response, array $args): Response
{
$body = $request->getParsedBody();
$repository = new WorkingHoursRepository($this->databases);
$model = $this->buildModel($request, $args['id'], $body['workingTime']);
try {
$repository->update($model);
} catch (RepositoryRecordNotFoundException $exception) {
throw new HttpNotFoundException($request, 'Der Eintrag ist nicht vorhanden.', $exception);
}
return $this->printResponse($response, $model, StatusCodeInterface::STATUS_OK);
}
/**
* @param Request $request
* @param Response $response
* @param mixed[] $args
*
* @return Response
*
* @throws HttpInternalServerErrorException
* @noinspection PhpUnusedParameterInspection
*/
public function browse(Request $request, Response $response, array $args): Response
{
$repository = new WorkingHoursRepository($this->databases);
try {
return $this->printResponse($response, $repository->findAll(), StatusCodeInterface::STATUS_OK);
} catch (Exception $exception) {
throw new HttpInternalServerErrorException($request, 'Der Wert für das Datum oder die Zeit ist falsch in der Datenbank', $exception);
}
}
/**
* @param Request $request
* @param Response $response
* @param mixed[] $args
*
* @return Response
*
* @throws HttpNotFoundException
* @throws HttpInternalServerErrorException
*/
public function read(Request $request, Response $response, array $args): Response
{
$repository = new WorkingHoursRepository($this->databases);
try {
return $this->printResponse($response, $repository->findByKey($args['id']), StatusCodeInterface::STATUS_OK);
} catch (RepositoryRecordNotFoundException $exception) {
throw new HttpNotFoundException($request, 'Der Eintrag ist nicht vorhanden.', $exception);
} catch (Exception $exception) {
throw new HttpInternalServerErrorException($request, 'Der Wert für das Datum oder die Zeit ist falsch in der Datenbank', $exception);
}
}
/**
* @param Request $request
* @param Response $response
* @param mixed[] $args
*
* @return Response
*
* @throws HttpBadRequestException
* @throws HttpConflictRequestException
* @throws HttpInternalServerErrorException
* @noinspection PhpUnusedParameterInspection
*/
public function creat(Request $request, Response $response, array $args): Response
{
$body = $request->getParsedBody();
$repository = new WorkingHoursRepository($this->databases);
$model = $this->buildModel($request, $body['workingDay'], $body['workingTime']);
try {
$repository->insert($model);
} catch (RepositoryRecordAlreadyExistException $exception) {
throw new HttpConflictRequestException($request, 'Der Eintrag ist schon vorhanden.', $exception);
} catch (Exception $exception) {
throw new HttpInternalServerErrorException($request, 'Der Wert für das Datum oder die Zeit ist falsch in der Datenbank', $exception);
}
return $this->printResponse($response, $model, StatusCodeInterface::STATUS_CREATED);
}
/**
* @param Request $request
* @param string $date
* @param string $time
*
* @return WorkingHours
* @throws HttpBadRequestException
*/
protected function buildModel(Request $request, string $date, string $time): WorkingHours
{
try {
$workingDay = new DateTime($date);
} catch (Exception $exception) {
throw new HttpBadRequestException($request, 'Der Wert für das Datum ist falsch', $exception);
}
try {
$workingTime = new DateInterval('P0000-00-00T' . $time);
} catch (Exception $exception) {
throw new HttpBadRequestException($request, 'Der Wert für die Zeit ist falsch', $exception);
}
return (new WorkingHours())->setWorkingDay($workingDay)->setWorkingTime($workingTime);
}
}
@@ -0,0 +1,80 @@
<?php
namespace TorstenHettstedt\TimekeepingApi\Controller;
use Exception;
use Fig\Http\Message\StatusCodeInterface;
use Slim\Exception\HttpInternalServerErrorException;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
use TorstenHettstedt\TimekeepingApi\Repositories\WorkingHoursMonthlyViewRepository;
use TorstenHettstedt\TimekeepingApi\Repositories\WorkingHoursWeeklyViewRepository;
use TorstenHettstedt\TimekeepingApi\Repositories\WorkingHoursYearlyViewRepository;
class WorkingHoursViewController extends AbstractController
{
/**
* @param Request $request
* @param Response $response
* @param mixed[] $args
*
* @return Response
*
* @throws HttpInternalServerErrorException
* @noinspection PhpUnusedParameterInspection
*/
public function browseWeekly(Request $request, Response $response, array $args): Response
{
$repository = new WorkingHoursWeeklyViewRepository($this->databases);
try {
return $this->printResponse($response, $repository->findAll(), StatusCodeInterface::STATUS_OK);
} catch (Exception $exception) {
throw new HttpInternalServerErrorException($request,
'Der Wert für das Datum oder die Zeit ist falsch in der Datenbank', $exception);
}
}
/**
* @param Request $request
* @param Response $response
* @param mixed[] $args
*
* @return Response
*
* @throws HttpInternalServerErrorException
* @noinspection PhpUnusedParameterInspection
*/
public function browseMonthly(Request $request, Response $response, array $args): Response
{
$repository = new WorkingHoursMonthlyViewRepository($this->databases);
try {
return $this->printResponse($response, $repository->findAll(), StatusCodeInterface::STATUS_OK);
} catch (Exception $exception) {
throw new HttpInternalServerErrorException($request,
'Der Wert für das Datum oder die Zeit ist falsch in der Datenbank', $exception);
}
}
/**
* @param Request $request
* @param Response $response
* @param mixed[] $args
*
* @return Response
*
* @throws HttpInternalServerErrorException
* @noinspection PhpUnusedParameterInspection
*/
public function browseYearly(Request $request, Response $response, array $args): Response
{
$repository = new WorkingHoursYearlyViewRepository($this->databases);
try {
return $this->printResponse($response, $repository->findAll(), StatusCodeInterface::STATUS_OK);
} catch (Exception $exception) {
throw new HttpInternalServerErrorException($request,
'Der Wert für das Datum oder die Zeit ist falsch in der Datenbank', $exception);
}
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php /** @noinspection PhpUndefinedClassInspection */
namespace TorstenHettstedt\TimekeepingApi\Middleware;
use JetBrains\PhpStorm\ArrayShape;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Slim\App;
use Slim\Exception\HttpException;
use Throwable;
class ErrorHandler
{
protected App $app;
/**
* ErrorHandler constructor.
*
* @param App $app
*/
public function __construct(App $app)
{
$this->app = $app;
}
/**
* @param Throwable $exception
*
* @return mixed[]
*/
#[ArrayShape([
'timestamp' => "false|string",
'status' => "int",
"error" => "string",
'message' => "string",
'path' => "string"
])] protected function buildJsonArray(Throwable $exception): array
{
$payload = [
'timestamp' => date('Y-m-d\TH:m:sP'),
'status' => $exception->getCode(),
"error" => '',
'message' => $exception->getMessage(),
'path' => $exception->getFile()
];
if ($exception instanceof HttpException) {
$payload['error'] = $exception->getTitle();
}
return $payload;
}
/**
* @param ServerRequestInterface $request
* @param Throwable $exception
* @param bool $displayErrorDetails
* @param bool $logErrors
* @param bool $logErrorDetails
* @param LoggerInterface|null $logger
*
* @return ResponseInterface
*/
public function __invoke(
ServerRequestInterface $request,
Throwable $exception,
bool $displayErrorDetails,
bool $logErrors,
bool $logErrorDetails,
?LoggerInterface $logger = null
): ResponseInterface
{
if ($logger !== null && $logErrors === true) {
$logger->error($exception->getMessage());
}
$payload = $this->buildJsonArray($exception);
$response = $this->app->getResponseFactory()->createResponse($exception->getCode());
$response->getBody()->write(
json_encode($payload, JSON_UNESCAPED_UNICODE)
);
$originAccessControlHandler = new OriginAccessControlHandler();
return $originAccessControlHandler->originAccessControl($request, $response);
}
}
@@ -0,0 +1,25 @@
<?php
namespace TorstenHettstedt\TimekeepingApi\Middleware;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
class JsonBodyParserMiddleware implements MiddlewareInterface
{
public function process(Request $request, RequestHandler $handler): Response
{
$contentType = $request->getHeaderLine('Content-Type');
if (strstr($contentType, 'application/json')) {
$contents = json_decode(file_get_contents('php://input'), true);
if (json_last_error() === JSON_ERROR_NONE) {
$request = $request->withParsedBody($contents);
}
}
return $handler->handle($request);
}
}
@@ -0,0 +1,58 @@
<?php
namespace TorstenHettstedt\TimekeepingApi\Middleware;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
use Slim\Routing\RouteContext;
class OriginAccessControlHandler implements MiddlewareInterface
{
/**
* Process an incoming server request.
*
* Processes an incoming server request in order to produce a response.
* If unable to produce the response itself, it may delegate to the provided
* request handler to do so.
*/
public function process(Request $request, RequestHandler $handler): Response
{
$response = $handler->handle($request);
return $this->originAccessControl($request, $response);
}
public function originAccessControl(Request $request, Response $response): Response
{
$routeContext = RouteContext::fromRequest($request);
$routingResults = $routeContext->getRoutingResults();
$methods = $routingResults->getAllowedMethods();
$requestHeaders = $request->getHeaderLine('Access-Control-Request-Headers');
if ($this->testRoute($request)) {
// Stunden Minuten Sekunden
$accessControlMaxAge = 24 * 60 * 60;
$response = $response->withHeader('Access-Control-Allow-Origin', $this->buildOrigin($request));
$response = $response->withHeader('Access-Control-Allow-Methods', implode(',', $methods));
$response = $response->withHeader('Access-Control-Allow-Headers', $requestHeaders);
$response = $response->withHeader('Access-Control-Max-Age', (string)$accessControlMaxAge);
}
return $response;
}
protected function testRoute(Request $request): bool
{
$corsAllowOriginRegex = $_ENV['CORS_ALLOW_ORIGIN_REGEX'] ?? '/https?:\/\/.*:.*/';
return preg_match($corsAllowOriginRegex, $this->buildOrigin($request));
}
protected function buildOrigin(Request $request): string
{
return $request->getHeaderLine('Origin') ?? '';
}
}
+2 -1
View File
@@ -91,12 +91,13 @@ class WorkingHoursView implements ModelInterface
'overtime' => "string" 'overtime' => "string"
])] public function jsonSerialize(): array ])] public function jsonSerialize(): array
{ {
$formatOvertime = (($this->getOvertime()->invert === 1) ? '-' : '') . '%H:%I:%S';
return [ return [
'period' => $this->getPeriod()->format($this->getPeriodDesignation()->getValue()), 'period' => $this->getPeriod()->format($this->getPeriodDesignation()->getValue()),
'periodDesignation' => strtolower($this->getPeriodDesignation()->getKey()), 'periodDesignation' => strtolower($this->getPeriodDesignation()->getKey()),
'workingDays' => $this->getWorkingDays(), 'workingDays' => $this->getWorkingDays(),
'totalHours' => $this->getTotalHours()->format('%H:%I:%S'), 'totalHours' => $this->getTotalHours()->format('%H:%I:%S'),
'overtime' => $this->getOvertime()->format('%H:%I:%S') 'overtime' => $this->getOvertime()->format($formatOvertime)
]; ];
} }
} }
@@ -1,10 +0,0 @@
<?php
namespace TorstenHettstedt\TimekeepingApi\Repositories;
class RepositoryModelAlreadyExists extends RepositoryException
{
}
@@ -0,0 +1,10 @@
<?php
namespace TorstenHettstedt\TimekeepingApi\Repositories;
class RepositoryRecordAlreadyExistException extends RepositoryException
{
}
@@ -11,7 +11,7 @@ interface RepositoryWriterInterface
/** /**
* @param T $model * @param T $model
* *
* @throws RepositoryModelAlreadyExists * @throws RepositoryRecordAlreadyExistException
*/ */
public function insert(mixed $model): void; public function insert(mixed $model): void;
@@ -44,7 +44,7 @@ class WorkingHoursRepository implements RepositoryReaderInterface, RepositoryWri
public function findAll(): array public function findAll(): array
{ {
$models = []; $models = [];
$stmt = $this->database->prepare('select "Datum" as "workingDay", "Arbeitszeit" as "workingTime" from public."Arbeitszeiten"'); $stmt = $this->database->prepare('select "Datum" as "workingDay", "Arbeitszeit" as "workingTime" from public."Arbeitszeiten" order by "Datum"');
$stmt->execute(); $stmt->execute();
while ($row = $stmt->fetch()) { while ($row = $stmt->fetch()) {
$model = new WorkingHours(); $model = new WorkingHours();
@@ -85,7 +85,7 @@ class WorkingHoursRepository implements RepositoryReaderInterface, RepositoryWri
/** /**
* @param WorkingHours|ModelInterface $model * @param WorkingHours|ModelInterface $model
* *
* @throws RepositoryModelAlreadyExists * @throws RepositoryRecordAlreadyExistException
* @throws Exception * @throws Exception
*/ */
public function insert(mixed $model): void public function insert(mixed $model): void
@@ -99,7 +99,7 @@ class WorkingHoursRepository implements RepositoryReaderInterface, RepositoryWri
try { try {
$this->findByKey($workingDay); $this->findByKey($workingDay);
throw new RepositoryModelAlreadyExists(); throw new RepositoryRecordAlreadyExistException();
} /** @noinspection PhpUnusedLocalVariableInspection */ } /** @noinspection PhpUnusedLocalVariableInspection */
catch (RepositoryRecordNotFoundException $exception) { catch (RepositoryRecordNotFoundException $exception) {
$stmt = $this->database->prepare('insert into public."Arbeitszeiten" ("Datum", "Arbeitszeit") values (?, ?) on conflict do nothing'); $stmt = $this->database->prepare('insert into public."Arbeitszeiten" ("Datum", "Arbeitszeit") values (?, ?) on conflict do nothing');
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace Helper;
// here you can define custom actions
// all public methods declared in helper class will be available in $I
use Codeception\Module;
class Api extends Module
{
const FORMAT_TIME = 'string:regex(/\d+:[0-5]\d:[0-5]\d/)';
const FORMAT_DATE = 'string:regex(/\d{4}-\d{2}-\d{2}/)';
const WORKING_HOURS_JSON_FORMAT = [
'workingDay' => self::FORMAT_DATE,
'workingTime' => self::FORMAT_TIME,
];
const WORKING_HOURS_VIEW_JSON_FORMAT = [
"period" => "string",
"periodDesignation" => "string",
"totalHours" => self::FORMAT_TIME,
"workingDays" => 'integer:>0',
"overtime" => self::FORMAT_TIME,
];
const ERROR_JSON_FORMAT = [
"timestamp" => "string:date",
"status" => "integer",
"error" => "string",
"message" => "string",
"path" => "string",
];
const TEST_ORIGIN = 'http://localhost:1234';
}
+8
View File
@@ -0,0 +1,8 @@
actor: ApiTester
modules:
enabled:
- \Helper\Api
- REST:
url: http://localhost:8091/
depends: PhpBrowser
part: Json
@@ -0,0 +1,35 @@
<?php /** @noinspection PhpUnused */
namespace TorstenHettstedt\TimekeepingApi\Tests\Api\Views;
use ApiTester;
use Codeception\Util\HttpCode;
use Helper\Api;
class BrowseWorkingHoursMonthlyCest
{
public static function preflight(ApiTester $I): void
{
$I->haveHttpHeader('Origin', Api::TEST_ORIGIN);
$I->haveHttpHeader('Access-Control-Request-Method', 'GET');
$I->sendOptions('/views/working-hours/monthly');
$I->seeResponseCodeIs(HttpCode::OK);
$I->seeHttpHeader('Access-Control-Allow-Origin', Api::TEST_ORIGIN);
$I->seeHttpHeader('Access-Control-Allow-Methods');
$I->seeHttpHeader('Access-Control-Allow-Headers');
$I->seeHttpHeader('Access-Control-Max-Age');
}
/**
* @param ApiTester $I
*/
public function browseWorkingHoursView(ApiTester $I): void
{
$I->haveHttpHeader('Origin', Api::TEST_ORIGIN);
$I->sendGet('/views/working-hours/monthly');
$I->seeResponseCodeIs(HttpCode::OK);
$I->canSeeHttpHeader('Access-Control-Allow-Origin', Api::TEST_ORIGIN);
$I->seeResponseIsJson();
$I->seeResponseMatchesJsonType(Api::WORKING_HOURS_VIEW_JSON_FORMAT);
}
}
@@ -0,0 +1,35 @@
<?php /** @noinspection PhpUnused */
namespace TorstenHettstedt\TimekeepingApi\Tests\Api\Views;
use ApiTester;
use Codeception\Util\HttpCode;
use Helper\Api;
class BrowseWorkingHoursWeeklyCest
{
public static function preflight(ApiTester $I): void
{
$I->haveHttpHeader('Origin', Api::TEST_ORIGIN);
$I->haveHttpHeader('Access-Control-Request-Method', 'GET');
$I->sendOptions('/views/working-hours/weekly');
$I->seeResponseCodeIs(HttpCode::OK);
$I->seeHttpHeader('Access-Control-Allow-Origin', Api::TEST_ORIGIN);
$I->seeHttpHeader('Access-Control-Allow-Methods');
$I->seeHttpHeader('Access-Control-Allow-Headers');
$I->seeHttpHeader('Access-Control-Max-Age');
}
/**
* @param ApiTester $I
*/
public function browseWorkingHoursView(ApiTester $I): void
{
$I->haveHttpHeader('Origin', Api::TEST_ORIGIN);
$I->sendGet('/views/working-hours/weekly');
$I->seeResponseCodeIs(HttpCode::OK);
$I->canSeeHttpHeader('Access-Control-Allow-Origin', Api::TEST_ORIGIN);
$I->seeResponseIsJson();
$I->seeResponseMatchesJsonType(Api::WORKING_HOURS_VIEW_JSON_FORMAT);
}
}
@@ -0,0 +1,35 @@
<?php /** @noinspection PhpUnused */
namespace TorstenHettstedt\TimekeepingApi\Tests\Api\Views;
use ApiTester;
use Codeception\Util\HttpCode;
use Helper\Api;
class BrowseWorkingHoursYearlyCest
{
public static function preflight(ApiTester $I): void
{
$I->haveHttpHeader('Origin', Api::TEST_ORIGIN);
$I->haveHttpHeader('Access-Control-Request-Method', 'GET');
$I->sendOptions('/views/working-hours/yearly');
$I->seeResponseCodeIs(HttpCode::OK);
$I->seeHttpHeader('Access-Control-Allow-Origin', Api::TEST_ORIGIN);
$I->seeHttpHeader('Access-Control-Allow-Methods');
$I->seeHttpHeader('Access-Control-Allow-Headers');
$I->seeHttpHeader('Access-Control-Max-Age');
}
/**
* @param ApiTester $I
*/
public function browseWorkingHoursView(ApiTester $I): void
{
$I->haveHttpHeader('Origin', Api::TEST_ORIGIN);
$I->sendGet('/views/working-hours/yearly');
$I->seeResponseCodeIs(HttpCode::OK);
$I->canSeeHttpHeader('Access-Control-Allow-Origin', Api::TEST_ORIGIN);
$I->seeResponseIsJson();
$I->seeResponseMatchesJsonType(Api::WORKING_HOURS_VIEW_JSON_FORMAT);
}
}
@@ -0,0 +1,35 @@
<?php /** @noinspection PhpUnused */
namespace TorstenHettstedt\TimekeepingApi\Tests\Api\WorkingHours;
use ApiTester;
use Codeception\Util\HttpCode;
use Helper\Api;
class BrowseWorkingHoursCest
{
public static function preflight(ApiTester $I): void
{
$I->haveHttpHeader('Origin', Api::TEST_ORIGIN);
$I->haveHttpHeader('Access-Control-Request-Method', 'GET');
$I->sendOptions('/working-hours');
$I->seeResponseCodeIs(HttpCode::OK);
$I->seeHttpHeader('Access-Control-Allow-Origin', Api::TEST_ORIGIN);
$I->seeHttpHeader('Access-Control-Allow-Methods');
$I->seeHttpHeader('Access-Control-Allow-Headers');
$I->seeHttpHeader('Access-Control-Max-Age');
}
/**
* @param ApiTester $I
*/
public function browseWorkingHours(ApiTester $I): void
{
$I->haveHttpHeader('Origin', Api::TEST_ORIGIN);
$I->sendGet('/working-hours');
$I->seeResponseCodeIs(HttpCode::OK);
$I->canSeeHttpHeader('Access-Control-Allow-Origin', Api::TEST_ORIGIN);
$I->seeResponseIsJson();
$I->seeResponseMatchesJsonType(Api::WORKING_HOURS_JSON_FORMAT);
}
}
@@ -0,0 +1,69 @@
<?php /** @noinspection PhpUnused */
namespace TorstenHettstedt\TimekeepingApi\Tests\Api\WorkingHours;
use ApiTester;
use Codeception\Util\HttpCode;
use Helper\Api;
class CreateWorkingHoursCest
{
public static function preflight(ApiTester $I): void
{
$I->haveHttpHeader('Origin', Api::TEST_ORIGIN);
$I->haveHttpHeader('Access-Control-Request-Method', 'POST');
$I->sendOptions('/working-hours');
$I->seeResponseCodeIs(HttpCode::OK);
$I->seeHttpHeader('Access-Control-Allow-Origin', Api::TEST_ORIGIN);
$I->seeHttpHeader('Access-Control-Allow-Methods');
$I->seeHttpHeader('Access-Control-Allow-Headers');
$I->seeHttpHeader('Access-Control-Max-Age');
}
public function createWorkingHoursWithNewValidRecord(ApiTester $I): void
{
$I->haveHttpHeader('accept', 'application/json');
$I->haveHttpHeader('content-type', 'application/json');
$I->haveHttpHeader('Origin', Api::TEST_ORIGIN);
$I->sendPost('/working-hours', [
'workingDay' => '2020-04-01',
'workingTime' => '08:00:00',
]);
$I->seeResponseCodeIs(HttpCode::CREATED);
$I->canSeeHttpHeader('Access-Control-Allow-Origin', Api::TEST_ORIGIN);
$I->seeResponseIsJson();
$I->seeResponseMatchesJsonType(Api::WORKING_HOURS_JSON_FORMAT);
}
// tests
public function createWorkingHoursWithNewInvalidRecord(ApiTester $I): void
{
$I->haveHttpHeader('accept', 'application/json');
$I->haveHttpHeader('content-type', 'application/json');
$I->haveHttpHeader('Origin', Api::TEST_ORIGIN);
$I->sendPost('/working-hours', [
'workingDay' => '2020-04-01',
'workingTime' => 8.0,
]);
$I->seeResponseCodeIs(HttpCode::BAD_REQUEST);
$I->canSeeHttpHeader('Access-Control-Allow-Origin', Api::TEST_ORIGIN);
$I->seeResponseIsJson();
$I->seeResponseMatchesJsonType(Api::ERROR_JSON_FORMAT);
}
// tests
public function createWorkingHoursWithExistingValidRecord(ApiTester $I): void
{
$I->haveHttpHeader('accept', 'application/json');
$I->haveHttpHeader('content-type', 'application/json');
$I->haveHttpHeader('Origin', Api::TEST_ORIGIN);
$I->sendPost('/working-hours', [
'workingDay' => '2020-01-15',
'workingTime' => '08:00:00',
]);
$I->seeResponseCodeIs(HttpCode::CONFLICT);
$I->canSeeHttpHeader('Access-Control-Allow-Origin', Api::TEST_ORIGIN);
$I->seeResponseIsJson();
$I->seeResponseMatchesJsonType(Api::ERROR_JSON_FORMAT);
}
}
@@ -0,0 +1,42 @@
<?php /** @noinspection PhpUnused */
namespace TorstenHettstedt\TimekeepingApi\Tests\Api\WorkingHours;
use ApiTester;
use Codeception\Util\HttpCode;
use Helper\Api;
class ReadWorkingHoursCest
{
public static function preflight(ApiTester $I): void
{
$I->haveHttpHeader('Origin', Api::TEST_ORIGIN);
$I->haveHttpHeader('Access-Control-Request-Method', 'GET');
$I->sendOptions('/working-hours/2020-04-15');
$I->seeResponseCodeIs(HttpCode::OK);
$I->seeHttpHeader('Access-Control-Allow-Origin', Api::TEST_ORIGIN);
$I->seeHttpHeader('Access-Control-Allow-Methods');
$I->seeHttpHeader('Access-Control-Allow-Headers');
$I->seeHttpHeader('Access-Control-Max-Age');
}
public function readExistingWorkingHours(ApiTester $I): void
{
$I->haveHttpHeader('Origin', Api::TEST_ORIGIN);
$I->sendGet('/working-hours/2020-01-15');
$I->seeResponseCodeIs(HttpCode::OK);
$I->canSeeHttpHeader('Access-Control-Allow-Origin', Api::TEST_ORIGIN);
$I->seeResponseIsJson();
$I->seeResponseMatchesJsonType(Api::WORKING_HOURS_JSON_FORMAT);
}
public function readNotExistingWorkingHours(ApiTester $I): void
{
$I->haveHttpHeader('Origin', Api::TEST_ORIGIN);
$I->sendGet('/working-hours/2020-04-15');
$I->seeResponseCodeIs(HttpCode::NOT_FOUND);
$I->canSeeHttpHeader('Access-Control-Allow-Origin', Api::TEST_ORIGIN);
$I->seeResponseIsJson();
$I->seeResponseMatchesJsonType(Api::ERROR_JSON_FORMAT);
}
}
@@ -0,0 +1,67 @@
<?php /** @noinspection PhpUnused */
namespace TorstenHettstedt\TimekeepingApi\Tests\Api\WorkingHours;
use ApiTester;
use Codeception\Util\HttpCode;
use Helper\Api;
class UpdateWorkingHoursCest
{
public static function preflight(ApiTester $I): void
{
$I->haveHttpHeader('Origin', Api::TEST_ORIGIN);
$I->haveHttpHeader('Access-Control-Request-Method', 'PUT');
$I->sendOptions('/working-hours/2020-01-15');
$I->seeResponseCodeIs(HttpCode::OK);
$I->seeHttpHeader('Access-Control-Allow-Origin', Api::TEST_ORIGIN);
$I->seeHttpHeader('Access-Control-Allow-Methods');
$I->seeHttpHeader('Access-Control-Allow-Headers');
$I->seeHttpHeader('Access-Control-Max-Age');
}
public function updateWorkingHoursWithExistingValidRecord(ApiTester $I): void
{
$I->haveHttpHeader('accept', 'application/json');
$I->haveHttpHeader('content-type', 'application/json');
$I->haveHttpHeader('Origin', Api::TEST_ORIGIN);
$I->sendPut('/working-hours/2020-01-15', [
'workingDay' => '2020-01-15',
'workingTime' => '10:00:00',
]);
$I->seeResponseCodeIs(HttpCode::OK);
$I->canSeeHttpHeader('Access-Control-Allow-Origin', Api::TEST_ORIGIN);
$I->seeResponseIsJson();
$I->seeResponseMatchesJsonType(Api::WORKING_HOURS_JSON_FORMAT);
}
public function updateWorkingHoursWithNewValidRecord(ApiTester $I): void
{
$I->haveHttpHeader('accept', 'application/json');
$I->haveHttpHeader('content-type', 'application/json');
$I->haveHttpHeader('Origin', Api::TEST_ORIGIN);
$I->sendPut('/working-hours/2020-04-15', [
'workingDay' => '2020-04-15',
'workingTime' => '10:00:00',
]);
$I->seeResponseCodeIs(HttpCode::NOT_FOUND);
$I->canSeeHttpHeader('Access-Control-Allow-Origin', Api::TEST_ORIGIN);
$I->seeResponseIsJson();
$I->seeResponseMatchesJsonType(Api::ERROR_JSON_FORMAT);
}
public function updateWorkingHoursWithNewInvalidRecord(ApiTester $I): void
{
$I->haveHttpHeader('accept', 'application/json');
$I->haveHttpHeader('content-type', 'application/json');
$I->haveHttpHeader('Origin', Api::TEST_ORIGIN);
$I->sendPut('/working-hours/2020-01-15', [
'workingDay' => '2020-01-15',
'workingTime' => 8.0,
]);
$I->seeResponseCodeIs(HttpCode::BAD_REQUEST);
$I->canSeeHttpHeader('Access-Control-Allow-Origin', Api::TEST_ORIGIN);
$I->seeResponseIsJson();
$I->seeResponseMatchesJsonType(Api::ERROR_JSON_FORMAT);
}
}
@@ -0,0 +1,222 @@
<?php
namespace TorstenHettstedt\TimekeepingApi\Tests\Unit\Controller;
use Codeception\Test\Unit;
use Exception;
use PDO;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\StreamInterface;
use Slim\Exception\HttpBadRequestException;
use Slim\Exception\HttpInternalServerErrorException;
use Slim\Exception\HttpNotFoundException;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
use TorstenHettstedt\TimekeepingApi\Controller\HttpConflictRequestException;
use TorstenHettstedt\TimekeepingApi\Controller\NotDatabasesException;
use TorstenHettstedt\TimekeepingApi\Controller\WorkingHoursController;
class WorkingHoursControllerTest extends Unit
{
const EXISTING_DATE = '2020-01-28';
const EXISTING_INTERVAL = '07:20:00';
const NEW_DATE = '2020-04-01';
const NEW_INTERVAL = '07:59:00';
protected ContainerInterface $container;
protected Request $request;
protected Response $response;
/**
* @throws Exception
*/
protected function _before(): void
{
parent::_before();
/** @noinspection SpellCheckingInspection */
$this->container = $this->makeEmpty(ContainerInterface::class, [
'has' => true,
'get' => new PDO('pgsql:host=psql.torsten-hettstedt.net;port=5432;dbname=testdb;user=bruce;password=mypass')
]);
$this->request = $this->makeEmpty(Request::class, [
'getParsedBody' => [
'workingDay' => self::EXISTING_DATE,
'workingTime' => self::EXISTING_INTERVAL,
]
]);
$this->response = $this->makeEmpty(Response::class, [
'getBody' => $this->makeEmpty(StreamInterface::class, [
'write' => function (mixed $data) {
$this->assertIsString($data);
$this->assertJson($data);
},
]),
'withHeader' => $this->makeEmpty(Response::class, [
'withStatus' => $this->makeEmpty(Response::class),
]),
]);
}
/**
* @throws NotDatabasesException
* @throws Exception
*/
public function testConstructWithNonDatabase(): void
{
$this->container = $this->makeEmpty(ContainerInterface::class, [
'has' => false
]);
$this->expectException(NotDatabasesException::class);
new WorkingHoursController($this->container);
}
/**
* @throws NotDatabasesException
* @throws Exception
*/
public function testBrowse(): void
{
$controller = new WorkingHoursController($this->container);
$response = $controller->browse($this->request, $this->response, []);
$this->assertInstanceOf(Response::class, $response);
}
/**
* @throws NotDatabasesException
* @throws HttpBadRequestException
* @throws HttpNotFoundException
*/
public function testUpdateExistRecord(): void
{
$controller = new WorkingHoursController($this->container);
$response = $controller->update($this->request, $this->response, [
'id' => self::EXISTING_DATE
]);
$this->assertInstanceOf(Response::class, $response);
}
/**
* @throws NotDatabasesException
* @throws HttpBadRequestException
* @throws HttpNotFoundException
*/
public function testUpdateNotExistRecord(): void
{
$controller = new WorkingHoursController($this->container);
$this->expectException(HttpNotFoundException::class);
$controller->update($this->request, $this->response, [
'id' => self::NEW_DATE
]);
}
/**
* @return string[][]
*/
public function invalidDataProvider(): array
{
return [
[
self::NEW_DATE,
'07:59'
],
[
'2020-13-33',
self::NEW_INTERVAL
],
];
}
/**
* @param string $date
* @param string $time
*
* @dataProvider invalidDataProvider
*
* @throws HttpBadRequestException
* @throws HttpConflictRequestException
* @throws HttpInternalServerErrorException
* @throws NotDatabasesException
* @throws Exception
*/
public function testCreatInvalidData(string $date, string $time): void
{
$this->request = $this->makeEmpty(Request::class, [
'getParsedBody' => [
'workingDay' => $date,
'workingTime' => $time,
]
]);
$controller = new WorkingHoursController($this->container);
$this->expectException(HttpBadRequestException::class);
$controller->creat($this->request, $this->response, []);
}
/**
* @throws HttpBadRequestException
* @throws NotDatabasesException
* @throws HttpInternalServerErrorException
* @throws HttpConflictRequestException
* @throws Exception
*/
public function testCreatNewRecord(): void
{
$this->request = $this->makeEmpty(Request::class, [
'getParsedBody' => [
'workingDay' => self::NEW_DATE,
'workingTime' => self::NEW_INTERVAL,
]
]);
$controller = new WorkingHoursController($this->container);
$response = $controller->creat($this->request, $this->response, []);
$this->assertInstanceOf(Response::class, $response);
}
/**
* @throws HttpBadRequestException
* @throws HttpConflictRequestException
* @throws HttpInternalServerErrorException
* @throws NotDatabasesException
* @throws Exception
*/
public function testCreatExistRecord(): void
{
$this->request = $this->makeEmpty(Request::class, [
'getParsedBody' => [
'workingDay' => self::EXISTING_DATE,
'workingTime' => self::EXISTING_INTERVAL,
]
]);
$controller = new WorkingHoursController($this->container);
$this->expectException(HttpConflictRequestException::class);
$controller->creat($this->request, $this->response, []);
}
/**
* @throws HttpInternalServerErrorException
* @throws HttpNotFoundException
* @throws NotDatabasesException
*/
public function testReadExistRecord(): void
{
$controller = new WorkingHoursController($this->container);
$response = $controller->read($this->request, $this->response, [
'id' => self::EXISTING_DATE
]);
$this->assertInstanceOf(Response::class, $response);
}
/**
* @throws HttpInternalServerErrorException
* @throws HttpNotFoundException
* @throws NotDatabasesException
*/
public function testReadNotExistRecord(): void
{
$controller = new WorkingHoursController($this->container);
$this->expectException(HttpNotFoundException::class);
$controller->read($this->request, $this->response, [
'id' => self::NEW_DATE
]);
}
}
@@ -0,0 +1,88 @@
<?php
namespace TorstenHettstedt\TimekeepingApi\Tests\Unit\Controller;
use Codeception\Test\Unit;
use Exception;
use PDO;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\StreamInterface;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
use TorstenHettstedt\TimekeepingApi\Controller\NotDatabasesException;
use TorstenHettstedt\TimekeepingApi\Controller\WorkingHoursViewController;
class WorkingHoursViewControllerTest extends Unit
{
protected ContainerInterface $container;
protected Request $request;
protected Response $response;
/**
* @throws Exception
*/
protected function _before(): void
{
parent::_before();
/** @noinspection SpellCheckingInspection */
$this->container = $this->makeEmpty(ContainerInterface::class, [
'has' => true,
'get' => new PDO('pgsql:host=psql.torsten-hettstedt.net;port=5432;dbname=testdb;user=bruce;password=mypass')
]);
$this->request = $this->makeEmpty(Request::class, []);
$this->response = $this->makeEmpty(Response::class, [
'getBody' => $this->makeEmpty(StreamInterface::class, [
'write' => function (mixed $data) {
$this->assertIsString($data);
$this->assertJson($data);
},
]),
'withHeader' => $this->makeEmpty(Response::class, [
'withStatus' => $this->makeEmpty(Response::class),
]),
]);
}
/**
* @throws NotDatabasesException
* @throws Exception
*/
public function testConstructWithNonDatabase(): void
{
$this->container = $this->makeEmpty(ContainerInterface::class, [
'has' => false
]);
$this->expectException(NotDatabasesException::class);
new WorkingHoursViewController($this->container);
}
/**
* @throws Exception
*/
public function testBrowseMonthly(): void
{
$controller = new WorkingHoursViewController($this->container);
$response = $controller->browseMonthly($this->request, $this->response, []);
$this->assertInstanceOf(Response::class, $response);
}
/**
* @throws Exception
*/
public function testBrowseYearly(): void
{
$controller = new WorkingHoursViewController($this->container);
$response = $controller->browseYearly($this->request, $this->response, []);
$this->assertInstanceOf(Response::class, $response);
}
/**
* @throws Exception
*/
public function testBrowseWeekly(): void
{
$controller = new WorkingHoursViewController($this->container);
$response = $controller->browseWeekly($this->request, $this->response, []);
$this->assertInstanceOf(Response::class, $response);
}
}
@@ -0,0 +1,116 @@
<?php
namespace TorstenHettstedt\TimekeepingApi\Tests\Unit\Middleware;
use Codeception\Stub\Expected;
use Codeception\Test\Unit;
use Exception;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Log\LoggerInterface;
use Slim\App;
use Slim\Exception\HttpException;
use Slim\Interfaces\RouteParserInterface;
use Slim\Routing\RouteContext;
use Slim\Routing\RoutingResults;
use TorstenHettstedt\TimekeepingApi\Middleware\ErrorHandler;
class ErrorHandlerTest extends Unit
{
protected App $app;
protected ServerRequestInterface $request;
protected Exception $exception;
protected LoggerInterface $logger;
/**
* @throws Exception
*/
protected function _before(): void
{
parent::_before();
/** @noinspection PhpFieldAssignmentTypeMismatchInspection */
$this->app = $this->makeEmpty(App::class, [
'getResponseFactory' => $this->makeEmpty(ResponseFactoryInterface::class, [
'createResponse' => $this->makeEmpty(ResponseInterface::class, [
'getBody' => $this->makeEmpty(StreamInterface::class, [
'write' => function (mixed $data) {
$this->assertIsString($data);
$this->assertJson($data);
},
]),
]),
]),
]);
/** @noinspection PhpFieldAssignmentTypeMismatchInspection */
$this->request = $this->makeEmpty(ServerRequestInterface::class, [
'getAttribute' => function($name) {
return match ($name) {
RouteContext::ROUTE_PARSER => $this->makeEmpty(RouteParserInterface::class),
RouteContext::ROUTING_RESULTS => $this->makeEmpty(RoutingResults::class),
default => null,
};
}
]);
}
/**
* @throws Exception
*/
public function testUseWithLogger(): void
{
/** @noinspection PhpFieldAssignmentTypeMismatchInspection */
$this->exception = $this->make(Exception::class, [
'message' => '',
'code' => 400,
'file' => '/path(to/file',
]);
/** @noinspection PhpFieldAssignmentTypeMismatchInspection */
$this->logger = $this->makeEmpty(LoggerInterface::class, [
'error' => Expected::once(),
]);
$middleWare = new ErrorHandler($this->app);
$middleWare($this->request, $this->exception, true, true, true, $this->logger);
}
/**
* @throws Exception
*/
public function testUseWithHttpException(): void
{
/** @noinspection PhpFieldAssignmentTypeMismatchInspection */
$this->exception = $this->make(HttpException::class, [
'message' => '',
'code' => 400,
'file' => '/path(to/file',
'getTitle' => Expected::once('The Title'),
]);
/** @noinspection PhpFieldAssignmentTypeMismatchInspection */
$this->logger = $this->makeEmpty(LoggerInterface::class, []);
$middleWare = new ErrorHandler($this->app);
$middleWare($this->request, $this->exception, true, true, true, $this->logger);
}
/**
* @throws Exception
*/
public function testUseWithGeneralException(): void
{
/** @noinspection PhpFieldAssignmentTypeMismatchInspection */
$this->exception = $this->make(Exception::class, [
'message' => '',
'code' => 400,
'file' => '/path(to/file',
]);
/** @noinspection PhpFieldAssignmentTypeMismatchInspection */
$this->logger = $this->makeEmpty(LoggerInterface::class, []);
$middleWare = new ErrorHandler($this->app);
$middleWare($this->request, $this->exception, true, true, true, $this->logger);
}
}
+37 -15
View File
@@ -17,13 +17,15 @@ class WorkingHoursViewTest extends Unit
*/ */
public function workingHoursProvider(): array public function workingHoursProvider(): array
{ {
$date = new DateTime('2020-11-30');
$work_days = 15;
$total_hours = new DateInterval("PT32H22M");
$overtime = new DateInterval("PT22M");
$absence_time = clone $overtime;
$absence_time->invert = 1;
return [ return [
[ [
new DateTime('2020-11-30'), $date, PeriodDesignationEnum::WEEKLY(), $work_days, $total_hours, $overtime,
PeriodDesignationEnum::WEEKLY(),
15,
new DateInterval("PT32H22M"),
new DateInterval("PT22M"),
[ [
'period' => '2020#49', 'period' => '2020#49',
'periodDesignation' => 'weekly', 'periodDesignation' => 'weekly',
@@ -33,11 +35,17 @@ class WorkingHoursViewTest extends Unit
], ],
], ],
[ [
new DateTime('2020-11-30'), $date, PeriodDesignationEnum::WEEKLY(), $work_days, $total_hours, $absence_time,
PeriodDesignationEnum::MONTHLY(), [
15, 'period' => '2020#49',
new DateInterval("PT32H22M"), 'periodDesignation' => 'weekly',
new DateInterval("PT22M"), 'workingDays' => 15,
'totalHours' => '32:22:00',
'overtime' => '-00:22:00'
],
],
[
$date, PeriodDesignationEnum::MONTHLY(), $work_days, $total_hours, $overtime,
[ [
'period' => '2020-11', 'period' => '2020-11',
'periodDesignation' => 'monthly', 'periodDesignation' => 'monthly',
@@ -47,12 +55,17 @@ class WorkingHoursViewTest extends Unit
], ],
], ],
[ [
new DateTime('2020-11-30'), $date, PeriodDesignationEnum::MONTHLY(), $work_days, $total_hours, $absence_time,
PeriodDesignationEnum::YEARLY(),
15,
new DateInterval("PT32H22M"),
new DateInterval("PT22M"),
[ [
'period' => '2020-11',
'periodDesignation' => 'monthly',
'workingDays' => 15,
'totalHours' => '32:22:00',
'overtime' => '-00:22:00'
],
],
[
$date, PeriodDesignationEnum::YEARLY(), $work_days, $total_hours, $overtime, [
'period' => '2020', 'period' => '2020',
'periodDesignation' => 'yearly', 'periodDesignation' => 'yearly',
'workingDays' => 15, 'workingDays' => 15,
@@ -60,6 +73,15 @@ class WorkingHoursViewTest extends Unit
'overtime' => '00:22:00' 'overtime' => '00:22:00'
], ],
], ],
[
$date, PeriodDesignationEnum::YEARLY(), $work_days, $total_hours, $absence_time, [
'period' => '2020',
'periodDesignation' => 'yearly',
'workingDays' => 15,
'totalHours' => '32:22:00',
'overtime' => '-00:22:00'
],
],
]; ];
} }
@@ -8,10 +8,9 @@ use DateTime;
use Exception; use Exception;
use InvalidArgumentException; use InvalidArgumentException;
use PDO; use PDO;
use PDOStatement;
use TorstenHettstedt\TimekeepingApi\Models\ModelInterface; use TorstenHettstedt\TimekeepingApi\Models\ModelInterface;
use TorstenHettstedt\TimekeepingApi\Models\WorkingHours; use TorstenHettstedt\TimekeepingApi\Models\WorkingHours;
use TorstenHettstedt\TimekeepingApi\Repositories\RepositoryModelAlreadyExists; use TorstenHettstedt\TimekeepingApi\Repositories\RepositoryRecordAlreadyExistException;
use TorstenHettstedt\TimekeepingApi\Repositories\RepositoryRecordNotFoundException; use TorstenHettstedt\TimekeepingApi\Repositories\RepositoryRecordNotFoundException;
use TorstenHettstedt\TimekeepingApi\Repositories\WorkingHoursRepository; use TorstenHettstedt\TimekeepingApi\Repositories\WorkingHoursRepository;
@@ -24,7 +23,6 @@ class WorkingHoursRepositoryTest extends Unit
const RECORDS_COUNT = 61; const RECORDS_COUNT = 61;
protected PDO $pdoObject; protected PDO $pdoObject;
protected PDOStatement $pdoStatement;
protected function _before(): void protected function _before(): void
{ {
@@ -104,7 +102,7 @@ class WorkingHoursRepositoryTest extends Unit
'workingDay' => new DateTime(self::EXISTING_DATE), 'workingDay' => new DateTime(self::EXISTING_DATE),
'workingTime' => new DateInterval(self::NEW_INTERVAL), 'workingTime' => new DateInterval(self::NEW_INTERVAL),
]); ]);
$this->expectException(RepositoryModelAlreadyExists::class); $this->expectException(RepositoryRecordAlreadyExistException::class);
$repository->insert($model); $repository->insert($model);
} }
+13
View File
@@ -8,6 +8,19 @@ services:
docker: "true" docker: "true"
ports: ports:
- 8090:80 - 8090:80
volumes:
- ./api:/var/www
- logs:/var/www/logs
test-api:
build: ./api/
environment:
docker: "true"
DATABASES_HOST: psql.torsten-hettstedt.net
DATABASES_NAME: testdb
DATABASES_USER: bruce
DATABASES_PASS: mypass
ports:
- 8091:80
volumes: volumes:
- ./api:/var/www - ./api:/var/www
- logs:/var/www/logs - logs:/var/www/logs
+11
View File
@@ -0,0 +1,11 @@
@startuml
'https://plantuml.com/salt
salt
{
Datum |"Datum <&calendar>"
Uhrzeit | "Uhrzeit <&clock>"
[Übernehmen <&plus>]
[Abbrechen <&action-undo>]
}
@enduml
@@ -0,0 +1,20 @@
@startuml
'https://plantuml.com/salt
salt
{
Woche vom <&calendar> bis zum <&calendar>
[<&chevron-left> Woche zurück] | [Woche vor <&chevron-right>]
{!
Montag | "00:00:00 <&clock>"
Dienstag | "00:00:00 <&clock>"
Mittwoch | "00:00:00 <&clock>"
Donnerstag | "00:00:00 <&clock>"
Freitag | "00:00:00 <&clock>"
<color:red>Samstag | "<color:red>00:00:00 <&clock>"
<color:red>Sontag | "<color:red>00:00:00 <&clock>"
}
[Übernehmen <&plus>]
[Abbrechen <&action-undo>]
}
@enduml
+2
View File
@@ -0,0 +1,2 @@
# Url ohne abschlißenden Slash
API_URL= http://localhost:8080
+6
View File
@@ -0,0 +1,6 @@
.DS_Store
node_modules
package-lock.json
yarn.lock
public
/.env
+30
View File
@@ -0,0 +1,30 @@
{
"private": true,
"name": "svelte-demo",
"scripts": {
"build": "rollup -c",
"autobuild": "rollup -c -w",
"dev": "run-p start:dev autobuild",
"start": "sirv public --single",
"start:dev": "sirv public --dev --single"
},
"dependencies": {
"ganalytics": "^3.1.2",
"navaid": "^1.0.2",
"open-iconic": "^1.1.1"
},
"devDependencies": {
"@iconify-icons/oi": "^1.1.0",
"@iconify/svelte": "^1.0.4",
"@rollup/plugin-commonjs": "^15.0.0",
"@rollup/plugin-node-resolve": "^9.0.0",
"@rollup/plugin-replace": "^2.3.0",
"dotenv": "^8.2.0",
"npm-run-all": "^4.1.3",
"rollup": "^2.30.0",
"rollup-plugin-svelte": "^6.0.0",
"rollup-plugin-terser": "^7.0.0",
"sirv-cli": "^1.0.8",
"svelte": "^3.4.4"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<!--suppress HtmlUnknownTarget -->
<!--suppress JSUnresolvedLibraryURL -->
<html lang="de">
<head>
<base href="/" />
<meta charset="utf8">
<meta name="viewport" content="width=device-width">
<title>Svelte app</title>
<link rel='icon' type='image/png' href='/favicon.png'>
<link rel="stylesheet" href="/global.css" media="screen">
<link rel="stylesheet" href="/print.css" media="print">
<link rel="stylesheet" href="/bundle.css">
<script type="module" src="https://unpkg.com/dimport?module" data-main="/index.js"></script>
<script nomodule src="https://unpkg.com/dimport/nomodule" data-main="/index.js"></script>
</head>
<body>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
// Einstellungen auf die Grundlagen zurücksetzen,
// beinhaltet auch Einstellungen für die neuen HTML5-Tags
//noinspection CssUnknownTarget
@import "meyer_resets.css";
// Import und Einbindung der Zeichensätze
@import "lesslib/fonts.less";
// Bestimmung der Farben
@import "lesslib/colors.less";
// Mixins des Layouts
@import "lesslib/mixins.less";
// Layout-Unabhängige Mixins
@import "lesslib/mixins.framework.less";
// Bestimmung des grundsätzlichen Erscheinungsbildes der einzelnen Elemente,
// einschließlich des Hauptbereiches
@import "lesslib/basics.less";
// Darstellung unabhängig der Darstellungsgrößen
@import "lesslib/layout.less";
// Darstellung in den verschiedenen Ansichtsgrößen
@import "lesslib/media_queries.less";
+76
View File
@@ -0,0 +1,76 @@
html {
font-size: 100%;
}
body {
color: #000;
font-size: 1em;
font-family: OpenSans, "Lucida Grande", "Lucida Sans Unicode", Verdana, Helvetica, Arial, sans-serif;
text-align: left;
background-color: #fff;
}
strong, b {
font-weight: bold;
}
em, i {
font-style: italic;
}
small {
font-size: 0.8rem;
}
section article {
font-size: 95%;
p, ul, ol, dl {
line-height: 1.5em;
.hyphens();
font-size: 1.00em;
}
p {
text-align: justify;
}
ul, ol {
li {
list-style: initial;
}
}
dl {
dt {
font-weight: 600;
}
}
h1, h2, h3 {
font-weight: bold;
}
h1 {
font-size: 1.50em;
}
h2 {
font-size: 1.30em;
}
h3 {
font-size: 1.20em;
}
table {
colgroup:not(:first-child) col:first-child{
border-left: solid thin;
}
thead {
th {
font-size: 100%;
font-weight: bold;
}
}
}
form {
fieldset {
legend {
font-size: 1.30em;
font-weight: bold;
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
/**
Definition der Farben
*/
@hueMain: 35;
@hueSub: 215;
@mainColor: hsl(@hueMain, 90%, 29%);
@redColor: hsl(0, 90%, 29%);
@mainColorDark: darken(@mainColor,66%,relativ);
@mainColorLight: lighten(@mainColor,66%,relativ);
@subColor: hsl(@hueSub, 90%, 29%);
@subColorDark: darken(@subColor,66%,relativ);
@subColorLight: lighten(@subColor,66%,relativ);
+43
View File
@@ -0,0 +1,43 @@
@font-face {
font-family: OpenSans;
src: url(../fonts/opensans-regular.eot);
src: url(../fonts/opensans-regular.eot?#iefix) format("embedded-opentype"),
url(../fonts/opensans-regular.woff) format("woff"),
url(../fonts/opensans-regular.ttf) format("truetype"),
url(../fonts/opensans-regular.svg#open_sansregular) format("svg");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: OpenSans;
src: url(../fonts/opensans-italic.eot);
src: url(../fonts/opensans-italic.eot?#iefix) format("embedded-opentype"),
url(../fonts/opensans-italic.woff) format("woff"),
url(../fonts/opensans-italic.ttf) format("truetype"),
url(../fonts/opensans-italic.svg#open_sansitalic) format("svg");
font-weight: normal;
font-style: italic;
}
@font-face {
font-family: OpenSans;
src: url(../fonts/opensans-bold.eot);
src: url(../fonts/opensans-bold.eot?#iefix) format("embedded-opentype"),
url(../fonts/opensans-bold.woff) format("woff"),
url(../fonts/opensans-bold.ttf) format("truetype"),
url(../fonts/opensans-bold.svg#open_sansbold) format("svg");
font-weight: bold;
font-style: normal;
}
@font-face {
font-family: OpenSans;
src: url(../fonts/opensans-bolditalic.eot);
src: url(../fonts/opensans-bolditalic.eot?#iefix) format("embedded-opentype"),
url(../fonts/opensans-bolditalic.woff) format("woff"),
url(../fonts/opensans-bolditalic.ttf) format("truetype"),
url(../fonts/opensans-bolditalic.svg#open_sansbold_italic) format("svg");
font-weight: bold;
font-style: italic;
}
+153
View File
@@ -0,0 +1,153 @@
@breiteMainBereich: 70rem;
@breiteTextBereich: 60rem;
@breiteNavBereich: @breiteMainBereich - @breiteTextBereich;
html {
body {
background: @mainColorLight;
> header, > footer {
color: @mainColorDark;
background: @mainColor;
font-family: serif;
h1 {
font-size: 3rem;
text-align: center;
}
p {
text-indent: inherit;
&:first-letter {
font-size: inherit;
}
font-style: italic;
}
}
> header h1 {
margin: 1rem auto;
}
> footer {
color: contrast(@mainColorDark);
}
& > nav {
ul {
background: darken(@mainColorLight,33%,relativ);
li {
font-weight: bold;
&:not(:first-child) {
border-left: @mainColorDark solid thin;
}
a {
text-decoration: none;
color: @mainColorDark;
}
&:hover {
background: lighten(@mainColor,20%,relativ);
a {
color: lighten(@mainColor,66%,relativ);
}
}
&.selected, &.selected:hover {
background: lighten(@mainColor,10%,relativ);
a {
color: lighten(@mainColor,66%,relativ);
}
}
}
}
}
}
}
main {
* {
border-style: none;
border-color: @mainColorDark;
border-width: medium;
}
section {
@sectionMargin: 1rem;
h1, h2 {
margin: @sectionMargin;
}
p, ul, ol, dl {
margin: @sectionMargin;
text-indent: @sectionMargin * 2;
}
table {
thead {
tr:last-child {
td, th {
border-bottom: @mainColor solid thin;
}
}
};
tbody {
td.absence-time {
color: @redColor;
text-decoration-line: underline;
text-decoration-style: dashed;
}
}
}
form {
@formLabelWidth: 10rem;
@formItemWidth: 40rem;
fieldset {
width: @formItemWidth + @formLabelWidth + 2rem;
margin: 1rem auto;
border-style: solid ;
border-color: @mainColor;
padding: @sectionMargin;
&.tasten {
border: none;
background-color: @mainColorLight;
}
legend {
margin: @sectionMargin;
margin-left: @sectionMargin * 2;
}
div {
& > label {
width: @formLabelWidth;
display: inline-block;
margin: 0.5rem;
}
input {
display: inline-block;
width: @formItemWidth;
padding: 0.25rem;
margin-bottom: 1.0rem;
border-style: solid;
border-width: thin;
}
}
}
}
button {
.button(@mainColor, spin(lighten(@mainColor,66%,relativ),@subColor));
&.sub-button {
.button(lighten(@mainColor,20%,relativ), spin(@mainColor,@subColor));
}
}
footer {
text-align: right;
padding: 0;
p {
padding: 0.5rem;
display: inline-block;
margin: inherit;
text-indent: inherit;
&:first-letter {
font-size: inherit;
}
background: @mainColorLight ;
color: @mainColor ;
font-style: italic;
font-size: 85%;
}
nav li {
padding: 0.5rem;
}
border-bottom-style: solid;
}
}
}
+306
View File
@@ -0,0 +1,306 @@
//Media Queries
@media print {
* {
font-size: 10pt;
}
header, footer, main, nav, aside {
body > & {
width: 19cm;
margin: auto;
.border-box();
}
}
header, footer{
padding: 0.5rem;
;
}
body {
& > header {
font-size: 200%;
font-weight: bolder;
}
& > nav, > header {
display: none;
}
}
main {
padding: 1.5rem 0.5rem 3.25rem;
.flexKinder(flow,'row');
width: @breiteTextBereich;
section {
padding: 0 0.5rem;
h1, h2, p {
margin-bottom: 1rem;
}
footer {
padding: 0;
p {
padding: 0.5rem;
font-size: 85%;
margin-bottom: 0;
}
}
article.liste {
page-break-after: always;
}
}
h1, h2 {
margin-left: 5em;
}
h1 {
font-size: 2em;
}
h2 {
font-size: 1.75em;
}
table {
margin: auto;
td, th {
padding: 0.5rem;
collapse: true;
}
}
}
aside {
padding-top: 2rem;
.flexKinder(flow,'row');
width: @breiteNavBereich;
}
}
@media screen and (min-width: 831px) {
header, footer, main, nav, aside {
body > & {
width: @breiteMainBereich;
margin: auto;
.border-box();
}
}
header, footer{
padding: 0.5rem;
}
body {
& > header {
font-size: 200%;
font-weight: bolder;
}
& > nav {
ul {
.flexEltern();
margin: 0;
li {
display: inline-block;
height: 1.5rem;
padding: 0.5rem 1rem;
}
}
}
}
main {
padding: 1.5rem 0.5rem 3.25rem;
.flexKinder(flow,'row');
width: @breiteTextBereich;
section {
padding: 0 0.5rem;
h1, h2, p {
margin-bottom: 1rem;
}
footer {
padding: 0;
p {
padding: 0.5rem;
font-size: 85%;
margin-bottom: 0;
}
}
}
h1, h2 {
margin-left: 5em;
}
h1 {
font-size: 2em;
}
h2 {
font-size: 1.75em;
}
table {
margin: auto;
td, th {
padding: 0.5rem;
}
}
}
aside {
padding-top: 2rem;
.flexKinder(flow,'row');
width: @breiteNavBereich;
}
}
@media screen and (max-width: 830px) {
html body {
@boxHeight: 5rem;
& > header {
height: @boxHeight;
font-size: @boxHeight;
text-align: center;
padding: 1rem;
}
& > footer {
padding: 1rem;
}
& > nav {
ul {
li {
display: inline-block;
border: none;
width: auto;
height: 1.5em;
padding-top: 0.25em;
a {
padding: 0.75rem;
}
&:not(:first-child) {
border-left: none;
}
}
}
}
main section {
header {
padding: 0 0.5rem;
h1, h2, p {
margin-bottom: 1rem;
}
h1, h2 {
margin-left: 5em;
}
h1 {
font-size: 2em;
}
h2 {
font-size: 1.75em;
}
}
form {
@formLabelWidth: 10rem;
@formItemWidth: 40rem;
@sectionMargin: 1rem;
margin: @sectionMargin;
fieldset {
width: auto;
margin: 1rem auto;
padding: @sectionMargin;
legend {
margin: @sectionMargin;
margin-left: @sectionMargin * 2;
}
div {
& > label {
width: auto;
display: inline-block;
margin: 0.5rem;
}
input, textarea {
display: inline-block;
width: auto;
padding: 0.25rem;
margin: 0.5rem;
margin-bottom: 1.0rem;
border-style: solid;
border-width: thin;
}
div.radio-button {
padding-left: 20%;
label, input {
display: inline;
width: auto;
}
input {
margin-top: inherit;
margin-right: 1rem;
margin-left: 1rem;
margin-bottom: inherit;
}
}
textarea {
height: 10rem;
}
ul.suche {
width: 30rem;
margin-left: @formLabelWidth + 1.5rem;
margin-top: -1rem;
background: white;
position: absolute;
border: solid thin;
li {list-style: none;
text-indent: 0;
cursor: pointer;
&:hover {
background: @mainColorLight;
color: @mainColor;
}
}
}
}
}
}
}
table {
width: 90%;
margin: 5%;
td, th {
padding: 0.25rem;
}
colgroup:not(:first-child) col:first-child{
border-left: solid thin;
}
thead {
th {
font-size: 100%;
font-weight: bold;
}
}
}
}
}
@media screen and (max-width: 720px) {
html body {
@boxHeight: 2.5rem;
& > header {
height: @boxHeight;
font-size: @boxHeight;
text-align: center;
padding: 1rem;
}
& > nav {
.hamburgerMenu();
}
main section header {
h1, h2 {
text-align: center;
margin: 0.5rem auto;
}
}
table {
width: 100%;
margin: 1rem 0;
td, th {
padding: 0.25rem;
}
colgroup:not(:first-child) col:first-child{
border-left: solid thin;
}
thead {
th {
font-size: 100%;
font-weight: bold;
.hyphens();
}
}
}
}
}
@@ -0,0 +1,145 @@
.hyphens() {
-webkit-hyphens: auto;
-moz-hyphens: auto;
hyphens: auto;
}
.border-box() {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
.vertical-gradient(@stop1, @stop2) {
.gradient(0deg, @stop1, @stop2);
}
.horizontal-gradient(@stop1, @stop2) {
.gradient(-90deg, @stop1, @stop2);
}
.gradient(@winkel, @stop1, @stop2) {
@winkelOld: @winkel + 90deg;
@groundColor: @stop2;
background: @groundColor -moz-linear-gradient(@winkelOld, @stop2, @stop1);
background: @groundColor -webkit-linear-gradient(@winkelOld, @stop2, @stop1);
background: @groundColor -o-linear-gradient(@winkelOld, @stop2, @stop1);
background: @groundColor -ms-linear-gradient(@winkelOld, @stop2, @stop1);
background: @groundColor linear-gradient(@winkel, @stop2, @stop1);
background-repeat: no-repeat;
}
.box-shadow(...) {
-webkit-box-shadow: @arguments;
-moz-box-shadow: @arguments;
box-shadow: @arguments;
}
.border-radius(...) {
-webkit-border-radius: @arguments;
-moz-border-radius: @arguments;
border-radius: @arguments;
}
.nowrap() {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hamburgerMenu() {
color: white;
width: 3rem;
height: 2rem;
top: 0.25rem;
left: 0.25rem;
.border-radius(4px);
border: none;
.vertical-gradient(@mainColorDark; @mainColor);
> ul {
height: auto;
width: auto;
position: absolute;
left: 0;
top: 100%;
background: fade(@mainColorDark,80%);
.border-radius(5px);
display: none;
> li {
display: block;
text-align: left;
line-height: normal;
border: none;
&:not(:first-child) {
border-top: 1px #888 solid;
}
a {
.nowrap();
display: block;
height: auto;
width: auto;
padding: 0.5rem 1rem;
color: white;
&:hover,
&:focus {
color: white;
.vertical-gradient(@mainColorLight; @mainColor);
}
}
&:first-child {
a {
.border-radius( 5px 5px 0 0 );
}
}
&:last-child {
a {
.border-radius( 0 0 5px 5px );
}
}
}
}
&:hover, &:focus, &:active, &:checked {
ul {
display: block;
}
}
&:before {
content: "\2261";
font-size: 2rem;
text-align: center;
line-height: 0.8em;
display: block;
cursor: pointer;
}
}
.flexEltern() {
display:-ms-flexbox;
display: flexbox;
display: -webkit-flex;
display: flex;
}
.flexEltern(column) {
display:-ms-flexbox;
display: flexbox;
display: -webkit-flex;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
}
.flexKinder(flow, @rest...) {
-webkit-flex-flow: @rest;
-ms-flex-flow: @rest;
flex-flow: @rest;
}
.flexKinder(glow, @num) {
-webkit-flex-grow: @num;
-ms-flex: @num;
flex: @num;
flex-grow: @num;
}
+16
View File
@@ -0,0 +1,16 @@
.button(@color, @colorFont) {
@colorBackground: @color;
@colorBackgroundLight: lighten(@colorBackground,50%,relativ);
@colorShadow: fade(@colorBackground,50%);
height: 2.5em;
.border-radius(1.25em);
padding: 0.5em 1.25em;
.vertical-gradient(@colorBackgroundLight, @colorBackground);
.box-shadow(0.1em 0.2em 0.5em @colorShadow);
color: @colorFont;
font-weight: bold;
&:hover {
.vertical-gradient(@colorBackground, @colorBackgroundLight);
}
margin: 0.25rem;
}
+80
View File
@@ -0,0 +1,80 @@
html {
font-size: 100%;
}
body {
color: #000;
font-size: 1em;
font-family: OpenSans, "Lucida Grande", "Lucida Sans Unicode", Verdana, Helvetica, Arial, sans-serif;
text-align: left;
background-color: #fff;
}
strong, b {
font-weight: bold;
}
em, i {
font-style: italic;
}
small {
font-size: 0.8rem;
}
section article {
font-size: 95%;
p, ul, ol, dl {
line-height: 1.5em;
//noinspection TaskProblemsInspection,LessResolvedByNameOnly
.hyphens();
font-size: 1.00em;
}
p {
text-align: justify;
}
ul, ol {
li {
list-style: initial;
}
}
dl {
dt {
font-weight: 600;
}
}
h1, h2, h3 {
font-weight: bold;
}
h1 {
font-size: 1.50em;
}
h2 {
font-size: 1.30em;
}
h3 {
font-size: 1.20em;
}
table {
thead {
th {
font-size: 100%;
font-weight: bold;
}
}
}
form {
fieldset {
legend {
font-size: 1.30em;
font-weight: bold;
}
div.zelle > label {
}
}
}
&.http-error {
display: none;
}
}
+13
View File
@@ -0,0 +1,13 @@
/**
Definition der Farben
*/
@hueMain: 180;
@hueSub: 60;
@mainColor: hsl(@hueMain, 90%, 29%);
@mainColorDark: darken(@mainColor,66%,relativ);
@mainColorLight: lighten(@mainColor,66%,relativ);
@subColor: hsl(@hueSub, 90%, 29%);
@subColorDark: darken(@subColor,66%,relativ);
@subColorLight: lighten(@subColor,66%,relativ);
+99
View File
@@ -0,0 +1,99 @@
html {
body {
> header, > footer {
background: none;
font-family: serif;
h1 {
font-size: 3rem;
text-align: left;
}
p {
text-indent: inherit;
&:first-letter {
font-size: inherit;
}
font-style: italic;
}
}
> header {
border-bottom: black solid thick;
h1 {
margin: 1rem;
}
}
> nav {
display: none;
}
> footer {
color: contrast(@mainColor);
}
}
}
main {
* {
border-style: none;
border-color: @mainColorDark;
border-width: medium;
}
section {
@sectionMargin: 1rem;
header {
text-align: center;
}
h1, h2 {
margin: @sectionMargin;
}
p {
margin: @sectionMargin;
text-indent: @sectionMargin * 2;
}
table {
colgroup:not(:first-child) col:first-child{
border-left: solid medium;
}
thead {
tr:last-child {
td, th {
border-bottom: @mainColorDark solid thick;
}
}
}
tbody {
th {
font-weight: bold;
text-align: center;
padding: 1rem 0.5rem;
}
th, td {
padding: 0.5rem;
border-bottom: solid thin;
}
}
}
form {
display: none;
}
button {
display: none;
}
footer {
text-align: right;
padding: 0;
p {
padding: 0.5rem;
display: inline-block;
margin: inherit;
text-indent: inherit;
&:first-letter {
font-size: inherit;
}
background: @mainColorLight ;
color: @mainColor ;
font-style: italic;
font-size: 85%;
}
border-bottom-style: solid;
}
}
}
+15
View File
@@ -0,0 +1,15 @@
// Einstellungen auf die Grundlagen zurücksetzen,
// beinhaltet auch Einstellungen für die neuen HTML5-Tags
//noinspection CssUnknownTarget
@import "meyer_resets.css";
// Bestimmung der Farben
@import "lesslib/colors.less";
// Mixins des Layouts
@import "lesslib/mixins.less";
// Layout-Unabhängige Mixins
@import "lesslib/mixins.framework.less";
// Bestimmung des grundsätzlichen Erscheinungsbildes der einzelnen Elemente,
// einschließlich des Hauptbereiches
@import "lesslib/print/basics.less";
// Darstellung unabhängig der Darstellungsgrößen
@import "lesslib/print/layout.less";
+51
View File
@@ -0,0 +1,51 @@
/* http://meyerweb.com/eric/tools/css/reset/
v2.0 | 20110126
License: none (public domain)
*/
@media screen {
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font: inherit;
font-size: 100%;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
main, /* Korektur: Torsten Lücke 2016-09-01 */
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
}
/* END: meyer_ressets */
+51
View File
@@ -0,0 +1,51 @@
import svelte from 'rollup-plugin-svelte';
import replace from '@rollup/plugin-replace';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import { terser } from 'rollup-plugin-terser';
import {config} from 'dotenv';
const production = !process.env.ROLLUP_WATCH;
export default {
input: 'src/index.js',
output: {
name: 'app',
format: 'esm',
sourcemap: true,
dir: 'public',
},
preserveEntrySignatures: false,
plugins: [
svelte({
// enable run-time checks when not in production
dev: !production,
// we'll extract any component CSS out into
// a separate file — better for performance
css: css => {
css.write('bundle.css');
}
}),
// If you have external dependencies installed from
// npm, you'll most likely need these plugins. In
// some cases you'll need additional configuration —
// consult the documentation for details:
// https://github.com/rollup/rollup-plugin-commonjs
resolve(),
commonjs(),
replace({
'process.env.NODE_ENV': JSON.stringify(production ? 'production' : 'development'),
'env': JSON.stringify({...config().parsed /* attached the .env config*/})
}),
// If we're building for production (npm run build
// instead of npm run dev), minify
production && terser()
],
watch: {
clearScreen: false
}
};
+57
View File
@@ -0,0 +1,57 @@
<header>
Arbeitszeiten
</header>
<Nav {active} />
<main>
<svelte:component this={Route} {params} />
</main>
<footer><p>© Torsten Lücke 2021</p></footer>
<script>
import Navaid from 'navaid';
import { onDestroy } from 'svelte';
import Nav from './Nav.svelte';
let Route, params={}, active;
let uri = location.pathname;
$: active = uri.split('/').pop() || 'home';
function run(thunk, obj) {
const target = uri;
thunk.then(m => {
if (target !== uri) return;
params = obj || {};
if (m.preload) {
m.preload({ params }).then(() => {
if (target !== uri) return;
Route = m.default;
window.scrollTo(0, 0);
});
} else {
Route = m.default;
window.scrollTo(0, 0);
}
});
}
function track(obj) {
uri = obj.state || obj.uri || location.pathname;
}
addEventListener('replacestate', track);
addEventListener('pushstate', track);
addEventListener('popstate', track);
const router = Navaid('/')
.on('/', () => run(import('../routes/Home.svelte')))
.on('/views/weekly', () => run(import('../routes/WeeklyViews.svelte')))
.on('/views/monthly', () => run(import('../routes/MonthlyViews.svelte')))
.on('/views/yearly', () => run(import('../routes/YearlyViews.svelte')))
.on('/working-hours', () => run(import('../routes/WorkingHours.svelte')))
.listen();
onDestroy(router.unlisten);
</script>
+15
View File
@@ -0,0 +1,15 @@
<!--suppress HtmlUnknownTarget -->
<nav>
<ul>
<li class="{ isActive('home') }"><a href="/">Home</a></li>
<li class="{ isActive('weekly') }"><a href="/views/weekly">Wochenansicht</a></li>
<li class="{ isActive('monthly') }"><a href="/views/monthly">Monatsansicht</a></li>
<li class="{ isActive('yearly') }"><a href="/views/yearly">Jahresansicht</a></li>
<li class="{ isActive('working-hours') }"><a href="/working-hours">Einträge</a></li>
</ul>
</nav>
<script>
export let active;
$: isActive = str => active === str ? 'selected' : '';
</script>
+5
View File
@@ -0,0 +1,5 @@
import App from './components/App.svelte';
new App({
target: document.body
});
+5
View File
@@ -0,0 +1,5 @@
<Views />
<script>
import Views from "./WeeklyViews.svelte";
</script>
+31
View File
@@ -0,0 +1,31 @@
<script>
import Views from "./Views.svelte";
export let params;
params = {
title: 'Monatsansicht',
records: [],
isLoading: true
}
const View = {
'browse': () => {
fetch(env.API_URL + '/views/working-hours/monthly')
.then(res => {
if (!res.ok) {
throw new Error('Fail!');
}
return res.json();
})
.then(data => {
params.records = data;
params.isLoading = false;
})
.catch(err => {
params.isLoading = false;
console.log(err);
});
}
};
View.browse();
</script>
<Views {params} />
+54
View File
@@ -0,0 +1,54 @@
<script>
export let params;
console.log(params)
if (!String.prototype.startsWith) {
String.prototype.startsWith = function(searchString, position) {
position = position || 0;
return this.indexOf(searchString, position) === position;
};
}
$: isNegative = time => time.startsWith('-') ? 'absence-time' : '';
$: cutMinusChar = time => time.startsWith('-') ? time.substr(1) : time;
</script>
<svelte:head>
<title>{params.title}</title>
</svelte:head>
<section id="list-records">
<header>
<h1>{params.title}</h1>
</header>
<article>
{#if params.isLoading === true}
<p>Loading...</p>
{:else}
<table>
<colgroup>
<col />
<col />
<col />
<col />
</colgroup>
<thead>
<tr>
<th>Zeitraum</th>
<th>Arbeitstage</th>
<th>Arbeitsstunden</th>
<th>Über- / Unterstunden</th>
</tr>
</thead>
<tbody>
{#each params.records as record}
<tr>
<td>{record.period}</td>
<td>{record.workingDays}</td>
<td>{record.totalHours}</td>
<td class="{ isNegative(record.overtime) }">{cutMinusChar(record.overtime)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</article>
</section>
+31
View File
@@ -0,0 +1,31 @@
<script>
import Views from "./Views.svelte";
export let params;
params = {
title: 'Wochenansicht',
records: [],
isLoading: true
}
const View = {
'browse': () => {
fetch(env.API_URL + '/views/working-hours/weekly')
.then(res => {
if (!res.ok) {
throw new Error('Fail!');
}
return res.json();
})
.then(data => {
params.records = data;
params.isLoading = false;
})
.catch(err => {
params.isLoading = false;
console.log(err);
});
}
};
View.browse();
</script>
<Views {params} />
+216
View File
@@ -0,0 +1,216 @@
<script>
export let params;
let title = "Bearbeitung Einträge"
import IconifyIcon from '@iconify/svelte';
import wrenchIcon from '@iconify-icons/oi/wrench';
import checkIcon from '@iconify-icons/oi/check';
import xIcon from '@iconify-icons/oi/x';
import documentIcon from '@iconify-icons/oi/document';
let activeRecord = null;
let newRecord = false;
params = {
title: 'Einträge',
records: [],
isLoading: true
}
const Records = {
'browse': () => {
// noinspection JSUnresolvedVariable
fetch(env.API_URL + '/working-hours')
.then(res => {
if (!res.ok) {
throw new Error('Fail!');
}
return res.json();
})
.then(data => {
Controller.cancelActiveRecord();
params.records = data;
params.isLoading = false;
})
.catch(err => {
params.isLoading = false;
console.log(err);
});
},
'read': date => {
// noinspection JSUnresolvedVariable
fetch(env.API_URL + '/working-hours/' + date)
.then(res => {
if (!res.ok) {
throw new Error('Fail!');
}
return res.json();
})
.then(data => {
activeRecord = data;
if (date === null) {
Controller.listRecords();
}
})
.catch(err => {
console.log(err);
});
},
'add': async record => {
console.log(record);
// noinspection JSUnresolvedVariable
await fetch(env.API_URL + '/working-hours', {
method: 'POST',
mode: 'cors',
cache: 'no-cache',
credentials: 'omit',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(record)
})
.then(res => {
if (!res.ok) {
throw new Error('Fail!');
}
})
.then(() => {
Controller.listRecords();
})
.catch(err => {
console.log(err);
});
},
'update': async (date, record) => {
console.log(record);
// noinspection JSUnresolvedVariable
await fetch(env.API_URL + '/working-hours/' + date, {
method: 'PUT',
mode: 'cors',
cache: 'no-cache',
credentials: 'omit',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(record)
})
.then(res => {
if (!res.ok) {
throw new Error('Fail!');
}
})
.then(() => {
Controller.listRecords();
})
.catch(err => {
console.log(err);
});
}
};
const Controller = {
'listRecords': () => {
Controller.cancelActiveRecord();
Records.browse();
},
'newRecord': () => {
activeRecord = {workingTime: '00:00:00'};
newRecord = true;
},
'saveRecord': () => {
if (newRecord === true) {
Records.add(activeRecord);
} else {
Records.update(activeRecord.workingDay, activeRecord);
}
},
'selectActiveRecord': record => Records.read(record.workingDay),
'cancelActiveRecord': () => {
activeRecord = null;
newRecord = false;
}
};
const initSite = () => {
Controller.listRecords()
};
initSite()
</script>
<svelte:head>
<title>{title}</title>
</svelte:head>
{#if activeRecord === null}
<section id="list-records">
<header>
<h1>{title}</h1>
</header>
<article>
{#if params.isLoading === true}
<p>Loading...</p>
{:else}
<!--suppress HtmlUnknownTarget -->
<form action="/working-hours">
<fieldset>
<button on:click={Controller.newRecord} type="button">Neuer Eintrag
<IconifyIcon icon={documentIcon}/>
</button>
</fieldset>
</form>
<table>
<colgroup>
<col />
<col />
<col />
</colgroup>
<thead>
<tr>
<th>Datum</th>
<th>Arbeitsstunden</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{#each params.records as record}
<tr>
<td>{record.workingDay}</td>
<td>{record.workingTime}</td>
<td>
<button on:click={() => Controller.selectActiveRecord(record)} type="button">Bearbeiten
<IconifyIcon icon={wrenchIcon}/>
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</article>
</section>
{:else}
<section id="edit-record">
<header>
<h1>{title}</h1>
</header>
<article>
<!--suppress HtmlUnknownTarget -->
<form action="/working-hours" name="editRecord">
<fieldset name="record-data">
<div>
<label for="workingDay">Arbeitstag</label>
<input bind:value={activeRecord.workingDay} type="date"
id="workingDay" required pattern="\d\{4}-[0-1]\d-[0-3]\d\">
</div>
<div>
<label for="workingTime">Arbeitszeit</label>
<input bind:value={activeRecord.workingTime} placeholder="Arbeitszeit" type="time"
id="workingTime" required pattern="[0-5]\d:[0-5]\d:[0-5]\d">
</div>
</fieldset>
<fieldset name="buttons">
<button on:click={Controller.saveRecord} type="button">Übernehmen
<IconifyIcon icon={checkIcon} color="green"/>
</button>
<button on:click={Controller.cancelActiveRecord} type="button" class="sub-button">Abbrechen
<IconifyIcon icon={xIcon}/>
</button>
</fieldset>
</form>
</article>
</section>
{/if}
+31
View File
@@ -0,0 +1,31 @@
<script>
import Views from "./Views.svelte";
export let params;
params = {
title: 'Jahresansicht',
records: [],
isLoading: true
}
const View = {
'browse': () => {
fetch(env.API_URL + '/views/working-hours/yearly')
.then(res => {
if (!res.ok) {
throw new Error('Fail!');
}
return res.json();
})
.then(data => {
params.records = data;
params.isLoading = false;
})
.catch(err => {
params.isLoading = false;
console.log(err);
});
}
};
View.browse();
</script>
<Views {params} />