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/*.suite.yml
/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 docker-php-ext-install -j$(nproc) pdo pdo_pgsql
+9 -8
View File
@@ -16,21 +16,22 @@
}
],
"require": {
"php": "8.4.*",
"slim/slim": "^4.7.1",
"vlucas/phpdotenv": "^4.2",
"slim/psr7": "^1.3",
"vlucas/phpdotenv": "^v5.6.1",
"slim/psr7": "^1.7.0",
"php-di/slim-bridge": "^3.1.0",
"jetbrains/phpstorm-attributes": "^1.0.0",
"myclabs/php-enum": "^1.8.0",
"ext-pdo": "*"
},
"require-dev": {
"phpstan/phpstan": "^0.12.80",
"codeception/codeception": "^4.1.18",
"codeception/module-phpbrowser": "^1.0.0",
"codeception/module-asserts": "^1.0.0",
"codeception/module-db": "^1.1.0",
"codeception/module-rest": "^1.2.8"
"phpstan/phpstan": "^2.1.12",
"codeception/codeception": "^5.2.1",
"codeception/module-phpbrowser": "^3.0.1",
"codeception/module-asserts": "^3.1.0",
"codeception/module-db": "^3.2.2",
"codeception/module-rest": "^3.4.1"
},
"autoload": {
"psr-4": {
+1 -1
View File
@@ -17,7 +17,7 @@ use TorstenHettstedt\TimekeepingApi\Middleware\OriginAccessControlHandler;
$container = new Container();
$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']);
});
+6 -1
View File
@@ -4,7 +4,9 @@ namespace TorstenHettstedt\TimekeepingApi\Controller;
use JsonSerializable;
use PDO;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\ContainerInterface;
use Psr\Container\NotFoundExceptionInterface;
use Slim\Psr7\Response;
abstract class AbstractController
@@ -18,6 +20,8 @@ abstract class AbstractController
* @param ContainerInterface $container
*
* @throws NotDatabasesException
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function __construct(ContainerInterface $container)
{
@@ -34,7 +38,8 @@ abstract class AbstractController
*
* @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);
$response->getBody()->write($payload);
@@ -8,13 +8,8 @@ class HttpConflictRequestException extends HttpSpecializedException
{
/** @var int */
protected $code = 409;
/** @var string */
protected $message = 'Conflict.';
/** @var string */
protected $title = '409 Conflict';
/** @var string */
protected $description = 'The 409 (Conflict) status code indicates that the request could not be completed due to a conflict with the current state of the target resource.';
protected string $title = '409 Conflict';
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.';
}
@@ -11,6 +11,7 @@ use TorstenHettstedt\TimekeepingApi\Middleware\OriginAccessControlHandler;
class PreflightController
{
/** @noinspection PhpUnused */
public function preflight(Request $request, Response $response): ResponseInterface
{
return (new OriginAccessControlHandler())->originAccessControl($request, $response);
+25 -9
View File
@@ -23,7 +23,7 @@ class WorkingHoursController extends AbstractController
/**
* @param Request $request
* @param Response $response
* @param mixed[] $args
* @param array<string, mixed> $args
*
* @return Response
*
@@ -46,7 +46,7 @@ class WorkingHoursController extends AbstractController
/**
* @param Request $request
* @param Response $response
* @param mixed[] $args
* @param array<string, mixed> $args
*
* @return Response
*
@@ -59,21 +59,29 @@ class WorkingHoursController extends AbstractController
$repository = new WorkingHoursRepository($this->databases);
$queryParams = $request->getQueryParams();
try {
return $this->printResponse($response, $repository->findFiltered(
return $this->printResponse(
$response,
$repository->findFiltered(
$queryParams['start-date'] ?? null,
$queryParams['end-date'] ?? null
), StatusCodeInterface::STATUS_OK);
),
StatusCodeInterface::STATUS_OK
);
} catch (RepositoryBadWhereDataException $exception) {
throw new HttpBadRequestException($request, 'Ein Wert für das Datum im Query ist ungültig', $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 Response $response
* @param mixed[] $args
* @param array<string, mixed> $args
*
* @return Response
*
@@ -88,14 +96,18 @@ class WorkingHoursController extends AbstractController
} catch (RepositoryRecordNotFoundException $exception) {
throw new HttpNotFoundException($request, 'Der Eintrag ist nicht vorhanden.', $exception);
} catch (Exception $exception) {
throw new HttpInternalServerErrorException($request, 'Der Wert für das Datum oder die Zeit ist falsch in der Datenbank', $exception);
throw new HttpInternalServerErrorException(
$request,
'Der Wert für das Datum oder die Zeit ist falsch in der Datenbank',
$exception
);
}
}
/**
* @param Request $request
* @param Response $response
* @param mixed[] $args
* @param array<string, mixed> $args
*
* @return Response
*
@@ -114,7 +126,11 @@ class WorkingHoursController extends AbstractController
} catch (RepositoryRecordAlreadyExistException $exception) {
throw new HttpConflictRequestException($request, 'Der Eintrag ist schon vorhanden.', $exception);
} catch (Exception $exception) {
throw new HttpInternalServerErrorException($request, 'Der Wert für das Datum oder die Zeit ist falsch in der Datenbank', $exception);
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);
}
@@ -17,7 +17,7 @@ class WorkingHoursViewController extends AbstractController
/**
* @param Request $request
* @param Response $response
* @param mixed[] $args
* @param array<string, mixed> $args
*
* @return Response
*
@@ -30,15 +30,17 @@ class WorkingHoursViewController extends AbstractController
try {
return $this->printResponse($response, $repository->findAll(), StatusCodeInterface::STATUS_OK);
} catch (Exception $exception) {
throw new HttpInternalServerErrorException($request,
'Der Wert für das Datum oder die Zeit ist falsch in der Datenbank', $exception);
throw new HttpInternalServerErrorException(
$request,
'Der Wert für das Datum oder die Zeit ist falsch in der Datenbank', $exception
);
}
}
/**
* @param Request $request
* @param Response $response
* @param mixed[] $args
* @param array<string, mixed> $args
*
* @return Response
*
@@ -51,15 +53,17 @@ class WorkingHoursViewController extends AbstractController
try {
return $this->printResponse($response, $repository->findAll(), StatusCodeInterface::STATUS_OK);
} catch (Exception $exception) {
throw new HttpInternalServerErrorException($request,
'Der Wert für das Datum oder die Zeit ist falsch in der Datenbank', $exception);
throw new HttpInternalServerErrorException(
$request,
'Der Wert für das Datum oder die Zeit ist falsch in der Datenbank', $exception
);
}
}
/**
* @param Request $request
* @param Response $response
* @param mixed[] $args
* @param array<string, mixed> $args
*
* @return Response
*
@@ -72,8 +76,10 @@ class WorkingHoursViewController extends AbstractController
try {
return $this->printResponse($response, $repository->findAll(), StatusCodeInterface::STATUS_OK);
} catch (Exception $exception) {
throw new HttpInternalServerErrorException($request,
'Der Wert für das Datum oder die Zeit ist falsch in der Datenbank', $exception);
throw new HttpInternalServerErrorException(
$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;
use JetBrains\PhpStorm\ArrayShape;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
@@ -14,12 +16,15 @@ use Throwable;
class ErrorHandler
{
/**
* @var App<ContainerInterface>
*/
protected App $app;
/**
* ErrorHandler constructor.
*
* @param App $app
* @param App<ContainerInterface> $app
*/
public function __construct(App $app)
{
@@ -29,7 +34,7 @@ class ErrorHandler
/**
* @param Throwable $exception
*
* @return mixed[]
* @return array<string, mixed>
*/
#[ArrayShape([
'timestamp' => "false|string",
@@ -71,8 +76,7 @@ class ErrorHandler
bool $logErrors,
bool $logErrorDetails,
?LoggerInterface $logger = null
): ResponseInterface
{
): ResponseInterface {
if ($logger !== null && $logErrors === true) {
$logger->error($exception->getMessage());
}
@@ -13,7 +13,7 @@ class JsonBodyParserMiddleware implements MiddlewareInterface
{
$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);
if (json_last_error() === JSON_ERROR_NONE) {
$request = $request->withParsedBody($contents);
@@ -28,7 +28,6 @@ class OriginAccessControlHandler implements MiddlewareInterface
public function originAccessControl(Request $request, Response $response): Response
{
$routeContext = RouteContext::fromRequest($request);
$routingResults = $routeContext->getRoutingResults();
$methods = $routingResults->getAllowedMethods();
@@ -53,6 +52,6 @@ class OriginAccessControlHandler implements MiddlewareInterface
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;
use MyCLabs\Enum\Enum;
/**
* 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}.
*
* @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';
private const MONTHLY = 'Y-m';
private const YEARLY = 'Y';
case WEEKLY = 'Y#W';
case MONTHLY = 'Y-m';
case YEARLY = 'Y';
}
+2 -18
View File
@@ -13,39 +13,23 @@ class WorkingHours implements ModelInterface
protected ?DateTime $workingDay = null;
protected ?DateInterval $workingTime = null;
/**
* @return DateTime|null
*/
public function getWorkingDay(): ?DateTime
{
return $this->workingDay;
}
/**
* @param DateTime $workingDay
*
* @return WorkingHours
*/
public function setWorkingDay(DateTime $workingDay): WorkingHours
public function setWorkingDay(DateTime $workingDay): static
{
$this->workingDay = $workingDay;
return $this;
}
/**
* @return DateInterval|null
*/
public function getWorkingTime(): ?DateInterval
{
return $this->workingTime;
}
/**
* @param DateInterval $workingTime
*
* @return WorkingHours
*/
public function setWorkingTime(DateInterval $workingTime): WorkingHours
public function setWorkingTime(DateInterval $workingTime): static
{
$this->workingTime = $workingTime;
return $this;
+11 -43
View File
@@ -10,70 +10,36 @@ use JetBrains\PhpStorm\ArrayShape;
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(
DateTimeInterface $period,
PeriodDesignationEnum $periodDesignation,
int $workingDays,
DateInterval $totalHours,
DateInterval $overtime
protected DateTimeInterface $period,
protected PeriodDesignationEnum $periodDesignation,
protected int $workingDays,
protected DateInterval $totalHours,
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
{
return $this->period;
}
/**
* @return PeriodDesignationEnum
*/
public function getPeriodDesignation(): PeriodDesignationEnum
{
return $this->periodDesignation;
}
/**
* @return int
*/
public function getWorkingDays(): int
{
return $this->workingDays;
}
/**
* @return DateInterval
*/
public function getTotalHours(): DateInterval
{
return $this->totalHours;
}
/**
* @return DateInterval
*/
public function getOvertime(): DateInterval
{
return $this->overtime;
@@ -84,17 +50,19 @@ class WorkingHoursView implements ModelInterface
*
* @return array<string, mixed>
*/
#[ArrayShape(['period' => "string",
#[ArrayShape([
'period' => "string",
'periodDesignation' => "string",
'totalHours' => "string",
'workingDays' => "int",
'overtime' => "string"
])] public function jsonSerialize(): array
])]
public function jsonSerialize(): array
{
$formatOvertime = (($this->getOvertime()->invert === 1) ? '-' : '') . '%H:%I:%S';
return [
'period' => $this->getPeriod()->format($this->getPeriodDesignation()->getValue()),
'periodDesignation' => strtolower($this->getPeriodDesignation()->getKey()),
'period' => $this->getPeriod()->format($this->getPeriodDesignation()->value),
'periodDesignation' => strtolower($this->getPeriodDesignation()->name),
'workingDays' => $this->getWorkingDays(),
'totalHours' => $this->getTotalHours()->format('%H:%I:%S'),
'overtime' => $this->getOvertime()->format($formatOvertime)
@@ -20,7 +20,6 @@ abstract class AbstractWorkingHoursViewRepository implements RepositoryReaderInt
protected const SQL_SELECT = '';
protected const SQL_WHERE = '';
protected PDO $database;
protected PeriodDesignationEnum $periodDesignation;
/**
@@ -28,9 +27,8 @@ abstract class AbstractWorkingHoursViewRepository implements RepositoryReaderInt
*
* @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);
}
@@ -3,8 +3,10 @@
namespace TorstenHettstedt\TimekeepingApi\Repositories;
use TorstenHettstedt\TimekeepingApi\Models\ModelInterface;
/**
* @template T of \TorstenHettstedt\TimekeepingApi\Models\ModelInterface
* @template T of ModelInterface
*/
interface RepositoryReaderInterface
{
@@ -3,8 +3,10 @@
namespace TorstenHettstedt\TimekeepingApi\Repositories;
use TorstenHettstedt\TimekeepingApi\Models\ModelInterface;
/**
* @template T of \TorstenHettstedt\TimekeepingApi\Models\ModelInterface
* @template T of ModelInterface
*/
interface RepositoryWriterInterface
{
@@ -12,7 +12,7 @@ use TorstenHettstedt\TimekeepingApi\Models\PeriodDesignationEnum;
class WorkingHoursMonthlyViewRepository extends AbstractWorkingHoursViewRepository
{
protected const SQL_SELECT = <<<SQL
protected const string SQL_SELECT = <<<SQL
select
"Monat" as "period",
"Gesamtarbeitszeit" as "totalHours",
@@ -20,15 +20,12 @@ class WorkingHoursMonthlyViewRepository extends AbstractWorkingHoursViewReposito
"Überstunden" as "overtime"
from "Arbeitszeiten - Monat"
SQL;
protected const SQL_WHERE = ' where "Monat" = ?';
protected PDO $database;
protected PeriodDesignationEnum $periodDesignation;
protected const string SQL_WHERE = ' where "Monat" = ?';
public function __construct(PDO $database)
{
parent::__construct($database);
$this->periodDesignation = PeriodDesignationEnum::MONTHLY();
$this->periodDesignation = PeriodDesignationEnum::MONTHLY;
}
protected function buildDateFromPeriod(string $period): DateTimeInterface
+13 -12
View File
@@ -20,17 +20,16 @@ use TorstenHettstedt\TimekeepingApi\Models\WorkingHours;
*/
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;
/**
* WorkingHoursRepository constructor.
*
* @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);
}
@@ -96,7 +95,7 @@ class WorkingHoursRepository implements RepositoryReaderInterface, RepositoryWri
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 .= 'order by "Datum"';
$stmt = $this->database->prepare($query);
@@ -106,7 +105,7 @@ class WorkingHoursRepository implements RepositoryReaderInterface, RepositoryWri
if (is_string($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
{
$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->execute();
$row = $stmt->fetch();
@@ -155,7 +154,9 @@ class WorkingHoursRepository implements RepositoryReaderInterface, RepositoryWri
throw new RepositoryRecordAlreadyExistException();
} /** @noinspection PhpUnusedLocalVariableInspection */
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(2, $workingTime);
$stmt->execute();
@@ -11,7 +11,7 @@ use TorstenHettstedt\TimekeepingApi\Models\PeriodDesignationEnum;
class WorkingHoursWeeklyViewRepository extends AbstractWorkingHoursViewRepository
{
protected const SQL_SELECT = <<<SQL
protected const string SQL_SELECT = <<<SQL
select
"Woche" as "period",
"Gesamtarbeitszeit" as "totalHours",
@@ -19,15 +19,12 @@ class WorkingHoursWeeklyViewRepository extends AbstractWorkingHoursViewRepositor
"Überstunden" as "overtime"
from "Arbeitszeiten - Woche"
SQL;
protected const SQL_WHERE = ' where "Woche" = ?';
protected PDO $database;
protected PeriodDesignationEnum $periodDesignation;
protected const string SQL_WHERE = ' where "Woche" = ?';
public function __construct(PDO $database)
{
parent::__construct($database);
$this->periodDesignation = PeriodDesignationEnum::WEEKLY();
$this->periodDesignation = PeriodDesignationEnum::WEEKLY;
}
protected function buildDateFromPeriod(string $period): DateTimeInterface
@@ -11,7 +11,7 @@ use TorstenHettstedt\TimekeepingApi\Models\PeriodDesignationEnum;
class WorkingHoursYearlyViewRepository extends AbstractWorkingHoursViewRepository
{
protected const SQL_SELECT = <<<SQL
protected const string SQL_SELECT = <<<SQL
select
"Jahr" as "period",
"Gesamtarbeitszeit" as "totalHours",
@@ -19,15 +19,12 @@ class WorkingHoursYearlyViewRepository extends AbstractWorkingHoursViewRepositor
"Überstunden" as "overtime"
from "Arbeitszeiten - Jahr"
SQL;
protected const SQL_WHERE = ' where "Jahr" = ?';
protected PDO $database;
protected PeriodDesignationEnum $periodDesignation;
protected const string SQL_WHERE = ' where "Jahr" = ?';
public function __construct(PDO $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 row_security = off;
DROP TABLE IF EXISTS public."Arbeitszeiten";
DROP VIEW IF EXISTS public."Arbeitszeiten - Woche";
DROP VIEW IF EXISTS public."Arbeitszeiten - Monat";
DROP VIEW IF EXISTS public."Arbeitszeiten - Jahr";
DROP TABLE IF EXISTS public."Arbeitszeiten";
--
-- 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
--
COPY public."Arbeitszeiten" ("Datum", "Arbeitszeit") FROM stdin;
2020-01-07 08:06:00
2020-01-08 08:16:00
2020-01-09 07:49:00
2020-01-10 07:30:00
2020-01-13 08:06:00
2020-01-14 08:04:00
2020-01-15 08:20:00
2020-01-16 07:13:00
2020-01-17 07:57:00
2020-01-20 07:53:00
2020-01-21 07:49:00
2020-01-22 07:56:00
2020-01-23 07:55:00
2020-01-24 07:08:00
2020-01-27 08:23:00
2020-01-28 07:20:00
2020-01-29 08:13:00
2020-01-30 08:43:00
2020-01-31 07:21:00
2020-02-03 07:56:00
2020-02-04 08:05:00
2020-02-05 07:58:00
2020-02-06 08:01:00
2020-02-07 07:59:00
2020-02-10 07:50:00
2020-02-11 08:01:00
2020-02-12 08:14:00
2020-02-13 08:12:00
2020-02-14 07:51:00
2020-02-17 07:59:00
2020-02-18 08:05:00
2020-02-19 07:34:00
2020-02-20 07:33:00
2020-02-21 07:56:00
2020-02-24 08:02:00
2020-02-25 08:13:00
2020-02-26 08:42:00
2020-02-27 07:49:00
2020-02-28 08:16:00
2020-03-02 08:22:00
2020-03-03 08:20:00
2020-03-04 08:12:00
2020-03-05 08:32:00
2020-03-06 04:41:00
2020-03-09 07:05:00
2020-03-10 07:34:00
2020-03-11 07:39:00
2020-03-12 07:50:00
2020-03-13 08:26:00
2020-03-16 07:51:00
2020-03-17 07:50:00
2020-03-18 07:19:00
2020-03-19 07:55:00
2020-03-20 05:43:00
2020-03-23 08:05:00
2020-03-24 08:21:00
2020-03-25 08:10:00
2020-03-26 10:31:00
2020-03-27 04:55:00
2020-03-30 08:21:00
2020-03-31 08:01:00
\.
INSERT INTO public."Arbeitszeiten" ("Datum", "Arbeitszeit") VALUES
('2020-01-07','08:06:00'),
('2020-01-08','08:16:00'),
('2020-01-09','07:49:00'),
('2020-01-10','07:30:00'),
('2020-01-13','08:06:00'),
('2020-01-14','08:04:00'),
('2020-01-15','08:20:00'),
('2020-01-16','07:13:00'),
('2020-01-17','07:57:00'),
('2020-01-20','07:53:00'),
('2020-01-21','07:49:00'),
('2020-01-22','07:56:00'),
('2020-01-23','07:55:00'),
('2020-01-24','07:08:00'),
('2020-01-27','08:23:00'),
('2020-01-28','07:20:00'),
('2020-01-29','08:13:00'),
('2020-01-30','08:43:00'),
('2020-01-31','07:21:00'),
('2020-02-03','07:56:00'),
('2020-02-04','08:05:00'),
('2020-02-05','07:58:00'),
('2020-02-06','08:01:00'),
('2020-02-07','07:59:00'),
('2020-02-10','07:50:00'),
('2020-02-11','08:01:00'),
('2020-02-12','08:14:00'),
('2020-02-13','08:12:00'),
('2020-02-14','07:51:00'),
('2020-02-17','07:59:00'),
('2020-02-18','08:05:00'),
('2020-02-19','07:34:00'),
('2020-02-20','07:33:00'),
('2020-02-21','07:56:00'),
('2020-02-24','08:02:00'),
('2020-02-25','08:13:00'),
('2020-02-26','08:42:00'),
('2020-02-27','07:49:00'),
('2020-02-28','08:16:00'),
('2020-03-02','08:22:00'),
('2020-03-03','08:20:00'),
('2020-03-04','08:12:00'),
('2020-03-05','08:32:00'),
('2020-03-06','04:41:00'),
('2020-03-09','07:05:00'),
('2020-03-10','07:34:00'),
('2020-03-11','07:39:00'),
('2020-03-12','07:50:00'),
('2020-03-13','08:26:00'),
('2020-03-16','07:51:00'),
('2020-03-17','07:50:00'),
('2020-03-18','07:19:00'),
('2020-03-19','07:55:00'),
('2020-03-20','05:43:00'),
('2020-03-23','08:05:00'),
('2020-03-24','08:21:00'),
('2020-03-25','08:10:00'),
('2020-03-26','10:31:00'),
('2020-03-27','04:55:00'),
('2020-03-30','08:21:00'),
('2020-03-31','08:01:00')
;
+1 -1
View File
@@ -3,6 +3,6 @@ modules:
enabled:
- \Helper\Api
- REST:
url: http://localhost:8091/
url: http://api:80/
depends: PhpBrowser
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;
use Codeception\Example;
use Codeception\Test\Unit;
use Codeception\Attribute\DataProvider;
use Exception;
use PDO;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Container\NotFoundExceptionInterface;
use Slim\Exception\HttpBadRequestException;
use Slim\Exception\HttpInternalServerErrorException;
use Slim\Exception\HttpNotFoundException;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
use TorstenHettstedt\TimekeepingApi\Controller\HttpConflictRequestException;
use TorstenHettstedt\TimekeepingApi\Controller\NotDatabasesException;
use TorstenHettstedt\TimekeepingApi\Controller\WorkingHoursController;
class WorkingHoursControllerTest extends Unit
class WorkingHoursControllerTest extends AbstractControllerTest
{
const EXISTING_DATE = '2020-01-28';
const EXISTING_INTERVAL = '07:20:00';
const NEW_DATE = '2020-04-01';
const NEW_INTERVAL = '07:59:00';
protected ContainerInterface $container;
protected Request $request;
protected Response $response;
protected const int FICTIONAL_STATUS_CODE = 666;
public const string EXISTING_DATE = '2020-01-28';
public const string EXISTING_INTERVAL = '07:20:00';
public const string NEW_DATE = '2020-04-01';
public const string NEW_INTERVAL = '07:59:00';
/**
* @throws Exception
* @throws \PHPUnit\Framework\MockObject\Exception
*/
protected function _before(): void
TorstenHettstedt marked this conversation as resolved Outdated
Outdated
Review

muss das noch sein?

muss das noch sein?
{
parent::_before();
/** @noinspection SpellCheckingInspection */
$this->container = $this->makeEmpty(ContainerInterface::class, [
'has' => true,
'get' => new PDO('pgsql:host=psql.torsten-hettstedt.net;port=5432;dbname=testdb;user=bruce;password=mypass'),
]);
$this->request = $this->makeEmpty(Request::class, [
'getParsedBody' => [
'workingDay' => self::EXISTING_DATE,
'workingTime' => self::EXISTING_INTERVAL,
],
]);
$this->response = $this->makeEmpty(Response::class, [
'getBody' => $this->makeEmpty(StreamInterface::class, [
'write' => function (mixed $data) {
$this->assertIsString($data);
$this->assertJson($data);
},
]),
'withHeader' => $this->makeEmpty(Response::class, [
'withStatus' => $this->makeEmpty(Response::class),
]),
]);
}
/**
* @throws NotDatabasesException
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
* @throws Exception
*/
public function testConstructWithNonDatabase(): void
@@ -103,12 +84,12 @@ class WorkingHoursControllerTest extends Unit
/**
* @param array<string, string> $queryParams
*
* @dataProvider valideFilterDataProvider
*
* @throws HttpInternalServerErrorException
* @throws ContainerExceptionInterface
* @throws NotDatabasesException
* @throws NotFoundExceptionInterface
* @throws Exception
*/
#[DataProvider('valideFilterDataProvider')]
public function testBrowseWithValideParameter(array $queryParams): void
{
$this->request = $this->makeEmpty(Request::class, [
@@ -116,7 +97,8 @@ class WorkingHoursControllerTest extends Unit
]);
$controller = new WorkingHoursController($this->container);
$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
*
* @dataProvider invalideFilterDataProvider
*
* @throws HttpInternalServerErrorException
* @throws ContainerExceptionInterface
* @throws NotDatabasesException
* @throws NotFoundExceptionInterface
* @throws Exception
*/
#[DataProvider('invalideFilterDataProvider')]
public function testBrowseWithInvalideParameter(array $queryParams): void
{
$this->request = $this->makeEmpty(Request::class, [
@@ -263,9 +245,10 @@ class WorkingHoursControllerTest extends Unit
}
/**
* @throws ContainerExceptionInterface
* @throws NotDatabasesException
* @throws HttpBadRequestException
* @throws HttpNotFoundException
* @throws NotFoundExceptionInterface
* @throws Exception
*/
public function testUpdateExistRecord(): void
{
@@ -273,13 +256,15 @@ class WorkingHoursControllerTest extends Unit
$response = $controller->update($this->request, $this->response, [
'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 HttpBadRequestException
* @throws HttpNotFoundException
* @throws NotFoundExceptionInterface
* @throws Exception
*/
public function testUpdateNotExistRecord(): void
{
@@ -311,14 +296,12 @@ class WorkingHoursControllerTest extends Unit
* @param string $date
* @param string $time
*
* @dataProvider invalidCreatDataProvider
*
* @throws HttpBadRequestException
* @throws HttpConflictRequestException
* @throws HttpInternalServerErrorException
* @throws ContainerExceptionInterface
* @throws NotDatabasesException
* @throws NotFoundExceptionInterface
* @throws Exception
*/
#[DataProvider('invalidCreatDataProvider')]
public function testCreatInvalidData(string $date, string $time): void
{
$this->request = $this->makeEmpty(Request::class, [
@@ -333,10 +316,9 @@ class WorkingHoursControllerTest extends Unit
}
/**
* @throws HttpBadRequestException
* @throws ContainerExceptionInterface
* @throws NotDatabasesException
* @throws HttpInternalServerErrorException
* @throws HttpConflictRequestException
* @throws NotFoundExceptionInterface
* @throws Exception
*/
public function testCreatNewRecord(): void
@@ -349,14 +331,14 @@ class WorkingHoursControllerTest extends Unit
]);
$controller = new WorkingHoursController($this->container);
$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 HttpConflictRequestException
* @throws HttpInternalServerErrorException
* @throws ContainerExceptionInterface
* @throws NotDatabasesException
* @throws NotFoundExceptionInterface
* @throws Exception
*/
public function testCreatExistRecord(): void
@@ -373,9 +355,10 @@ class WorkingHoursControllerTest extends Unit
}
/**
* @throws HttpInternalServerErrorException
* @throws HttpNotFoundException
* @throws ContainerExceptionInterface
* @throws NotDatabasesException
* @throws NotFoundExceptionInterface
* @throws Exception
*/
public function testReadExistRecord(): void
{
@@ -383,13 +366,15 @@ class WorkingHoursControllerTest extends Unit
$response = $controller->read($this->request, $this->response, [
'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 HttpNotFoundException
* @throws ContainerExceptionInterface
* @throws NotDatabasesException
* @throws NotFoundExceptionInterface
* @throws Exception
*/
public function testReadNotExistRecord(): void
{
@@ -2,49 +2,30 @@
namespace TorstenHettstedt\TimekeepingApi\Tests\Unit\Controller;
use Codeception\Test\Unit;
use Exception;
use PDO;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Container\NotFoundExceptionInterface;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
use TorstenHettstedt\TimekeepingApi\Controller\NotDatabasesException;
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 \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->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),
]),
]);
$this->request = $this->makeEmpty(Request::class);
}
/**
* @throws NotDatabasesException
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
* @throws Exception
*/
public function testConstructWithNonDatabase(): void
@@ -57,32 +38,44 @@ class WorkingHoursViewControllerTest extends Unit
}
/**
* @throws NotDatabasesException
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
* @throws Exception
*/
public function testBrowseMonthly(): void
{
$controller = new WorkingHoursViewController($this->container);
$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
*/
public function testBrowseYearly(): void
{
$controller = new WorkingHoursViewController($this->container);
$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
*/
public function testBrowseWeekly(): void
{
$controller = new WorkingHoursViewController($this->container);
$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\Test\Unit;
use Exception;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
@@ -20,6 +21,7 @@ use TorstenHettstedt\TimekeepingApi\Middleware\ErrorHandler;
class ErrorHandlerTest extends Unit
{
/** @var App<ContainerInterface> */
protected App $app;
protected ServerRequestInterface $request;
protected Exception $exception;
@@ -38,6 +40,7 @@ class ErrorHandlerTest extends Unit
'write' => function (mixed $data) {
$this->assertIsString($data);
$this->assertJson($data);
return strlen($data);
},
]),
]),
@@ -87,11 +90,10 @@ class ErrorHandlerTest extends Unit
'file' => '/path(to/file',
'getTitle' => Expected::once('The Title'),
]);
$this->logger = $this->makeEmpty(LoggerInterface::class, []);
$this->logger = $this->makeEmpty(LoggerInterface::class);
$middleWare = new ErrorHandler($this->app);
$middleWare($this->request, $this->exception, true, true, true, $this->logger);
}
/**
@@ -104,7 +106,7 @@ class ErrorHandlerTest extends Unit
'code' => 400,
'file' => '/path(to/file',
]);
$this->logger = $this->makeEmpty(LoggerInterface::class, []);
$this->logger = $this->makeEmpty(LoggerInterface::class);
$middleWare = new ErrorHandler($this->app);
$middleWare($this->request, $this->exception, true, true, true, $this->logger);
+7 -11
View File
@@ -2,6 +2,7 @@
namespace TorstenHettstedt\TimekeepingApi\Tests\Unit\Models;
use Codeception\Attribute\DataProvider;
use Codeception\Test\Unit;
use DateInterval;
use DateTime;
@@ -22,7 +23,7 @@ class WorkingHoursTest extends Unit
}
/**
* @return array[]
* @return array<array<DateTime|DateInterval|array<string,string>>>
*/
public function workingHoursProvider(): array
{
@@ -49,17 +50,13 @@ class WorkingHoursTest extends Unit
/**
* @param DateTime $date
* @param DateInterval $interval
* @param array<string, string> $should_json
*
* @dataProvider workingHoursProvider
* @param array<string, string> $shouldJson
*/
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
->setWorkingDay($date)
->setWorkingTime($interval);
$obj->setWorkingDay($date)->setWorkingTime($interval);
$this->assertNotNull($obj->getWorkingDay());
$this->assertInstanceOf(DateTime::class, $obj->getWorkingDay());
@@ -70,7 +67,6 @@ class WorkingHoursTest extends Unit
$this->assertEquals($interval, $obj->getWorkingTime());
$json = $obj->jsonSerialize();
$this->assertIsArray($json);
$this->assertEquals($should_json, $json);
$this->assertEquals($shouldJson, $json);
}
}
+39 -22
View File
@@ -2,6 +2,7 @@
namespace TorstenHettstedt\TimekeepingApi\Tests\Unit\Models;
use Codeception\Attribute\DataProvider;
use Codeception\Test\Unit;
use DateInterval;
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
{
@@ -25,7 +26,11 @@ class WorkingHoursViewTest extends Unit
$absence_time->invert = 1;
return [
[
$date, PeriodDesignationEnum::WEEKLY(), $work_days, $total_hours, $overtime,
$date,
PeriodDesignationEnum::WEEKLY,
$work_days,
$total_hours,
$overtime,
[
'period' => '2020#49',
'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',
'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',
'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',
'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',
'periodDesignation' => 'yearly',
'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',
'periodDesignation' => 'yearly',
'workingDays' => 15,
@@ -91,45 +118,35 @@ class WorkingHoursViewTest extends Unit
* @param int $workingDays
* @param DateInterval $totalHours
* @param DateInterval $overtime
* @param array<string, string> $should_json
*
* @dataProvider workingHoursProvider
* @param array<string, string> $shouldJson
*/
#[DataProvider('workingHoursProvider')]
public function testWorkingHoursViewWithContent(
DateTimeInterface $period,
PeriodDesignationEnum $periodDesignation,
int $workingDays,
DateInterval $totalHours,
DateInterval $overtime,
array $should_json
): void
{
array $shouldJson
): void {
$obj = new WorkingHoursView($period, $periodDesignation, $workingDays, $totalHours, $overtime);
$this->assertNotNull($obj->getPeriod());
$this->assertInstanceOf(DateTimeInterface::class, $obj->getPeriod());
$this->assertEquals($period, $obj->getPeriod());
$this->assertNotNull($obj->getPeriodDesignation());
$this->assertInstanceOf(PeriodDesignationEnum::class, $obj->getPeriodDesignation());
$this->assertEquals($periodDesignation, $obj->getPeriodDesignation());
$this->assertNotNull($obj->getWorkingDays());
$this->assertIsInt($obj->getWorkingDays());
$this->assertEquals($workingDays, $obj->getWorkingDays());
$this->assertNotNull($obj->getTotalHours());
$this->assertInstanceOf(DateInterval::class, $obj->getTotalHours());
$this->assertEquals($totalHours, $obj->getTotalHours());
$this->assertNotNull($obj->getOvertime());
$this->assertInstanceOf(DateInterval::class, $obj->getOvertime());
$this->assertEquals($overtime, $obj->getOvertime());
$json = $obj->jsonSerialize();
$this->assertIsArray($json);
$this->assertEquals($should_json, $json);
$this->assertEquals($shouldJson, $json);
}
}
@@ -2,7 +2,7 @@
namespace TorstenHettstedt\TimekeepingApi\Tests\Unit\Repositories;
use Codeception\Example;
use Codeception\Attribute\DataProvider;
use Codeception\Test\Unit;
use DateInterval;
use DateTime;
@@ -18,18 +18,20 @@ use TorstenHettstedt\TimekeepingApi\Repositories\WorkingHoursRepository;
class WorkingHoursRepositoryTest extends Unit
{
const EXISTING_DATE = '2020-01-28';
const EXISTING_INTERVAL = 'PT7H20M';
const NEW_DATE = '2020-04-01';
const NEW_INTERVAL = 'PT7H59M';
const RECORDS_COUNT = 61;
public const string EXISTING_DATE = '2020-01-28';
public const string EXISTING_INTERVAL = 'PT7H20M';
public const string NEW_DATE = '2020-04-01';
public const string NEW_INTERVAL = 'PT7H59M';
public const int RECORDS_COUNT = 61;
protected PDO $pdoObject;
protected function _before(): void
{
/** @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();
}
@@ -40,8 +42,8 @@ class WorkingHoursRepositoryTest extends Unit
{
$repository = new WorkingHoursRepository($this->pdoObject);
$models = $repository->findAll();
$this->assertIsArray($models);
$this->assertContainsOnly(WorkingHours::class, $models);
$this->assertContainsOnlyInstancesOf(WorkingHours::class, $models);
$this->assertCount(self::RECORDS_COUNT, $models);
}
@@ -66,16 +68,15 @@ class WorkingHoursRepositoryTest extends Unit
* @param string|null $ende
* @param int $count
*
* @dataProvider valideFilterParameter
*
* @throws Exception
*/
#[DataProvider('valideFilterParameter')]
public function testFindAllWithValideParameter(?string $start, ?string $ende, int $count): void
{
$repository = new WorkingHoursRepository($this->pdoObject);
$models = $repository->findFiltered($start, $ende);
$this->assertIsArray($models);
$this->assertContainsOnly(WorkingHours::class, $models);
$this->assertContainsOnlyInstancesOf(WorkingHours::class, $models);
$this->assertCount($count, $models);
}
@@ -109,10 +110,9 @@ class WorkingHoursRepositoryTest extends Unit
* @param string|null $start
* @param string|null $ende
*
* @dataProvider invalideFilterParameter
*
* @throws Exception
*/
#[DataProvider('invalideFilterParameter')]
public function testFindAllWithInvalideParameter(?string $start, ?string $ende): void
{
$repository = new WorkingHoursRepository($this->pdoObject);
@@ -127,7 +127,6 @@ class WorkingHoursRepositoryTest extends Unit
{
$repository = new WorkingHoursRepository($this->pdoObject);
$model = $repository->findByKey(self::EXISTING_DATE);
$this->assertInstanceOf(WorkingHours::class, $model);
$this->assertEquals(new DateTime(self::EXISTING_DATE), $model->getWorkingDay());
$this->assertEquals(new DateInterval(self::EXISTING_INTERVAL), $model->getWorkingTime());
}
@@ -163,7 +162,6 @@ class WorkingHoursRepositoryTest extends Unit
]);
$repository->insert($model);
$model = $repository->findByKey(self::NEW_DATE);
$this->assertInstanceOf(WorkingHours::class, $model);
$this->assertEquals(new DateTime(self::NEW_DATE), $model->getWorkingDay());
$this->assertEquals(new DateInterval(self::NEW_INTERVAL), $model->getWorkingTime());
$this->assertCount(self::RECORDS_COUNT + 1, $repository->findAll());
@@ -2,12 +2,11 @@
namespace TorstenHettstedt\TimekeepingApi\Tests\Unit\Repositories;
use Codeception\Attribute\DataProvider;
use Codeception\Test\Unit;
use DateTime;
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\WorkingHoursWeeklyViewRepository;
use TorstenHettstedt\TimekeepingApi\Repositories\WorkingHoursMonthlyViewRepository;
@@ -25,7 +24,7 @@ class WorkingHoursViewRepositoryTest extends Unit
}
/**
* @return array[]
* @return array<array{string, int}>
*/
public function listObjectProvider(): array
{
@@ -37,20 +36,19 @@ class WorkingHoursViewRepositoryTest extends Unit
}
/**
* @return array[]
* @throws Exception
* @return array<array{string, string, int, DateTime}>
*/
public function existingObjectProvider(): array
{
return [
[WorkingHoursYearlyViewRepository::class, '2020', 61, new DateTime('2020-01-01', new DateTimeZone('UCT'))],
[WorkingHoursMonthlyViewRepository::class, '2020 February', 20, new DateTime('2020-02-01', new DateTimeZone('UCT'))],
[WorkingHoursWeeklyViewRepository::class, '2020#03', 5, new DateTime('2020-01-13', new DateTimeZone('UCT'))],
[WorkingHoursYearlyViewRepository::class, '2020', 61, date_create('2020-01-01', new DateTimeZone('UCT'))],
[WorkingHoursMonthlyViewRepository::class, '2020 February', 20, date_create('2020-02-01', 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
{
@@ -61,49 +59,30 @@ class WorkingHoursViewRepositoryTest extends Unit
];
}
/**
* @param string $period_class
* @param int $count_records
*
* @dataProvider listObjectProvider
*/
public function testFindAll(string $period_class, int $count_records): void
#[DataProvider(('listObjectProvider'))]
public function testFindAll(string $periodClass, int $countRecords): void
{
$repository = new $period_class($this->pdoObject);
$repository = new $periodClass($this->pdoObject);
$models = $repository->findAll();
$this->assertIsArray($models);
$this->assertContainsOnly(WorkingHoursView::class, $models);
$this->assertCount($count_records, $models);
$this->assertContainsOnlyInstancesOf(WorkingHoursView::class, $models);
$this->assertCount($countRecords, $models);
}
/**
*
* @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
#[DataProvider(('existingObjectProvider'))]
public function testExistingFindByKey(string $periodClass, string $search, int $workingDays, DateTime $period): void
{
$repository = new $period_class($this->pdoObject);
$repository = new $periodClass($this->pdoObject);
$model = $repository->findByKey($search);
$this->assertInstanceOf(WorkingHoursView::class, $model);
$this->assertEquals($workingDays, $model->getWorkingDays());
$this->assertEquals($period->format('Ymd'), $model->getPeriod()->format('Ymd'));
}
/**
* @param string $period_class
* @param string $search
*
* @dataProvider notExistingObjectProvider
*
*/
public function testNotExistingFindByKey(string $period_class, string $search): void
#[DataProvider(('notExistingObjectProvider'))]
public function testNotExistingFindByKey(string $periodClass, string $search): void
{
$repository = new $period_class($this->pdoObject);
$repository = new $periodClass($this->pdoObject);
$this->expectException(RepositoryRecordNotFoundException::class);
$repository->findByKey($search);
}
+6 -3
View File
@@ -7,7 +7,7 @@ services:
environment:
docker: "true"
ports:
- 8090:80
- "8090:80"
volumes:
- ./api:/var/www
- logs:/var/www/logs
@@ -20,13 +20,16 @@ services:
DATABASES_USER: bruce
DATABASES_PASS: mypass
ports:
- 8091:80
- "8091:80"
volumes:
- ./api:/var/www
- logs:/var/www/logs
ui:
build: ./ui/
env_file:
- ui/.env
ports:
- 80:80
- "8080:3000"
depends_on:
- api
- test-api
+11 -4
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env bash
create_css_files() {
cd ui/public || exit 2
lessc --source-map less/print.less print.css
lessc --source-map less/global.less global.css
cd ui || exit 2
lessc --source-map less/print.less static/print.css
lessc --source-map less/global.less static/global.css
cd ../..
}
@@ -27,6 +27,13 @@ build_ui() {
cd ..
}
codeception_install() {
pwd
cd api/ || exit 2
test -e ./tests/unit.suite.yml || ./vendor/bin/codecept build
cd ..
}
test_unit() {
pwd
cd api/ || exit 2
@@ -44,4 +51,4 @@ composer_run() {
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 {
body {
background: @mainColorLight;
div {
> header, > footer {
color: @mainColorDark;
background: @mainColor;
font-family: serif;
h1 {
font-size: 3rem;
text-align: center;
}
p {
text-indent: inherit;
&:first-letter {
font-size: inherit;
}
font-style: italic;
}
}
> header h1 {
margin: 1rem auto;
}
> footer {
color: contrast(@mainColorDark);
}
& > nav {
ul {
background: darken(@mainColorLight, 33%, relativ);
li {
font-weight: bold;
&:not(:first-child) {
border-left: @mainColorDark solid thin;
}
a {
text-decoration: none;
color: @mainColorDark;
}
&:hover {
background: lighten(@mainColor, 20%, relativ);
a {
color: lighten(@mainColor, 66%, relativ);
}
}
&.selected, &.selected:hover {
background: lighten(@mainColor, 10%, relativ);
a {
color: lighten(@mainColor, 66%, relativ);
}
@@ -56,6 +71,7 @@ html {
}
}
}
}
main {
* {
@@ -63,15 +79,19 @@ main {
border-color: @mainColorDark;
border-width: medium;
}
section {
@sectionMargin: 1rem;
h1, h2 {
margin: @sectionMargin;
}
p, ul, ol, dl {
margin: @sectionMargin;
text-indent: @sectionMargin * 2;
}
table {
thead {
tr:last-child {
@@ -79,7 +99,9 @@ main {
border-bottom: @mainColor solid thin;
}
}
};
}
;
tbody {
td.absence-time {
color: @redColor;
@@ -87,15 +109,23 @@ main {
text-decoration-style: dashed;
}
}
tfoot {
* {
border: initial;
}
}
}
form {
@formLabelWidth: 10rem;
@formItemWidth: 40rem;
nav.menu {
width: @formItemWidth + @formLabelWidth + 4rem;
border-color: @mainColor;
border-bottom-style: solid;
margin: 1rem auto;
span {
border-color: @mainColor;
border-style: solid solid none;
@@ -103,37 +133,45 @@ main {
display: inline-block;
font-weight: bolder;
margin-right: 1rem;
&:first-child {
margin-left: 1.5rem;
}
&:hover {
background-color: darken(@mainColorLight, 10%, relativ);
}
&.active {
background-color: darken(@mainColorLight, 20%, relativ);
}
}
}
fieldset {
width: @formItemWidth + @formLabelWidth + 2rem;
margin: 1rem auto;
border-style: solid;
border-color: @mainColor;
padding: @sectionMargin;
&.tasten {
border: none;
background-color: @mainColorLight;
}
legend {
margin: @sectionMargin;
margin-left: @sectionMargin * 2;
}
div {
& > label {
width: @formLabelWidth;
display: inline-block;
margin: 0.5rem;
}
input {
display: inline-block;
width: @formItemWidth;
@@ -145,31 +183,39 @@ main {
}
}
}
button {
.button(@mainColor, spin(lighten(@mainColor, 66%, relativ), @subColor));
&.sub-button {
.button(lighten(@mainColor, 20%, relativ), spin(@mainColor, @subColor));
}
}
footer {
text-align: right;
padding: 0;
p {
padding: 0.5rem;
display: inline-block;
margin: inherit;
text-indent: inherit;
&:first-letter {
font-size: inherit;
}
background: @mainColorLight;
color: @mainColor;
font-style: italic;
font-size: 85%;
}
nav li {
padding: 0.5rem;
}
border-bottom-style: solid;
}
}
@@ -5,7 +5,7 @@
font-size: 10pt;
}
header, footer, main, nav, aside {
body > & {
body div > & {
width: 19cm;
margin: auto;
.border-box();
@@ -15,7 +15,7 @@
padding: 0.5rem;
;
}
body {
body div {
& > header {
font-size: 200%;
font-weight: bolder;
@@ -71,7 +71,7 @@
@media screen and (min-width: 831px) {
header, footer, main, nav, aside {
body > & {
body div > & {
width: @breiteMainBereich;
margin: auto;
.border-box();
@@ -80,7 +80,7 @@
header, footer{
padding: 0.5rem;
}
body {
body div {
& > header {
font-size: 200%;
font-weight: bolder;
@@ -139,7 +139,7 @@
}
@media screen and (max-width: 830px) {
html body {
html body div {
@boxHeight: 5rem;
& > header {
height: @boxHeight;
@@ -267,7 +267,7 @@
}
}
@media screen and (max-width: 720px) {
html body {
html body div {
@boxHeight: 2.5rem;
& > header {
height: @boxHeight;
@@ -2,7 +2,7 @@ html {
font-size: 100%;
}
body {
body div {
color: #000;
font-size: 1em;
font-family: OpenSans, "Lucida Grande", "Lucida Sans Unicode", Verdana, Helvetica, Arial, sans-serif;
@@ -1,5 +1,5 @@
html {
body {
body div {
> header, > footer {
background: none;
font-family: serif;
@@ -59,7 +59,7 @@ main {
}
}
}
tbody {
tbody div {
th {
font-weight: bold;
text-align: center;
+25 -18
View File
@@ -1,31 +1,38 @@
{
"private": true,
"name": "svelte-demo",
"type": "module",
"scripts": {
"build": "rollup -c",
"autobuild": "rollup -c -w",
"dev": "run-p start:dev autobuild",
"start": "sirv public --single",
"start:dev": "sirv public --dev --single"
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"dependencies": {
"ganalytics": "^3.1.2",
"navaid": "^1.0.2",
"open-iconic": "^1.1.1"
"open-iconic": "^1.1.1",
"papaparse": "^5.5.3"
},
"devDependencies": {
"@iconify-icons/oi": "^1.1.0",
"@iconify/svelte": "^1.0.4",
"@rollup/plugin-commonjs": "^15.0.0",
"@rollup/plugin-node-resolve": "^9.0.0",
"@rollup/plugin-replace": "^2.3.0",
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@sveltejs/adapter-node": "^5.2.12",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^6.2.6",
"dotenv": "^8.2.0",
"npm-run-all": "^4.1.3",
"rollup": "^2.30.0",
"rollup-plugin-svelte": "^6.0.0",
"rollup-plugin-terser": "^7.0.0",
"sirv-cli": "^1.0.8",
"svelte": "^3.4.4",
"svelte-paginate": "^0.1.0"
"wx-svelte-grid": "^2.1.5",
"@iconify-icons/oi": "^1.1.0",
"@iconify/svelte": "^5.0.0"
},
"exports": {
".": {
"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