refactor/update #30

Merged
TorstenHettstedt merged 15 commits from refactor/update into master 2025-06-16 15:48:34 +02:00
110 changed files with 1491 additions and 1418 deletions
+4
View File
@@ -109,3 +109,7 @@ Temporary Items
/api/tests/_* /api/tests/_*
/api/tests/*.suite.yml /api/tests/*.suite.yml
/api/.env /api/.env
/ui/static/print.css*
/ui/static/global.css*
/ui/static/bundle.css*
/ui/.svelte-kit/
+1 -1
View File
@@ -1,4 +1,4 @@
FROM php:8.0-apache FROM php:8.4-apache-bookworm
RUN apt update && apt install -y libpq-dev RUN apt update && apt install -y libpq-dev
RUN docker-php-ext-install -j$(nproc) pdo pdo_pgsql RUN docker-php-ext-install -j$(nproc) pdo pdo_pgsql
+9 -8
View File
@@ -16,21 +16,22 @@
} }
], ],
"require": { "require": {
"php": "8.4.*",
"slim/slim": "^4.7.1", "slim/slim": "^4.7.1",
"vlucas/phpdotenv": "^4.2", "vlucas/phpdotenv": "^v5.6.1",
"slim/psr7": "^1.3", "slim/psr7": "^1.7.0",
"php-di/slim-bridge": "^3.1.0", "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",
"ext-pdo": "*" "ext-pdo": "*"
}, },
"require-dev": { "require-dev": {
"phpstan/phpstan": "^0.12.80", "phpstan/phpstan": "^2.1.12",
"codeception/codeception": "^4.1.18", "codeception/codeception": "^5.2.1",
"codeception/module-phpbrowser": "^1.0.0", "codeception/module-phpbrowser": "^3.0.1",
"codeception/module-asserts": "^1.0.0", "codeception/module-asserts": "^3.1.0",
"codeception/module-db": "^1.1.0", "codeception/module-db": "^3.2.2",
"codeception/module-rest": "^1.2.8" "codeception/module-rest": "^3.4.1"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
+1 -1
View File
@@ -17,7 +17,7 @@ use TorstenHettstedt\TimekeepingApi\Middleware\OriginAccessControlHandler;
$container = new Container(); $container = new Container();
$container->set('databases', function () { $container->set('databases', function () {
$dsn = "pgsql:host=${_ENV['DATABASES_HOST']};port=5432;dbname=${_ENV['DATABASES_NAME']}"; $dsn = "pgsql:host={$_ENV['DATABASES_HOST']};port=5432;dbname={$_ENV['DATABASES_NAME']}";
return new PDO($dsn, $_ENV['DATABASES_USER'], $_ENV['DATABASES_PASS']); return new PDO($dsn, $_ENV['DATABASES_USER'], $_ENV['DATABASES_PASS']);
}); });
+6 -1
View File
@@ -4,7 +4,9 @@ namespace TorstenHettstedt\TimekeepingApi\Controller;
use JsonSerializable; use JsonSerializable;
use PDO; use PDO;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\ContainerInterface; use Psr\Container\ContainerInterface;
use Psr\Container\NotFoundExceptionInterface;
use Slim\Psr7\Response; use Slim\Psr7\Response;
abstract class AbstractController abstract class AbstractController
@@ -18,6 +20,8 @@ abstract class AbstractController
* @param ContainerInterface $container * @param ContainerInterface $container
* *
* @throws NotDatabasesException * @throws NotDatabasesException
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/ */
public function __construct(ContainerInterface $container) public function __construct(ContainerInterface $container)
{ {
@@ -34,7 +38,8 @@ abstract class AbstractController
* *
* @return Response * @return Response
*/ */
protected function printResponse(Response $response, mixed $data, int $status_code): Response { protected function printResponse(Response $response, mixed $data, int $status_code): Response
{
$payload = json_encode($data); $payload = json_encode($data);
$response->getBody()->write($payload); $response->getBody()->write($payload);
@@ -8,13 +8,8 @@ class HttpConflictRequestException extends HttpSpecializedException
{ {
/** @var int */ /** @var int */
protected $code = 409; protected $code = 409;
/** @var string */ /** @var string */
protected $message = 'Conflict.'; protected $message = 'Conflict.';
protected string $title = '409 Conflict';
/** @var string */ protected string $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.';
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.';
} }
@@ -11,6 +11,7 @@ use TorstenHettstedt\TimekeepingApi\Middleware\OriginAccessControlHandler;
class PreflightController class PreflightController
{ {
/** @noinspection PhpUnused */
public function preflight(Request $request, Response $response): ResponseInterface public function preflight(Request $request, Response $response): ResponseInterface
{ {
return (new OriginAccessControlHandler())->originAccessControl($request, $response); return (new OriginAccessControlHandler())->originAccessControl($request, $response);
+25 -9
View File
@@ -23,7 +23,7 @@ class WorkingHoursController extends AbstractController
/** /**
* @param Request $request * @param Request $request
* @param Response $response * @param Response $response
* @param mixed[] $args * @param array<string, mixed> $args
* *
* @return Response * @return Response
* *
@@ -46,7 +46,7 @@ class WorkingHoursController extends AbstractController
/** /**
* @param Request $request * @param Request $request
* @param Response $response * @param Response $response
* @param mixed[] $args * @param array<string, mixed> $args
* *
* @return Response * @return Response
* *
@@ -59,21 +59,29 @@ class WorkingHoursController extends AbstractController
$repository = new WorkingHoursRepository($this->databases); $repository = new WorkingHoursRepository($this->databases);
$queryParams = $request->getQueryParams(); $queryParams = $request->getQueryParams();
try { try {
return $this->printResponse($response, $repository->findFiltered( return $this->printResponse(
$response,
$repository->findFiltered(
$queryParams['start-date'] ?? null, $queryParams['start-date'] ?? null,
$queryParams['end-date'] ?? null $queryParams['end-date'] ?? null
), StatusCodeInterface::STATUS_OK); ),
StatusCodeInterface::STATUS_OK
);
} catch (RepositoryBadWhereDataException $exception) { } catch (RepositoryBadWhereDataException $exception) {
throw new HttpBadRequestException($request, 'Ein Wert für das Datum im Query ist ungültig', $exception); throw new HttpBadRequestException($request, 'Ein Wert für das Datum im Query ist ungültig', $exception);
} catch (Exception $exception) { } catch (Exception $exception) {
throw new HttpInternalServerErrorException($request, 'Der Wert für das Datum oder die Zeit ist falsch in der Datenbank', $exception); throw new HttpInternalServerErrorException(
$request,
'Der Wert für das Datum oder die Zeit ist falsch in der Datenbank',
$exception
);
} }
} }
/** /**
* @param Request $request * @param Request $request
* @param Response $response * @param Response $response
* @param mixed[] $args * @param array<string, mixed> $args
* *
* @return Response * @return Response
* *
@@ -88,14 +96,18 @@ class WorkingHoursController extends AbstractController
} catch (RepositoryRecordNotFoundException $exception) { } catch (RepositoryRecordNotFoundException $exception) {
throw new HttpNotFoundException($request, 'Der Eintrag ist nicht vorhanden.', $exception); throw new HttpNotFoundException($request, 'Der Eintrag ist nicht vorhanden.', $exception);
} catch (Exception $exception) { } catch (Exception $exception) {
throw new HttpInternalServerErrorException($request, 'Der Wert für das Datum oder die Zeit ist falsch in der Datenbank', $exception); throw new HttpInternalServerErrorException(
$request,
'Der Wert für das Datum oder die Zeit ist falsch in der Datenbank',
$exception
);
} }
} }
/** /**
* @param Request $request * @param Request $request
* @param Response $response * @param Response $response
* @param mixed[] $args * @param array<string, mixed> $args
* *
* @return Response * @return Response
* *
@@ -114,7 +126,11 @@ class WorkingHoursController extends AbstractController
} catch (RepositoryRecordAlreadyExistException $exception) { } catch (RepositoryRecordAlreadyExistException $exception) {
throw new HttpConflictRequestException($request, 'Der Eintrag ist schon vorhanden.', $exception); throw new HttpConflictRequestException($request, 'Der Eintrag ist schon vorhanden.', $exception);
} catch (Exception $exception) { } catch (Exception $exception) {
throw new HttpInternalServerErrorException($request, 'Der Wert für das Datum oder die Zeit ist falsch in der Datenbank', $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); return $this->printResponse($response, $model, StatusCodeInterface::STATUS_CREATED);
} }
@@ -17,7 +17,7 @@ class WorkingHoursViewController extends AbstractController
/** /**
* @param Request $request * @param Request $request
* @param Response $response * @param Response $response
* @param mixed[] $args * @param array<string, mixed> $args
* *
* @return Response * @return Response
* *
@@ -30,15 +30,17 @@ class WorkingHoursViewController extends AbstractController
try { try {
return $this->printResponse($response, $repository->findAll(), StatusCodeInterface::STATUS_OK); return $this->printResponse($response, $repository->findAll(), StatusCodeInterface::STATUS_OK);
} catch (Exception $exception) { } catch (Exception $exception) {
throw new HttpInternalServerErrorException($request, throw new HttpInternalServerErrorException(
'Der Wert für das Datum oder die Zeit ist falsch in der Datenbank', $exception); $request,
'Der Wert für das Datum oder die Zeit ist falsch in der Datenbank', $exception
);
} }
} }
/** /**
* @param Request $request * @param Request $request
* @param Response $response * @param Response $response
* @param mixed[] $args * @param array<string, mixed> $args
* *
* @return Response * @return Response
* *
@@ -51,15 +53,17 @@ class WorkingHoursViewController extends AbstractController
try { try {
return $this->printResponse($response, $repository->findAll(), StatusCodeInterface::STATUS_OK); return $this->printResponse($response, $repository->findAll(), StatusCodeInterface::STATUS_OK);
} catch (Exception $exception) { } catch (Exception $exception) {
throw new HttpInternalServerErrorException($request, throw new HttpInternalServerErrorException(
'Der Wert für das Datum oder die Zeit ist falsch in der Datenbank', $exception); $request,
'Der Wert für das Datum oder die Zeit ist falsch in der Datenbank', $exception
);
} }
} }
/** /**
* @param Request $request * @param Request $request
* @param Response $response * @param Response $response
* @param mixed[] $args * @param array<string, mixed> $args
* *
* @return Response * @return Response
* *
@@ -72,8 +76,10 @@ class WorkingHoursViewController extends AbstractController
try { try {
return $this->printResponse($response, $repository->findAll(), StatusCodeInterface::STATUS_OK); return $this->printResponse($response, $repository->findAll(), StatusCodeInterface::STATUS_OK);
} catch (Exception $exception) { } catch (Exception $exception) {
throw new HttpInternalServerErrorException($request, throw new HttpInternalServerErrorException(
'Der Wert für das Datum oder die Zeit ist falsch in der Datenbank', $exception); $request,
'Der Wert für das Datum oder die Zeit ist falsch in der Datenbank', $exception
);
} }
} }
+9 -5
View File
@@ -1,10 +1,12 @@
<?php /** @noinspection PhpUndefinedClassInspection */ <?php
/** @noinspection PhpUndefinedClassInspection */
namespace TorstenHettstedt\TimekeepingApi\Middleware; namespace TorstenHettstedt\TimekeepingApi\Middleware;
use JetBrains\PhpStorm\ArrayShape; use JetBrains\PhpStorm\ArrayShape;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@@ -14,12 +16,15 @@ use Throwable;
class ErrorHandler class ErrorHandler
{ {
/**
* @var App<ContainerInterface>
*/
protected App $app; protected App $app;
/** /**
* ErrorHandler constructor. * ErrorHandler constructor.
* *
* @param App $app * @param App<ContainerInterface> $app
*/ */
public function __construct(App $app) public function __construct(App $app)
{ {
@@ -29,7 +34,7 @@ class ErrorHandler
/** /**
* @param Throwable $exception * @param Throwable $exception
* *
* @return mixed[] * @return array<string, mixed>
*/ */
#[ArrayShape([ #[ArrayShape([
'timestamp' => "false|string", 'timestamp' => "false|string",
@@ -71,8 +76,7 @@ class ErrorHandler
bool $logErrors, bool $logErrors,
bool $logErrorDetails, bool $logErrorDetails,
?LoggerInterface $logger = null ?LoggerInterface $logger = null
): ResponseInterface ): ResponseInterface {
{
if ($logger !== null && $logErrors === true) { if ($logger !== null && $logErrors === true) {
$logger->error($exception->getMessage()); $logger->error($exception->getMessage());
} }
@@ -13,7 +13,7 @@ class JsonBodyParserMiddleware implements MiddlewareInterface
{ {
$contentType = $request->getHeaderLine('Content-Type'); $contentType = $request->getHeaderLine('Content-Type');
if (strstr($contentType, 'application/json')) { if (str_contains($contentType, 'application/json')) {
$contents = json_decode(file_get_contents('php://input'), true); $contents = json_decode(file_get_contents('php://input'), true);
if (json_last_error() === JSON_ERROR_NONE) { if (json_last_error() === JSON_ERROR_NONE) {
$request = $request->withParsedBody($contents); $request = $request->withParsedBody($contents);
@@ -28,7 +28,6 @@ class OriginAccessControlHandler implements MiddlewareInterface
public function originAccessControl(Request $request, Response $response): Response public function originAccessControl(Request $request, Response $response): Response
{ {
$routeContext = RouteContext::fromRequest($request); $routeContext = RouteContext::fromRequest($request);
$routingResults = $routeContext->getRoutingResults(); $routingResults = $routeContext->getRoutingResults();
$methods = $routingResults->getAllowedMethods(); $methods = $routingResults->getAllowedMethods();
@@ -53,6 +52,6 @@ class OriginAccessControlHandler implements MiddlewareInterface
protected function buildOrigin(Request $request): string protected function buildOrigin(Request $request): string
{ {
return $request->getHeaderLine('Origin') ?? ''; return $request->getHeaderLine('Origin');
} }
} }
+6 -13
View File
@@ -1,23 +1,16 @@
<?php /** @noinspection PhpUnusedPrivateFieldInspection */ <?php
/** @noinspection PhpUnusedPrivateFieldInspection */
namespace TorstenHettstedt\TimekeepingApi\Models; namespace TorstenHettstedt\TimekeepingApi\Models;
use MyCLabs\Enum\Enum;
/** /**
* Enum-Klasse für die unterstützten Periode-Definitionen. * Enum-Klasse für die unterstützten Periode-Definitionen.
* Der Wert der Periode ist ein Format-String für {@link https://secure.php.net/manual/en/datetime.format.php}. * Der Wert der Periode ist ein Format-String für {@link https://secure.php.net/manual/en/datetime.format.php}.
*
* @extends Enum<string>
*
* @method static PeriodDesignationEnum WEEKLY()
* @method static PeriodDesignationEnum MONTHLY()
* @method static PeriodDesignationEnum YEARLY()
*/ */
class PeriodDesignationEnum extends Enum enum PeriodDesignationEnum: string
{ {
private const WEEKLY = 'Y#W'; case WEEKLY = 'Y#W';
private const MONTHLY = 'Y-m'; case MONTHLY = 'Y-m';
private const YEARLY = 'Y'; case YEARLY = 'Y';
} }
+2 -18
View File
@@ -13,39 +13,23 @@ class WorkingHours implements ModelInterface
protected ?DateTime $workingDay = null; protected ?DateTime $workingDay = null;
protected ?DateInterval $workingTime = null; protected ?DateInterval $workingTime = null;
/**
* @return DateTime|null
*/
public function getWorkingDay(): ?DateTime public function getWorkingDay(): ?DateTime
{ {
return $this->workingDay; return $this->workingDay;
} }
/** public function setWorkingDay(DateTime $workingDay): static
* @param DateTime $workingDay
*
* @return WorkingHours
*/
public function setWorkingDay(DateTime $workingDay): WorkingHours
{ {
$this->workingDay = $workingDay; $this->workingDay = $workingDay;
return $this; return $this;
} }
/**
* @return DateInterval|null
*/
public function getWorkingTime(): ?DateInterval public function getWorkingTime(): ?DateInterval
{ {
return $this->workingTime; return $this->workingTime;
} }
/** public function setWorkingTime(DateInterval $workingTime): static
* @param DateInterval $workingTime
*
* @return WorkingHours
*/
public function setWorkingTime(DateInterval $workingTime): WorkingHours
{ {
$this->workingTime = $workingTime; $this->workingTime = $workingTime;
return $this; return $this;
+11 -43
View File
@@ -10,70 +10,36 @@ use JetBrains\PhpStorm\ArrayShape;
class WorkingHoursView implements ModelInterface class WorkingHoursView implements ModelInterface
{ {
protected DateTimeInterface $period;
protected PeriodDesignationEnum $periodDesignation;
protected int $workingDays;
protected DateInterval $totalHours;
protected DateInterval $overtime;
/**
* WorkingHoursView constructor.
*
* @param DateTimeInterface $period
* @param PeriodDesignationEnum $periodDesignation
* @param int $workingDays
* @param DateInterval $totalHours
* @param DateInterval $overtime
*/
public function __construct( public function __construct(
DateTimeInterface $period, protected DateTimeInterface $period,
PeriodDesignationEnum $periodDesignation, protected PeriodDesignationEnum $periodDesignation,
int $workingDays, protected int $workingDays,
DateInterval $totalHours, protected DateInterval $totalHours,
DateInterval $overtime protected DateInterval $overtime
TorstenHettstedt marked this conversation as resolved Outdated
Outdated
Review

ist es nicht besser, die ganzen Parameter über den Konstrukt zu bestimmen?

ist es nicht besser, die ganzen Parameter über den Konstrukt zu bestimmen?
) { ) {
$this->period = $period;
$this->periodDesignation = $periodDesignation;
$this->workingDays = $workingDays;
$this->totalHours = $totalHours;
$this->overtime = $overtime;
} }
/**
* @return DateTimeInterface
*/
public function getPeriod(): DateTimeInterface public function getPeriod(): DateTimeInterface
{ {
return $this->period; return $this->period;
} }
/**
* @return PeriodDesignationEnum
*/
public function getPeriodDesignation(): PeriodDesignationEnum public function getPeriodDesignation(): PeriodDesignationEnum
{ {
return $this->periodDesignation; return $this->periodDesignation;
} }
/**
* @return int
*/
public function getWorkingDays(): int public function getWorkingDays(): int
{ {
return $this->workingDays; return $this->workingDays;
} }
/**
* @return DateInterval
*/
public function getTotalHours(): DateInterval public function getTotalHours(): DateInterval
{ {
return $this->totalHours; return $this->totalHours;
} }
/**
* @return DateInterval
*/
public function getOvertime(): DateInterval public function getOvertime(): DateInterval
{ {
return $this->overtime; return $this->overtime;
@@ -84,17 +50,19 @@ class WorkingHoursView implements ModelInterface
* *
* @return array<string, mixed> * @return array<string, mixed>
*/ */
#[ArrayShape(['period' => "string", #[ArrayShape([
'period' => "string",
'periodDesignation' => "string", 'periodDesignation' => "string",
'totalHours' => "string", 'totalHours' => "string",
'workingDays' => "int", 'workingDays' => "int",
'overtime' => "string" 'overtime' => "string"
])] public function jsonSerialize(): array ])]
public function jsonSerialize(): array
{ {
$formatOvertime = (($this->getOvertime()->invert === 1) ? '-' : '') . '%H:%I:%S'; $formatOvertime = (($this->getOvertime()->invert === 1) ? '-' : '') . '%H:%I:%S';
return [ return [
'period' => $this->getPeriod()->format($this->getPeriodDesignation()->getValue()), 'period' => $this->getPeriod()->format($this->getPeriodDesignation()->value),
'periodDesignation' => strtolower($this->getPeriodDesignation()->getKey()), 'periodDesignation' => strtolower($this->getPeriodDesignation()->name),
'workingDays' => $this->getWorkingDays(), 'workingDays' => $this->getWorkingDays(),
'totalHours' => $this->getTotalHours()->format('%H:%I:%S'), 'totalHours' => $this->getTotalHours()->format('%H:%I:%S'),
'overtime' => $this->getOvertime()->format($formatOvertime) 'overtime' => $this->getOvertime()->format($formatOvertime)
@@ -20,7 +20,6 @@ abstract class AbstractWorkingHoursViewRepository implements RepositoryReaderInt
protected const SQL_SELECT = ''; protected const SQL_SELECT = '';
protected const SQL_WHERE = ''; protected const SQL_WHERE = '';
protected PDO $database;
protected PeriodDesignationEnum $periodDesignation; protected PeriodDesignationEnum $periodDesignation;
/** /**
@@ -28,9 +27,8 @@ abstract class AbstractWorkingHoursViewRepository implements RepositoryReaderInt
* *
* @param PDO $database * @param PDO $database
*/ */
public function __construct(PDO $database) public function __construct(protected PDO $database)
{ {
$this->database = $database;
$this->database->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->database->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} }
@@ -3,8 +3,10 @@
namespace TorstenHettstedt\TimekeepingApi\Repositories; namespace TorstenHettstedt\TimekeepingApi\Repositories;
use TorstenHettstedt\TimekeepingApi\Models\ModelInterface;
/** /**
* @template T of \TorstenHettstedt\TimekeepingApi\Models\ModelInterface * @template T of ModelInterface
*/ */
interface RepositoryReaderInterface interface RepositoryReaderInterface
{ {
@@ -3,8 +3,10 @@
namespace TorstenHettstedt\TimekeepingApi\Repositories; namespace TorstenHettstedt\TimekeepingApi\Repositories;
use TorstenHettstedt\TimekeepingApi\Models\ModelInterface;
/** /**
* @template T of \TorstenHettstedt\TimekeepingApi\Models\ModelInterface * @template T of ModelInterface
*/ */
interface RepositoryWriterInterface interface RepositoryWriterInterface
{ {
@@ -12,7 +12,7 @@ use TorstenHettstedt\TimekeepingApi\Models\PeriodDesignationEnum;
class WorkingHoursMonthlyViewRepository extends AbstractWorkingHoursViewRepository class WorkingHoursMonthlyViewRepository extends AbstractWorkingHoursViewRepository
{ {
protected const SQL_SELECT = <<<SQL protected const string SQL_SELECT = <<<SQL
select select
"Monat" as "period", "Monat" as "period",
"Gesamtarbeitszeit" as "totalHours", "Gesamtarbeitszeit" as "totalHours",
@@ -20,15 +20,12 @@ class WorkingHoursMonthlyViewRepository extends AbstractWorkingHoursViewReposito
"Überstunden" as "overtime" "Überstunden" as "overtime"
from "Arbeitszeiten - Monat" from "Arbeitszeiten - Monat"
SQL; SQL;
protected const SQL_WHERE = ' where "Monat" = ?'; protected const string SQL_WHERE = ' where "Monat" = ?';
protected PDO $database;
protected PeriodDesignationEnum $periodDesignation;
public function __construct(PDO $database) public function __construct(PDO $database)
{ {
parent::__construct($database); parent::__construct($database);
$this->periodDesignation = PeriodDesignationEnum::MONTHLY(); $this->periodDesignation = PeriodDesignationEnum::MONTHLY;
} }
protected function buildDateFromPeriod(string $period): DateTimeInterface protected function buildDateFromPeriod(string $period): DateTimeInterface
+13 -12
View File
@@ -20,17 +20,16 @@ use TorstenHettstedt\TimekeepingApi\Models\WorkingHours;
*/ */
class WorkingHoursRepository implements RepositoryReaderInterface, RepositoryWriterInterface class WorkingHoursRepository implements RepositoryReaderInterface, RepositoryWriterInterface
{ {
protected const string SQL_SELECT = <<<'SQL'
select
"Datum" as "workingDay",
"Arbeitszeit" as "workingTime"
from public."Arbeitszeiten"
SQL;
protected PDO $database;
/** public function __construct(protected PDO $database)
* WorkingHoursRepository constructor.
*
* @param PDO $database
*/
public function __construct(PDO $database)
{ {
$this->database = $database;
$this->database->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->database->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} }
@@ -96,7 +95,7 @@ class WorkingHoursRepository implements RepositoryReaderInterface, RepositoryWri
protected function buildFindFilteredStatement(?string $start, ?string $end): ?PDOStatement protected function buildFindFilteredStatement(?string $start, ?string $end): ?PDOStatement
{ {
$query = 'select "Datum" as "workingDay", "Arbeitszeit" as "workingTime" from public."Arbeitszeiten" '; $query = static::SQL_SELECT . ' ';
$query .= $this->buildFilterString($start, $end); $query .= $this->buildFilterString($start, $end);
$query .= 'order by "Datum"'; $query .= 'order by "Datum"';
$stmt = $this->database->prepare($query); $stmt = $this->database->prepare($query);
@@ -106,7 +105,7 @@ class WorkingHoursRepository implements RepositoryReaderInterface, RepositoryWri
if (is_string($end)) { if (is_string($end)) {
$stmt->bindParam(':end', $end); $stmt->bindParam(':end', $end);
} }
return $stmt ?? null; return $stmt ?: null;
} }
/** /**
1
@@ -121,7 +120,7 @@ class WorkingHoursRepository implements RepositoryReaderInterface, RepositoryWri
*/ */
public function findByKey(mixed $primary_key): WorkingHours public function findByKey(mixed $primary_key): WorkingHours
{ {
$stmt = $this->database->prepare('select "Datum" as "workingDay", "Arbeitszeit" as "workingTime" from public."Arbeitszeiten" where "Datum" = ?'); $stmt = $this->database->prepare(static::SQL_SELECT . ' where "Datum" = ?');
$stmt->bindParam(1, $primary_key); $stmt->bindParam(1, $primary_key);
$stmt->execute(); $stmt->execute();
$row = $stmt->fetch(); $row = $stmt->fetch();
@@ -155,7 +154,9 @@ class WorkingHoursRepository implements RepositoryReaderInterface, RepositoryWri
throw new RepositoryRecordAlreadyExistException(); 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'
);
$stmt->bindParam(1, $workingDay); $stmt->bindParam(1, $workingDay);
$stmt->bindParam(2, $workingTime); $stmt->bindParam(2, $workingTime);
$stmt->execute(); $stmt->execute();
@@ -11,7 +11,7 @@ use TorstenHettstedt\TimekeepingApi\Models\PeriodDesignationEnum;
class WorkingHoursWeeklyViewRepository extends AbstractWorkingHoursViewRepository class WorkingHoursWeeklyViewRepository extends AbstractWorkingHoursViewRepository
{ {
protected const SQL_SELECT = <<<SQL protected const string SQL_SELECT = <<<SQL
select select
"Woche" as "period", "Woche" as "period",
"Gesamtarbeitszeit" as "totalHours", "Gesamtarbeitszeit" as "totalHours",
@@ -19,15 +19,12 @@ class WorkingHoursWeeklyViewRepository extends AbstractWorkingHoursViewRepositor
"Überstunden" as "overtime" "Überstunden" as "overtime"
from "Arbeitszeiten - Woche" from "Arbeitszeiten - Woche"
SQL; SQL;
protected const SQL_WHERE = ' where "Woche" = ?'; protected const string SQL_WHERE = ' where "Woche" = ?';
protected PDO $database;
protected PeriodDesignationEnum $periodDesignation;
public function __construct(PDO $database) public function __construct(PDO $database)
{ {
parent::__construct($database); parent::__construct($database);
$this->periodDesignation = PeriodDesignationEnum::WEEKLY(); $this->periodDesignation = PeriodDesignationEnum::WEEKLY;
} }
protected function buildDateFromPeriod(string $period): DateTimeInterface protected function buildDateFromPeriod(string $period): DateTimeInterface
@@ -11,7 +11,7 @@ use TorstenHettstedt\TimekeepingApi\Models\PeriodDesignationEnum;
class WorkingHoursYearlyViewRepository extends AbstractWorkingHoursViewRepository class WorkingHoursYearlyViewRepository extends AbstractWorkingHoursViewRepository
{ {
protected const SQL_SELECT = <<<SQL protected const string SQL_SELECT = <<<SQL
select select
"Jahr" as "period", "Jahr" as "period",
"Gesamtarbeitszeit" as "totalHours", "Gesamtarbeitszeit" as "totalHours",
@@ -19,15 +19,12 @@ class WorkingHoursYearlyViewRepository extends AbstractWorkingHoursViewRepositor
"Überstunden" as "overtime" "Überstunden" as "overtime"
from "Arbeitszeiten - Jahr" from "Arbeitszeiten - Jahr"
SQL; SQL;
protected const SQL_WHERE = ' where "Jahr" = ?'; protected const string SQL_WHERE = ' where "Jahr" = ?';
protected PDO $database;
protected PeriodDesignationEnum $periodDesignation;
public function __construct(PDO $database) public function __construct(PDO $database)
{ {
parent::__construct($database); parent::__construct($database);
$this->periodDesignation = PeriodDesignationEnum::YEARLY(); $this->periodDesignation = PeriodDesignationEnum::YEARLY;
} }
/** /**
+64 -64
View File
@@ -13,10 +13,10 @@ SET xmloption = content;
SET client_min_messages = warning; SET client_min_messages = warning;
SET row_security = off; SET row_security = off;
DROP TABLE IF EXISTS public."Arbeitszeiten";
DROP VIEW IF EXISTS public."Arbeitszeiten - Woche"; DROP VIEW IF EXISTS public."Arbeitszeiten - Woche";
DROP VIEW IF EXISTS public."Arbeitszeiten - Monat"; DROP VIEW IF EXISTS public."Arbeitszeiten - Monat";
DROP VIEW IF EXISTS public."Arbeitszeiten - Jahr"; DROP VIEW IF EXISTS public."Arbeitszeiten - Jahr";
DROP TABLE IF EXISTS public."Arbeitszeiten";
-- --
-- Name: Arbeitszeiten; Type: TABLE; Schema: public; Owner: torsten -- Name: Arbeitszeiten; Type: TABLE; Schema: public; Owner: torsten
@@ -83,66 +83,66 @@ ALTER TABLE public."Arbeitszeiten - Woche" OWNER TO bruce;
-- Data for Name: Arbeitszeiten; Type: TABLE DATA; Schema: public; Owner: torsten -- Data for Name: Arbeitszeiten; Type: TABLE DATA; Schema: public; Owner: torsten
-- --
COPY public."Arbeitszeiten" ("Datum", "Arbeitszeit") FROM stdin; INSERT INTO public."Arbeitszeiten" ("Datum", "Arbeitszeit") VALUES
2020-01-07 08:06:00 ('2020-01-07','08:06:00'),
2020-01-08 08:16:00 ('2020-01-08','08:16:00'),
2020-01-09 07:49:00 ('2020-01-09','07:49:00'),
2020-01-10 07:30:00 ('2020-01-10','07:30:00'),
2020-01-13 08:06:00 ('2020-01-13','08:06:00'),
2020-01-14 08:04:00 ('2020-01-14','08:04:00'),
2020-01-15 08:20:00 ('2020-01-15','08:20:00'),
2020-01-16 07:13:00 ('2020-01-16','07:13:00'),
2020-01-17 07:57:00 ('2020-01-17','07:57:00'),
2020-01-20 07:53:00 ('2020-01-20','07:53:00'),
2020-01-21 07:49:00 ('2020-01-21','07:49:00'),
2020-01-22 07:56:00 ('2020-01-22','07:56:00'),
2020-01-23 07:55:00 ('2020-01-23','07:55:00'),
2020-01-24 07:08:00 ('2020-01-24','07:08:00'),
2020-01-27 08:23:00 ('2020-01-27','08:23:00'),
2020-01-28 07:20:00 ('2020-01-28','07:20:00'),
2020-01-29 08:13:00 ('2020-01-29','08:13:00'),
2020-01-30 08:43:00 ('2020-01-30','08:43:00'),
2020-01-31 07:21:00 ('2020-01-31','07:21:00'),
2020-02-03 07:56:00 ('2020-02-03','07:56:00'),
2020-02-04 08:05:00 ('2020-02-04','08:05:00'),
2020-02-05 07:58:00 ('2020-02-05','07:58:00'),
2020-02-06 08:01:00 ('2020-02-06','08:01:00'),
2020-02-07 07:59:00 ('2020-02-07','07:59:00'),
2020-02-10 07:50:00 ('2020-02-10','07:50:00'),
2020-02-11 08:01:00 ('2020-02-11','08:01:00'),
2020-02-12 08:14:00 ('2020-02-12','08:14:00'),
2020-02-13 08:12:00 ('2020-02-13','08:12:00'),
2020-02-14 07:51:00 ('2020-02-14','07:51:00'),
2020-02-17 07:59:00 ('2020-02-17','07:59:00'),
2020-02-18 08:05:00 ('2020-02-18','08:05:00'),
2020-02-19 07:34:00 ('2020-02-19','07:34:00'),
2020-02-20 07:33:00 ('2020-02-20','07:33:00'),
2020-02-21 07:56:00 ('2020-02-21','07:56:00'),
2020-02-24 08:02:00 ('2020-02-24','08:02:00'),
2020-02-25 08:13:00 ('2020-02-25','08:13:00'),
2020-02-26 08:42:00 ('2020-02-26','08:42:00'),
2020-02-27 07:49:00 ('2020-02-27','07:49:00'),
2020-02-28 08:16:00 ('2020-02-28','08:16:00'),
2020-03-02 08:22:00 ('2020-03-02','08:22:00'),
2020-03-03 08:20:00 ('2020-03-03','08:20:00'),
2020-03-04 08:12:00 ('2020-03-04','08:12:00'),
2020-03-05 08:32:00 ('2020-03-05','08:32:00'),
2020-03-06 04:41:00 ('2020-03-06','04:41:00'),
2020-03-09 07:05:00 ('2020-03-09','07:05:00'),
2020-03-10 07:34:00 ('2020-03-10','07:34:00'),
2020-03-11 07:39:00 ('2020-03-11','07:39:00'),
2020-03-12 07:50:00 ('2020-03-12','07:50:00'),
2020-03-13 08:26:00 ('2020-03-13','08:26:00'),
2020-03-16 07:51:00 ('2020-03-16','07:51:00'),
2020-03-17 07:50:00 ('2020-03-17','07:50:00'),
2020-03-18 07:19:00 ('2020-03-18','07:19:00'),
2020-03-19 07:55:00 ('2020-03-19','07:55:00'),
2020-03-20 05:43:00 ('2020-03-20','05:43:00'),
2020-03-23 08:05:00 ('2020-03-23','08:05:00'),
2020-03-24 08:21:00 ('2020-03-24','08:21:00'),
2020-03-25 08:10:00 ('2020-03-25','08:10:00'),
2020-03-26 10:31:00 ('2020-03-26','10:31:00'),
2020-03-27 04:55:00 ('2020-03-27','04:55:00'),
2020-03-30 08:21:00 ('2020-03-30','08:21:00'),
2020-03-31 08:01:00 ('2020-03-31','08:01:00')
\. ;
+1 -1
View File
@@ -3,6 +3,6 @@ modules:
enabled: enabled:
- \Helper\Api - \Helper\Api
- REST: - REST:
url: http://localhost:8091/ url: http://api:80/
depends: PhpBrowser depends: PhpBrowser
part: Json part: Json
@@ -0,0 +1,52 @@
<?php
namespace TorstenHettstedt\TimekeepingApi\Tests\Unit\Controller;
use Codeception\Test\Unit;
use Exception;
use PDO;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\StreamInterface;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
class AbstractControllerTest extends Unit
{
protected const FICTIONAL_STATUS_CODE = 666;
protected ContainerInterface $container;
protected Request $request;
protected Response|MockObject $response;
/**
* @throws Exception
* @throws \PHPUnit\Framework\MockObject\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->response = $this->createMock(Response::class);
$this->response->expects($this->any())->method('getStatusCode')->willReturn(self::FICTIONAL_STATUS_CODE);
$this->response->expects($this->any())->method('getBody')->willReturn(
$this->makeEmpty(StreamInterface::class, [
'write' => function (mixed $data) {
$this->assertIsString($data);
$this->assertJson($data);
return strlen($data);
},
'getContents' => 'abc',
])
);
$this->response->expects($this->any())->method('withHeader')->willReturn($this->response);
$this->response->expects($this->any())->method('withStatus')->willReturn($this->response);
}
}
@@ -2,65 +2,46 @@
namespace TorstenHettstedt\TimekeepingApi\Tests\Unit\Controller; namespace TorstenHettstedt\TimekeepingApi\Tests\Unit\Controller;
use Codeception\Example; use Codeception\Attribute\DataProvider;
use Codeception\Test\Unit;
use Exception; use Exception;
use PDO; use Psr\Container\ContainerExceptionInterface;
use Psr\Container\ContainerInterface; use Psr\Container\ContainerInterface;
use Psr\Http\Message\StreamInterface; use Psr\Container\NotFoundExceptionInterface;
use Slim\Exception\HttpBadRequestException; use Slim\Exception\HttpBadRequestException;
use Slim\Exception\HttpInternalServerErrorException;
use Slim\Exception\HttpNotFoundException; use Slim\Exception\HttpNotFoundException;
use Slim\Psr7\Request; use Slim\Psr7\Request;
use Slim\Psr7\Response;
use TorstenHettstedt\TimekeepingApi\Controller\HttpConflictRequestException; use TorstenHettstedt\TimekeepingApi\Controller\HttpConflictRequestException;
use TorstenHettstedt\TimekeepingApi\Controller\NotDatabasesException; use TorstenHettstedt\TimekeepingApi\Controller\NotDatabasesException;
use TorstenHettstedt\TimekeepingApi\Controller\WorkingHoursController; use TorstenHettstedt\TimekeepingApi\Controller\WorkingHoursController;
class WorkingHoursControllerTest extends Unit class WorkingHoursControllerTest extends AbstractControllerTest
{ {
const EXISTING_DATE = '2020-01-28'; protected const int FICTIONAL_STATUS_CODE = 666;
const EXISTING_INTERVAL = '07:20:00'; public const string EXISTING_DATE = '2020-01-28';
const NEW_DATE = '2020-04-01'; public const string EXISTING_INTERVAL = '07:20:00';
const NEW_INTERVAL = '07:59:00'; public const string NEW_DATE = '2020-04-01';
public const string NEW_INTERVAL = '07:59:00';
protected ContainerInterface $container;
protected Request $request;
protected Response $response;
/** /**
* @throws Exception * @throws Exception
* @throws \PHPUnit\Framework\MockObject\Exception
*/ */
protected function _before(): void protected function _before(): void
TorstenHettstedt marked this conversation as resolved Outdated
Outdated
Review

muss das noch sein?

muss das noch sein?
{ {
parent::_before(); 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->request = $this->makeEmpty(Request::class, [
'getParsedBody' => [ 'getParsedBody' => [
'workingDay' => self::EXISTING_DATE, 'workingDay' => self::EXISTING_DATE,
'workingTime' => self::EXISTING_INTERVAL, '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 NotDatabasesException
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
* @throws Exception * @throws Exception
*/ */
public function testConstructWithNonDatabase(): void public function testConstructWithNonDatabase(): void
@@ -103,12 +84,12 @@ class WorkingHoursControllerTest extends Unit
/** /**
* @param array<string, string> $queryParams * @param array<string, string> $queryParams
* *
* @dataProvider valideFilterDataProvider * @throws ContainerExceptionInterface
*
* @throws HttpInternalServerErrorException
* @throws NotDatabasesException * @throws NotDatabasesException
* @throws NotFoundExceptionInterface
* @throws Exception * @throws Exception
*/ */
#[DataProvider('valideFilterDataProvider')]
public function testBrowseWithValideParameter(array $queryParams): void public function testBrowseWithValideParameter(array $queryParams): void
{ {
$this->request = $this->makeEmpty(Request::class, [ $this->request = $this->makeEmpty(Request::class, [
@@ -116,7 +97,8 @@ class WorkingHoursControllerTest extends Unit
]); ]);
$controller = new WorkingHoursController($this->container); $controller = new WorkingHoursController($this->container);
$response = $controller->browse($this->request, $this->response, []); $response = $controller->browse($this->request, $this->response, []);
$this->assertInstanceOf(Response::class, $response); $this->assertEquals(self::FICTIONAL_STATUS_CODE, $response->getStatusCode());
$this->assertNotEmpty($response->getBody()->getContents());
} }
/** /**
@@ -246,12 +228,12 @@ class WorkingHoursControllerTest extends Unit
/** /**
* @param array<string, string> $queryParams * @param array<string, string> $queryParams
* *
* @dataProvider invalideFilterDataProvider * @throws ContainerExceptionInterface
*
* @throws HttpInternalServerErrorException
* @throws NotDatabasesException * @throws NotDatabasesException
* @throws NotFoundExceptionInterface
* @throws Exception * @throws Exception
*/ */
#[DataProvider('invalideFilterDataProvider')]
public function testBrowseWithInvalideParameter(array $queryParams): void public function testBrowseWithInvalideParameter(array $queryParams): void
{ {
$this->request = $this->makeEmpty(Request::class, [ $this->request = $this->makeEmpty(Request::class, [
@@ -263,9 +245,10 @@ class WorkingHoursControllerTest extends Unit
} }
/** /**
* @throws ContainerExceptionInterface
* @throws NotDatabasesException * @throws NotDatabasesException
* @throws HttpBadRequestException * @throws NotFoundExceptionInterface
* @throws HttpNotFoundException * @throws Exception
*/ */
public function testUpdateExistRecord(): void public function testUpdateExistRecord(): void
{ {
@@ -273,13 +256,15 @@ class WorkingHoursControllerTest extends Unit
$response = $controller->update($this->request, $this->response, [ $response = $controller->update($this->request, $this->response, [
'id' => self::EXISTING_DATE, 'id' => self::EXISTING_DATE,
]); ]);
$this->assertInstanceOf(Response::class, $response); $this->assertEquals(self::FICTIONAL_STATUS_CODE, $response->getStatusCode());
$this->assertNotEmpty($response->getBody()->getContents());
} }
/** /**
* @throws ContainerExceptionInterface
* @throws NotDatabasesException * @throws NotDatabasesException
* @throws HttpBadRequestException * @throws NotFoundExceptionInterface
* @throws HttpNotFoundException * @throws Exception
*/ */
public function testUpdateNotExistRecord(): void public function testUpdateNotExistRecord(): void
{ {
@@ -311,14 +296,12 @@ class WorkingHoursControllerTest extends Unit
* @param string $date * @param string $date
* @param string $time * @param string $time
* *
* @dataProvider invalidCreatDataProvider * @throws ContainerExceptionInterface
*
* @throws HttpBadRequestException
* @throws HttpConflictRequestException
* @throws HttpInternalServerErrorException
* @throws NotDatabasesException * @throws NotDatabasesException
* @throws NotFoundExceptionInterface
* @throws Exception * @throws Exception
*/ */
#[DataProvider('invalidCreatDataProvider')]
public function testCreatInvalidData(string $date, string $time): void public function testCreatInvalidData(string $date, string $time): void
{ {
$this->request = $this->makeEmpty(Request::class, [ $this->request = $this->makeEmpty(Request::class, [
@@ -333,10 +316,9 @@ class WorkingHoursControllerTest extends Unit
} }
/** /**
* @throws HttpBadRequestException * @throws ContainerExceptionInterface
* @throws NotDatabasesException * @throws NotDatabasesException
* @throws HttpInternalServerErrorException * @throws NotFoundExceptionInterface
* @throws HttpConflictRequestException
* @throws Exception * @throws Exception
*/ */
public function testCreatNewRecord(): void public function testCreatNewRecord(): void
@@ -349,14 +331,14 @@ class WorkingHoursControllerTest extends Unit
]); ]);
$controller = new WorkingHoursController($this->container); $controller = new WorkingHoursController($this->container);
$response = $controller->creat($this->request, $this->response, []); $response = $controller->creat($this->request, $this->response, []);
$this->assertInstanceOf(Response::class, $response); $this->assertEquals(self::FICTIONAL_STATUS_CODE, $response->getStatusCode());
$this->assertNotEmpty($response->getBody()->getContents());
} }
/** /**
* @throws HttpBadRequestException * @throws ContainerExceptionInterface
* @throws HttpConflictRequestException
* @throws HttpInternalServerErrorException
* @throws NotDatabasesException * @throws NotDatabasesException
* @throws NotFoundExceptionInterface
* @throws Exception * @throws Exception
*/ */
public function testCreatExistRecord(): void public function testCreatExistRecord(): void
@@ -373,9 +355,10 @@ class WorkingHoursControllerTest extends Unit
} }
/** /**
* @throws HttpInternalServerErrorException * @throws ContainerExceptionInterface
* @throws HttpNotFoundException
* @throws NotDatabasesException * @throws NotDatabasesException
* @throws NotFoundExceptionInterface
* @throws Exception
*/ */
public function testReadExistRecord(): void public function testReadExistRecord(): void
{ {
@@ -383,13 +366,15 @@ class WorkingHoursControllerTest extends Unit
$response = $controller->read($this->request, $this->response, [ $response = $controller->read($this->request, $this->response, [
'id' => self::EXISTING_DATE, 'id' => self::EXISTING_DATE,
]); ]);
$this->assertInstanceOf(Response::class, $response); $this->assertEquals(self::FICTIONAL_STATUS_CODE, $response->getStatusCode());
$this->assertNotEmpty($response->getBody()->getContents());
} }
/** /**
* @throws HttpInternalServerErrorException * @throws ContainerExceptionInterface
* @throws HttpNotFoundException
* @throws NotDatabasesException * @throws NotDatabasesException
* @throws NotFoundExceptionInterface
* @throws Exception
*/ */
public function testReadNotExistRecord(): void public function testReadNotExistRecord(): void
{ {
@@ -2,49 +2,30 @@
namespace TorstenHettstedt\TimekeepingApi\Tests\Unit\Controller; namespace TorstenHettstedt\TimekeepingApi\Tests\Unit\Controller;
use Codeception\Test\Unit;
use Exception; use Exception;
use PDO; use Psr\Container\ContainerExceptionInterface;
use Psr\Container\ContainerInterface; use Psr\Container\ContainerInterface;
use Psr\Http\Message\StreamInterface; use Psr\Container\NotFoundExceptionInterface;
use Slim\Psr7\Request; use Slim\Psr7\Request;
use Slim\Psr7\Response;
use TorstenHettstedt\TimekeepingApi\Controller\NotDatabasesException; use TorstenHettstedt\TimekeepingApi\Controller\NotDatabasesException;
use TorstenHettstedt\TimekeepingApi\Controller\WorkingHoursViewController; use TorstenHettstedt\TimekeepingApi\Controller\WorkingHoursViewController;
class WorkingHoursViewControllerTest extends Unit class WorkingHoursViewControllerTest extends AbstractControllerTest
{ {
protected ContainerInterface $container;
protected Request $request;
protected Response $response;
/** /**
* @throws Exception * @throws Exception
* @throws \PHPUnit\Framework\MockObject\Exception
*/ */
protected function _before(): void protected function _before(): void
{ {
parent::_before(); parent::_before();
/** @noinspection SpellCheckingInspection */ $this->request = $this->makeEmpty(Request::class);
$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 NotDatabasesException
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
* @throws Exception * @throws Exception
*/ */
public function testConstructWithNonDatabase(): void public function testConstructWithNonDatabase(): void
@@ -57,32 +38,44 @@ class WorkingHoursViewControllerTest extends Unit
} }
/** /**
* @throws NotDatabasesException
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
* @throws Exception * @throws Exception
*/ */
public function testBrowseMonthly(): void public function testBrowseMonthly(): void
{ {
$controller = new WorkingHoursViewController($this->container); $controller = new WorkingHoursViewController($this->container);
$response = $controller->browseMonthly($this->request, $this->response, []); $response = $controller->browseMonthly($this->request, $this->response, []);
$this->assertInstanceOf(Response::class, $response); $this->assertEquals(self::FICTIONAL_STATUS_CODE, $response->getStatusCode());
$this->assertNotEmpty($response->getBody()->getContents());
} }
/** /**
* @throws NotDatabasesException
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
* @throws Exception * @throws Exception
*/ */
public function testBrowseYearly(): void public function testBrowseYearly(): void
{ {
$controller = new WorkingHoursViewController($this->container); $controller = new WorkingHoursViewController($this->container);
$response = $controller->browseYearly($this->request, $this->response, []); $response = $controller->browseYearly($this->request, $this->response, []);
$this->assertInstanceOf(Response::class, $response); $this->assertEquals(self::FICTIONAL_STATUS_CODE, $response->getStatusCode());
$this->assertNotEmpty($response->getBody()->getContents());
} }
/** /**
* @throws NotDatabasesException
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
* @throws Exception * @throws Exception
*/ */
public function testBrowseWeekly(): void public function testBrowseWeekly(): void
{ {
$controller = new WorkingHoursViewController($this->container); $controller = new WorkingHoursViewController($this->container);
$response = $controller->browseWeekly($this->request, $this->response, []); $response = $controller->browseWeekly($this->request, $this->response, []);
$this->assertInstanceOf(Response::class, $response); $this->assertEquals(self::FICTIONAL_STATUS_CODE, $response->getStatusCode());
$this->assertNotEmpty($response->getBody()->getContents());
} }
} }
@@ -5,6 +5,7 @@ namespace TorstenHettstedt\TimekeepingApi\Tests\Unit\Middleware;
use Codeception\Stub\Expected; use Codeception\Stub\Expected;
use Codeception\Test\Unit; use Codeception\Test\Unit;
use Exception; use Exception;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
@@ -20,6 +21,7 @@ use TorstenHettstedt\TimekeepingApi\Middleware\ErrorHandler;
class ErrorHandlerTest extends Unit class ErrorHandlerTest extends Unit
{ {
/** @var App<ContainerInterface> */
protected App $app; protected App $app;
protected ServerRequestInterface $request; protected ServerRequestInterface $request;
protected Exception $exception; protected Exception $exception;
@@ -38,6 +40,7 @@ class ErrorHandlerTest extends Unit
'write' => function (mixed $data) { 'write' => function (mixed $data) {
$this->assertIsString($data); $this->assertIsString($data);
$this->assertJson($data); $this->assertJson($data);
return strlen($data);
}, },
]), ]),
]), ]),
@@ -87,11 +90,10 @@ class ErrorHandlerTest extends Unit
'file' => '/path(to/file', 'file' => '/path(to/file',
'getTitle' => Expected::once('The Title'), 'getTitle' => Expected::once('The Title'),
]); ]);
$this->logger = $this->makeEmpty(LoggerInterface::class, []); $this->logger = $this->makeEmpty(LoggerInterface::class);
$middleWare = new ErrorHandler($this->app); $middleWare = new ErrorHandler($this->app);
$middleWare($this->request, $this->exception, true, true, true, $this->logger); $middleWare($this->request, $this->exception, true, true, true, $this->logger);
} }
/** /**
@@ -104,7 +106,7 @@ class ErrorHandlerTest extends Unit
'code' => 400, 'code' => 400,
'file' => '/path(to/file', 'file' => '/path(to/file',
]); ]);
$this->logger = $this->makeEmpty(LoggerInterface::class, []); $this->logger = $this->makeEmpty(LoggerInterface::class);
$middleWare = new ErrorHandler($this->app); $middleWare = new ErrorHandler($this->app);
$middleWare($this->request, $this->exception, true, true, true, $this->logger); $middleWare($this->request, $this->exception, true, true, true, $this->logger);
+7 -11
View File
@@ -2,6 +2,7 @@
namespace TorstenHettstedt\TimekeepingApi\Tests\Unit\Models; namespace TorstenHettstedt\TimekeepingApi\Tests\Unit\Models;
use Codeception\Attribute\DataProvider;
use Codeception\Test\Unit; use Codeception\Test\Unit;
use DateInterval; use DateInterval;
use DateTime; use DateTime;
@@ -22,7 +23,7 @@ class WorkingHoursTest extends Unit
} }
/** /**
* @return array[] * @return array<array<DateTime|DateInterval|array<string,string>>>
*/ */
public function workingHoursProvider(): array public function workingHoursProvider(): array
{ {
@@ -49,17 +50,13 @@ class WorkingHoursTest extends Unit
/** /**
* @param DateTime $date * @param DateTime $date
* @param DateInterval $interval * @param DateInterval $interval
* @param array<string, string> $should_json * @param array<string, string> $shouldJson
*
* @dataProvider workingHoursProvider
*/ */
public function testWorkingHoursWithContent(DateTime $date, DateInterval $interval, array $should_json): void #[DataProvider('workingHoursProvider')]
public function testWorkingHoursWithContent(DateTime $date, DateInterval $interval, array $shouldJson): void
{ {
$obj = new WorkingHours(); $obj = new WorkingHours();
$obj $obj->setWorkingDay($date)->setWorkingTime($interval);
->setWorkingDay($date)
->setWorkingTime($interval);
$this->assertNotNull($obj->getWorkingDay()); $this->assertNotNull($obj->getWorkingDay());
$this->assertInstanceOf(DateTime::class, $obj->getWorkingDay()); $this->assertInstanceOf(DateTime::class, $obj->getWorkingDay());
@@ -70,7 +67,6 @@ class WorkingHoursTest extends Unit
$this->assertEquals($interval, $obj->getWorkingTime()); $this->assertEquals($interval, $obj->getWorkingTime());
$json = $obj->jsonSerialize(); $json = $obj->jsonSerialize();
$this->assertIsArray($json); $this->assertEquals($shouldJson, $json);
$this->assertEquals($should_json, $json);
} }
} }
+39 -22
View File
@@ -2,6 +2,7 @@
namespace TorstenHettstedt\TimekeepingApi\Tests\Unit\Models; namespace TorstenHettstedt\TimekeepingApi\Tests\Unit\Models;
use Codeception\Attribute\DataProvider;
use Codeception\Test\Unit; use Codeception\Test\Unit;
use DateInterval; use DateInterval;
use DateTime; use DateTime;
@@ -13,7 +14,7 @@ class WorkingHoursViewTest extends Unit
{ {
/** /**
* @return array[] * @return array<array{DateTime,PeriodDesignationEnum,int,DateInterval,DateInterval,array<string,mixed>}>
*/ */
public function workingHoursProvider(): array public function workingHoursProvider(): array
{ {
@@ -25,7 +26,11 @@ class WorkingHoursViewTest extends Unit
$absence_time->invert = 1; $absence_time->invert = 1;
return [ return [
[ [
$date, PeriodDesignationEnum::WEEKLY(), $work_days, $total_hours, $overtime, $date,
PeriodDesignationEnum::WEEKLY,
$work_days,
$total_hours,
$overtime,
[ [
'period' => '2020#49', 'period' => '2020#49',
'periodDesignation' => 'weekly', 'periodDesignation' => 'weekly',
@@ -35,7 +40,11 @@ class WorkingHoursViewTest extends Unit
], ],
], ],
[ [
$date, PeriodDesignationEnum::WEEKLY(), $work_days, $total_hours, $absence_time, $date,
PeriodDesignationEnum::WEEKLY,
$work_days,
$total_hours,
$absence_time,
[ [
'period' => '2020#49', 'period' => '2020#49',
'periodDesignation' => 'weekly', 'periodDesignation' => 'weekly',
@@ -45,7 +54,11 @@ class WorkingHoursViewTest extends Unit
], ],
], ],
[ [
$date, PeriodDesignationEnum::MONTHLY(), $work_days, $total_hours, $overtime, $date,
PeriodDesignationEnum::MONTHLY,
$work_days,
$total_hours,
$overtime,
[ [
'period' => '2020-11', 'period' => '2020-11',
'periodDesignation' => 'monthly', 'periodDesignation' => 'monthly',
@@ -55,7 +68,11 @@ class WorkingHoursViewTest extends Unit
], ],
], ],
[ [
$date, PeriodDesignationEnum::MONTHLY(), $work_days, $total_hours, $absence_time, $date,
PeriodDesignationEnum::MONTHLY,
$work_days,
$total_hours,
$absence_time,
[ [
'period' => '2020-11', 'period' => '2020-11',
'periodDesignation' => 'monthly', 'periodDesignation' => 'monthly',
@@ -65,7 +82,12 @@ class WorkingHoursViewTest extends Unit
], ],
], ],
[ [
$date, PeriodDesignationEnum::YEARLY(), $work_days, $total_hours, $overtime, [ $date,
PeriodDesignationEnum::YEARLY,
$work_days,
$total_hours,
$overtime,
[
'period' => '2020', 'period' => '2020',
'periodDesignation' => 'yearly', 'periodDesignation' => 'yearly',
'workingDays' => 15, 'workingDays' => 15,
@@ -74,7 +96,12 @@ class WorkingHoursViewTest extends Unit
], ],
], ],
[ [
$date, PeriodDesignationEnum::YEARLY(), $work_days, $total_hours, $absence_time, [ $date,
PeriodDesignationEnum::YEARLY,
$work_days,
$total_hours,
$absence_time,
[
'period' => '2020', 'period' => '2020',
'periodDesignation' => 'yearly', 'periodDesignation' => 'yearly',
'workingDays' => 15, 'workingDays' => 15,
@@ -91,45 +118,35 @@ class WorkingHoursViewTest extends Unit
* @param int $workingDays * @param int $workingDays
* @param DateInterval $totalHours * @param DateInterval $totalHours
* @param DateInterval $overtime * @param DateInterval $overtime
* @param array<string, string> $should_json * @param array<string, string> $shouldJson
*
* @dataProvider workingHoursProvider
*/ */
#[DataProvider('workingHoursProvider')]
public function testWorkingHoursViewWithContent( public function testWorkingHoursViewWithContent(
DateTimeInterface $period, DateTimeInterface $period,
PeriodDesignationEnum $periodDesignation, PeriodDesignationEnum $periodDesignation,
int $workingDays, int $workingDays,
DateInterval $totalHours, DateInterval $totalHours,
DateInterval $overtime, DateInterval $overtime,
array $should_json array $shouldJson
): void ): void {
{
$obj = new WorkingHoursView($period, $periodDesignation, $workingDays, $totalHours, $overtime); $obj = new WorkingHoursView($period, $periodDesignation, $workingDays, $totalHours, $overtime);
$this->assertNotNull($obj->getPeriod());
$this->assertInstanceOf(DateTimeInterface::class, $obj->getPeriod()); $this->assertInstanceOf(DateTimeInterface::class, $obj->getPeriod());
$this->assertEquals($period, $obj->getPeriod()); $this->assertEquals($period, $obj->getPeriod());
$this->assertNotNull($obj->getPeriodDesignation());
$this->assertInstanceOf(PeriodDesignationEnum::class, $obj->getPeriodDesignation()); $this->assertInstanceOf(PeriodDesignationEnum::class, $obj->getPeriodDesignation());
$this->assertEquals($periodDesignation, $obj->getPeriodDesignation()); $this->assertEquals($periodDesignation, $obj->getPeriodDesignation());
$this->assertNotNull($obj->getWorkingDays());
$this->assertIsInt($obj->getWorkingDays());
$this->assertEquals($workingDays, $obj->getWorkingDays()); $this->assertEquals($workingDays, $obj->getWorkingDays());
$this->assertNotNull($obj->getTotalHours());
$this->assertInstanceOf(DateInterval::class, $obj->getTotalHours()); $this->assertInstanceOf(DateInterval::class, $obj->getTotalHours());
$this->assertEquals($totalHours, $obj->getTotalHours()); $this->assertEquals($totalHours, $obj->getTotalHours());
$this->assertNotNull($obj->getOvertime());
$this->assertInstanceOf(DateInterval::class, $obj->getOvertime()); $this->assertInstanceOf(DateInterval::class, $obj->getOvertime());
$this->assertEquals($overtime, $obj->getOvertime()); $this->assertEquals($overtime, $obj->getOvertime());
$json = $obj->jsonSerialize(); $json = $obj->jsonSerialize();
$this->assertIsArray($json); $this->assertEquals($shouldJson, $json);
$this->assertEquals($should_json, $json);
} }
} }
@@ -2,7 +2,7 @@
namespace TorstenHettstedt\TimekeepingApi\Tests\Unit\Repositories; namespace TorstenHettstedt\TimekeepingApi\Tests\Unit\Repositories;
use Codeception\Example; use Codeception\Attribute\DataProvider;
use Codeception\Test\Unit; use Codeception\Test\Unit;
use DateInterval; use DateInterval;
use DateTime; use DateTime;
@@ -18,18 +18,20 @@ use TorstenHettstedt\TimekeepingApi\Repositories\WorkingHoursRepository;
class WorkingHoursRepositoryTest extends Unit class WorkingHoursRepositoryTest extends Unit
{ {
const EXISTING_DATE = '2020-01-28'; public const string EXISTING_DATE = '2020-01-28';
const EXISTING_INTERVAL = 'PT7H20M'; public const string EXISTING_INTERVAL = 'PT7H20M';
const NEW_DATE = '2020-04-01'; public const string NEW_DATE = '2020-04-01';
const NEW_INTERVAL = 'PT7H59M'; public const string NEW_INTERVAL = 'PT7H59M';
const RECORDS_COUNT = 61; public const int RECORDS_COUNT = 61;
protected PDO $pdoObject; protected PDO $pdoObject;
protected function _before(): void protected function _before(): void
{ {
/** @noinspection SpellCheckingInspection */ /** @noinspection SpellCheckingInspection */
$this->pdoObject = new PDO('pgsql:host=psql.torsten-hettstedt.net;port=5432;dbname=testdb;user=bruce;password=mypass'); $this->pdoObject = new PDO(
'pgsql:host=psql.torsten-hettstedt.net;port=5432;dbname=testdb;user=bruce;password=mypass'
);
parent::_before(); parent::_before();
} }
@@ -40,8 +42,8 @@ class WorkingHoursRepositoryTest extends Unit
{ {
$repository = new WorkingHoursRepository($this->pdoObject); $repository = new WorkingHoursRepository($this->pdoObject);
$models = $repository->findAll(); $models = $repository->findAll();
$this->assertIsArray($models);
$this->assertContainsOnly(WorkingHours::class, $models); $this->assertContainsOnlyInstancesOf(WorkingHours::class, $models);
$this->assertCount(self::RECORDS_COUNT, $models); $this->assertCount(self::RECORDS_COUNT, $models);
} }
@@ -66,16 +68,15 @@ class WorkingHoursRepositoryTest extends Unit
* @param string|null $ende * @param string|null $ende
* @param int $count * @param int $count
* *
* @dataProvider valideFilterParameter
*
* @throws Exception * @throws Exception
*/ */
#[DataProvider('valideFilterParameter')]
public function testFindAllWithValideParameter(?string $start, ?string $ende, int $count): void public function testFindAllWithValideParameter(?string $start, ?string $ende, int $count): void
{ {
$repository = new WorkingHoursRepository($this->pdoObject); $repository = new WorkingHoursRepository($this->pdoObject);
$models = $repository->findFiltered($start, $ende); $models = $repository->findFiltered($start, $ende);
$this->assertIsArray($models);
$this->assertContainsOnly(WorkingHours::class, $models); $this->assertContainsOnlyInstancesOf(WorkingHours::class, $models);
$this->assertCount($count, $models); $this->assertCount($count, $models);
} }
@@ -109,10 +110,9 @@ class WorkingHoursRepositoryTest extends Unit
* @param string|null $start * @param string|null $start
* @param string|null $ende * @param string|null $ende
* *
* @dataProvider invalideFilterParameter
*
* @throws Exception * @throws Exception
*/ */
#[DataProvider('invalideFilterParameter')]
public function testFindAllWithInvalideParameter(?string $start, ?string $ende): void public function testFindAllWithInvalideParameter(?string $start, ?string $ende): void
{ {
$repository = new WorkingHoursRepository($this->pdoObject); $repository = new WorkingHoursRepository($this->pdoObject);
@@ -127,7 +127,6 @@ class WorkingHoursRepositoryTest extends Unit
{ {
$repository = new WorkingHoursRepository($this->pdoObject); $repository = new WorkingHoursRepository($this->pdoObject);
$model = $repository->findByKey(self::EXISTING_DATE); $model = $repository->findByKey(self::EXISTING_DATE);
$this->assertInstanceOf(WorkingHours::class, $model);
$this->assertEquals(new DateTime(self::EXISTING_DATE), $model->getWorkingDay()); $this->assertEquals(new DateTime(self::EXISTING_DATE), $model->getWorkingDay());
$this->assertEquals(new DateInterval(self::EXISTING_INTERVAL), $model->getWorkingTime()); $this->assertEquals(new DateInterval(self::EXISTING_INTERVAL), $model->getWorkingTime());
} }
@@ -163,7 +162,6 @@ class WorkingHoursRepositoryTest extends Unit
]); ]);
$repository->insert($model); $repository->insert($model);
$model = $repository->findByKey(self::NEW_DATE); $model = $repository->findByKey(self::NEW_DATE);
$this->assertInstanceOf(WorkingHours::class, $model);
$this->assertEquals(new DateTime(self::NEW_DATE), $model->getWorkingDay()); $this->assertEquals(new DateTime(self::NEW_DATE), $model->getWorkingDay());
$this->assertEquals(new DateInterval(self::NEW_INTERVAL), $model->getWorkingTime()); $this->assertEquals(new DateInterval(self::NEW_INTERVAL), $model->getWorkingTime());
$this->assertCount(self::RECORDS_COUNT + 1, $repository->findAll()); $this->assertCount(self::RECORDS_COUNT + 1, $repository->findAll());
@@ -2,12 +2,11 @@
namespace TorstenHettstedt\TimekeepingApi\Tests\Unit\Repositories; namespace TorstenHettstedt\TimekeepingApi\Tests\Unit\Repositories;
use Codeception\Attribute\DataProvider;
use Codeception\Test\Unit; use Codeception\Test\Unit;
use DateTime; use DateTime;
use DateTimeZone; use DateTimeZone;
use Exception; use PDO;use TorstenHettstedt\TimekeepingApi\Models\WorkingHoursView;
use PDO;
use TorstenHettstedt\TimekeepingApi\Models\WorkingHoursView;
use TorstenHettstedt\TimekeepingApi\Repositories\RepositoryRecordNotFoundException; use TorstenHettstedt\TimekeepingApi\Repositories\RepositoryRecordNotFoundException;
use TorstenHettstedt\TimekeepingApi\Repositories\WorkingHoursWeeklyViewRepository; use TorstenHettstedt\TimekeepingApi\Repositories\WorkingHoursWeeklyViewRepository;
use TorstenHettstedt\TimekeepingApi\Repositories\WorkingHoursMonthlyViewRepository; use TorstenHettstedt\TimekeepingApi\Repositories\WorkingHoursMonthlyViewRepository;
@@ -25,7 +24,7 @@ class WorkingHoursViewRepositoryTest extends Unit
} }
/** /**
* @return array[] * @return array<array{string, int}>
*/ */
public function listObjectProvider(): array public function listObjectProvider(): array
{ {
@@ -37,20 +36,19 @@ class WorkingHoursViewRepositoryTest extends Unit
} }
/** /**
* @return array[] * @return array<array{string, string, int, DateTime}>
* @throws Exception
*/ */
public function existingObjectProvider(): array public function existingObjectProvider(): array
{ {
return [ return [
[WorkingHoursYearlyViewRepository::class, '2020', 61, new DateTime('2020-01-01', new DateTimeZone('UCT'))], [WorkingHoursYearlyViewRepository::class, '2020', 61, date_create('2020-01-01', new DateTimeZone('UCT'))],
[WorkingHoursMonthlyViewRepository::class, '2020 February', 20, new DateTime('2020-02-01', new DateTimeZone('UCT'))], [WorkingHoursMonthlyViewRepository::class, '2020 February', 20, date_create('2020-02-01', new DateTimeZone('UCT'))],
[WorkingHoursWeeklyViewRepository::class, '2020#03', 5, new DateTime('2020-01-13', new DateTimeZone('UCT'))], [WorkingHoursWeeklyViewRepository::class, '2020#03', 5, date_create('2020-01-13', new DateTimeZone('UCT'))],
]; ];
} }
/** /**
* @return array[] * @return array<array{string, string}>
*/ */
public function notExistingObjectProvider(): array public function notExistingObjectProvider(): array
{ {
@@ -61,49 +59,30 @@ class WorkingHoursViewRepositoryTest extends Unit
]; ];
} }
/** #[DataProvider(('listObjectProvider'))]
* @param string $period_class public function testFindAll(string $periodClass, int $countRecords): void
* @param int $count_records
*
* @dataProvider listObjectProvider
*/
public function testFindAll(string $period_class, int $count_records): void
{ {
$repository = new $period_class($this->pdoObject); $repository = new $periodClass($this->pdoObject);
$models = $repository->findAll(); $models = $repository->findAll();
$this->assertIsArray($models); $this->assertIsArray($models);
$this->assertContainsOnly(WorkingHoursView::class, $models); $this->assertContainsOnlyInstancesOf(WorkingHoursView::class, $models);
$this->assertCount($count_records, $models); $this->assertCount($countRecords, $models);
} }
/** #[DataProvider(('existingObjectProvider'))]
* public function testExistingFindByKey(string $periodClass, string $search, int $workingDays, DateTime $period): void
* @dataProvider existingObjectProvider
*
* @param string $period_class
* @param string $search
* @param int $workingDays
* @param DateTime $period
*/
public function testExistingFindByKey(string $period_class, string $search, int $workingDays, DateTime $period): void
{ {
$repository = new $period_class($this->pdoObject); $repository = new $periodClass($this->pdoObject);
$model = $repository->findByKey($search); $model = $repository->findByKey($search);
$this->assertInstanceOf(WorkingHoursView::class, $model); $this->assertInstanceOf(WorkingHoursView::class, $model);
$this->assertEquals($workingDays, $model->getWorkingDays()); $this->assertEquals($workingDays, $model->getWorkingDays());
$this->assertEquals($period->format('Ymd'), $model->getPeriod()->format('Ymd')); $this->assertEquals($period->format('Ymd'), $model->getPeriod()->format('Ymd'));
} }
/** #[DataProvider(('notExistingObjectProvider'))]
* @param string $period_class public function testNotExistingFindByKey(string $periodClass, string $search): void
* @param string $search
*
* @dataProvider notExistingObjectProvider
*
*/
public function testNotExistingFindByKey(string $period_class, string $search): void
{ {
$repository = new $period_class($this->pdoObject); $repository = new $periodClass($this->pdoObject);
$this->expectException(RepositoryRecordNotFoundException::class); $this->expectException(RepositoryRecordNotFoundException::class);
$repository->findByKey($search); $repository->findByKey($search);
} }
+6 -3
View File
@@ -7,7 +7,7 @@ services:
environment: environment:
docker: "true" docker: "true"
ports: ports:
- 8090:80 - "8090:80"
volumes: volumes:
- ./api:/var/www - ./api:/var/www
- logs:/var/www/logs - logs:/var/www/logs
@@ -20,13 +20,16 @@ services:
DATABASES_USER: bruce DATABASES_USER: bruce
DATABASES_PASS: mypass DATABASES_PASS: mypass
ports: ports:
- 8091:80 - "8091:80"
volumes: volumes:
- ./api:/var/www - ./api:/var/www
- logs:/var/www/logs - logs:/var/www/logs
ui: ui:
build: ./ui/ build: ./ui/
env_file:
- ui/.env
ports: ports:
- 80:80 - "8080:3000"
depends_on: depends_on:
- api - api
- test-api
+11 -4
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
create_css_files() { create_css_files() {
cd ui/public || exit 2 cd ui || exit 2
lessc --source-map less/print.less print.css lessc --source-map less/print.less static/print.css
lessc --source-map less/global.less global.css lessc --source-map less/global.less static/global.css
cd ../.. cd ../..
} }
@@ -27,6 +27,13 @@ build_ui() {
cd .. cd ..
} }
codeception_install() {
pwd
cd api/ || exit 2
test -e ./tests/unit.suite.yml || ./vendor/bin/codecept build
cd ..
}
test_unit() { test_unit() {
pwd pwd
cd api/ || exit 2 cd api/ || exit 2
@@ -44,4 +51,4 @@ composer_run() {
docker compose up -d docker compose up -d
} }
create_css_files && install_ui_dependence && install_api_dependence && build_ui && test_unit && composer_run && test_api create_css_files && install_ui_dependence && install_api_dependence && build_ui && codeception_install && test_unit && composer_run && test_api
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+15 -2
View File
@@ -1,3 +1,16 @@
FROM httpd:2.4 FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --production
COPY ./public/ /usr/local/apache2/htdocs/ FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/build build/
COPY --from=builder /app/node_modules node_modules/
COPY package.json .
EXPOSE 3000
ENV NODE_ENV=production
CMD [ "node", "build" ]
@@ -5,48 +5,63 @@
html { html {
body { body {
background: @mainColorLight; background: @mainColorLight;
div {
> header, > footer { > header, > footer {
color: @mainColorDark; color: @mainColorDark;
background: @mainColor; background: @mainColor;
font-family: serif; font-family: serif;
h1 { h1 {
font-size: 3rem; font-size: 3rem;
text-align: center; text-align: center;
} }
p { p {
text-indent: inherit; text-indent: inherit;
&:first-letter { &:first-letter {
font-size: inherit; font-size: inherit;
} }
font-style: italic; font-style: italic;
} }
} }
> header h1 { > header h1 {
margin: 1rem auto; margin: 1rem auto;
} }
> footer { > footer {
color: contrast(@mainColorDark); color: contrast(@mainColorDark);
} }
& > nav { & > nav {
ul { ul {
background: darken(@mainColorLight, 33%, relativ); background: darken(@mainColorLight, 33%, relativ);
li { li {
font-weight: bold; font-weight: bold;
&:not(:first-child) { &:not(:first-child) {
border-left: @mainColorDark solid thin; border-left: @mainColorDark solid thin;
} }
a { a {
text-decoration: none; text-decoration: none;
color: @mainColorDark; color: @mainColorDark;
} }
&:hover { &:hover {
background: lighten(@mainColor, 20%, relativ); background: lighten(@mainColor, 20%, relativ);
a { a {
color: lighten(@mainColor, 66%, relativ); color: lighten(@mainColor, 66%, relativ);
} }
} }
&.selected, &.selected:hover { &.selected, &.selected:hover {
background: lighten(@mainColor, 10%, relativ); background: lighten(@mainColor, 10%, relativ);
a { a {
color: lighten(@mainColor, 66%, relativ); color: lighten(@mainColor, 66%, relativ);
} }
@@ -56,6 +71,7 @@ html {
} }
} }
} }
}
main { main {
* { * {
@@ -63,15 +79,19 @@ main {
border-color: @mainColorDark; border-color: @mainColorDark;
border-width: medium; border-width: medium;
} }
section { section {
@sectionMargin: 1rem; @sectionMargin: 1rem;
h1, h2 { h1, h2 {
margin: @sectionMargin; margin: @sectionMargin;
} }
p, ul, ol, dl { p, ul, ol, dl {
margin: @sectionMargin; margin: @sectionMargin;
text-indent: @sectionMargin * 2; text-indent: @sectionMargin * 2;
} }
table { table {
thead { thead {
tr:last-child { tr:last-child {
@@ -79,7 +99,9 @@ main {
border-bottom: @mainColor solid thin; border-bottom: @mainColor solid thin;
} }
} }
}; }
;
tbody { tbody {
td.absence-time { td.absence-time {
color: @redColor; color: @redColor;
@@ -87,15 +109,23 @@ main {
text-decoration-style: dashed; text-decoration-style: dashed;
} }
} }
tfoot {
* {
border: initial;
} }
}
}
form { form {
@formLabelWidth: 10rem; @formLabelWidth: 10rem;
@formItemWidth: 40rem; @formItemWidth: 40rem;
nav.menu { nav.menu {
width: @formItemWidth + @formLabelWidth + 4rem; width: @formItemWidth + @formLabelWidth + 4rem;
border-color: @mainColor; border-color: @mainColor;
border-bottom-style: solid; border-bottom-style: solid;
margin: 1rem auto; margin: 1rem auto;
span { span {
border-color: @mainColor; border-color: @mainColor;
border-style: solid solid none; border-style: solid solid none;
@@ -103,37 +133,45 @@ main {
display: inline-block; display: inline-block;
font-weight: bolder; font-weight: bolder;
margin-right: 1rem; margin-right: 1rem;
&:first-child { &:first-child {
margin-left: 1.5rem; margin-left: 1.5rem;
} }
&:hover { &:hover {
background-color: darken(@mainColorLight, 10%, relativ); background-color: darken(@mainColorLight, 10%, relativ);
} }
&.active { &.active {
background-color: darken(@mainColorLight, 20%, relativ); background-color: darken(@mainColorLight, 20%, relativ);
} }
} }
} }
fieldset { fieldset {
width: @formItemWidth + @formLabelWidth + 2rem; width: @formItemWidth + @formLabelWidth + 2rem;
margin: 1rem auto; margin: 1rem auto;
border-style: solid; border-style: solid;
border-color: @mainColor; border-color: @mainColor;
padding: @sectionMargin; padding: @sectionMargin;
&.tasten { &.tasten {
border: none; border: none;
background-color: @mainColorLight; background-color: @mainColorLight;
} }
legend { legend {
margin: @sectionMargin; margin: @sectionMargin;
margin-left: @sectionMargin * 2; margin-left: @sectionMargin * 2;
} }
div { div {
& > label { & > label {
width: @formLabelWidth; width: @formLabelWidth;
display: inline-block; display: inline-block;
margin: 0.5rem; margin: 0.5rem;
} }
input { input {
display: inline-block; display: inline-block;
width: @formItemWidth; width: @formItemWidth;
@@ -145,31 +183,39 @@ main {
} }
} }
} }
button { button {
.button(@mainColor, spin(lighten(@mainColor, 66%, relativ), @subColor)); .button(@mainColor, spin(lighten(@mainColor, 66%, relativ), @subColor));
&.sub-button { &.sub-button {
.button(lighten(@mainColor, 20%, relativ), spin(@mainColor, @subColor)); .button(lighten(@mainColor, 20%, relativ), spin(@mainColor, @subColor));
} }
} }
footer { footer {
text-align: right; text-align: right;
padding: 0; padding: 0;
p { p {
padding: 0.5rem; padding: 0.5rem;
display: inline-block; display: inline-block;
margin: inherit; margin: inherit;
text-indent: inherit; text-indent: inherit;
&:first-letter { &:first-letter {
font-size: inherit; font-size: inherit;
} }
background: @mainColorLight; background: @mainColorLight;
color: @mainColor; color: @mainColor;
font-style: italic; font-style: italic;
font-size: 85%; font-size: 85%;
} }
nav li { nav li {
padding: 0.5rem; padding: 0.5rem;
} }
border-bottom-style: solid; border-bottom-style: solid;
} }
} }
@@ -5,7 +5,7 @@
font-size: 10pt; font-size: 10pt;
} }
header, footer, main, nav, aside { header, footer, main, nav, aside {
body > & { body div > & {
width: 19cm; width: 19cm;
margin: auto; margin: auto;
.border-box(); .border-box();
@@ -15,7 +15,7 @@
padding: 0.5rem; padding: 0.5rem;
; ;
} }
body { body div {
& > header { & > header {
font-size: 200%; font-size: 200%;
font-weight: bolder; font-weight: bolder;
@@ -71,7 +71,7 @@
@media screen and (min-width: 831px) { @media screen and (min-width: 831px) {
header, footer, main, nav, aside { header, footer, main, nav, aside {
body > & { body div > & {
width: @breiteMainBereich; width: @breiteMainBereich;
margin: auto; margin: auto;
.border-box(); .border-box();
@@ -80,7 +80,7 @@
header, footer{ header, footer{
padding: 0.5rem; padding: 0.5rem;
} }
body { body div {
& > header { & > header {
font-size: 200%; font-size: 200%;
font-weight: bolder; font-weight: bolder;
@@ -139,7 +139,7 @@
} }
@media screen and (max-width: 830px) { @media screen and (max-width: 830px) {
html body { html body div {
@boxHeight: 5rem; @boxHeight: 5rem;
& > header { & > header {
height: @boxHeight; height: @boxHeight;
@@ -267,7 +267,7 @@
} }
} }
@media screen and (max-width: 720px) { @media screen and (max-width: 720px) {
html body { html body div {
@boxHeight: 2.5rem; @boxHeight: 2.5rem;
& > header { & > header {
height: @boxHeight; height: @boxHeight;
@@ -2,7 +2,7 @@ html {
font-size: 100%; font-size: 100%;
} }
body { body div {
color: #000; color: #000;
font-size: 1em; font-size: 1em;
font-family: OpenSans, "Lucida Grande", "Lucida Sans Unicode", Verdana, Helvetica, Arial, sans-serif; font-family: OpenSans, "Lucida Grande", "Lucida Sans Unicode", Verdana, Helvetica, Arial, sans-serif;
@@ -1,5 +1,5 @@
html { html {
body { body div {
> header, > footer { > header, > footer {
background: none; background: none;
font-family: serif; font-family: serif;
@@ -59,7 +59,7 @@ main {
} }
} }
} }
tbody { tbody div {
th { th {
font-weight: bold; font-weight: bold;
text-align: center; text-align: center;
+25 -18
View File
@@ -1,31 +1,38 @@
{ {
"private": true, "private": true,
"name": "svelte-demo", "name": "svelte-demo",
"type": "module",
"scripts": { "scripts": {
"build": "rollup -c", "dev": "vite dev",
"autobuild": "rollup -c -w", "build": "vite build",
"dev": "run-p start:dev autobuild", "preview": "vite preview",
"start": "sirv public --single", "prepare": "svelte-kit sync || echo ''",
"start:dev": "sirv public --dev --single" "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
}, },
"dependencies": { "dependencies": {
"ganalytics": "^3.1.2", "ganalytics": "^3.1.2",
"navaid": "^1.0.2", "navaid": "^1.0.2",
"open-iconic": "^1.1.1" "open-iconic": "^1.1.1",
"papaparse": "^5.5.3"
}, },
"devDependencies": { "devDependencies": {
"@iconify-icons/oi": "^1.1.0", "@sveltejs/adapter-auto": "^6.0.0",
"@iconify/svelte": "^1.0.4", "@sveltejs/kit": "^2.16.0",
"@rollup/plugin-commonjs": "^15.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@rollup/plugin-node-resolve": "^9.0.0", "@sveltejs/adapter-node": "^5.2.12",
"@rollup/plugin-replace": "^2.3.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^6.2.6",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"npm-run-all": "^4.1.3", "wx-svelte-grid": "^2.1.5",
"rollup": "^2.30.0", "@iconify-icons/oi": "^1.1.0",
"rollup-plugin-svelte": "^6.0.0", "@iconify/svelte": "^5.0.0"
"rollup-plugin-terser": "^7.0.0", },
"sirv-cli": "^1.0.8", "exports": {
"svelte": "^3.4.4", ".": {
"svelte-paginate": "^0.1.0" "svelte": "./puplic/index.js"
}
} }
} }
-22
View File
@@ -1,22 +0,0 @@
<!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>
-51
View File
@@ -1,51 +0,0 @@
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
}
};
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<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='%sveltekit.assets%/favicon.png'>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="color-scheme" content="dark light"/>
%sveltekit.head%
<link rel="stylesheet" href="%sveltekit.assets%/global.css" media="screen">
<link rel="stylesheet" href="%sveltekit.assets%/print.css" media="print">
</head>
<body>
<div style="display: contents">
%sveltekit.body%
</div>
</body>
</html>
-57
View File
@@ -1,57 +0,0 @@
<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
@@ -1,15 +0,0 @@
<!--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>
-110
View File
@@ -1,110 +0,0 @@
<script context="module">
const MyFetch = {
'post': async (rest_url, request_body) => {
return MyFetch._fetch(rest_url, MyFetch._buildRequestOptions(request_body), {})
},
'put': async (rest_url, http_body) => {
return MyFetch._fetch(rest_url, MyFetch._buildRequestOptions(http_body, true), {})
},
'get': async (rest_url, query_parameters) => {
if (query_parameters !== null) {
let filter = []
for (let parameter in query_parameters) {
filter.push(parameter + '=' + query_parameters[parameter])
}
if (filter.length > 0) {
rest_url += '?' + filter.join('&')
}
}
return MyFetch._fetch(rest_url, {})
},
'_fetch': async (rest_url, request_options) => {
// noinspection JSUnresolvedVariable
let response = await MyFetch._pureFetch(rest_url, request_options).catch(err => {
console.error(err)
return null
})
if (!response.ok) {
throw new Error('Fail!')
}
return response.json()
},
'_buildRequestOptions': (request_body, needPut) => {
return {
method: (needPut === true) ? 'PUT' : 'POST',
mode: 'cors',
cache: 'no-cache',
credentials: 'omit',
headers: {
'Content-Type': 'application/json',
},
body: request_body,
}
},
'_pureFetch': async (rest_url, request_options) => {
// noinspection JSUnresolvedVariable
return await fetch(env.API_URL + rest_url, request_options)
}
}
export const WorkingHoursRepository = {
'browse': (startDate = '', endDate = '') => {
let filter = {}
if (startDate !== '') {
filter['start-date'] = startDate
}
if (endDate !== '') {
filter['end-date'] = endDate
}
return MyFetch.get('/working-hours', filter)
},
'read': date => {
return MyFetch.get('/working-hours/' + date)
},
'readOrNew': async date => {
let response = await MyFetch._pureFetch('/working-hours/' + date)
if (response.status === 404) {
return {workingTime: '00:00:00', workingDay: date}
} else if (!response.ok) {
throw new Error('Fail!')
}
return response.json()
},
'add': async (record) => {
console.debug(record);
return MyFetch.post('/working-hours', JSON.stringify(record))
},
'update': async (date, record) => {
console.debug(record);
return MyFetch.put('/working-hours/' + date, JSON.stringify(record))
},
'addOrUpdate': async (date, record) => {
console.debug(record);
let request_options = MyFetch._buildRequestOptions(JSON.stringify(record))
let response = await MyFetch._pureFetch('/working-hours', request_options)
if (response.status === 409) {
return WorkingHoursRepository.update(date, record)
} else if (!response.ok) {
throw new Error('Fail!')
}
return response.json()
}
}
export const WorkingHoursViewsRepository = {
'weekly': async () => {
return MyFetch.get('/views/working-hours/weekly')
},
'monthly': async () => {
return MyFetch.get('/views/working-hours/monthly')
},
'yearly': async () => {
return MyFetch.get('/views/working-hours/yearly')
},
}
</script>
-15
View File
@@ -1,15 +0,0 @@
<script context="module">
export class TimekeepingDate extends Date {
getDateString() {
return this.getFullYear() + "-"
+ (this.getMonth() + 1).toString().padStart(2, '0') + "-"
+ this.getDate().toString().padStart(2, '0')
}
getNextDay() {
let nextDay = new TimekeepingDate(this);
nextDay.setDate(this.getDate() + 1)
return nextDay
}
}
</script>
-5
View File
@@ -1,5 +0,0 @@
import App from './components/App.svelte';
new App({
target: document.body
});
+17
View File
@@ -0,0 +1,17 @@
export class PeriodRecord {
/**
* @param {{ workingDays: Number; totalHours: any; overtime: string; period: string; }} record
*/
constructor(record) {
this.workingDays = Number(record.workingDays)
this.totalHours = String(record.totalHours)
this.period = String(record.period)
this.overtime = String('##:##:##')
this.isMinus = false
const interval = record.overtime.match(/^(-?)(\d\d:\d\d:\d\d)$/)
if (interval) {
this.overtime = String(interval[2])
this.isMinus = String(interval[1]) === '-'
}
}
}
+163
View File
@@ -0,0 +1,163 @@
<script module>
const MyFetch = {
/**
* @param {String} rest_url
* @param {String} request_body
*/
'post': async (rest_url, request_body) => {
return MyFetch._fetch(rest_url, MyFetch._buildRequestOptions(request_body, false))
},
/**
* @param {String} rest_url
* @param {String} http_body
*/
'put': async (rest_url, http_body) => {
return MyFetch._fetch(rest_url, MyFetch._buildRequestOptions(http_body, true))
},
/**
* @param {String} rest_url
* @param {Object|null} query_parameters
*/
'get': async (rest_url, query_parameters = null) => {
if (query_parameters !== null) {
let filter = []
for (let parameter in query_parameters) {
filter.push(parameter + '=' + query_parameters[parameter])
}
if (filter.length > 0) {
rest_url += '?' + filter.join('&')
}
}
return MyFetch._fetch(rest_url, {})
},
/**
* @param {String} rest_url
* @param {Object} request_options
*/
'_fetch': async (rest_url, request_options) => {
// noinspection JSUnresolvedVariable
let response = await MyFetch._pureFetch(rest_url, request_options).catch(err => {
console.error(err)
return null
})
if (response === null || !response.ok) {
throw new Error('Fail!')
}
return response.json()
},
/**
* @param {String} request_body
* @param {Boolean} needPut
*/
'_buildRequestOptions': (request_body, needPut) => {
return {
method: (needPut === true) ? 'PUT' : 'POST',
mode: 'cors',
cache: 'no-cache',
credentials: 'omit',
headers: {
'Content-Type': 'application/json',
},
body: request_body,
}
},
/**
* @param {String} rest_url
* @param {Object} request_options
*/
'_pureFetch': async (rest_url, request_options = {}) => {
// noinspection JSUnresolvedVariable
return await fetch(rest_url, request_options)
}
}
export const WorkingHoursRepository = {
/**
* @param {String} api_url
* @param {String} startDate
* @param {String} endDate
*/
'browse': (api_url, startDate = '', endDate = '') => {
let filter = {}
if (startDate !== '') {
filter['start-date'] = startDate
}
if (endDate !== '') {
filter['end-date'] = endDate
}
return MyFetch.get(api_url + '/working-hours', filter)
},
/**
* @param {String} api_url
* @param {String} date
*/
'read': (api_url, date) => {
return MyFetch.get(api_url + '/working-hours/' + date)
},
/**
* @param {String} api_url
* @param {String} date
*/
'readOrNew': async (api_url, date) => {
let response = await MyFetch._pureFetch(api_url + '/working-hours/' + date)
if (response.status === 404) {
return {workingTime: '00:00:00', workingDay: date}
} else if (!response.ok) {
throw new Error('Fail!')
}
return response.json()
},
/**
* @param {String} api_url
* @param {Object} record
*/
'add': async (api_url, record) => {
return MyFetch.post(api_url + '/working-hours', JSON.stringify(record))
},
/**
* @param {String} api_url
* @param {String} date
* @param {Object} record
*/
'update': async (api_url, date, record) => {
return MyFetch.put(api_url + '/working-hours/' + date, JSON.stringify(record))
},
/**
* @param {String} api_url
* @param {String} date
* @param {Object} record
*/
'addOrUpdate': async (api_url, date, record) => {
let request_options = MyFetch._buildRequestOptions(JSON.stringify(record), false)
let response = await MyFetch._pureFetch(api_url + '/working-hours', request_options)
if (response.status === 409) {
return WorkingHoursRepository.update(api_url, date, record)
} else if (!response.ok) {
throw new Error('Fail!')
}
return response.json()
}
}
export const WorkingHoursViewsRepository = {
/**
* @param {String} api_url
*/
'weekly': async api_url => {
return MyFetch.get(api_url + '/views/working-hours/weekly', {})
},
/**
* @param {String} api_url
*/
'monthly': async api_url => {
return MyFetch.get(api_url + '/views/working-hours/monthly', {})
},
/**
* @param {String} api_url
*/
'yearly': async api_url => {
return MyFetch.get(api_url + '/views/working-hours/yearly', {})
},
}
</script>
+63
View File
@@ -0,0 +1,63 @@
<script>
import {PeriodRecord} from './Models.svelte.js'
/**
* @type {{ promise: Promise<object>; title: string;}}
*/
let {params} = $props();
/** @type PeriodRecord[] **/
let records = $state([])
let isLoading = $state(true)
const init = async () => await params.promise.then(
/**
* @param {Array<object>} data
*/
data => {
records = data.map(row => new PeriodRecord(row))
isLoading = false
}
)
init()
</script>
<svelte:head>
<title>{params.title}</title>
</svelte:head>
<section id="list-records">
<header>
<h1>{params.title}</h1>
</header>
<article>
{#if 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 records as record}
<tr>
<td>{record.period}</td>
<td>{record.workingDays}</td>
<td>{record.totalHours}</td>
<td class="{record.isMinus ? 'absence-time' : ''}">{record.overtime}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</article>
</section>
-7
View File
File diff suppressed because one or more lines are too long
+24
View File
@@ -0,0 +1,24 @@
<script>
import { page } from '$app/state';
let { children } = $props();
/** @param {String} str **/
const isActive = str => page.url.pathname === str;
/** @param {String} str **/
const isSelected = str => isActive(str) ? 'selected' : '';
</script>
<nav>
<ul>
<li class="{isSelected('/')}"><a href="/" aria-current={isActive('/')}>Home</a></li>
<li class="{isSelected('/views/weekly')}"><a href="/views/weekly" aria-current={isActive('/views/weekly')}>Wochenansicht</a></li>
<li class="{isSelected('/views/monthly')}"><a href="/views/monthly" aria-current={isActive('/views/monthly')}>Monatsansicht</a></li>
<li class="{isSelected('/views/yearly')}"><a href="/views/yearly" aria-current={isActive('/views/yearly')}>Jahresansicht</a></li>
<li class="{isSelected('/working-hours')}"><a href="/working-hours" aria-current={isActive('/working-hours')}>Einträge</a></li>
</ul>
</nav>
<main>
{@render children()}
</main>
<footer><p>© Torsten Lücke 2021</p></footer>
+9
View File
@@ -0,0 +1,9 @@
import {env} from '$env/dynamic/private';
import {WorkingHoursViewsRepository} from "../lib/Repositories.svelte";
export function load() {
return {
title: 'Wochenansicht',
promise: WorkingHoursViewsRepository.weekly(env.API_URL)
};
}
+7
View File
@@ -0,0 +1,7 @@
<script>
import Views from "$lib/Views.svelte";
let { data } = $props();
</script>
<Views params={data}/>
-6
View File
@@ -1,6 +0,0 @@
<WeeklyViews />
<script>
import WeeklyViews from "./WeeklyViews.svelte";
export const params = {};
</script>
-11
View File
@@ -1,11 +0,0 @@
<script>
import Views from "./Views.svelte";
import {WorkingHoursViewsRepository} from "../components/Repositories.svelte";
export let params
params = {
title: 'Monatsansicht',
promise: WorkingHoursViewsRepository.monthly()
}
</script>
<Views {params} />
-81
View File
@@ -1,81 +0,0 @@
<script>
import { paginate, PaginationNav } from 'svelte-paginate'
export let params = {}
let records = []
let isLoading = true
console.log(params)
let currentPage = 1
let pageSize = 15
$: paginatedItems = paginate({ items: records, pageSize, currentPage })
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
const init = async () => await params.promise.then(data => {
records = data
isLoading = false
})
init()
</script>
<svelte:head>
<title>{params.title}</title>
</svelte:head>
<section id="list-records">
<header>
<h1>{params.title}</h1>
</header>
<article>
{#if 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 paginatedItems 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>
<tfoot>
<tr>
<td colspan="4">
<PaginationNav
totalItems="{records.length}"
pageSize="{pageSize}"
currentPage="{currentPage}"
limit="{1}"
showStepOptions="{true}"
on:setPage="{(e) => currentPage = e.detail.page}"
/>
</td>
</tr>
</tfoot>
</table>
{/if}
</article>
</section>
-11
View File
@@ -1,11 +0,0 @@
<script>
import Views from "./Views.svelte";
import {WorkingHoursViewsRepository} from "../components/Repositories.svelte";
export let params
params = {
title: 'Wochenansicht',
promise: WorkingHoursViewsRepository.weekly()
}
</script>
<Views {params} />
-33
View File
@@ -1,33 +0,0 @@
<script>
import List from "./WorkingHours/List.svelte";
import Formular from "./WorkingHours/Formular.svelte";
export const params = {};
let TITLE = "Bearbeitung Einträge"
let activeRecord = null;
let currentPage = 1
</script>
<svelte:head>
<title>{TITLE}</title>
</svelte:head>
{#if activeRecord === null}
<section id="list-records">
<header>
<h1>{TITLE}</h1>
</header>
<article>
<List bind:activeRecord={activeRecord} bind:currentPage/>
</article>
</section>
{:else}
<section id="edit-record">
<header>
<h1>{TITLE}</h1>
</header>
<article>
<Formular bind:activeRecord={activeRecord}/>
</article>
</section>
{/if}
-126
View File
@@ -1,126 +0,0 @@
<script>
import {WorkingHoursRepository} from "../../components/Repositories.svelte"
import IconifyIcon from '@iconify/svelte'
import checkIcon from '@iconify-icons/oi/check'
import xIcon from '@iconify-icons/oi/x'
import EditByDay from "./Formular/EditByDay.svelte";
import EditByWeek from "./Formular/EditByWeek.svelte";
import ImportCsv from "./Formular/ImportCsv.svelte";
import {TimekeepingDate} from "../../components/TimekeepingDate.svelte";
const DAILY_EDIT = 0
const WEEKLY_EDIT = 1
const CSV_IMPORT = 2
const Controller = {
'saveRecord': () => {
activeRecordList.forEach((actualRecord) => {
if (actualRecord.workingTime === '00:00:00') {
return
}
WorkingHoursRepository.addOrUpdate(actualRecord.workingDay, actualRecord);
})
activeDate = nextDate
makeTimekeepingList()
},
'cancelActiveRecord': () => {
activeRecordList = []
activeRecord = null
}
}
const WeekController = {
'makeTimekeepingList': () => {
let actualDay = WeekController.getMondayFromWeek(
activeDate === null ? new TimekeepingDate() : new TimekeepingDate(activeDate)
)
activeRecordList = []
activeDate = actualDay.getDateString()
for (const daysDiff in [0, 1, 2, 3, 4, 5, 6]) {
activeRecordList.push({workingTime: '00:00:00', workingDay: actualDay.getDateString()})
actualDay = actualDay.getNextDay()
}
nextDate = actualDay.getDateString()
WorkingHoursRepository.browse(activeRecordList[0].workingDay, activeRecordList[6].workingDay).then(data => {
data.forEach(item => {
let actualDay = new TimekeepingDate(item.workingDay)
activeRecordList[actualDay.getDay()-1].workingTime = item.workingTime
})
})
},
'getMondayFromWeek': actualDayObject => {
if (actualDayObject.getDate() === 0) {
actualDayObject.setDate(actualDayObject.getDate() - 7)
}
if (actualDayObject.getDate() !== 1) {
actualDayObject.setDate(actualDayObject.getDate() - (actualDayObject.getDay() - 1))
}
return actualDayObject
}
}
const DayController = {
'makeTimekeepingList': () => {
let actualDay = activeDate === null ? new TimekeepingDate() : new TimekeepingDate(activeDate)
WorkingHoursRepository.readOrNew(actualDay.getDateString()).then(data => {
activeRecordList = [ data ]
activeDate = actualDay.getDateString()
nextDate = actualDay.getNextDay().getDateString()
})
}
}
const CsvController = {
'makeTimekeepingList': () => {
MenuController.dailyEdit()
}
}
const MenuController = {
'dailyEdit': () => {
period_of_edit = DAILY_EDIT
makeTimekeepingList = DayController.makeTimekeepingList
makeTimekeepingList()
},
'weeklyEdit': () => {
period_of_edit = WEEKLY_EDIT
makeTimekeepingList = WeekController.makeTimekeepingList
makeTimekeepingList()
},
'csvImport': () => {
period_of_edit = CSV_IMPORT
activeRecordList = []
makeTimekeepingList = CsvController.makeTimekeepingList
}
}
let activeDate = null
let nextDate = null
let activeRecordList = []
export let activeRecord = null
let period_of_edit
let makeTimekeepingList
MenuController.dailyEdit()
</script>
<!--suppress HtmlUnknownTarget -->
<form action="/working-hours" name="editRecord">
<nav class="menu">
<span on:click={MenuController.dailyEdit} on:keypress={() => {}} class:active={period_of_edit === DAILY_EDIT}>Täglich</span>
<span on:click={MenuController.weeklyEdit} on:keypress={() => {}} class:active={period_of_edit === WEEKLY_EDIT}>Wöchentlich</span>
<span on:click={MenuController.csvImport} on:keypress={() => {}} class:active={period_of_edit === CSV_IMPORT}>Import per CSV</span>
</nav>
{#if period_of_edit === DAILY_EDIT}
<EditByDay bind:activeRecordList bind:activeDate bind:makeTimekeepingList/>
{/if}
{#if period_of_edit === WEEKLY_EDIT}
<EditByWeek bind:activeRecordList bind:activeDate bind:makeTimekeepingList/>
{/if}
{#if period_of_edit === CSV_IMPORT}
<ImportCsv bind:activeRecordList bind:makeTimekeepingList/>
{/if}
<fieldset name="buttons">
<button on:click={Controller.saveRecord} type="button">Übernehmen und Weiter
<IconifyIcon icon={checkIcon} color="green"/>
</button>
<button on:click={Controller.cancelActiveRecord} type="button" class="sub-button">Abbrechen
<IconifyIcon icon={xIcon}/>
</button>
</fieldset>
</form>
@@ -1,24 +0,0 @@
<script>
export let activeDate = null
export let activeRecordList = []
export let makeTimekeepingList = () => {}
makeTimekeepingList()
</script>
{#if activeRecordList.length < 1}
<p>Loading...</p>
{:else}
<fieldset name="record-data-by-day">
<div>
<label for="workingDay">Arbeitstag</label>
<input bind:value={activeDate} type="date" on:change={makeTimekeepingList}
id="workingDay" required pattern="\d\{4}-[0-1]\d-[0-3]\d\">
</div>
<div>
<label for="workingTime">Arbeitszeit</label>
<input bind:value={activeRecordList[0].workingTime} placeholder="Arbeitszeit" type="time"
id="workingTime" required pattern="[0-5]\d:[0-5]\d:[0-5]\d">
</div>
</fieldset>
{/if}
@@ -1,62 +0,0 @@
<script>
export let activeDate = null
export let activeRecordList = []
export let makeTimekeepingList = () => {}
makeTimekeepingList()
</script>
<style>
tbody.weekend td input, tbody.weekend td {
color: red;
}
input#monday {
display: inline;
width: auto;
}
</style>
{#if activeRecordList.length < 1}
<p>Loading...</p>
{:else}
<fieldset name="how-week-is-use">
<div>
<label for="monday">Woche vom</label>
<input bind:value={activeDate} type="date" on:change={makeTimekeepingList}
id="monday" required pattern="\d\{4}-[0-1]\d-[0-3]\d\">
</div>
</fieldset>
<fieldset name="record-data-by-week">
<div>
<table>
<tbody>
{#each ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag'] as day, index}
<tr>
<td>
{day}
</td>
<td>
<input bind:value={activeRecordList[index].workingTime} placeholder="Arbeitszeit" type="time"
required pattern="[0-5]\d:[0-5]\d:[0-5]\d">
</td>
</tr>
{/each}
</tbody>
<tbody class="weekend">
{#each ['Samstag', 'Sontag'] as day, index}
<tr>
<td>
{day}
</td>
<td>
<input bind:value={activeRecordList[index+5].workingTime} placeholder="Arbeitszeit" type="time"
required pattern="[0-5]\d:[0-5]\d:[0-5]\d">
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</fieldset>
{/if}
@@ -1,96 +0,0 @@
<script>
import Papa from '../../../papaparse.min'
import { paginate, PaginationNav } from 'svelte-paginate'
export let activeRecordList = []
let imported_data = []
export let currentPage = 1
let pageSize = 15
$: paginatedItems = paginate({ items: imported_data, pageSize, currentPage })
let files = null
const VerarbeiteDateien = () => {
for (const file of files) {
VerarbeiteCsv(file)
}
}
const VerarbeiteCsv = file => {
Papa.parse(file, {
header: true,
skipEmptyLines: true,
complete: results => {
let cash_work_days = {}
results.data.forEach(item => {
// noinspection JSNonASCIINames
let activity = item['Aktivität']
if (activity.trim() === 'Mittagspause') {
return
}
let work_from = new Date(item['Von'])
let work_to = new Date(item['Bis'])
let actual_day = work_from.toISOString().substring(0, 10)
let actual_work_hours = (work_to - work_from) / (1000 * 60 * 60)
cash_work_days[actual_day] ??= 0
cash_work_days[actual_day] += actual_work_hours
})
for (let day in cash_work_days) {
let hours = cash_work_days[day]
let hours_digits = Math.trunc(hours)
let minutes_digits = Math.round((hours - hours_digits) * 60)
let working_time = String(hours_digits).padStart(2, '0') + ':'
+ String(minutes_digits).padStart(2, '0') + ':00'
activeRecordList.push({ workingTime: working_time, workingDay: day })
}
imported_data = activeRecordList
},
})
}
</script>
<fieldset>
<legend>Download-Liste</legend>
<div class="zelle">
<label for="csv-file">Download der Zeiten von <i>aTimeLogger2</i></label>
<input type="file" accept="text/csv" id="csv-file" bind:files/>
<button type="button" on:click={VerarbeiteDateien}>Upload</button>
</div>
</fieldset>
<fieldset name="record-data-for-import">
<div>
{#if imported_data.length < 1}
<p>Loading...</p>
{:else}
<table>
<tbody>
{#each imported_data as item}
<tr>
<td>
{item.workingDay}
</td>
<td>
{item.workingTime}
</td>
</tr>
{/each}
</tbody>
<tfoot>
<tr>
<td colspan="4">
<PaginationNav
totalItems="{imported_data.length}"
pageSize="{pageSize}"
currentPage="{currentPage}"
limit="{1}"
showStepOptions="{true}"
on:setPage="{(e) => currentPage = e.detail.page}"
/>
</td>
</tr>
</tfoot>
</table>
{/if}
</div>
</fieldset>
-95
View File
@@ -1,95 +0,0 @@
<script>
import { WorkingHoursRepository } from '../../components/Repositories.svelte'
import { TimekeepingDate } from '../../components/TimekeepingDate.svelte'
import { paginate, PaginationNav } from 'svelte-paginate'
import IconifyIcon from '@iconify/svelte'
import wrenchIcon from '@iconify-icons/oi/wrench'
import documentIcon from '@iconify-icons/oi/document'
export let activeRecord = null
let isLoading = true
let records = []
export let currentPage = 1
let pageSize = 15
$: paginatedItems = paginate({ items: records, pageSize, currentPage })
const Controller = {
'listRecords': () => {
Controller.cancelActiveRecord()
WorkingHoursRepository.browse().then(data => {
Controller.cancelActiveRecord()
records = data
isLoading = false
})
},
'newRecord': async () => {
const actDate = new TimekeepingDate()
activeRecord = await WorkingHoursRepository.readOrNew(actDate.getDateString())
},
'selectActiveRecord': record => WorkingHoursRepository.read(record.workingDay).then(data => {
if (data === null) {
Controller.listRecords()
return
}
activeRecord = data
}),
'cancelActiveRecord': () => {
activeRecord = null
},
}
Controller.listRecords()
</script>
<!--suppress HtmlUnknownTarget -->
<form action="/working-hours">
<fieldset>
<button on:click={Controller.newRecord} type="button">Neuer Eintrag
<IconifyIcon icon={documentIcon}/>
</button>
</fieldset>
</form>
{#if isLoading === true}
<p>Loading...</p>
{:else}
<table>
<colgroup>
<col/>
<col/>
<col/>
</colgroup>
<thead>
<tr>
<th>Datum</th>
<th>Arbeitsstunden</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{#each paginatedItems 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>
<tfoot>
<tr>
<td colspan="4">
<PaginationNav
totalItems="{records.length}"
pageSize="{pageSize}"
currentPage="{currentPage}"
limit="{1}"
showStepOptions="{true}"
on:setPage="{(e) => currentPage = e.detail.page}"
/>
</td>
</tr>
</tfoot>
</table>
{/if}
-11
View File
@@ -1,11 +0,0 @@
<script>
import Views from "./Views.svelte";
import {WorkingHoursViewsRepository} from "../components/Repositories.svelte";
export let params
params = {
title: 'Jahresansicht',
promise: WorkingHoursViewsRepository.yearly()
}
</script>
<Views {params} />
@@ -0,0 +1,9 @@
import {env} from '$env/dynamic/private';
import {WorkingHoursViewsRepository} from "$lib/Repositories.svelte";
export function load() {
return {
title: 'Monatsansicht',
promise: WorkingHoursViewsRepository.monthly(env.API_URL)
};
}
+7
View File
@@ -0,0 +1,7 @@
<script>
import Views from "$lib/Views.svelte";
let { data } = $props();
</script>
<Views params={data}/>
@@ -0,0 +1,9 @@
import {env} from '$env/dynamic/private';
import {WorkingHoursViewsRepository} from "$lib/Repositories.svelte";
export function load() {
return {
title: 'Wochenansicht',
promise: WorkingHoursViewsRepository.weekly(env.API_URL)
};
}
+7
View File
@@ -0,0 +1,7 @@
<script>
import Views from "$lib/Views.svelte";
let { data } = $props();
</script>
<Views params={data}/>
@@ -0,0 +1,9 @@
import {env} from '$env/dynamic/private';
import {WorkingHoursViewsRepository} from "$lib/Repositories.svelte";
export function load() {
return {
title: 'Jahresansicht',
promise: WorkingHoursViewsRepository.yearly(env.API_URL)
};
}
+7
View File
@@ -0,0 +1,7 @@
<script>
import Views from "$lib/Views.svelte";
let { data } = $props();
</script>
<Views params={data}/>
@@ -0,0 +1,81 @@
import {env} from '$env/dynamic/private';
import {WorkingHoursRepository} from "$lib/Repositories.svelte";
export function load() {
return {
promise: WorkingHoursRepository.browse(env.API_URL)
};
}
/** @param {Date} actualDayObject **/
const getMonday = actualDayObject => {
if (actualDayObject.getDate() === 0) {
actualDayObject.setDate(actualDayObject.getDate() - 7)
}
if (actualDayObject.getDate() !== 1) {
actualDayObject.setDate(actualDayObject.getDate() - (actualDayObject.getDay() - 1))
}
return actualDayObject
}
/** @type {String[]} **/
const weekday_list = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sontag']
/** @param {Date} actualDayObject **/
const getDateString = actualDayObject => {
return actualDayObject.getFullYear() + "-"
+ (actualDayObject.getMonth() + 1).toString().padStart(2, '0') + "-"
+ actualDayObject.getDate().toString().padStart(2, '0')
}
export const actions = {
update: async ({ request }) => {
const data = await request.formData();
const body_data = {
workingDay: data.get('workingDay'),
workingTime: data.get('workingTime'),
}
await WorkingHoursRepository.update(env.API_URL, data.get('workingDay'), body_data);
},
create: async ({ request }) => {
const form_data = await request.formData();
const body_data = {
workingDay: form_data.get('workingDay'),
workingTime: form_data.get('workingTime'),
}
await WorkingHoursRepository.add(env.API_URL, body_data);
},
"create-by-week": async ({ request }) => {
TorstenHettstedt marked this conversation as resolved
Review

Nutzung als Prämisse ist glaube ich besser.

Nutzung als Prämisse ist glaube ich besser.
const form_data = await request.formData();
// @ts-ignore
let actualDayObject = getMonday(new Date(form_data.get('week')))
/** @type {Object<String,String>} **/
let day_list = {}
weekday_list.forEach(day => {
day_list[day] = getDateString(actualDayObject)
actualDayObject.setDate(actualDayObject.getDate() + 1)
})
for (const pair of form_data.entries()) {
const working_day = day_list[pair[0]] ?? null
if (working_day === null) {
continue
}
const body_data = {
workingDay: working_day,
workingTime: pair[1],
}
await WorkingHoursRepository.addOrUpdate(env.API_URL, working_day, body_data);
}
},
'import-csv': async ({ request }) => {
const form_data = await request.formData();
for (const pair of form_data.entries()) {
const working_day = pair[0]
const body_data = {
workingDay: working_day,
workingTime: pair[1],
}
await WorkingHoursRepository.addOrUpdate(env.API_URL, working_day, body_data);
}
}
};
+76
View File
@@ -0,0 +1,76 @@
<script>
import documentIcon from "@iconify-icons/oi/document.js";
import wrenchIcon from "@iconify-icons/oi/wrench.js";
import IconifyIcon from "@iconify/svelte";
let { data } = $props();
const TITLE = "Bearbeitung Einträge"
/** @type {{workingDay: String, workingTime: String, }[]|null} **/
let records = $state([])
const init = async () => await data.promise.then(
/**
* @param {{workingDay: String, workingTime: String, }[]} data
*/
data => {
records = data
}
)
init()
</script>
<svelte:head>
<title>{TITLE}</title>
</svelte:head>
<section id="list-records">
<header>
<h1>{TITLE}</h1>
</header>
<article>
<!--suppress HtmlUnknownTarget -->
<form action="/working-hours">
<fieldset>
<legend>Neuer Eintrag</legend>
<button formaction="/working-hours/create-by-day" type="submit">per Tag
<IconifyIcon icon={documentIcon}/>
</button>
<button formaction="/working-hours/create-by-week" type="submit">per Woche
<IconifyIcon icon={documentIcon}/>
</button>
<button formaction="/working-hours/import-csv" type="submit">Import per CSV
<IconifyIcon icon={documentIcon}/>
</button>
</fieldset>
{#if records === null}
<p>Loading...</p>
{:else}
<table>
<colgroup>
<col/>
<col/>
<col/>
</colgroup>
<thead>
<tr>
<th>Datum</th>
<th>Arbeitsstunden</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{#each records as record}
<tr>
<td>{record.workingDay}</td>
<td>{record.workingTime}</td>
<td>
<button formaction={'/working-hours/' + record.workingDay} type="submit">Bearbeiten
<IconifyIcon icon={wrenchIcon}/>
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</form>
</article>
</section>
@@ -0,0 +1,9 @@
import {env} from '$env/dynamic/private';
import {WorkingHoursRepository} from "$lib/Repositories.svelte";
export function load({ params }) {
return {
working_day: params.working_day,
promise: WorkingHoursRepository.read(env.API_URL, params.working_day)
};
}
@@ -0,0 +1,57 @@
<script>
import IconifyIcon from '@iconify/svelte'
import checkIcon from "@iconify-icons/oi/check";
import xIcon from "@iconify-icons/oi/x";
let { data } = $props();
const TITLE = "Bearbeitung eines Eintrags"
/** @type {{workingDay: String, workingTime: String, }|null} **/
let activeDate = $state(null)
const init = async () => await data.promise.then(
/**
* @param {{workingDay: String, workingTime: String, }} data
*/
data => {
activeDate = data
}
)
init()
</script>
<svelte:head>
<title>{TITLE}</title>
</svelte:head>
<section id="edit-record">
<header>
<h1>{TITLE}</h1>
</header>
<article>
<form action="/working-hours?/update" name="editRecord" method="POST">
{#if activeDate === null}
<p>Loading...</p>
{:else}
<fieldset name="record-data-by-day">
<div>
<label for="workingDay">Arbeitstag</label>
<input name="workingDay" type="date" id="workingDay" value="{activeDate.workingDay}" required pattern="\d\{4}-[0-1]\d-[0-3]\d">
</div>
<div>
<label for="workingTime">Arbeitszeit</label>
<input name="workingTime" placeholder="Arbeitszeit" type="time" step="1"
value="{activeDate.workingTime}"
id="workingTime" required pattern="[0-5]\d:[0-5]\d:[0-5]\d">
</div>
</fieldset>
{/if}
<fieldset name="buttons">
<button type="submit">Übernehmen und Weiter
<IconifyIcon icon={checkIcon} color="green"/>
</button>
<button formmethod="GET" type="submit" class="sub-button">Abbrechen
<IconifyIcon icon={xIcon}/>
</button>
</fieldset>
</form>
</article>
</section>
@@ -0,0 +1,43 @@
<script>
import IconifyIcon from '@iconify/svelte'
import checkIcon from "@iconify-icons/oi/check";
import xIcon from "@iconify-icons/oi/x";
import {goto} from "$app/navigation";
const TITLE = "Erstellung eines neuen Eintrags"
</script>
<svelte:head>
<title>{TITLE}</title>
</svelte:head>
<section id="edit-record">
<header>
<h1>{TITLE}</h1>
</header>
<article>
<form action="/working-hours?/create" name="newRecord" method="POST">
<fieldset name="record-data-by-day">
<div>
<label for="workingDay">Arbeitstag</label>
<input name="workingDay" type="date" id="workingDay" value="" required
pattern="\d\{4}-[0-1]\d-[0-3]\d">
</div>
<div>
<label for="workingTime">Arbeitszeit</label>
<input name="workingTime" placeholder="Arbeitszeit" type="time" step="1" value=""
id="workingTime" required pattern="[0-5]\d:[0-5]\d:[0-5]\d">
</div>
</fieldset>
<fieldset name="buttons">
<button type="submit">Übernehmen und Weiter
<IconifyIcon icon={checkIcon} color="green"/>
</button>
<button type="button" class="sub-button" onclick={() => goto('/working-hours')}>Abbrechen
<IconifyIcon icon={xIcon}/>
</button>
</fieldset>
</form>
</article>
</section>
@@ -0,0 +1,74 @@
<script>
import IconifyIcon from '@iconify/svelte'
import checkIcon from "@iconify-icons/oi/check";
import xIcon from "@iconify-icons/oi/x";
import {goto} from "$app/navigation";
const TITLE = "Erstellung neuer Einträge für eine Woche"
</script>
<svelte:head>
<title>{TITLE}</title>
</svelte:head>
<style>
tbody.weekend td input, tbody.weekend td {
color: red;
}
</style>
<section id="edit-record">
<header>
<h1>{TITLE}</h1>
</header>
<article>
<form action="/working-hours?/create-by-week" name="newRecord" method="POST">
<fieldset name="how-week-is-use">
<div>
<label for="monday">Woche vom</label>
<input name="week" type="date" id="week" required value="">
</div>
</fieldset>
<fieldset name="record-data-by-week">
<div>
<table>
<tbody>
{#each ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag'] as day}
<tr>
<td>
{day}
</td>
<td>
<input name="{day}" value="08:00:00" type="time" step="1"
required pattern="[0-5]\d:[0-5]\d:[0-5]\d">
</td>
</tr>
{/each}
</tbody>
<tbody class="weekend">
{#each ['Samstag', 'Sontag'] as day}
<tr>
<td>
{day}
</td>
<td>
<input name="{day}" value="00:00:00" type="time" step="1"
required pattern="[0-5]\d:[0-5]\d:[0-5]\d">
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</fieldset>
<fieldset name="buttons">
<button type="submit">Übernehmen und Weiter
<IconifyIcon icon={checkIcon} color="green"/>
</button>
<button type="button" class="sub-button" onclick={() => goto('/working-hours')}>Abbrechen
<IconifyIcon icon={xIcon}/>
</button>
</fieldset>
</form>
</article>
</section>
@@ -0,0 +1,112 @@
<script>
import IconifyIcon from '@iconify/svelte'
import checkIcon from "@iconify-icons/oi/check";
import xIcon from "@iconify-icons/oi/x";
import {goto} from "$app/navigation";
// @ts-ignore
import Papa from 'papaparse';
const TITLE = "Erstellung neuer Einträge für eine Woche"
/** @type {{ workingTime: String, workingDay: String, }[]} **/
let imported_data = $state([])
let files = $state(null)
const VerarbeiteDateien = () => {
for (const file of files ?? []) {
VerarbeiteCsv(file)
}
}
/** @param {File} file **/
const VerarbeiteCsv = file => {
Papa.parse(file, {
header: true,
skipEmptyLines: true,
// @ts-ignore
complete: results => {
/** @type {Object<String, Number>} **/
let cash_work_days = {}
results.data.forEach(
/** @param {Object<String, any>} item **/
item => {
// noinspection JSNonASCIINames
let activity = item['Aktivität']
if (activity.trim() === 'Mittagspause') {
return
}
let work_from = new Date(item['Von'])
let work_to = new Date(item['Bis'])
let actual_day = work_from.toISOString().substring(0, 10)
let actual_work_hours = (work_to.getTime() - work_from.getTime()) / (1000 * 60 * 60)
cash_work_days[actual_day] ??= 0
cash_work_days[actual_day] += actual_work_hours
}
)
for (let day in cash_work_days) {
let hours = cash_work_days[day]
let hours_digits = Math.trunc(hours)
let minutes_digits = Math.round((hours - hours_digits) * 60)
let working_time = String(hours_digits).padStart(2, '0') + ':'
+ String(minutes_digits).padStart(2, '0') + ':00'
imported_data.push({workingTime: working_time, workingDay: day})
}
},
})
}
</script>
<svelte:head>
<title>{TITLE}</title>
</svelte:head>
<section id="edit-record">
<header>
<h1>{TITLE}</h1>
</header>
<article>
<form action="/working-hours?/import-csv" name="newRecord" method="POST">
{#if imported_data.length < 1}
<fieldset>
<legend>Download-Liste</legend>
<div class="zelle">
<label for="csv-file">Download der Zeiten von <i>aTimeLogger2</i></label>
<input type="file" accept="text/csv" id="csv-file" bind:files/>
<button type="button" onclick={VerarbeiteDateien}>Upload</button>
</div>
</fieldset>
{:else}
<fieldset name="record-data-for-import">
<div>
<table>
<tbody>
{#each imported_data as item}
<tr>
<td>
{item.workingDay}
</td>
<td>
<input type="time" readonly name="{item.workingDay}" value="{item.workingTime}">
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</fieldset>
<fieldset name="buttons">
<button type="submit">Übernehmen und Weiter
<IconifyIcon icon={checkIcon} color="green"/>
</button>
<button type="button" class="sub-button" onclick={() => goto('/working-hours')}>Abbrechen
<IconifyIcon icon={xIcon}/>
</button>
</fieldset>
{/if}
</form>
</article>
</section>

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Some files were not shown because too many files have changed in this diff Show More