소스 검색

initial checkin with maaaaannnny functions

master
FlorianEisenmenger 1 주 전
커밋
bf831dfa9d
100개의 변경된 파일21031개의 추가작업 그리고 0개의 파일을 삭제
  1. +1
    -0
      .gitignore
  2. +10
    -0
      httpdocs/.ddev/addon-metadata/phpmyadmin/manifest.yaml
  3. +19
    -0
      httpdocs/.ddev/commands/host/phpmyadmin
  4. +296
    -0
      httpdocs/.ddev/config.yaml
  5. +27
    -0
      httpdocs/.ddev/docker-compose.phpmyadmin.yaml
  6. +4
    -0
      httpdocs/.ddev/docker-compose.phpmyadmin_norouter.yaml
  7. +32
    -0
      httpdocs/.ddev/traefik/certs/testtimetracking.crt
  8. +29
    -0
      httpdocs/.ddev/traefik/certs/testtimetracking.key
  9. +17
    -0
      httpdocs/.editorconfig
  10. +48
    -0
      httpdocs/.env
  11. +4
    -0
      httpdocs/.env.dev
  12. +17
    -0
      httpdocs/.gitignore
  13. +35
    -0
      httpdocs/1-reset-and-seed.sh
  14. +19
    -0
      httpdocs/LICENSE
  15. +37
    -0
      httpdocs/README.md
  16. +11
    -0
      httpdocs/assets/app.js
  17. +276
    -0
      httpdocs/assets/scripts/calendar.js
  18. +473
    -0
      httpdocs/assets/scripts/crud.js
  19. +96
    -0
      httpdocs/assets/scripts/duration.js
  20. +441
    -0
      httpdocs/assets/scripts/entries.js
  21. +87
    -0
      httpdocs/assets/scripts/registration.js
  22. +223
    -0
      httpdocs/assets/scripts/team.js
  23. +67
    -0
      httpdocs/assets/styles/atoms/_buttons.scss
  24. +89
    -0
      httpdocs/assets/styles/atoms/_inputs.scss
  25. +37
    -0
      httpdocs/assets/styles/atoms/_typography.scss
  26. +75
    -0
      httpdocs/assets/styles/atoms/_variables.scss
  27. +156
    -0
      httpdocs/assets/styles/components/_account.scss
  28. +208
    -0
      httpdocs/assets/styles/components/_crud.scss
  29. +56
    -0
      httpdocs/assets/styles/components/_duration-help.scss
  30. +50
    -0
      httpdocs/assets/styles/components/_entry-form.scss
  31. +170
    -0
      httpdocs/assets/styles/components/_entry-list.scss
  32. +15
    -0
      httpdocs/assets/styles/components/_greeting.scss
  33. +98
    -0
      httpdocs/assets/styles/components/_login.scss
  34. +50
    -0
      httpdocs/assets/styles/components/_main-nav.scss
  35. +137
    -0
      httpdocs/assets/styles/components/_month-calendar.scss
  36. +193
    -0
      httpdocs/assets/styles/components/_register.scss
  37. +164
    -0
      httpdocs/assets/styles/components/_team.scss
  38. +122
    -0
      httpdocs/assets/styles/components/_week-nav.scss
  39. +42
    -0
      httpdocs/assets/styles/main.scss
  40. +67
    -0
      httpdocs/assets/styles/sections/_home.scss
  41. +54
    -0
      httpdocs/assets/styles/sections/_timetracking.scss
  42. +21
    -0
      httpdocs/bin/console
  43. +84
    -0
      httpdocs/composer.json
  44. +6634
    -0
      httpdocs/composer.lock
  45. +12
    -0
      httpdocs/config/bundles.php
  46. +19
    -0
      httpdocs/config/packages/cache.yaml
  47. +11
    -0
      httpdocs/config/packages/csrf.yaml
  48. +76
    -0
      httpdocs/config/packages/doctrine.yaml
  49. +5
    -0
      httpdocs/config/packages/doctrine_migrations.yaml
  50. +19
    -0
      httpdocs/config/packages/framework.yaml
  51. +5
    -0
      httpdocs/config/packages/mailer.yaml
  52. +20
    -0
      httpdocs/config/packages/monolog.yaml
  53. +3
    -0
      httpdocs/config/packages/property_info.yaml
  54. +10
    -0
      httpdocs/config/packages/routing.yaml
  55. +65
    -0
      httpdocs/config/packages/security.yaml
  56. +7
    -0
      httpdocs/config/packages/translation.yaml
  57. +6
    -0
      httpdocs/config/packages/twig.yaml
  58. +11
    -0
      httpdocs/config/packages/validator.yaml
  59. +45
    -0
      httpdocs/config/packages/webpack_encore.yaml
  60. +5
    -0
      httpdocs/config/preload.php
  61. +1550
    -0
      httpdocs/config/reference.php
  62. +11
    -0
      httpdocs/config/routes.yaml
  63. +4
    -0
      httpdocs/config/routes/framework.yaml
  64. +3
    -0
      httpdocs/config/routes/security.yaml
  65. +60
    -0
      httpdocs/config/services.yaml
  66. +0
    -0
      httpdocs/migrations/.gitignore
  67. +0
    -0
      httpdocs/migrations/central/.gitkeep
  68. +39
    -0
      httpdocs/migrations/central/Version20260523122322.php
  69. +31
    -0
      httpdocs/migrations/central/Version20260523190211.php
  70. +31
    -0
      httpdocs/migrations/central/Version20260523203200.php
  71. +33
    -0
      httpdocs/migrations/central/Version20260523212725.php
  72. +31
    -0
      httpdocs/migrations/central/Version20260523221257.php
  73. +0
    -0
      httpdocs/migrations/tenant/.gitkeep
  74. +5812
    -0
      httpdocs/package-lock.json
  75. +21
    -0
      httpdocs/package.json
  76. +9
    -0
      httpdocs/public/index.php
  77. +198
    -0
      httpdocs/src/Command/SeedCommand.php
  78. +0
    -0
      httpdocs/src/Controller/.gitignore
  79. +125
    -0
      httpdocs/src/Controller/AccountController.php
  80. +143
    -0
      httpdocs/src/Controller/ClientController.php
  81. +26
    -0
      httpdocs/src/Controller/HomeController.php
  82. +101
    -0
      httpdocs/src/Controller/InviteController.php
  83. +146
    -0
      httpdocs/src/Controller/ProjectController.php
  84. +103
    -0
      httpdocs/src/Controller/RegistrationController.php
  85. +38
    -0
      httpdocs/src/Controller/SecurityController.php
  86. +138
    -0
      httpdocs/src/Controller/ServiceController.php
  87. +313
    -0
      httpdocs/src/Controller/TeamController.php
  88. +265
    -0
      httpdocs/src/Controller/TimeTrackingController.php
  89. +38
    -0
      httpdocs/src/Doctrine/TenantConnectionMiddleware.php
  90. +0
    -0
      httpdocs/src/Entity/.gitignore
  91. +70
    -0
      httpdocs/src/Entity/Central/Account.php
  92. +65
    -0
      httpdocs/src/Entity/Central/AccountUser.php
  93. +66
    -0
      httpdocs/src/Entity/Central/InviteToken.php
  94. +69
    -0
      httpdocs/src/Entity/Central/RegistrationToken.php
  95. +59
    -0
      httpdocs/src/Entity/Central/User.php
  96. +71
    -0
      httpdocs/src/Entity/Tenant/Client.php
  97. +47
    -0
      httpdocs/src/Entity/Tenant/Project.php
  98. +46
    -0
      httpdocs/src/Entity/Tenant/Service.php
  99. +102
    -0
      httpdocs/src/Entity/Tenant/TimeEntry.php
  100. +57
    -0
      httpdocs/src/EventSubscriber/SlidingSessionSubscriber.php

+ 1
- 0
.gitignore 파일 보기

@@ -0,0 +1 @@
.idea

+ 10
- 0
httpdocs/.ddev/addon-metadata/phpmyadmin/manifest.yaml 파일 보기

@@ -0,0 +1,10 @@
name: phpmyadmin
repository: ddev/ddev-phpmyadmin
version: v1.0.3
install_date: "2026-05-22T08:18:15+02:00"
project_files:
- docker-compose.phpmyadmin.yaml
- docker-compose.phpmyadmin_norouter.yaml
- commands/host/phpmyadmin
global_files: []
removal_actions: []

+ 19
- 0
httpdocs/.ddev/commands/host/phpmyadmin 파일 보기

@@ -0,0 +1,19 @@
#!/usr/bin/env bash

## #ddev-generated: If you want to edit and own this file, remove this line.
## Description: Launch a browser with PhpMyAdmin
## Usage: phpmyadmin
## Example: "ddev phpmyadmin"

DDEV_PHPMYADMIN_PORT=8036
DDEV_PHPMYADMIN_HTTPS_PORT=8037
if [ ${DDEV_PRIMARY_URL%://*} = "http" ] || [ -n "${GITPOD_WORKSPACE_ID:-}" ] || [ "${CODESPACES:-}" = "true" ]; then
# Gitpod: "gp preview" opens a blank page for PhpMyAdmin, use "xdg-open" instead
if [ "${OSTYPE:-}" = "linux-gnu" ] && [ -n "${GITPOD_WORKSPACE_ID:-}" ] && [ -z "${DDEV_DEBUG:-}" ]; then
xdg-open "$(DDEV_DEBUG=true ddev launch :$DDEV_PHPMYADMIN_PORT | grep "FULLURL" | awk '{print $2}')"
else
ddev launch :$DDEV_PHPMYADMIN_PORT
fi
else
ddev launch :$DDEV_PHPMYADMIN_HTTPS_PORT
fi

+ 296
- 0
httpdocs/.ddev/config.yaml 파일 보기

@@ -0,0 +1,296 @@
name: timetracking
type: php
docroot: public
php_version: "8.4"
webserver_type: nginx-fpm
xdebug_enabled: false
additional_hostnames:
[]
additional_fqdns:
- spawntree.timetracking.ddev.site
- nova-sign.timetracking.ddev.site
database:
type: mariadb
version: "10.11"
use_dns_when_possible: true
composer_version: "2"
web_environment: []
corepack_enable: false
router_http_port: "8089"
router_https_port: "8459"

# Key features of DDEV's config.yaml:

# name: <projectname> # Name of the project, automatically provides
# http://projectname.ddev.site and https://projectname.ddev.site
# If the name is omitted, the project will take the name of the enclosing directory,
# which is useful if you want to have a copy of the project side by side with this one.

# type: <projecttype> # asterios, backdrop, cakephp, codeigniter, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, drupal12, generic, joomla, laravel, magento, magento2, php, shopware6, silverstripe, symfony, typo3, wordpress, wp-bedrock
# See https://docs.ddev.com/en/stable/users/quickstart/ for more
# information on the different project types

# docroot: <relative_path> # Relative path to the directory containing index.php.

# php_version: "8.4" # PHP version to use, "5.6" through "8.5"

# You can explicitly specify the webimage but this
# is not recommended, as the images are often closely tied to DDEV's' behavior,
# so this can break upgrades.

# webimage: <docker_image>
# It’s unusual to change this option, and we don’t recommend it without Docker experience and a good reason.
# Typically, this means additions to the existing web image using a .ddev/web-build/Dockerfile.*

# database:
# type: <dbtype> # mysql, mariadb, postgres
# version: <version> # database version, like "10.11" or "8.0"
# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8
# MySQL versions can be 5.5-8.0, 8.4
# PostgreSQL versions can be 9-18

# router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80)
# router_https_port: <port> # Port for https (defaults to global configuration, usually 443)

# xdebug_enabled: false # Set to true to enable Xdebug and "ddev start" or "ddev restart"
# Note that for most people the commands
# "ddev xdebug" to enable Xdebug and "ddev xdebug off" to disable it work better,
# as leaving Xdebug enabled all the time is a big performance hit.

# xhgui_http_port: "8143"
# xhgui_https_port: "8142"
# The XHGui ports can be changed from the default 8143 and 8142
# Very rarely used

# host_xhgui_port: "8142"
# Can be used to change the host binding port of the XHGui
# application. Rarely used; only when port conflict and
# bind_all_ports is used (normally with router disabled)

# xhprof_mode: [prepend|xhgui|global]
# Default is "xhgui"

# webserver_type: nginx-fpm, apache-fpm, generic

# timezone: Europe/Berlin
# If timezone is unset, DDEV will attempt to derive it from the host system timezone
# using the $TZ environment variable or the /etc/localtime symlink.
# This is the timezone used in the containers and by PHP;
# it can be set to any valid timezone,
# see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
# For example Europe/Dublin or MST7MDT

# composer_root: <relative_path>
# Relative path to the Composer root directory from the project root. This is
# the directory which contains the composer.json and where all Composer related
# commands are executed.

# composer_version: "2"
# You can set it to "" or "2" (default) for Composer v2
# to use the latest major version available at the time your container is built.
# It is also possible to use each other Composer version channel. This includes:
# - 2.2 (latest Composer LTS version)
# - stable
# - preview
# - snapshot
# Alternatively, an explicit Composer version may be specified, for example "2.2.18".
# To reinstall Composer after the image was built, run "ddev utility rebuild".

# nodejs_version: "24"
# change from the default system Node.js version to any other version.
# See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation.

# corepack_enable: false
# Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm

# additional_hostnames:
# - somename
# - someothername
# would provide http and https URLs for "somename.ddev.site"
# and "someothername.ddev.site".

# additional_fqdns:
# - example.com
# - sub1.example.com
# would provide http and https URLs for "example.com" and "sub1.example.com"
# Please take care with this because it can cause great confusion.

# upload_dirs: "custom/upload/dir"
#
# upload_dirs:
# - custom/upload/dir
# - ../private
#
# would set the destination paths for ddev import-files to <docroot>/custom/upload/dir
# When Mutagen is enabled this path is bind-mounted so that all the files
# in the upload_dirs don't have to be synced into Mutagen.

# disable_upload_dirs_warning: false
# If true, turns off the normal warning that says
# "You have Mutagen enabled and your 'php' project type doesn't have upload_dirs set"

# ddev_version_constraint: ""
# Example:
# ddev_version_constraint: ">= 1.24.8"
# This will enforce that the running ddev version is within this constraint.
# See https://github.com/Masterminds/semver#checking-version-constraints for
# supported constraint formats

# working_dir:
# web: /var/www/html
# db: /home
# would set the default working directory for the web and db services.
# These values specify the destination directory for ddev ssh and the
# directory in which commands passed into ddev exec are run.

# omit_containers: [db, ddev-ssh-agent]
# Currently only these containers are supported. Some containers can also be
# omitted globally in the ~/.ddev/global_config.yaml. Note that if you omit
# the "db" container, several standard features of DDEV that access the
# database container will be unusable. In the global configuration it is also
# possible to omit ddev-router, but not here.

# performance_mode: "global"
# DDEV offers performance optimization strategies to improve the filesystem
# performance depending on your host system. Should be configured globally.
#
# If set, will override the global config. Possible values are:
# - "global": uses the value from the global config.
# - "none": disables performance optimization for this project.
# - "mutagen": enables Mutagen for this project.
#
# See https://docs.ddev.com/en/stable/users/install/performance/#mutagen

# fail_on_hook_fail: False
# Decide whether 'ddev start' should be interrupted by a failing hook

# host_https_port: "59002"
# The host port binding for https can be explicitly specified. It is
# dynamic unless otherwise specified.
# This is not used by most people, most people use the *router* instead
# of the localhost port.

# host_webserver_port: "59001"
# The host port binding for the ddev-webserver can be explicitly specified. It is
# dynamic unless otherwise specified.
# This is not used by most people, most people use the *router* instead
# of the localhost port.

# host_db_port: "59002"
# The host port binding for the ddev-dbserver can be explicitly specified. It is dynamic
# unless explicitly specified.

# mailpit_http_port: "8025"
# mailpit_https_port: "8026"
# The Mailpit ports can be changed from the default 8025 and 8026

# host_mailpit_port: "8025"
# The mailpit port is not normally bound on the host at all, instead being routed
# through ddev-router, but it can be bound directly to localhost if specified here.

# webimage_extra_packages: ['php${DDEV_PHP_VERSION}-tidy', 'php${DDEV_PHP_VERSION}-yac']
# Extra Debian packages that are needed in the webimage can be added here

# dbimage_extra_packages: [netcat, telnet, sudo]
# Extra Debian packages that are needed in the dbimage can be added here

# use_dns_when_possible: true
# If the host has internet access and the domain configured can
# successfully be looked up, DNS will be used for hostname resolution
# instead of editing /etc/hosts
# Defaults to true

# project_tld: ddev.site
# The top-level domain used for project URLs
# The default "ddev.site" allows DNS lookup via a wildcard

# share_default_provider: ngrok
# The default share provider to use for "ddev share"
# Defaults to global configuration, usually "ngrok"
# Can be "ngrok" or "cloudflared" or the name of a custom provider from .ddev/share-providers/

# share_provider_args: --basic-auth username:pass1234
# Provide extra flags to the share provider script
# See https://docs.ddev.com/en/stable/users/configuration/config/#share_provider_args

# disable_settings_management: false
# If true, DDEV will not create CMS-specific settings files like
# Drupal's settings.php/settings.ddev.php or TYPO3's additional.php
# In this case the user must provide all such settings.

# You can inject environment variables into the web container with:
# web_environment:
# - SOMEENV=somevalue
# - SOMEOTHERENV=someothervalue

# no_project_mount: false
# (Experimental) If true, DDEV will not mount the project into the web container;
# the user is responsible for mounting it manually or via a script.
# This is to enable experimentation with alternate file mounting strategies.
# For advanced users only!

# bind_all_interfaces: false
# If true, host ports will be bound on all network interfaces,
# not the localhost interface only. This means that ports
# will be available on the local network if the host firewall
# allows it.

# default_container_timeout: 120
# The default time that DDEV waits for all containers to become ready can be increased from
# the default 120. This helps in importing huge databases, for example.

#web_extra_exposed_ports:
#- name: nodejs
# container_port: 3000
# http_port: 2999
# https_port: 3000
#- name: something
# container_port: 4000
# https_port: 4000
# http_port: 3999
# Allows a set of extra ports to be exposed via ddev-router
# Fill in all three fields even if you don’t intend to use the https_port!
# If you don’t add https_port, then it defaults to 0 and ddev-router will fail to start.
#
# The port behavior on the ddev-webserver must be arranged separately, for example
# using web_extra_daemons.
# For example, with a web app on port 3000 inside the container, this config would
# expose that web app on https://<project>.ddev.site:9999 and http://<project>.ddev.site:9998
# web_extra_exposed_ports:
# - name: myapp
# container_port: 3000
# http_port: 9998
# https_port: 9999

#web_extra_daemons:
#- name: "http-1"
# command: "/var/www/html/node_modules/.bin/http-server -p 3000"
# directory: /var/www/html
#- name: "http-2"
# command: "/var/www/html/node_modules/.bin/http-server /var/www/html/sub -p 3000"
# directory: /var/www/html

# override_config: false
# By default, config.*.yaml files are *merged* into the configuration
# But this means that some things can't be overridden
# For example, if you have 'use_dns_when_possible: true'' you can't override it with a merge
# and you can't erase existing hooks or all environment variables.
# However, with "override_config: true" in a particular config.*.yaml file,
# 'use_dns_when_possible: false' can override the existing values, and
# hooks:
# post-start: []
# or
# web_environment: []
# or
# additional_hostnames: []
# can have their intended affect. 'override_config' affects only behavior of the
# config.*.yaml file it exists in.

# Many DDEV commands can be extended to run tasks before or after the
# DDEV command is executed, for example "post-start", "post-import-db",
# "pre-composer", "post-composer"
# See https://docs.ddev.com/en/stable/users/extend/custom-commands/ for more
# information on the commands that can be extended and the tasks you can define
# for them. Example:
#hooks:

+ 27
- 0
httpdocs/.ddev/docker-compose.phpmyadmin.yaml 파일 보기

@@ -0,0 +1,27 @@
#ddev-generated
services:
phpmyadmin:
container_name: ddev-${DDEV_SITENAME}-phpmyadmin
image: ${PHPMYADMIN_DOCKER_IMAGE:-phpmyadmin:5}
working_dir: "/root"
restart: "no"
labels:
com.ddev.site-name: ${DDEV_SITENAME}
com.ddev.approot: ${DDEV_APPROOT}
volumes:
- ".:/mnt/ddev_config"
- "ddev-global-cache:/mnt/ddev-global-cache"
expose:
- "80"
environment:
- PMA_USER=root
- PMA_PASSWORD=root
- PMA_HOST=db
- PMA_PORT=3306
- VIRTUAL_HOST=$DDEV_HOSTNAME
- UPLOAD_LIMIT=4000M
- HTTP_EXPOSE=8036:80
- HTTPS_EXPOSE=8037:80
depends_on:
db:
condition: service_healthy

+ 4
- 0
httpdocs/.ddev/docker-compose.phpmyadmin_norouter.yaml 파일 보기

@@ -0,0 +1,4 @@
#ddev-generated
# If omit_containers[ddev-router] then this file will be replaced
# with another with a `ports` statement to directly expose port 80 to 8036
services: {}

+ 32
- 0
httpdocs/.ddev/traefik/certs/testtimetracking.crt 파일 보기

@@ -0,0 +1,32 @@
#ddev-generated
-----BEGIN CERTIFICATE-----
MIIFUjCCA7qgAwIBAgIQJ/cWyC4WBkzi24Iit9v3gzANBgkqhkiG9w0BAQsFADCB
jTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMTEwLwYDVQQLDChmbG9l
aXNATWFjQm9vay1Qcm8tdm9uLUZsb3JpYW4uZnJpdHouYm94MTgwNgYDVQQDDC9t
a2NlcnQgZmxvZWlzQE1hY0Jvb2stUHJvLXZvbi1GbG9yaWFuLmZyaXR6LmJveDAe
Fw0yNjA1MjMxOTM0NDhaFw0yODA4MjMxOTM0NDhaMGoxJzAlBgNVBAoTHm1rY2Vy
dCBkZXZlbG9wbWVudCBjZXJ0aWZpY2F0ZTE/MD0GA1UECww2ZmxvZWlzQE1CUC12
b24tRmxvcmlhbi5mcml0ei5ib3ggKEZsb3JpYW4gRWlzZW5tZW5nZXIpMIIBIjAN
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAze00g0lF6cZaFnmJvT0q6cmhqDW/
7n9SQlVvUuRAIbQG0s74776P6t6q1/MaiivSdX7hZLFMi2iVkMKIuLtdUV7SXlNJ
1EZqRpNrwvUpoohfgEfDctZFNPSDmT3VUZir+axGEULjXw9ALAmKtAzo4riuTy6M
/1udXr0PZLxJ1wsDHbDWnkLwl9Y1fwv/tRANJ01xvHbdwCxLj2VBNngcUlI65P9Y
0ggGOGaXoI5MMt6gBnBbDUydJCRkvRLi5hooTbz56OYwhhGXDgQWOyXr6YBnto6+
mZdnTgdMcpCidNS0IY6j5xs+68IFd8hGuf3XrhdjxhR/nyuBaDwhGN/mGwIDAQAB
o4IBTjCCAUowDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8G
A1UdIwQYMBaAFAV2EXvo8eNPfrl2DOI9m/YyNKwyMIIBAAYDVR0RBIH4MIH1ggsq
LmRkZXYuc2l0ZYIJbG9jYWxob3N0ggwqLmRkZXYubG9jYWyCC2RkZXYtcm91dGVy
ghBkZGV2LXJvdXRlci5kZGV2ghhkZGV2LXJvdXRlci5kZGV2X2RlZmF1bHSCGnRl
c3R0aW1ldHJhY2tpbmcuZGRldi5zaXRlgiRub3ZhLXNpZ24udGVzdHRpbWV0cmFj
a2luZy5kZGV2LnNpdGWCJnNwYXdudHJlZS0yLnRlc3R0aW1ldHJhY2tpbmcuZGRl
di5zaXRlgiRzcGF3bnRyZWUudGVzdHRpbWV0cmFja2luZy5kZGV2LnNpdGWHBH8A
AAEwDQYJKoZIhvcNAQELBQADggGBAGe6bsmZ/d8TKfjrUP65xxBZr73Y3OCg8is+
exd8MCXlL9M29Wd2Jc4Qp0DRCqAsLcz8HCxN+XMc82WYNT/zJ3Av2jxqmodhtz4j
vqfWXBTrEdoKogEUmTrwAFbLxIS8RxvVqP/aiZDnuhbyGFaC8jWmOMdw4HWt3Bdj
1aut3fhoKUY1UyiP9C7UaDs82kiWCrRbH2iUTFh0HOWD5E/gFstxghqYUzXSQrWX
BTKjILUjIOrpfEULdY8NKNmgo/TKeyiQvQFi2x1ZYTgQVbCRCeC9/UF5Lx6tLQz5
7pv0Ejj3GbITWCGtu0f5lSCDz7W8ESU2XTZg8Bu3oAp7T+A1Kd8hMgMjRsxoDpLS
qnKVXkzAO5NhOFrYK9U2NSydvuXAWOFQpynGyS/YGHNLlOQEiL8ENzeQ0RSSbRWd
259CmZIjICngaViNuOUwTkd2qmraXT4w5zy0qt7JaX7ocU+WSaFwbx+ftbkkRv5d
UH1NgWlduI255qogbBnpQql57EkwjQ==
-----END CERTIFICATE-----

+ 29
- 0
httpdocs/.ddev/traefik/certs/testtimetracking.key 파일 보기

@@ -0,0 +1,29 @@
#ddev-generated
-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDN7TSDSUXpxloW
eYm9PSrpyaGoNb/uf1JCVW9S5EAhtAbSzvjvvo/q3qrX8xqKK9J1fuFksUyLaJWQ
woi4u11RXtJeU0nURmpGk2vC9SmiiF+AR8Ny1kU09IOZPdVRmKv5rEYRQuNfD0As
CYq0DOjiuK5PLoz/W51evQ9kvEnXCwMdsNaeQvCX1jV/C/+1EA0nTXG8dt3ALEuP
ZUE2eBxSUjrk/1jSCAY4Zpegjkwy3qAGcFsNTJ0kJGS9EuLmGihNvPno5jCGEZcO
BBY7JevpgGe2jr6Zl2dOB0xykKJ01LQhjqPnGz7rwgV3yEa5/deuF2PGFH+fK4Fo
PCEY3+YbAgMBAAECggEBAJDdhku1iFFlEIsvBQ7zsPS2u9qxtUv6lcvEfoQ5vkP3
ebVjlQxTars32cgBZXI+UdgGYlmLwOVxtYYY1EXgyU0s/6ELxqxmvOzZWL3V6mxE
s6py0bQ/uIAAY3OyZBb66EDESKZr/7gn6mUQcVsomcylTzq07MvXj2XOar3bF7cG
7PwQM+Co+H/g4FmDy32IYACXSAWSQVQSlTGKb5AyXrdELUJ/keaHN9FwdbJQBEC3
6yHaSSOJEQJvyqh+P1In8cJi4GQp7vDEnZEQTI+XH9H/9TjtUIL2FeOXreOEuvks
/jXgFEu/g/kyShrGEQT4+pYUTWGIiKP5LJa1xD+u+QECgYEA3l/WW6LlmVcykQEQ
7E/S4tJ1y1XCc8rHpvsHwqwhkEdWUuBDoWntlK2WzrEQZagibu9zV+BrHoa6MgUJ
+ElspLIdjjAOKMVu/hUcJoxKIMtRypibQRPGZZu7d6hytKC6aLN0ZtMwLUSF51wH
zSQzoVB5DZEF1FG/cVNRhe6x4ksCgYEA7RCrncJM/ML2tYlfwEacdnbUIydY6YBd
ngbXSpjlxHLp71r4/lBJig8kJVECX3PhIP/b8kNNJYfdtavbvbMot5FhTeuxf/EI
3Vl9YfrMbUs5/Wrf79yUO8pMFupDbdFkqMmqEA7J40YBmK7f0LkMn+p8EZaeOgwo
OnXUpUM3KXECgYBdIJmu6rtoymG85EtoC83ve+Ak9Zdn0sZmIb8QQfIUcCuwrYbl
NG1w1HnRucl6KT2yY8lURgHWWOnlRML2HhnHp2hFQc7MOFLRSZnuctYggcWRKPPr
/xIZP2z1IbBYAO/QJUdcQJlue8HwMFR8DusoZYEss01Tq6CXHyOHCX2pnQKBgQDi
w1x11mNQMKpPMi3OPXzy8G2xhrTM/sYOIFsV9zVp+cX9+BZPJbuCfUNFEr1jUvQZ
XcUlcu07pkAUxGS4i8S5+y2JnJe4W3bwTObbr0yWiyvYVcAJsAR3QOYR0VpYlMBl
mCm9nHfPl6p1Q2nCPRBvc5vkMx/9RJ3Cde3He4krcQKBgQDRN8NRUH64W5Oaw9Oo
K7S1JZCb3FqV4NxKKJrEWVyU6HTyCdLpcPd4gZtsmMHGr5M6/Uuq/FsZ0FVn2S6f
42wecSfmJ69BiFnVR/tRV+5sTg2RF/zrPnGKVld/mQHqwLiETyqg+c0Y1YAVagJy
qR3GtzV7LFRsUjkmmgsCiOQ2aQ==
-----END PRIVATE KEY-----

+ 17
- 0
httpdocs/.editorconfig 파일 보기

@@ -0,0 +1,17 @@
# editorconfig.org

root = true

[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true

[{compose.yaml,compose.*.yaml}]
indent_size = 2

[*.md]
trim_trailing_whitespace = false

+ 48
- 0
httpdocs/.env 파일 보기

@@ -0,0 +1,48 @@
# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
# * .env contains default values for the environment variables needed by the app
# * .env.local uncommitted file with local overrides
# * .env.$APP_ENV committed environment-specific defaults
# * .env.$APP_ENV.local uncommitted environment-specific overrides
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
# https://symfony.com/doc/current/configuration/secrets.html
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration

###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=
APP_SHARE_DIR=var/share
###< symfony/framework-bundle ###

###> symfony/routing ###
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
DEFAULT_URI=http://localhost
###< symfony/routing ###

###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
#DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ###

DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
APP_DOMAIN=testtimetracking.ddev.site

# ── Mailer ────────────────────────────────────────────────────────────────────
# Lokal (DDEV Mailpit): smtp://127.0.0.1:1025
# Produktion: smtp://user:pass@smtp.deinserver.de:587
MAILER_DSN=smtp://127.0.0.1:1025

# Benachrichtigung bei Neuanmeldung
REGISTRATION_NOTIFY_EMAIL=re@spawntree.de

+ 4
- 0
httpdocs/.env.dev 파일 보기

@@ -0,0 +1,4 @@

###> symfony/framework-bundle ###
APP_SECRET=f19f2bcb34a48e20e66302a0e88408a9
###< symfony/framework-bundle ###

+ 17
- 0
httpdocs/.gitignore 파일 보기

@@ -0,0 +1,17 @@

###> symfony/framework-bundle ###
/.env.local
/.env.local.php
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
/var/
/vendor/
###< symfony/framework-bundle ###

###> symfony/webpack-encore-bundle ###
/node_modules/
/public/build/
npm-debug.log
yarn-error.log
###< symfony/webpack-encore-bundle ###

+ 35
- 0
httpdocs/1-reset-and-seed.sh 파일 보기

@@ -0,0 +1,35 @@
#!/bin/bash
set -e

echo "⏳ Datenbank zurücksetzen..."
ddev exec php bin/console doctrine:database:drop --force --if-exists --connection=central
ddev exec php bin/console doctrine:database:create --connection=central
ddev mysql -uroot -proot -e "DROP DATABASE IF EXISTS db_spawntree;"
ddev mysql -uroot -proot -e "DROP DATABASE IF EXISTS db_nova_sign;"

echo "⏳ Central-Migrationen ausführen..."
ddev exec php bin/console doctrine:migrations:migrate --em=central --no-interaction

# db-User darf neue Tenant-DBs (db_*) anlegen und verwalten:
ddev mysql -uroot -proot -e "GRANT ALL PRIVILEGES ON \`db_%\`.* TO 'db'@'%'; FLUSH PRIVILEGES;"

read -r -p "Testdaten einspielen? [j/N] " answer
if [[ "$answer" =~ ^[jJ]$ ]]; then
echo "⏳ Tenant-DBs anlegen..."
ddev mysql -uroot -proot -e "CREATE DATABASE db_spawntree CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
ddev mysql -uroot -proot -e "GRANT ALL PRIVILEGES ON db_spawntree.* TO 'db'@'%'; FLUSH PRIVILEGES;"
ddev mysql -uroot -proot -e "CREATE DATABASE db_nova_sign CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
ddev mysql -uroot -proot -e "GRANT ALL PRIVILEGES ON db_nova_sign.* TO 'db'@'%'; FLUSH PRIVILEGES;"
echo "⏳ Testdaten einspielen..."
ddev exec php bin/console app:seed
else
echo "⏭ Testdaten übersprungen."
fi

echo "⏳ Cache leeren..."
ddev exec php bin/console cache:clear

echo "⏳ Assets bauen..."
ddev exec npm run build

echo "✅ Fertig!"

+ 19
- 0
httpdocs/LICENSE 파일 보기

@@ -0,0 +1,19 @@
Copyright (c) Fabien Potencier

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

+ 37
- 0
httpdocs/README.md 파일 보기

@@ -0,0 +1,37 @@
# INSTALLATION

cd httpdocs
ddev start => Läuft dann unter https://timetracking.ddev.site:8459
ddev exec composer install
ddev exec npm install
sh 1-reset-and-seed.sh (Entweder mit oder ohne Testdaten)

# Dann SCSS / JS bauen und beobachten:
ddev exec npm run watch

# LIVE EINMALIG AUSFÜHREN:
GRANT ALL PRIVILEGES ON \`db_%\`.* TO 'deindbuser'@'%'; FLUSH PRIVILEGES;

# Central Entity geändert → Migration erstellen + ausführen:
ddev exec php bin/console doctrine:migrations:diff --em=central --namespace=DoctrineMigrations
ddev exec php bin/console doctrine:migrations:migrate --em=central --no-interaction

# Tenant Entity geändert → kein Migrations-Workflow.
sh reset-and-seed.sh
# Das legt die Tenant-DB per SchemaTool neu an.

# Alle URLs anzeigen
ddev describe

# Cache clear
ddev exec php bin/console cache:clear

# WEBPACK
# Einmalig bauen (Dev)
ddev exec npm run dev

# Watch-Modus (bei Änderungen automatisch neu bauen)
ddev exec npm run watch

# Production Build
ddev exec npm run build

+ 11
- 0
httpdocs/assets/app.js 파일 보기

@@ -0,0 +1,11 @@
/*
* Welcome to your app's main JavaScript file!
*
* We recommend including the built version of this JavaScript file
* (and its CSS file) in your base layout (base.html.twig).
*/

// any CSS you import will output into a single css file (app.css in this case)
import './styles/main.scss';
import './scripts/calendar.js';
import './scripts/entries.js';

+ 276
- 0
httpdocs/assets/scripts/calendar.js 파일 보기

@@ -0,0 +1,276 @@
// assets/scripts/calendar.js
// Strings aus window.TT.i18n – keine hardcodierten deutschen Texte mehr

function t(key) {
return window.TT?.i18n?.[key] ?? key;
}

class WeekCalendar {
constructor() {
this.nav = document.querySelector('.week-nav');
this.daysContainer = document.querySelector('.week-nav__days');
this.calBtn = document.querySelector('.week-nav__cal');
this.prevBtn = document.querySelector('.week-nav__arrow--prev');
this.nextBtn = document.querySelector('.week-nav__arrow--next');
this.header = document.querySelector('.tt-header');

const raw = this.nav?.dataset.activeDate;
this.activeDate = raw ? new Date(raw + 'T00:00:00') : new Date();
this.today = new Date();
this.today.setHours(0, 0, 0, 0);

this.monthOpen = false;
this.monthDate = new Date(this.activeDate);
this.monthEl = null;

if (!this.nav) return;
this.init();
}

get months() { return t('months') || []; }
get monthsShort() { return t('monthsShort') || []; }
get weekdays() { return t('weekdays') || []; }
get weekdaysShort() { return t('weekdaysShort') || []; }

init() {
this.prevBtn?.addEventListener('click', e => { e.preventDefault(); this.navigateWeek(-1); });
this.nextBtn?.addEventListener('click', e => { e.preventDefault(); this.navigateWeek(1); });
this.calBtn?.addEventListener('click', e => { e.preventDefault(); this.toggleMonth(); });

this.daysContainer?.addEventListener('click', e => {
const dayEl = e.target.closest('.week-nav__day');
if (!dayEl) return;
e.preventDefault();
const dateStr = dayEl.dataset.date;
if (dateStr) this.goToDate(new Date(dateStr + 'T00:00:00'));
});
}

// ── Wochen-Navigation ─────────────────────────────────────────────────────

navigateWeek(direction) {
const slideOut = direction > 0 ? 'slide-out-left' : 'slide-out-right';
const slideIn = direction > 0 ? 'slide-in-right' : 'slide-in-left';

this.daysContainer.classList.add(slideOut);

setTimeout(() => {
const newDate = new Date(this.activeDate);
newDate.setDate(newDate.getDate() + direction * 7);
this.activeDate = newDate;
this.renderWeekDays();
this.updateHeaderMeta();

this.daysContainer.classList.remove(slideOut);
this.daysContainer.classList.add(slideIn);
requestAnimationFrame(() => requestAnimationFrame(() => {
this.daysContainer.classList.remove(slideIn);
}));

window.history.pushState({}, '', `/week/${this.formatDate(this.getMonday(this.activeDate))}`);
window.entryManager?.loadEntriesForDate(this.formatDate(this.activeDate));
}, 180);
}

renderWeekDays() {
const monday = this.getMonday(this.activeDate);
this.daysContainer.innerHTML = '';

for (let i = 0; i < 7; i++) {
const d = new Date(monday);
d.setDate(d.getDate() + i);
const isActive = this.isSameDay(d, this.activeDate);
const isToday = this.isSameDay(d, this.today);

// Führungsnull: padStart(2, '0')
const dayNum = String(d.getDate()).padStart(2, '0');
const monthShort = this.monthsShort[d.getMonth()] ?? '';

const a = document.createElement('a');
a.href = `/week/${this.formatDate(d)}`;
a.className = 'week-nav__day'
+ (isActive ? ' week-nav__day--active' : '')
+ (isToday ? ' week-nav__day--today' : '');
a.dataset.date = this.formatDate(d);
a.innerHTML = `
<span class="week-nav__day-name">${this.weekdaysShort[i] ?? ''}</span>
<span class="week-nav__day-date">${dayNum}. ${monthShort}</span>
`;
this.daysContainer.appendChild(a);
}
}

goToDate(date) {
this.activeDate = date;
this.renderWeekDays();
this.updateHeaderMeta();
window.history.pushState({}, '', `/week/${this.formatDate(date)}`);
window.entryManager?.loadEntriesForDate(this.formatDate(date));
if (this.monthOpen) this.closeMonth();
}

updateHeaderMeta() {
const dateEl = document.querySelector('.tt-header__date');
const kwEl = document.querySelector('.tt-header__kw');

if (dateEl) {
const d = this.activeDate;
const day = d.getDate();
const month = this.months[d.getMonth()] ?? '';
const tomorrow = new Date(this.today); tomorrow.setDate(this.today.getDate() + 1);
const yesterday = new Date(this.today); yesterday.setDate(this.today.getDate() - 1);

// JS getDay(): 0=So, 1=Mo...6=Sa → weekdays[0]=Montag, also index = getDay()-1, So=6
const jsDay = d.getDay();
const isoIdx = jsDay === 0 ? 6 : jsDay - 1;
const weekday = this.weekdays[isoIdx] ?? '';

let prefix;
if (this.isSameDay(d, this.today)) prefix = t('today');
else if (this.isSameDay(d, tomorrow)) prefix = t('tomorrow');
else if (this.isSameDay(d, yesterday)) prefix = t('yesterday');
else prefix = weekday;

dateEl.textContent = `${prefix}, ${day}. ${month}`;
document.title = `${prefix}, ${day}. ${month}`;
}

if (kwEl) kwEl.textContent = `${t('weekLabel')} ${this.getWeekNumber(this.activeDate)}`;
}

// ── Monats-Ansicht ────────────────────────────────────────────────────────

toggleMonth() { this.monthOpen ? this.closeMonth() : this.openMonth(); }

openMonth() {
this.monthOpen = true;
this.monthDate = new Date(this.activeDate);
this.monthEl = document.createElement('div');
this.monthEl.className = 'month-calendar month-calendar--hidden';
this.header.appendChild(this.monthEl);
this.renderMonthGrid();
requestAnimationFrame(() => requestAnimationFrame(() => {
this.monthEl.classList.remove('month-calendar--hidden');
this.monthEl.classList.add('month-calendar--visible');
}));
this.calBtn.classList.add('week-nav__cal--active');
}

closeMonth() {
if (!this.monthEl) return;
this.monthEl.classList.remove('month-calendar--visible');
this.monthEl.classList.add('month-calendar--hidden');
setTimeout(() => { this.monthEl?.remove(); this.monthEl = null; }, 280);
this.monthOpen = false;
this.calBtn.classList.remove('week-nav__cal--active');
}

renderMonthGrid() {
if (!this.monthEl) return;

const year = this.monthDate.getFullYear();
const month = this.monthDate.getMonth();

const firstDay = new Date(year, month, 1);
let startDow = firstDay.getDay();
startDow = startDow === 0 ? 6 : startDow - 1;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const daysInPrev = new Date(year, month, 0).getDate();

let html = `
<div class="month-calendar__header">
<button class="month-calendar__arrow month-nav-prev" title="${t('prevMonth')}">
<svg viewBox="0 0 8 14" fill="none"><path d="M7 1L1 7L7 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<span class="month-calendar__title">${this.months[month] ?? ''} ${year}</span>
<button class="month-calendar__arrow month-nav-next" title="${t('nextMonth')}">
<svg viewBox="0 0 8 14" fill="none"><path d="M1 1L7 7L1 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button class="month-calendar__close week-nav__cal" title="${t('monthView')}">
<svg viewBox="0 0 18 18" fill="none">
<rect x="1" y="3" width="16" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/>
<path d="M1 7h16" stroke="currentColor" stroke-width="1.5"/>
<path d="M5 1v4M13 1v4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<rect x="4" y="10" width="2" height="2" rx="0.5" fill="currentColor"/>
<rect x="8" y="10" width="2" height="2" rx="0.5" fill="currentColor"/>
<rect x="12" y="10" width="2" height="2" rx="0.5" fill="currentColor"/>
</svg>
</button>
</div>
<div class="month-calendar__grid">
<div class="month-calendar__weekdays">
${this.weekdaysShort.map(d => `<span>${d}</span>`).join('')}
</div>
<div class="month-calendar__days">`;

for (let i = startDow - 1; i >= 0; i--)
html += `<span class="month-day month-day--other">${daysInPrev - i}</span>`;

for (let d = 1; d <= daysInMonth; d++) {
const date = new Date(year, month, d);
const isToday = this.isSameDay(date, this.today);
const isActive= this.isSameDay(date, this.activeDate);
const cls = 'month-day'
+ (isToday ? ' month-day--today' : '')
+ (isActive ? ' month-day--active' : '');
html += `<span class="${cls}" data-date="${this.formatDate(date)}">${d}</span>`;
}

const totalCells = Math.ceil((startDow + daysInMonth) / 7) * 7;
for (let d = 1; d <= totalCells - startDow - daysInMonth; d++)
html += `<span class="month-day month-day--other">${d}</span>`;

html += `</div></div>`;
this.monthEl.innerHTML = html;

this.monthEl.querySelector('.month-nav-prev')?.addEventListener('click', () => this.navigateMonth(-1));
this.monthEl.querySelector('.month-nav-next')?.addEventListener('click', () => this.navigateMonth(1));
this.monthEl.querySelector('.month-calendar__close')?.addEventListener('click', () => this.closeMonth());
this.monthEl.querySelectorAll('.month-day[data-date]').forEach(el => {
el.addEventListener('click', () => this.goToDate(new Date(el.dataset.date + 'T00:00:00')));
});
}

navigateMonth(direction) {
const grid = this.monthEl?.querySelector('.month-calendar__grid');
if (!grid) return;
const slideOut = direction > 0 ? 'slide-out-left' : 'slide-out-right';
grid.classList.add(slideOut);
setTimeout(() => {
this.monthDate.setMonth(this.monthDate.getMonth() + direction);
this.renderMonthGrid();
}, 160);
}

// ── Hilfsfunktionen ───────────────────────────────────────────────────────

getMonday(date) {
const d = new Date(date);
const day = d.getDay();
d.setDate(d.getDate() - day + (day === 0 ? -6 : 1));
d.setHours(0, 0, 0, 0);
return d;
}

isSameDay(a, b) {
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate();
}

formatDate(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}

getWeekNumber(date) {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
}
}

document.addEventListener('DOMContentLoaded', () => { new WeekCalendar(); });

+ 473
- 0
httpdocs/assets/scripts/crud.js 파일 보기

@@ -0,0 +1,473 @@
// assets/scripts/crud.js
// Generisches CRUD-Handler für Kunden, Projekte, Leistungen

const api = window.CRUD?.apiBase ?? '';

// ── Hilfsfunktionen ───────────────────────────────────────────────────────────

function buildClientOptions(selectedId = null) {
const clients = window.CRUD?.clients ?? [];
let html = '<option value="">Bitte wählen</option>';
clients.forEach(c => {
const sel = String(c.id) === String(selectedId) ? ' selected' : '';
html += `<option value="${c.id}"${sel}>${c.name}</option>`;
});
return html;
}

function rowPrefix() {
// Ermittelt den Entitätstyp aus der URL
if (location.pathname.includes('/clients')) return 'client';
if (location.pathname.includes('/projects')) return 'project';
if (location.pathname.includes('/services')) return 'service';
return 'row';
}

// ── Create-Formular ───────────────────────────────────────────────────────────

function initCreateForm() {
const btnNew = document.getElementById('btn-new');
const form = document.getElementById('crud-create');
const btnSave = document.getElementById('btn-create-save');
const btnCancel = document.getElementById('btn-create-cancel');

if (!btnNew || !form) return;

btnNew.addEventListener('click', () => {
form.classList.toggle('crud-create--visible');
document.getElementById('create-name')?.focus();
});

btnCancel?.addEventListener('click', () => {
form.classList.remove('crud-create--visible');
resetCreateForm();
});

btnSave?.addEventListener('click', () => createEntity());
}

function resetCreateForm() {
const fields = ['create-name', 'create-note'];
fields.forEach(id => {
const el = document.getElementById(id);
if (el) el.value = '';
});
const rate = document.getElementById('create-rate');
if (rate) rate.value = '';
const billable = document.getElementById('create-billable');
if (billable) billable.checked = true;
const client = document.getElementById('create-client');
if (client) client.value = '';
}

async function createEntity() {
const name = document.getElementById('create-name')?.value?.trim();
if (!name) { alert('Bitte einen Namen eingeben.'); return; }

const body = buildCreateBody();

try {
const res = await fetch(api, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});

if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert(err.error ?? 'Fehler beim Speichern.');
return;
}

const data = await res.json();
appendRowToList(data);
document.getElementById('crud-create')?.classList.remove('crud-create--visible');
resetCreateForm();

} catch (err) {
console.error(err);
alert('Fehler beim Speichern.');
}
}

function buildCreateBody() {
const body = {
name: document.getElementById('create-name')?.value?.trim(),
note: document.getElementById('create-note')?.value || null,
};

// Kunden-spezifisch
const rate = document.getElementById('create-rate');
if (rate) body.hourlyRate = rate.value || null;

// Projekt-spezifisch
const client = document.getElementById('create-client');
if (client) body.clientId = parseInt(client.value) || null;

// Leistungs-spezifisch
const billable = document.getElementById('create-billable');
if (billable) body.billable = billable.checked;

return body;
}

// ── Liste: Event Delegation ────────────────────────────────────────────────────

function initList() {
const list = document.getElementById('crud-list');
if (!list) return;

list.addEventListener('click', e => {
const actionEl = e.target.closest('[data-action]');
if (!actionEl) return;

const action = actionEl.dataset.action;
const row = e.target.closest('.crud-row');
if (!row) return;

switch (action) {
case 'edit': openEdit(row); break;
case 'delete': deleteRow(row); break;
case 'save': saveEdit(row); break;
case 'cancel': closeEdit(row); break;
case 'unarchive': unarchiveRow(row); break;
}
});
}

// ── Inline Edit ───────────────────────────────────────────────────────────────

function openEdit(row) {
row.querySelector('.crud-row__display').hidden = true;
row.querySelector('.crud-row__edit').hidden = false;
row.querySelector('.edit-name')?.focus();
}

function closeEdit(row) {
row.querySelector('.crud-row__display').hidden = false;
row.querySelector('.crud-row__edit').hidden = true;
}

async function saveEdit(row) {
const id = row.dataset.id;
const name = row.querySelector('.edit-name')?.value?.trim();

if (!name) { alert('Bitte einen Namen eingeben.'); return; }

const body = buildEditBody(row);

try {
const res = await fetch(`${api}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});

if (!res.ok) { alert('Fehler beim Speichern.'); return; }

const data = await res.json();
updateRowDisplay(row, data);
closeEdit(row);

} catch (err) {
console.error(err);
alert('Fehler beim Speichern.');
}
}

function buildEditBody(row) {
const body = {
name: row.querySelector('.edit-name')?.value?.trim(),
note: row.querySelector('.edit-note')?.value || null,
};

// Kunden
const rate = row.querySelector('.edit-rate');
if (rate) body.hourlyRate = rate.value || null;

// Projekt
const client = row.querySelector('.edit-client');
if (client) body.clientId = parseInt(client.value) || null;

// Leistung
const billable = row.querySelector('.edit-billable');
if (billable) body.billable = billable.checked;

return body;
}

function updateRowDisplay(row, data) {
const nameEl = row.querySelector('.crud-row__name');
const metaEl = row.querySelector('.crud-row__meta');

if (nameEl) nameEl.textContent = data.name;

// Kunden: Meta-Text unverändert (Projektanzahl ändert sich nicht)
// Projekte: Client-Name aktualisieren
if (data.clientName && metaEl) metaEl.textContent = data.clientName;

// data-Attribute aktualisieren
row.dataset.name = data.name;
if (data.hourlyRate !== undefined) row.dataset.rate = data.hourlyRate ?? '';
if (data.clientId !== undefined) row.dataset.clientId = data.clientId;
if (data.billable !== undefined) row.dataset.billable = data.billable ? '1' : '0';
if (data.note !== undefined) row.dataset.note = data.note ?? '';

// Edit-Felder aktualisieren
const editName = row.querySelector('.edit-name');
if (editName) editName.value = data.name;

const editNote = row.querySelector('.edit-note');
if (editNote) editNote.value = data.note ?? '';

const editRate = row.querySelector('.edit-rate');
if (editRate) editRate.value = data.hourlyRate ?? '';

const editBillable = row.querySelector('.edit-billable');
if (editBillable) editBillable.checked = !!data.billable;
}

// ── Delete ────────────────────────────────────────────────────────────────────

async function deleteRow(row) {
if (!confirm('Wirklich löschen?')) return;

try {
const res = await fetch(`${api}/${row.dataset.id}`, { method: 'DELETE' });

if (res.status === 409) {
if (confirm('Dieser Eintrag hat abhängige Zeiteinträge und kann nicht gelöscht werden.\nStattdessen archivieren?')) {
await archiveRow(row);
}
return;
}

if (!res.ok) { alert('Fehler beim Löschen.'); return; }

row.classList.add('crud-row--removing');
setTimeout(() => row.remove(), 280);

} catch {
alert('Fehler beim Löschen.');
}
}

async function archiveRow(row) {
try {
const res = await fetch(`${api}/${row.dataset.id}/archive`, { method: 'PATCH' });
if (!res.ok) { alert('Fehler beim Archivieren.'); return; }

row.dataset.archived = '1';
row.classList.add('crud-row--archived');
updateRowArchivedState(row, true);
filterByTab(document.querySelector('.crud-tab--active')?.dataset.tab ?? 'active');

} catch {
alert('Fehler beim Archivieren.');
}
}

async function unarchiveRow(row) {
try {
const res = await fetch(`${api}/${row.dataset.id}/unarchive`, { method: 'PATCH' });
if (!res.ok) { alert('Fehler beim Wiederherstellen.'); return; }

row.dataset.archived = '0';
row.classList.remove('crud-row--archived');
updateRowArchivedState(row, false);
filterByTab(document.querySelector('.crud-tab--active')?.dataset.tab ?? 'active');

} catch {
alert('Fehler beim Wiederherstellen.');
}
}

function updateRowArchivedState(row, archived) {
const actions = row.querySelector('.crud-row__actions');
if (!actions) return;

if (archived) {
actions.innerHTML = `
<button class="crud-row__btn crud-row__btn--restore" data-action="unarchive" title="Wiederherstellen">
<svg viewBox="0 0 16 16" fill="none"><path d="M2 8a6 6 0 1 1 1.5 3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><path d="M2 13V9h4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>`;
row.querySelector('.crud-row__edit')?.remove();
} else {
actions.innerHTML = `
<button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="Bearbeiten">
<svg viewBox="0 0 16 16" fill="none"><path d="M11 2l3 3L5 14H2v-3L11 2z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="Löschen">
<svg viewBox="0 0 16 16" fill="none"><path d="M3 4h10M6 4V2h4v2M5 4l.5 9h5l.5-9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>`;
}
}

function filterByTab(tab) {
document.querySelectorAll('#crud-list .crud-row').forEach(row => {
row.hidden = tab === 'active'
? row.dataset.archived === '1'
: row.dataset.archived === '0';
});
}

function initTabs() {
const tabs = document.querySelectorAll('.crud-tab');
if (!tabs.length) return;

filterByTab('active');

tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('crud-tab--active'));
tab.classList.add('crud-tab--active');
filterByTab(tab.dataset.tab);

const btnNew = document.getElementById('btn-new');
if (btnNew) btnNew.hidden = tab.dataset.tab === 'archived';
});
});
}

// ── Neue Zeile einfügen ───────────────────────────────────────────────────────

function appendRowToList(data) {
const list = document.getElementById('crud-list');
if (!list) return;

const html = buildRowHTML(data);

// Services haben Gruppen → in die richtige Gruppe einfügen
if (data.billable !== undefined) {
const groupLabel = data.billable ? 'Verrechenbar' : 'Nicht-verrechenbar';
let targetGroup = null;

list.querySelectorAll('.crud-list__group').forEach(g => {
if (g.querySelector('.crud-list__group-label')?.textContent === groupLabel) {
targetGroup = g;
}
});

if (targetGroup) {
targetGroup.insertAdjacentHTML('beforeend', html);
} else {
// Gruppe existiert noch nicht → neu anlegen
const groupHtml = `<div class="crud-list__group"><div class="crud-list__group-label">${groupLabel}</div>${html}</div>`;
if (!data.billable) {
// Nicht-verrechenbar immer ans Ende
list.insertAdjacentHTML('beforeend', groupHtml);
} else {
// Verrechenbar vor die erste existierende Gruppe
const firstGroup = list.querySelector('.crud-list__group');
firstGroup
? firstGroup.insertAdjacentHTML('beforebegin', groupHtml)
: list.insertAdjacentHTML('beforeend', groupHtml);
}
}
} else {
list.insertAdjacentHTML('beforeend', html);
}

const prefix = rowPrefix();
const el = document.getElementById(`${prefix}-${data.id}`);
if (el) {
requestAnimationFrame(() => requestAnimationFrame(() => {
el.classList.remove('crud-row--new');
}));
}
}

function buildRowHTML(data) {
const prefix = rowPrefix();
let metaHtml = '';
let editFields = '';

// Kunden
if (data.projectCount !== undefined) {
const c = data.projectCount;
metaHtml = `<span class="crud-row__meta">${c} ${c === 1 ? 'Projekt' : 'Projekte'}</span>`;
editFields = `
<label class="entry-form__label">Name</label>
<div class="entry-form__field"><input type="text" class="input edit-name" value="${data.name}" /></div>
<label class="entry-form__label">Stundensatz</label>
<div class="entry-form__field" style="gap:8px">
<input type="number" class="input edit-rate" style="width:100px" value="${data.hourlyRate ?? ''}" step="0.01" min="0" />
<span style="color:#7a8a9a;font-size:0.875rem">€</span>
</div>
<label class="entry-form__label">Bemerkung</label>
<div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${data.note ?? ''}</textarea></div>`;
}

// Projekte
if (data.clientName !== undefined && data.projectCount === undefined) {
metaHtml = `<span class="crud-row__meta">${data.clientName}</span>`;
editFields = `
<label class="entry-form__label">Name</label>
<div class="entry-form__field"><input type="text" class="input edit-name" value="${data.name}" /></div>
<label class="entry-form__label">Kunde</label>
<div class="entry-form__field"><select class="select edit-client">${buildClientOptions(data.clientId)}</select></div>
<label class="entry-form__label">Bemerkung</label>
<div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${data.note ?? ''}</textarea></div>`;
}

// Leistungen
if (data.billable !== undefined) {
editFields = `
<label class="entry-form__label">Name</label>
<div class="entry-form__field"><input type="text" class="input edit-name" value="${data.name}" /></div>
<label class="entry-form__label">Verrechenbar</label>
<div class="entry-form__field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" class="edit-billable" ${data.billable ? 'checked' : ''} />
<span style="font-size:0.875rem">Ja, diese Leistung ist verrechenbar</span>
</label>
</div>
<label class="entry-form__label">Bemerkung</label>
<div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${data.note ?? ''}</textarea></div>`;
}

return `
<div class="crud-row crud-row--new"
id="${prefix}-${data.id}"
data-id="${data.id}"
data-archived="0"
data-name="${data.name}"
${data.hourlyRate !== undefined ? `data-rate="${data.hourlyRate ?? ''}"` : ''}
${data.clientId !== undefined ? `data-client-id="${data.clientId}"` : ''}
${data.billable !== undefined ? `data-billable="${data.billable ? '1' : '0'}"` : ''}
data-note="${data.note ?? ''}">

<div class="crud-row__display">
<div class="crud-row__info">
<span class="crud-row__name">${data.name}</span>
${metaHtml}
</div>
<div class="crud-row__actions">
<button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="Bearbeiten">
<svg viewBox="0 0 16 16" fill="none"><path d="M11 2l3 3L5 14H2v-3L11 2z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="Löschen">
<svg viewBox="0 0 16 16" fill="none"><path d="M3 4h10M6 4V2h4v2M5 4l.5 9h5l.5-9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
</div>

<div class="crud-row__edit" hidden>
<div class="entry-form__grid entry-form__grid--inline">
${editFields}
<div class="entry-form__actions">
<button type="button" class="btn btn-primary" data-action="save">Sichern</button>
<button type="button" class="btn btn-secondary" data-action="cancel">Abbrechen</button>
</div>
</div>
</div>
</div>`;
}

// ── Init ──────────────────────────────────────────────────────────────────────

document.addEventListener('DOMContentLoaded', () => {
initCreateForm();
initList();
initTabs();
});

+ 96
- 0
httpdocs/assets/scripts/duration.js 파일 보기

@@ -0,0 +1,96 @@
// assets/scripts/duration.js
// Zentrale Logik für Zeiteingabe – wird von entries.js importiert

// ── Konfiguration ─────────────────────────────────────────────────────────────
// Auf false setzen um Viertelstunden-Runden zu deaktivieren
export const DURATION_CONFIG = {
roundToQuarter: true,
};

// ── Parser ────────────────────────────────────────────────────────────────────

/**
* Parst Zeiteingaben in Minuten.
*
* Unterstützte Formate:
* "1:30" → 90 (Stunden:Minuten)
* "8 12" → 240 (von 8 bis 12 Uhr)
* "1,75" → 105 (Dezimalstunden mit Komma)
* "1.75" → 105 (Dezimalstunden mit Punkt)
* "2" → 120 (nur Stunden als ganze Zahl)
* "0:00" → 0 (Stopp/Reset)
*/
export function parseDuration(input) {
input = String(input).trim();

if (!input || input === '0' || input === '0:00') return 0;

// "8 12" → von 8 bis 12 Uhr
if (/^\d+\s+\d+$/.test(input)) {
const parts = input.split(/\s+/).map(Number);
const minutes = (parts[1] - parts[0]) * 60;
return Math.max(0, minutes);
}

// "1:30" → Stunden:Minuten
if (input.includes(':')) {
const [h, m] = input.split(':').map(s => parseInt(s) || 0);
return h * 60 + m;
}

// "1,75" oder "1.75" → Dezimalstunden
if (input.includes(',') || input.includes('.')) {
const hours = parseFloat(input.replace(',', '.'));
return isNaN(hours) ? 0 : Math.round(hours * 60);
}

// "2" → 2 Stunden
const hours = parseInt(input);
return isNaN(hours) ? 0 : hours * 60;
}

// ── Rounding ──────────────────────────────────────────────────────────────────

/**
* Rundet Minuten auf die nächste Viertelstunde auf.
* 0 bleibt 0 (Stopp).
*/
export function roundToQuarter(minutes) {
if (!DURATION_CONFIG.roundToQuarter) return minutes;
if (minutes === 0) return 0;
const interval = window.TT?.trackingInterval ?? 15;
return Math.ceil(minutes / interval) * interval;
}

// ── Formatter ─────────────────────────────────────────────────────────────────

export function formatMinutes(minutes) {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${h}:${String(m).padStart(2, '0')}`;
}

// ── Blur-Handler (global, per Event Delegation) ───────────────────────────────
// Reagiert auf blur an allen Dauer-Inputs, egal ob server-gerendert oder JS-erstellt

export function initDurationBlurHandler() {
document.addEventListener('blur', e => {
if (!(e.target instanceof Element)) return;
if (!e.target.matches('#create-duration, .edit-duration')) return;

const raw = e.target.value;
const minutes = roundToQuarter(parseDuration(raw));
e.target.value = formatMinutes(minutes);
}, true); // capture=true, weil blur nicht bubbled
}

/**
* Validiert eine Dauer in Minuten.
* > 1440 (24h) → error
* > 480 (8h) → warn
*/
export function validateDuration(minutes) {
if (minutes > 1440) return { status: 'error' };
if (minutes > 480) return { status: 'warn' };
return { status: 'ok' };
}

+ 441
- 0
httpdocs/assets/scripts/entries.js 파일 보기

@@ -0,0 +1,441 @@
// assets/scripts/entries.js
import { parseDuration, roundToQuarter, formatMinutes, initDurationBlurHandler, validateDuration } from './duration.js';

const LAST_PROJECT_KEY = 'tt_last_project_id';
const LAST_SERVICE_KEY = 'tt_last_service_id';

function t(key) {
return window.TT?.i18n?.[key] ?? key;
}

function buildProjectOptions(selectedId = null) {
const groups = {};
(window.TT?.projects ?? []).forEach(p => {
if (!groups[p.clientName]) groups[p.clientName] = [];
groups[p.clientName].push(p);
});

let html = `<option value="">${t('selectPh')}</option>`;
for (const [client, projects] of Object.entries(groups)) {
html += `<optgroup label="${client}">`;
projects.forEach(p => {
const sel = String(p.id) === String(selectedId) ? ' selected' : '';
html += `<option value="${p.id}"${sel}>${p.name}</option>`;
});
html += '</optgroup>';
}
return html;
}

function buildServiceOptions(selectedId = null) {
const billable = (window.TT?.services ?? []).filter(s => s.billable);
const notBillable = (window.TT?.services ?? []).filter(s => !s.billable);

let html = `<option value="">${t('selectPh')}</option>`;

if (billable.length) {
html += `<optgroup label="${t('billable')}">`;
billable.forEach(s => {
const sel = String(s.id) === String(selectedId) ? ' selected' : '';
html += `<option value="${s.id}"${sel}>${s.name}</option>`;
});
html += '</optgroup>';
}

if (notBillable.length) {
html += `<optgroup label="${t('notBillable')}">`;
notBillable.forEach(s => {
const sel = String(s.id) === String(selectedId) ? ' selected' : '';
html += `<option value="${s.id}"${sel}>${s.name}</option>`;
});
html += '</optgroup>';
}

return html;
}

function buildEntryRowHTML(entry, animate = false) {
const servicePart = entry.serviceName ? ` / ${entry.serviceName}` : '';
const notePart = entry.note ? `<div class="entry-row__note">${entry.note}</div>` : '';

return `
<div class="entry-row${animate ? ' entry-row--new' : ''}"
id="entry-${entry.id}"
data-id="${entry.id}"
data-duration="${entry.duration}"
data-project-id="${entry.projectId}"
data-service-id="${entry.serviceId ?? ''}"
data-note="${(entry.note ?? '').replace(/"/g, '&quot;')}">

<div class="entry-row__display">
<div class="entry-row__info">
<div class="entry-row__title">${entry.clientName} / ${entry.projectName}${servicePart}</div>
${notePart}
</div>
<div class="entry-row__actions">
<span class="entry-row__badge">${entry.durationFormatted}</span>
<button class="entry-row__btn entry-row__btn--edit" title="${t('btnEdit')}" data-action="edit">
<svg viewBox="0 0 16 16" fill="none"><path d="M11 2l3 3L5 14H2v-3L11 2z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button class="entry-row__btn entry-row__btn--delete" title="${t('btnDelete')}" data-action="delete">
<svg viewBox="0 0 16 16" fill="none"><path d="M3 4h10M6 4V2h4v2M5 4l.5 9h5l.5-9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
</div>

<div class="entry-row__edit" hidden>
<div class="entry-form__grid entry-form__grid--inline">
<label class="entry-form__label">${t('labelDuration')}</label>
<div class="entry-form__field">
<input type="text" class="input input--sm edit-duration"
value="${entry.durationFormatted}" autocomplete="off" />
<div class="duration-help">
<span class="duration-help__icon">?</span>
<span class="duration-help__hint">${t('durationHint')}</span>
</div>
</div>
<label class="entry-form__label">${t('labelProjectService')}</label>
<div class="entry-form__field entry-form__field--selects">
<select class="select edit-project">${buildProjectOptions(entry.projectId)}</select>
<select class="select edit-service">${buildServiceOptions(entry.serviceId)}</select>
</div>
<label class="entry-form__label">${t('labelNote')}</label>
<div class="entry-form__field">
<textarea class="textarea edit-note" rows="3">${entry.note ?? ''}</textarea>
</div>
<div class="entry-form__actions">
<button type="button" class="btn btn-primary" data-action="save">${t('btnSave')}</button>
<button type="button" class="btn btn-secondary" data-action="cancel">${t('btnCancel')}</button>
</div>
</div>
</div>
</div>`;
}

class EntryManager {
constructor() {
this.list = document.getElementById('entry-list');
this.emptyState = document.getElementById('empty-state');

if (!this.list) return;

const cp = document.getElementById('create-project');
const cs = document.getElementById('create-service');

document.getElementById('create-service')?.addEventListener('change', e => {
saveLastService(e.target.value);
});

document.getElementById('create-project')?.addEventListener('change', e => {
saveLastProject(e.target.value);
});

if (cp) {
const lastProject = getLastProject();
cp.innerHTML = buildProjectOptions(lastProject);
if (lastProject) cp.value = lastProject;
}
if (cs) {
const lastService = getLastService();
cs.innerHTML = buildServiceOptions(lastService);
if (lastService) cs.value = lastService;
}

this.list.querySelectorAll('.entry-row').forEach(row => {
const ep = row.querySelector('.edit-project');
const es = row.querySelector('.edit-service');
if (ep) ep.innerHTML = buildProjectOptions(row.dataset.projectId);
if (es) es.innerHTML = buildServiceOptions(row.dataset.serviceId);
});

this.list.addEventListener('click', e => this.handleListClick(e));
document.getElementById('btn-create')?.addEventListener('click', () => this.createEntry());
}

handleListClick(e) {
const actionEl = e.target.closest('[data-action]');
if (!actionEl) return;
const action = actionEl.dataset.action;
const row = e.target.closest('.entry-row');
if (!row) return;

switch (action) {
case 'edit': this.openEdit(row); break;
case 'delete': this.deleteEntry(row); break;
case 'save': this.saveEdit(row); break;
case 'cancel': this.closeEdit(row); break;
}
}

async createEntry() {
const durationRaw = document.getElementById('create-duration')?.value ?? '0:00';
const projectId = document.getElementById('create-project')?.value;
const serviceId = document.getElementById('create-service')?.value;
const note = document.getElementById('create-note')?.value;

if (!projectId) { alert(t('errorNoProject')); return; }

const duration = formatMinutes(roundToQuarter(parseDuration(durationRaw)));

if (duration === '0:00') {
alert(t('errorZeroDuration'));
return;
}

const rawMinutes = roundToQuarter(parseDuration(durationRaw));
const validation = validateDuration(rawMinutes);
if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; }
if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return;

try {
const res = await fetch('/api/entries', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
date: window.TT.activeDate,
duration,
projectId: parseInt(projectId),
serviceId: serviceId ? parseInt(serviceId) : null,
note: note || null,
}),
});

if (!res.ok) {
const err = await res.json().catch(() => ({}));
console.error('API Fehler:', res.status, err);
alert(t('errorSave') + (err.error ? `\n${err.error}` : ''));
return;
}

const data = await res.json();
this.addEntryToDOM(data.entry);
this.updateTotal(data.totalDuration);
this.resetCreateForm();

} catch (err) {
console.error('Netzwerkfehler:', err);
alert(t('errorSave'));
}
}

addEntryToDOM(entry) {
this.hideEmptyState();

let items = document.getElementById('entry-items');
if (!items) {
items = document.createElement('div');
items.className = 'entry-list__items';
items.id = 'entry-items';
this.list.prepend(items);
}

items.insertAdjacentHTML('beforeend', buildEntryRowHTML(entry, true));

const el = document.getElementById(`entry-${entry.id}`);
requestAnimationFrame(() => requestAnimationFrame(() => {
el?.classList.remove('entry-row--new');
}));
}

resetCreateForm() {
const d = document.getElementById('create-duration');
const p = document.getElementById('create-project');
const s = document.getElementById('create-service');
const n = document.getElementById('create-note');
if (d) d.value = '0:00';
if (n) n.value = '';
if (p) p.value = getLastProject() ?? '';
if (s) s.value = getLastService() ?? '';
}

openEdit(row) {
row.querySelector('.entry-row__display').hidden = true;
row.querySelector('.entry-row__edit').hidden = false;
row.querySelector('.edit-duration')?.focus();
}

closeEdit(row) {
row.querySelector('.entry-row__display').hidden = false;
row.querySelector('.entry-row__edit').hidden = true;
}

async saveEdit(row) {
const id = row.dataset.id;
const durationRaw = row.querySelector('.edit-duration')?.value ?? '0:00';
const projectId = row.querySelector('.edit-project')?.value;
const serviceId = row.querySelector('.edit-service')?.value;
const note = row.querySelector('.edit-note')?.value;

if (!projectId) { alert(t('errorNoProject')); return; }

const duration = formatMinutes(roundToQuarter(parseDuration(durationRaw)));

if (duration === '0:00') {
alert(t('errorZeroDuration'));
return;
}

const rawMinutes = roundToQuarter(parseDuration(durationRaw));
const validation = validateDuration(rawMinutes);
if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; }
if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return;

try {
const res = await fetch(`/api/entries/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
duration,
projectId: parseInt(projectId),
serviceId: serviceId ? parseInt(serviceId) : null,
note: note || null,
}),
});

if (!res.ok) {
console.error('PATCH fehlgeschlagen:', res.status);
alert(t('errorSave'));
return;
}

const data = await res.json();
this.updateRowDisplay(row, data.entry);
this.updateTotal(data.totalDuration);
this.closeEdit(row);

} catch (err) {
console.error('saveEdit Fehler:', err);
alert(t('errorSave'));
}
}

updateRowDisplay(row, entry) {
const servicePart = entry.serviceName ? ` / ${entry.serviceName}` : '';
row.querySelector('.entry-row__title').textContent =
`${entry.clientName} / ${entry.projectName}${servicePart}`;

row.querySelector('.entry-row__note')?.remove();
if (entry.note) {
const noteEl = document.createElement('div');
noteEl.className = 'entry-row__note';
noteEl.textContent = entry.note;
row.querySelector('.entry-row__info').appendChild(noteEl);
}

row.querySelector('.entry-row__badge').textContent = entry.durationFormatted;
row.dataset.duration = entry.duration;
row.dataset.projectId = entry.projectId;
row.dataset.serviceId = entry.serviceId ?? '';
row.dataset.note = entry.note ?? '';

row.querySelector('.edit-duration').value = entry.durationFormatted;
row.querySelector('.edit-project').innerHTML = buildProjectOptions(entry.projectId);
row.querySelector('.edit-service').innerHTML = buildServiceOptions(entry.serviceId);
row.querySelector('.edit-note').value = entry.note ?? '';
}

async deleteEntry(row) {
if (!confirm(t('confirmDelete'))) return;

try {
const res = await fetch(`/api/entries/${row.dataset.id}`, { method: 'DELETE' });
if (!res.ok) { alert(t('errorDelete')); return; }

const data = await res.json();
row.classList.add('entry-row--removing');
setTimeout(() => {
row.remove();
this.updateTotal(data.totalDuration);
this.checkIfEmpty();
}, 280);

} catch { alert(t('errorDelete')); }
}

async loadEntriesForDate(dateStr) {
window.TT.activeDate = dateStr;

try {
this.list.classList.add('entry-list--fading');
await new Promise(r => setTimeout(r, 180));

const res = await fetch(`/api/entries?date=${dateStr}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();

this.renderEntries(data.entries, data.totalDuration);
} catch (err) {
console.error(t('errorLoad'), err);
} finally {
this.list.classList.remove('entry-list--fading');
}
}

renderEntries(entries, totalDuration) {
if (!entries.length) {
this.list.innerHTML = `<div class="empty-state" id="empty-state">
<p class="empty-state__title">${t('noEntries')}</p></div>`;
this.emptyState = this.list.querySelector('#empty-state');
return;
}

let html = '<div class="entry-list__items" id="entry-items">';
entries.forEach(e => { html += buildEntryRowHTML(e, false); });
html += `</div><div class="entry-list__footer" id="entry-footer">
<span class="entry-list__total">${totalDuration}</span></div>`;

this.list.innerHTML = html;
this.emptyState = null;

this.list.querySelectorAll('.entry-row').forEach(row => {
row.querySelector('.edit-project').innerHTML = buildProjectOptions(row.dataset.projectId);
row.querySelector('.edit-service').innerHTML = buildServiceOptions(row.dataset.serviceId);
});
}

updateTotal(totalDuration) {
let footer = document.getElementById('entry-footer');
if (!footer) {
footer = document.createElement('div');
footer.className = 'entry-list__footer';
footer.id = 'entry-footer';
this.list.appendChild(footer);
}
footer.innerHTML = `<span class="entry-list__total">${totalDuration}</span>`;
}

hideEmptyState() { this.emptyState?.remove(); this.emptyState = null; }

checkIfEmpty() {
const items = document.getElementById('entry-items');
if (items && !items.children.length) {
items.remove();
document.getElementById('entry-footer')?.remove();
this.list.innerHTML = `<div class="empty-state" id="empty-state">
<p class="empty-state__title">${t('noEntries')}</p></div>`;
this.emptyState = this.list.querySelector('#empty-state');
}
}
}

function saveLastProject(projectId) {
if (projectId) localStorage.setItem(LAST_PROJECT_KEY, projectId);
}

function getLastProject() {
return localStorage.getItem(LAST_PROJECT_KEY);
}

function saveLastService(serviceId) {
if (serviceId) localStorage.setItem(LAST_SERVICE_KEY, serviceId);
}

function getLastService() {
return localStorage.getItem(LAST_SERVICE_KEY);
}

window.entryManager = null;
document.addEventListener('DOMContentLoaded', () => {
initDurationBlurHandler();
window.entryManager = new EntryManager();
});

+ 87
- 0
httpdocs/assets/scripts/registration.js 파일 보기

@@ -0,0 +1,87 @@
// assets/scripts/registration.js

document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('register-form');
const companyInput = document.getElementById('companyName');
const slugPreview = document.getElementById('slug-preview');
const submitBtn = document.getElementById('submit-btn');
const errorBox = document.getElementById('register-errors');
const appDomain = window.REGISTER_APP_DOMAIN ?? '';

// ── Slug-Vorschau ─────────────────────────────────────────────────────────
let debounce = null;
companyInput?.addEventListener('input', () => {
clearTimeout(debounce);
debounce = setTimeout(async () => {
const value = companyInput.value.trim();
if (!value) { slugPreview.textContent = ''; return; }

try {
const res = await fetch('/api/register/preview-slug', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ companyName: value }),
});
const data = await res.json();
slugPreview.textContent = data.slug ? data.slug + '.' + appDomain : '–';
} catch {
slugPreview.textContent = '';
}
}, 350);
});

// ── Formular absenden ─────────────────────────────────────────────────────
form?.addEventListener('submit', async (e) => {
e.preventDefault();
errorBox.innerHTML = '';
submitBtn.disabled = true;
submitBtn.textContent = 'Wird gesendet …';

const payload = {
companyName: document.getElementById('companyName').value,
email: document.getElementById('email').value,
firstName: document.getElementById('firstName').value,
lastName: document.getElementById('lastName').value,
password: document.getElementById('password').value,
passwordRepeat: document.getElementById('passwordRepeat').value,
};

try {
const res = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();

if (res.ok) {
document.querySelector('.register-page').innerHTML = `
<div class="register-success">
<div class="register-success__icon">✓</div>
<h2 class="register-success__title">Fast geschafft!</h2>
<p class="register-success__text">
Wir haben eine Bestätigungs-E-Mail an
<strong>${payload.email}</strong> geschickt.
</p>
<p class="register-success__hint">
Bitte klicke auf den Link in der E-Mail um dein Konto zu aktivieren.
Der Link ist 24 Stunden gültig.
</p>
</div>
`;
} else {
(data.errors ?? ['Unbekannter Fehler.']).forEach(msg => {
const p = document.createElement('p');
p.textContent = msg;
errorBox.appendChild(p);
});
submitBtn.disabled = false;
submitBtn.textContent = 'Konto erstellen';
}
} catch {
errorBox.innerHTML = '<p>Verbindungsfehler. Bitte versuche es erneut.</p>';
submitBtn.disabled = false;
submitBtn.textContent = 'Konto erstellen';
}
});
});

+ 223
- 0
httpdocs/assets/scripts/team.js 파일 보기

@@ -0,0 +1,223 @@
// team.js
document.addEventListener('DOMContentLoaded', () => {

// ── Tabs ─────────────────────────────────────────────────────────────────────
document.querySelectorAll('.crud-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.crud-tab').forEach(t =>
t.classList.toggle('crud-tab--active', t === tab)
);
document.querySelectorAll('[data-tab-panel]').forEach(panel => {
panel.hidden = panel.dataset.tabPanel !== tab.dataset.tab;
});
});
});

// ── Einlade-Modal ─────────────────────────────────────────────────────────────
const modal = document.getElementById('team-modal');
const errorsBox = document.getElementById('team-modal-errors');

const openModal = () => { modal.hidden = false; };
const closeModal = () => {
modal.hidden = true;
errorsBox.hidden = true;
['inv-firstName', 'inv-lastName', 'inv-email'].forEach(id => {
document.getElementById(id).value = '';
});
const defaultRole = modal.querySelector('input[name="inv-role"][value="member"]');
if (defaultRole) defaultRole.checked = true;
};

document.getElementById('team-invite-btn').addEventListener('click', openModal);
document.getElementById('team-modal-close').addEventListener('click', closeModal);
document.getElementById('team-modal-cancel').addEventListener('click', closeModal);
modal.addEventListener('click', e => { if (e.target === modal) closeModal(); });

document.getElementById('team-modal-submit').addEventListener('click', async () => {
const payload = {
firstName: document.getElementById('inv-firstName').value.trim(),
lastName: document.getElementById('inv-lastName').value.trim(),
email: document.getElementById('inv-email').value.trim(),
role: modal.querySelector('input[name="inv-role"]:checked')?.value ?? 'member',
};

const res = await fetch('/api/team/invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();

if (!res.ok) {
errorsBox.hidden = false;
errorsBox.innerHTML = '<ul>' + (data.errors ?? [data.error]).map(e => `<li>${e}</li>`).join('') + '</ul>';
return;
}

closeModal();
window.location.reload();
});

// ── Listen-Delegation: aktive User + Einladungen ───────────────────────────
const list = document.getElementById('team-list');
if (list) {
list.addEventListener('click', e => {
const actionEl = e.target.closest('[data-action]');
if (!actionEl) return;

const action = actionEl.dataset.action;
const row = e.target.closest('.crud-row');
if (!row) return;

switch (action) {
case 'edit': openEdit(row); break;
case 'save': saveEdit(row); break;
case 'cancel': closeEdit(row); break;
case 'delete': deleteMember(row); break;
case 'delete-invite': deleteInvite(actionEl.dataset.id, row); break;
}
});
}

// ── Listen-Delegation: archivierte User ───────────────────────────────────
const archivedList = document.getElementById('team-list-archived');
if (archivedList) {
archivedList.addEventListener('click', e => {
const actionEl = e.target.closest('[data-action]');
if (!actionEl) return;
const row = e.target.closest('.crud-row');
if (!row) return;

if (actionEl.dataset.action === 'unarchive') {
unarchiveMember(row);
}
});
}

// ── Inline Edit ───────────────────────────────────────────────────────────
function openEdit(row) {
row.querySelector('.crud-row__display').hidden = true;
row.querySelector('.crud-row__edit').hidden = false;
row.querySelector('.edit-first-name')?.focus();
}

function closeEdit(row) {
row.querySelector('.crud-row__display').hidden = false;
row.querySelector('.crud-row__edit').hidden = true;

// Felder auf ursprüngliche Werte zurücksetzen
row.querySelector('.edit-first-name').value = row.dataset.firstName ?? '';
row.querySelector('.edit-last-name').value = row.dataset.lastName ?? '';
row.querySelector('.edit-email').value = row.dataset.email ?? '';
row.querySelector('.edit-note').value = row.dataset.note ?? '';

const currentRole = row.dataset.role;
row.querySelectorAll('.edit-role').forEach(radio => {
radio.checked = radio.value === currentRole;
});
}

async function saveEdit(row) {
const id = row.dataset.id;
const firstName = row.querySelector('.edit-first-name').value.trim();
const lastName = row.querySelector('.edit-last-name').value.trim();
const email = row.querySelector('.edit-email').value.trim();
const note = row.querySelector('.edit-note').value || null;
const role = row.querySelector('.edit-role:checked')?.value ?? row.dataset.role;

const res = await fetch(`/api/team/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ firstName, lastName, email, note, role }),
});

if (!res.ok) {
const data = await res.json();
alert((data.errors ?? [data.error]).join('\n'));
return;
}

const data = await res.json();
updateDisplay(row, data);
closeEdit(row);
}

function updateDisplay(row, data) {
row.querySelector('.crud-row__name').textContent = data.fullName;
row.querySelector('.crud-row__meta').textContent = `(${data.roleLabel})`;

row.dataset.firstName = data.firstName;
row.dataset.lastName = data.lastName;
row.dataset.email = data.email;
row.dataset.note = data.note ?? '';
row.dataset.role = data.role;

// Edit-Felder aktualisieren
row.querySelector('.edit-first-name').value = data.firstName;
row.querySelector('.edit-last-name').value = data.lastName;
row.querySelector('.edit-email').value = data.email;
row.querySelector('.edit-note').value = data.note ?? '';
row.querySelectorAll('.edit-role').forEach(radio => {
radio.checked = radio.value === data.role;
});
}

// ── Delete ────────────────────────────────────────────────────────────────
async function deleteMember(row) {
if (!confirm('Wirklich entfernen?')) return;

const id = row.dataset.id;
const res = await fetch(`/api/team/${id}`, { method: 'DELETE' });

if (res.status === 409) {
if (confirm('Dieser Benutzer hat Zeiteinträge und kann nicht gelöscht werden.\nStattdessen archivieren?')) {
await archiveMember(row);
}
return;
}

if (!res.ok) {
const data = await res.json();
alert(data.error ?? 'Fehler beim Löschen.');
return;
}

row.classList.add('crud-row--removing');
setTimeout(() => row.remove(), 280);
}

async function deleteInvite(id, row) {
if (!confirm('Einladung zurückziehen?')) return;

const res = await fetch(`/api/team/invite/${id}`, { method: 'DELETE' });
if (!res.ok) {
const data = await res.json();
alert(data.error ?? 'Fehler');
return;
}

row.classList.add('crud-row--removing');
setTimeout(() => row.remove(), 280);
}

// ── Archive / Unarchive ───────────────────────────────────────────────────
async function archiveMember(row) {
const id = row.dataset.id;
const res = await fetch(`/api/team/${id}/archive`, { method: 'PATCH' });

if (!res.ok) { alert('Fehler beim Archivieren.'); return; }

row.classList.add('crud-row--removing');
setTimeout(() => window.location.reload(), 280);
}

async function unarchiveMember(row) {
const id = row.dataset.id;
const res = await fetch(`/api/team/${id}/unarchive`, { method: 'PATCH' });

if (!res.ok) { alert('Fehler beim Wiederherstellen.'); return; }

row.classList.add('crud-row--removing');
setTimeout(() => window.location.reload(), 280);
}
});

+ 67
- 0
httpdocs/assets/styles/atoms/_buttons.scss 파일 보기

@@ -0,0 +1,67 @@
@use 'variables' as *;

// ─── Base Button ─────────────────────────────────────────────────────────────
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: $space-2;
padding: $space-2 $space-5;
font-family: $font-family-base;
font-size: $font-size-base;
font-weight: $font-weight-medium;
line-height: 1.4;
border: none;
border-radius: $radius-pill;
cursor: pointer;
text-decoration: none;
transition:
background-color $transition-fast,
box-shadow $transition-fast,
transform $transition-fast;
white-space: nowrap;

&:active {
transform: translateY(1px);
}

&:focus-visible {
outline: 2px solid $color-primary;
outline-offset: 3px;
}
}

// ─── Variants ────────────────────────────────────────────────────────────────
.btn-primary {
background: linear-gradient(135deg, $color-accent-light, $color-accent);
color: $color-white;
box-shadow: $shadow-button;

&:hover {
background: linear-gradient(135deg, $color-accent, $color-accent-dark);
box-shadow: 0 3px 12px rgba(240, 165, 0, 0.45);
}
}

.btn-secondary {
background: transparent;
color: $color-text-muted;
padding-left: $space-3;
padding-right: $space-3;

&:hover {
color: $color-text-dark;
text-decoration: underline;
}
}

.btn-ghost {
background: rgba(255, 255, 255, 0.15);
color: $color-white;
border-radius: $radius-sm;
padding: $space-1 $space-3;

&:hover {
background: rgba(255, 255, 255, 0.25);
}
}

+ 89
- 0
httpdocs/assets/styles/atoms/_inputs.scss 파일 보기

@@ -0,0 +1,89 @@
@use 'variables' as *;

// ─── Base Input ──────────────────────────────────────────────────────────────
.input,
.select,
.textarea {
display: block;
width: 100%;
padding: $space-2 $space-3;
font-family: $font-family-base;
font-size: $font-size-base;
color: $color-text-dark;
background-color: $color-input-bg;
border: 1px solid $color-input-border;
border-radius: $radius-sm;
box-shadow: $shadow-input;
transition:
border-color $transition-fast,
box-shadow $transition-fast;
appearance: none;
-webkit-appearance: none;

&::placeholder {
color: $color-text-light;
}

&:focus {
outline: none;
border-color: $color-primary;
box-shadow: 0 0 0 3px rgba(74, 144, 217, 0.15);
}
}

// ─── Input Sizes ─────────────────────────────────────────────────────────────
.input--sm {
width: 80px;
text-align: center;
font-weight: $font-weight-medium;
letter-spacing: 0.02em;
}

// ─── Select (with arrow) ─────────────────────────────────────────────────────
.select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='7' viewBox='0 0 12 7'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%237a8a9a' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right $space-3 center;
padding-right: $space-8;
cursor: pointer;

&:hover {
border-color: $color-primary-light;
}
}

// ─── Select Label Tag (wie "Dogument", "Verrechenbar") ───────────────────────
.select-hint {
font-size: $font-size-xs;
color: $color-text-muted;
font-style: italic;
}

// ─── Textarea ────────────────────────────────────────────────────────────────
.textarea {
resize: vertical;
min-height: 72px;
}

// ─── Help Icon ───────────────────────────────────────────────────────────────
.input-help {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
border: 1px solid $color-border;
background: $color-white;
color: $color-text-muted;
font-size: $font-size-xs;
font-weight: $font-weight-bold;
cursor: help;
flex-shrink: 0;
transition: border-color $transition-fast, color $transition-fast;

&:hover {
border-color: $color-primary;
color: $color-primary;
}
}

+ 37
- 0
httpdocs/assets/styles/atoms/_typography.scss 파일 보기

@@ -0,0 +1,37 @@
@use 'variables' as *;

// ─── Base Typography ─────────────────────────────────────────────────────────
body {
font-family: $font-family-base;
font-size: $font-size-base;
font-weight: $font-weight-regular;
line-height: $line-height-base;
color: $color-text-base;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

// ─── Utility Classes ─────────────────────────────────────────────────────────
.text-xs { font-size: $font-size-xs; }
.text-sm { font-size: $font-size-sm; }
.text-base { font-size: $font-size-base; }
.text-md { font-size: $font-size-md; }
.text-lg { font-size: $font-size-lg; }
.text-xl { font-size: $font-size-xl; }

.text-muted { color: $color-text-muted; }
.text-light { color: $color-text-light; }
.text-dark { color: $color-text-dark; }
.text-white { color: $color-white; }

.font-medium { font-weight: $font-weight-medium; }
.font-bold { font-weight: $font-weight-bold; }

.label {
display: block;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $color-text-muted;
margin-bottom: $space-2;
letter-spacing: 0.01em;
}

+ 75
- 0
httpdocs/assets/styles/atoms/_variables.scss 파일 보기

@@ -0,0 +1,75 @@
// ─── Color Palette ───────────────────────────────────────────────────────────
$color-primary: #4a90d9;
$color-primary-dark: #3178b8;
$color-primary-light: #6aaee8;
$color-header-from: #5b9fd6;
$color-header-to: #3a7bbf;

$color-accent: #f0a500;
$color-accent-dark: #d4900a;
$color-accent-light: #f5bc3a;

$color-white: #ffffff;
$color-bg: #dce9f5;
$color-card: #f0f0f0;
$color-card-white: #ffffff;

$color-text-dark: #1a2a3a;
$color-text-base: #3a4a5a;
$color-text-muted: #7a8a9a;
$color-text-light: #aab8c6;

$color-border: #d0d8e0;
$color-input-bg: #ffffff;
$color-input-border: #b8c4d0;

$color-day-active-bg: #1a2a3a;
$color-day-active-text:#ffffff;
$color-day-hover: rgba(255,255,255,0.2);

// ─── Typography ──────────────────────────────────────────────────────────────
$font-family-base: 'DM Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
$font-size-xs: 0.7rem;
$font-size-sm: 0.8rem;
$font-size-base: 0.875rem;
$font-size-md: 1rem;
$font-size-lg: 1.15rem;
$font-size-xl: 1.4rem;

$font-weight-regular: 400;
$font-weight-medium: 500;
$font-weight-bold: 700;

$line-height-base: 1.5;

// ─── Spacing ─────────────────────────────────────────────────────────────────
$space-1: 0.25rem;
$space-2: 0.5rem;
$space-3: 0.75rem;
$space-4: 1rem;
$space-5: 1.25rem;
$space-6: 1.5rem;
$space-8: 2rem;
$space-10: 2.5rem;
$space-12: 3rem;

// ─── Border Radius ───────────────────────────────────────────────────────────
$radius-sm: 4px;
$radius-md: 8px;
$radius-lg: 16px;
$radius-xl: 24px;
$radius-pill: 100px;

// ─── Shadows ─────────────────────────────────────────────────────────────────
$shadow-card: 0 2px 12px rgba(0, 60, 120, 0.08);
$shadow-input: 0 1px 3px rgba(0, 40, 80, 0.06) inset;
$shadow-button: 0 2px 8px rgba(240, 165, 0, 0.35);

// ─── Transitions ─────────────────────────────────────────────────────────────
$transition-fast: 0.15s ease;
$transition-base: 0.2s ease;
$transition-slow: 0.3s ease;

// ─── Layout ──────────────────────────────────────────────────────────────────
$header-height: 88px;
$content-max-width: 860px;

+ 156
- 0
httpdocs/assets/styles/components/_account.scss 파일 보기

@@ -0,0 +1,156 @@
@use '../atoms/variables' as *;

// ─── Page ─────────────────────────────────────────────────────────────────────
.account-page {
min-height: 100vh;
background: $color-bg;
display: flex;
flex-direction: column;
}

// ─── Header ──────────────────────────────────────────────────────────────────
.account-header {
background: linear-gradient(135deg, $color-header-from 0%, $color-header-to 100%);
padding: $space-6;
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-6;
box-shadow: 0 2px 16px rgba(0, 50, 120, 0.2);
}

.account-header__title {
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $color-white;
}

// ─── Tab-Navigation (Pill im Header) ─────────────────────────────────────────
.account-tabs {
display: flex;
background: rgba(255, 255, 255, 0.18);
border-radius: $radius-pill;
padding: 3px;
gap: $space-1;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}

.account-tab {
display: inline-flex;
align-items: center;
padding: $space-2 $space-5;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: rgba(255, 255, 255, 0.8);
text-decoration: none;
border-radius: $radius-pill;
transition: background $transition-fast, color $transition-fast;
white-space: nowrap;

&:hover:not(.account-tab--active) {
color: $color-white;
background: rgba(255, 255, 255, 0.12);
}

&--active {
color: $color-text-dark;
background: $color-white;
font-weight: $font-weight-bold;
}
}

// ─── Content ─────────────────────────────────────────────────────────────────
.account-content {
flex: 1;
max-width: 680px;
width: 100%;
margin: $space-8 auto;
padding: 0 $space-6;
}

// ─── Karte ───────────────────────────────────────────────────────────────────
.account-card {
background: $color-card-white;
border-radius: $radius-lg;
box-shadow: $shadow-card;
padding: $space-8;
}

// ─── Formular-Grid ───────────────────────────────────────────────────────────
.account-form__grid {
display: grid;
grid-template-columns: 160px 1fr;
gap: $space-4 $space-6;
align-items: start;
}

.account-form__label {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $color-text-muted;
padding-top: 7px;
}

.account-form__field {
display: flex;
flex-direction: column;
gap: $space-2;
}

.account-form__hint {
font-size: $font-size-xs;
color: $color-text-muted;
}

.account-form__link {
font-size: $font-size-sm;
color: $color-primary;
text-decoration: none;

&:hover { text-decoration: underline; }
}

// ─── Passwort-Sektion (toggle) ────────────────────────────────────────────────
.account-form__pw-section {
display: contents; // bleibt im Grid-Fluss

&[hidden] {
display: none !important;
}
}

// ─── Actions ─────────────────────────────────────────────────────────────────
.account-form__actions {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: $space-4;
margin-top: $space-2;
padding-top: $space-4;
border-top: 1px solid $color-border;
}

// ─── Toast ───────────────────────────────────────────────────────────────────
.account-toast {
position: fixed;
bottom: $space-6;
right: $space-6;
background: $color-text-dark;
color: $color-white;
padding: $space-3 $space-5;
border-radius: $radius-md;
font-size: $font-size-sm;
opacity: 0;
transform: translateY(8px);
transition: opacity $transition-base, transform $transition-base;
pointer-events: none;
z-index: 9999;

&--visible {
opacity: 1;
transform: translateY(0);
}

&--error { background: #c83232; }
}

+ 208
- 0
httpdocs/assets/styles/components/_crud.scss 파일 보기

@@ -0,0 +1,208 @@
@use '../atoms/variables' as *;

// ─── CRUD Seiten Layout ────────────────────────────────────────────────────────
.crud-page {
max-width: $content-max-width;
margin: 0 auto;
padding: $space-6;
}

.crud-page__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: $space-6;
}

.crud-page__title {
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $color-text-dark;
}

// ─── Liste ─────────────────────────────────────────────────────────────────────
.crud-list {
background: $color-card-white;
border-radius: $radius-lg;
box-shadow: $shadow-card;
overflow: hidden;
}

// ─── Zeile ─────────────────────────────────────────────────────────────────────
.crud-row {
border-bottom: 1px solid rgba($color-border, 0.5);
transition: opacity 0.28s ease, transform 0.28s ease;

&:last-child { border-bottom: none; }

&--removing {
opacity: 0;
transform: translateX(12px);
}

&--new {
opacity: 0;
transform: translateY(-4px);
}
}

.crud-row__display {
display: flex;
align-items: center;
gap: $space-4;
padding: $space-4 $space-6;
transition: background $transition-fast;

&:hover {
background: rgba($color-primary, 0.03);

.crud-row__btn { opacity: 1; }
}
}

.crud-row__info {
flex: 1;
min-width: 0;
}

.crud-row__name {
font-size: $font-size-base;
font-weight: $font-weight-bold;
color: $color-text-dark;
}

.crud-row__meta {
font-size: $font-size-sm;
color: $color-text-muted;
margin-left: $space-2;
font-weight: $font-weight-regular;
}

.crud-row__actions {
display: flex;
align-items: center;
gap: $space-2;
flex-shrink: 0;
}

.crud-row__btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: transparent;
border: none;
cursor: pointer;
opacity: 0;
transition: opacity $transition-fast, background $transition-fast, color $transition-fast;
color: $color-text-muted;

svg { width: 14px; height: 14px; pointer-events: none; }

&--edit:hover { background: rgba($color-primary, 0.1); color: $color-primary; }
&--delete:hover{ background: rgba(200, 50, 50, 0.1); color: #c83232; }

@media (hover: none) { opacity: 1; }
}

// ─── Edit-Formular innerhalb der Zeile ─────────────────────────────────────────
.crud-row__edit {
padding: $space-4 $space-6;
background: rgba($color-primary, 0.03);
border-top: 1px solid rgba($color-border, 0.5);
}

.crud-row__display[hidden] { display: none !important; }

// ─── Create-Formular oben ──────────────────────────────────────────────────────
.crud-create {
background: $color-card;
border-radius: $radius-lg;
padding: $space-5 $space-6;
margin-bottom: $space-4;
box-shadow: $shadow-card;
display: none;

&--visible { display: block; }
}

// ─── Tabs (Aktiv / Archiviert) ─────────────────────────────────────────────────
.crud-tabs {
display: inline-flex;
background: $color-card-white;
border-radius: $radius-pill;
padding: 3px;
margin-bottom: $space-4;
box-shadow: $shadow-card;
}

.crud-tab {
padding: $space-1 $space-5;
border: none;
background: transparent;
border-radius: $radius-pill;
font-family: $font-family-base;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $color-text-muted;
cursor: pointer;
transition: background $transition-fast, color $transition-fast;

&:hover { color: $color-text-dark; }

&--active {
background: $color-primary;
color: $color-white;
}
}

// ─── Archivierte Zeile ─────────────────────────────────────────────────────────
.crud-row--archived {
.crud-row__name {
color: $color-text-muted;
text-decoration: line-through;
text-decoration-color: rgba($color-text-muted, 0.5);
}
}

.crud-row__btn--restore {
&:hover { background: rgba(74, 180, 74, 0.12); color: #3a9a3a; }
}

// ─── Gruppen-Header (z.B. Verrechenbar / Nicht-verrechenbar) ──────────────────
.crud-list__group {
& + & {
border-top: 2px solid $color-border;
}
}

.crud-list__group-label {
padding: $space-3 $space-6;
font-size: $font-size-xs;
font-weight: $font-weight-bold;
color: $color-text-muted;
text-transform: uppercase;
letter-spacing: 0.06em;
background: rgba($color-primary, 0.03);
border-bottom: 1px solid rgba($color-border, 0.5);
}

// ─── Checkbox-Label (Verrechenbar-Feld) ────────────────────────────────────────
.crud-checkbox-label {
display: flex;
align-items: center;
gap: $space-2;
cursor: pointer;
font-size: $font-size-base;
color: $color-text-base;

input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
flex-shrink: 0;
accent-color: $color-primary;
}
}

+ 56
- 0
httpdocs/assets/styles/components/_duration-help.scss 파일 보기

@@ -0,0 +1,56 @@
@use '../atoms/variables' as *;

// ── Dauer-Hilfe Tooltip ───────────────────────────────────────────────────────
// Zeigt "?" an, auf Hover klappt der Hilfetext aus

.duration-help {
display: inline-flex;
align-items: center;
cursor: help;
flex-shrink: 0;

&__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
border: 1px solid $color-border;
background: $color-white;
color: $color-text-muted;
font-size: $font-size-xs;
font-weight: $font-weight-bold;
flex-shrink: 0;
transition:
width $transition-base,
opacity $transition-base,
border $transition-base,
margin $transition-base;
overflow: hidden;
}

&__hint {
font-size: $font-size-xs;
color: $color-text-muted;
white-space: nowrap;
max-width: 0;
overflow: hidden;
opacity: 0;
transition:
max-width 0.28s ease,
opacity 0.2s ease;
}

&:hover &__icon {
width: 0;
opacity: 0;
border-width: 0;
margin-right: 0;
}

&:hover &__hint {
max-width: 500px;
opacity: 1;
}
}

+ 50
- 0
httpdocs/assets/styles/components/_entry-form.scss 파일 보기

@@ -0,0 +1,50 @@
@use '../atoms/variables' as *;

// ─── Entry Form Card ─────────────────────────────────────────────────────────
.entry-form {
background: $color-card;
border-radius: $radius-lg;
padding: $space-6 $space-8;
box-shadow: $shadow-card;
}

.entry-form__grid {
display: grid;
grid-template-columns: 120px 1fr;
gap: $space-4 $space-6;
align-items: center;
}

.entry-form__label {
font-size: $font-size-base;
font-weight: $font-weight-regular;
color: $color-text-base;
text-align: right;
padding-right: $space-2;
white-space: nowrap;
}

.entry-form__field {
display: flex;
align-items: center;
gap: $space-2;
}

.entry-form__field--selects {
display: flex;
gap: $space-3;
flex-wrap: wrap;

.select {
flex: 1;
min-width: 180px;
}
}

.entry-form__actions {
grid-column: 2;
display: flex;
align-items: center;
gap: $space-4;
padding-top: $space-2;
}

+ 170
- 0
httpdocs/assets/styles/components/_entry-list.scss 파일 보기

@@ -0,0 +1,170 @@
@use '../atoms/variables' as *;

// ─── Entry List Container ──────────────────────────────────────────────────
.entry-list {
background: $color-card-white;
border-radius: $radius-lg;
box-shadow: $shadow-card;
overflow: hidden;
transition: opacity 0.18s ease;

&--fading { opacity: 0; }
}

// ─── Empty State ──────────────────────────────────────────────────────────
.empty-state {
background: $color-card-white;
border-radius: $radius-lg;
padding: $space-6 $space-8;
box-shadow: $shadow-card;
}

.empty-state__title {
font-size: $font-size-base;
font-weight: $font-weight-bold;
color: $color-text-dark;
margin: 0 0 $space-2;
}

// ─── Footer mit Gesamtdauer ───────────────────────────────────────────────
.entry-list__footer {
display: flex;
justify-content: flex-end;
// 2 Buttons (28px) + 2× gap (8px) + eigener padding = Badge bündig
padding: $space-3 calc(#{$space-8} + 28px + 28px + #{$space-2} + #{$space-2});
border-top: 1px solid $color-border;
}

.entry-list__total {
font-size: $font-size-base;
font-weight: $font-weight-bold;
color: $color-text-dark;
background: $color-card;
border-radius: $radius-pill;
padding: $space-1 $space-4;
}

// ─── Entry Row ────────────────────────────────────────────────────────────
.entry-row {
border-bottom: 1px solid rgba($color-border, 0.5);
transition: opacity 0.28s ease, transform 0.28s ease, max-height 0.28s ease;

&:last-child { border-bottom: none; }

// Fade-in bei neuem Eintrag
&--new {
opacity: 0;
transform: translateY(-6px);
}

// Fade-out beim Löschen
&--removing {
opacity: 0;
transform: translateX(12px);
max-height: 0;
overflow: hidden;
padding: 0;
margin: 0;
}
}

// ─── Anzeige-Modus ────────────────────────────────────────────────────────
.entry-row__display {
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-4;
padding: $space-4 $space-8;

&:hover {
background: rgba($color-primary, 0.03);

.entry-row__btn {
opacity: 1;
}
}

&[hidden] {
display: none !important;
}
}

.entry-row__info {
flex: 1;
min-width: 0;
}

.entry-row__title {
font-size: $font-size-base;
font-weight: $font-weight-bold;
color: $color-text-dark;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.entry-row__note {
font-size: $font-size-sm;
color: $color-text-muted;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.entry-row__actions {
display: flex;
align-items: center;
gap: $space-2;
flex-shrink: 0;
}

.entry-row__badge {
font-size: $font-size-sm;
font-weight: $font-weight-bold;
color: $color-text-dark;
background: $color-card;
border-radius: $radius-pill;
padding: $space-1 $space-3;
min-width: 48px;
text-align: center;
font-variant-numeric: tabular-nums;
}

.entry-row__btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: transparent;
border: none;
cursor: pointer;
opacity: 0;
transition: opacity $transition-fast, background $transition-fast, color $transition-fast;
color: $color-text-muted;

svg { width: 14px; height: 14px; pointer-events: none; }

&--edit:hover { background: rgba($color-primary, 0.1); color: $color-primary; }
&--delete:hover{ background: rgba(200, 50, 50, 0.1); color: #c83232; }

// immer sichtbar auf Touch-Geräten
@media (hover: none) { opacity: 1; }
}

// ─── Bearbeiten-Modus ─────────────────────────────────────────────────────
.entry-row__edit {
padding: $space-4 $space-8;
background: rgba($color-primary, 0.03);
border-top: 1px solid rgba($color-border, 0.5);
}

.entry-form__grid--inline {
// Gleiche Grid-Struktur wie das Haupt-Formular
display: grid;
grid-template-columns: 130px 1fr;
gap: $space-3 $space-6;
align-items: center;
}

+ 15
- 0
httpdocs/assets/styles/components/_greeting.scss 파일 보기

@@ -0,0 +1,15 @@
@use '../atoms/variables' as *;

// ─── Begrüßung zwischen Header und Formular ───────────────────────────────────
.greeting {
max-width: $content-max-width;
width: 100%;
margin: 0 auto;
padding: $space-5 $space-6 0;

&__text {
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $color-text-dark;
}
}

+ 98
- 0
httpdocs/assets/styles/components/_login.scss 파일 보기

@@ -0,0 +1,98 @@
@use '../atoms/variables' as *;

// ─── Login Page ───────────────────────────────────────────────────────────────
.login-body {
min-height: 100vh;
background: $color-bg;
display: flex;
align-items: center;
justify-content: center;
}

// ─── Card ─────────────────────────────────────────────────────────────────────
.login-card {
background: $color-card-white;
border-radius: $radius-xl;
padding: $space-10 $space-12;
width: 100%;
max-width: 540px;
box-shadow: $shadow-card;
}

.login-card__title {
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $color-text-dark;
text-align: center;
margin-bottom: $space-8;
}

.login-card__error {
background: rgba(200, 50, 50, 0.08);
border: 1px solid rgba(200, 50, 50, 0.25);
border-radius: $radius-sm;
color: #c83232;
font-size: $font-size-sm;
padding: $space-3 $space-4;
margin-bottom: $space-6;
}

// ─── Form Grid ────────────────────────────────────────────────────────────────
.login-form__grid {
display: grid;
grid-template-columns: 90px 1fr;
gap: $space-5 $space-4;
align-items: center;
margin-bottom: $space-5;
}

.login-form__label {
font-size: $font-size-base;
color: $color-text-muted;
text-align: right;
padding-right: $space-2;
}

.login-form__field {
display: flex;
align-items: center;
gap: $space-3;
}

.login-form__field--password {
// Platz für "vergessen?" Link, falls später hinzukommt
}

// ─── "Angemeldet bleiben" ─────────────────────────────────────────────────────
.login-form__remember {
display: flex;
justify-content: center;
margin-bottom: $space-6;
}

.login-form__remember-label {
display: flex;
align-items: center;
gap: $space-2;
cursor: pointer;
font-size: $font-size-base;
color: $color-text-base;

input[type='checkbox'] {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: $color-primary;
}
}

// ─── Submit ───────────────────────────────────────────────────────────────────
.login-form__actions {
display: flex;
justify-content: center;
}

.login-form__submit {
padding: $space-3 $space-10;
font-size: $font-size-md;
}

+ 50
- 0
httpdocs/assets/styles/components/_main-nav.scss 파일 보기

@@ -0,0 +1,50 @@
@use '../atoms/variables' as *;

// ─── Dunkle Top-Navigation ────────────────────────────────────────────────────
.main-nav {
background: #1a2a3a;
display: flex;
align-items: stretch;
justify-content: space-between;
padding: 0 $space-4;
height: 44px;
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 200;
}

.main-nav__left,
.main-nav__right {
display: flex;
align-items: stretch;
gap: 0;
}

.main-nav__item {
display: inline-flex;
align-items: center;
padding: 0 $space-4;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: rgba(255, 255, 255, 0.65);
text-decoration: none;
border-bottom: 2px solid transparent;
transition: color $transition-fast, border-color $transition-fast;
white-space: nowrap;

&:hover {
color: $color-white;
}

&--active {
color: $color-white;
border-bottom-color: $color-primary-light;
}

&--disabled {
opacity: 0.35;
pointer-events: none;
cursor: default;
}
}

+ 137
- 0
httpdocs/assets/styles/components/_month-calendar.scss 파일 보기

@@ -0,0 +1,137 @@
@use '../atoms/variables' as *;

// ─── Monatskalender Container ─────────────────────────────────────────────────
.month-calendar {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 380px;
background: linear-gradient(160deg, $color-primary-light, $color-primary-dark);
border-radius: $radius-xl;
padding: $space-4;
box-shadow: 0 8px 32px rgba(0, 60, 120, 0.35);
z-index: 200;
transform-origin: top right;
transition:
opacity 0.28s ease,
transform 0.28s ease;

&--hidden {
opacity: 0;
transform: scaleY(0.92) translateY(-8px);
pointer-events: none;
}

&--visible {
opacity: 1;
transform: scaleY(1) translateY(0);
}
}

// ─── Header (Monat/Jahr + Navigation) ────────────────────────────────────────
.month-calendar__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: $space-4;
}

.month-calendar__title {
font-size: $font-size-md;
font-weight: $font-weight-bold;
color: $color-white;
letter-spacing: 0.01em;
}

.month-calendar__arrow {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: transparent;
border: none;
color: $color-white;
cursor: pointer;
transition: background $transition-fast;

&:hover { background: rgba(255, 255, 255, 0.2); }

svg { width: 8px; height: 14px; }
}

.month-calendar__close {
// erbt .week-nav__cal Styles – hier nur Positionierung
margin-left: 0;
}

// ─── Grid ─────────────────────────────────────────────────────────────────────
.month-calendar__grid {
&.slide-out-left { animation: slideOutLeft 0.16s ease forwards; }
&.slide-out-right { animation: slideOutRight 0.16s ease forwards; }
}

.month-calendar__weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: $space-2;

span {
text-align: center;
font-size: $font-size-xs;
font-weight: $font-weight-bold;
color: rgba(255, 255, 255, 0.6);
padding: $space-1 0;
text-transform: uppercase;
letter-spacing: 0.04em;
}
}

.month-calendar__days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}

// ─── Einzelner Tag ───────────────────────────────────────────────────────────
.month-day {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
border-radius: $radius-md;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $color-white;
cursor: pointer;
transition: background $transition-fast;
user-select: none;

&:hover:not(&--other) {
background: rgba(255, 255, 255, 0.2);
}

// Tage aus Vor-/Nachmonat
&--other {
color: rgba(255, 255, 255, 0.35);
cursor: default;
}

// Heutiger Tag
&--today {
font-weight: $font-weight-bold;
background: $color-white;
color: $color-text-dark;

&:hover {
background: rgba(255, 255, 255, 0.9);
}
}

// Ausgewählter Tag
&--active:not(&--today) {
background: rgba(255, 255, 255, 0.25);
font-weight: $font-weight-bold;
}
}

+ 193
- 0
httpdocs/assets/styles/components/_register.scss 파일 보기

@@ -0,0 +1,193 @@
@use '../atoms/variables' as *;

.register-body {
min-height: 100vh;
background: $color-bg;
display: flex;
align-items: flex-start;
justify-content: center;
padding: $space-10 $space-4;
}

// ─── Wrapper ──────────────────────────────────────────────────────────────────
.register-page {
width: 100%;
max-width: 580px;
}

.register-card {
background: $color-card-white;
border-radius: $radius-xl;
padding: $space-10 $space-12;
box-shadow: $shadow-card;
}

.register-card__brand {
text-align: center;
margin-bottom: $space-6;

a {
font-size: $font-size-base;
font-weight: $font-weight-bold;
color: $color-primary;
text-decoration: none;
letter-spacing: 0.02em;
}
}

.register-card__title {
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $color-text-dark;
text-align: center;
margin-bottom: $space-2;
}

.register-card__sub {
font-size: $font-size-sm;
color: $color-text-muted;
text-align: center;
margin-bottom: $space-8;
}

// ─── Errors ───────────────────────────────────────────────────────────────────
.register-errors {
&:not(:empty) {
background: rgba(200, 50, 50, 0.07);
border: 1px solid rgba(200, 50, 50, 0.25);
border-radius: $radius-sm;
color: #c83232;
font-size: $font-size-sm;
padding: $space-3 $space-4;
margin-bottom: $space-6;

p { margin: 0; line-height: 1.6; }
p + p { margin-top: $space-1; }
}
}

// ─── Fieldsets ────────────────────────────────────────────────────────────────
.register-fieldset {
border: none;
padding: 0;
margin: 0 0 $space-8;
}

.register-fieldset__legend {
font-size: $font-size-sm;
font-weight: $font-weight-bold;
color: $color-text-muted;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: $space-5;
display: block;
}

// ─── Einzelnes Feld ───────────────────────────────────────────────────────────
.register-field {
margin-bottom: $space-5;

&:last-child { margin-bottom: 0; }
}

.register-field__label {
display: block;
font-size: $font-size-sm;
color: $color-text-muted;
margin-bottom: $space-2;
}

.register-field__hint {
margin-top: $space-2;
font-size: $font-size-sm;
color: $color-text-muted;
}

.register-field__slug {
color: $color-primary;
font-family: monospace;
font-size: $font-size-sm;
}

// ─── Zweispaltig ──────────────────────────────────────────────────────────────
.register-field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $space-4;
margin-bottom: $space-5;
}

// ─── Actions ──────────────────────────────────────────────────────────────────
.register-actions {
margin-top: $space-8;
}

.register-actions__submit {
width: 100%;
padding: $space-4;
font-size: $font-size-md;

&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}

.register-actions__login {
text-align: center;
margin-top: $space-4;
font-size: $font-size-sm;
color: $color-text-muted;

a {
color: $color-primary;
text-decoration: none;

&:hover { text-decoration: underline; }
}
}

// ─── Erfolgs-State ────────────────────────────────────────────────────────────
.register-success {
text-align: center;
padding: $space-6 0;
}

.register-success__icon {
width: 56px;
height: 56px;
border-radius: 50%;
background: #e6f5ee;
color: #2d9e60;
font-size: 1.6rem;
font-weight: $font-weight-bold;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto $space-6;

&--error {
background: rgba(200, 50, 50, 0.08);
color: #c83232;
}
}

.register-success__title {
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $color-text-dark;
margin-bottom: $space-4;
}

.register-success__text {
font-size: $font-size-md;
color: $color-text-base;
line-height: $line-height-base;
margin-bottom: $space-3;
}

.register-success__hint {
font-size: $font-size-sm;
color: $color-text-muted;
line-height: $line-height-base;
}

+ 164
- 0
httpdocs/assets/styles/components/_team.scss 파일 보기

@@ -0,0 +1,164 @@
@use '../atoms/variables' as *;

// ─── Ausstehend-Badge ──────────────────────────────────────────────────────────
.team-badge {
display: inline-block;
padding: 1px 8px;
border-radius: $radius-pill;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
margin-left: $space-2;

&--pending {
background: rgba(232, 130, 10, 0.12);
color: #b86200;
}
}

// ─── Modal ─────────────────────────────────────────────────────────────────────
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;

&[hidden] { display: none !important; }
}

.modal-card {
background: $color-card-white;
border-radius: $radius-lg;
box-shadow: $shadow-card;
width: 100%;
max-width: 460px;
padding: 0;
overflow: hidden;
}

.modal-card__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $space-5 $space-6;
border-bottom: 1px solid rgba($color-border, 0.6);
}

.modal-card__title {
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $color-text-dark;
}

.modal-card__close {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: none;
cursor: pointer;
color: $color-text-muted;
border-radius: 50%;
transition: background $transition-fast;
svg { width: 16px; height: 16px; }
&:hover { background: rgba($color-border, 0.5); }
}

.modal-card__body {
padding: $space-5 $space-6;
display: flex;
flex-direction: column;
gap: $space-4;
}

.modal-card__footer {
display: flex;
justify-content: flex-end;
gap: $space-3;
padding: $space-4 $space-6;
border-top: 1px solid rgba($color-border, 0.6);
}

// ─── Formularfelder im Modal ───────────────────────────────────────────────────
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $space-4;
}

.form-field {
display: flex;
flex-direction: column;
gap: $space-1;
}

.form-field__label {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $color-text-dark;
}

.form-errors {
margin: 0 $space-6;
padding: $space-3 $space-4;
background: rgba(200, 50, 50, 0.08);
border-radius: $radius-md;
border: 1px solid rgba(200, 50, 50, 0.2);
color: #c83232;
font-size: $font-size-sm;

&[hidden] { display: none !important; }

ul { margin: 0; padding-left: 1.2em; }
}

// ─── Empty State ───────────────────────────────────────────────────────────────
.crud-list__empty {
padding: $space-6;
text-align: center;
color: $color-text-muted;
font-size: $font-size-sm;
}

// ─── Rollen-Selector (Inline-Edit + Modal) ────────────────────────────────────
.team-role-selector {
display: flex;
flex-direction: row;
gap: $space-5;
flex-wrap: wrap;

&--disabled {
opacity: 0.45;
pointer-events: none;
}
}

.team-role-option {
display: flex;
align-items: center;
gap: $space-2;
cursor: pointer;

input[type='radio'] {
accent-color: $color-primary;
width: 15px;
height: 15px;
flex-shrink: 0;
cursor: pointer;
}

&__label {
font-size: $font-size-sm;
color: $color-text-dark;
}
}

.team-role-hint {
margin-top: $space-1;
font-size: $font-size-xs;
color: $color-text-muted;
}

+ 122
- 0
httpdocs/assets/styles/components/_week-nav.scss 파일 보기

@@ -0,0 +1,122 @@
@use '../atoms/variables' as *;

// ─── Wrapper ─────────────────────────────────────────────────────────────────
.week-nav {
display: flex;
align-items: center;
gap: $space-1;
background: rgba(255, 255, 255, 0.18);
border-radius: $radius-pill;
padding: $space-1 $space-2;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}

// ─── Pfeile ──────────────────────────────────────────────────────────────────
.week-nav__arrow {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: transparent;
border: none;
color: $color-white;
cursor: pointer;
text-decoration: none;
flex-shrink: 0;
transition: background $transition-fast;

&:hover { background: rgba(255, 255, 255, 0.2); }

svg { width: 8px; height: 14px; }
}

// ─── Tage-Container (Slide-Animation) ────────────────────────────────────────
.week-nav__days {
display: flex;
align-items: center;
gap: $space-1;
overflow: hidden;
position: relative;

&.slide-out-left { animation: slideOutLeft 0.18s ease forwards; }
&.slide-out-right { animation: slideOutRight 0.18s ease forwards; }
&.slide-in-right { animation: slideInRight 0.18s ease forwards; }
&.slide-in-left { animation: slideInLeft 0.18s ease forwards; }
}

@keyframes slideOutLeft { to { opacity: 0; transform: translateX(-24px); } }
@keyframes slideOutRight { to { opacity: 0; transform: translateX(24px); } }
@keyframes slideInRight { from { opacity: 0; transform: translateX(24px); } to { opacity: 1; transform: translateX(0); } }
@keyframes slideInLeft { from { opacity: 0; transform: translateX(-24px); } to { opacity: 1; transform: translateX(0); } }

// ─── Einzelner Tag ───────────────────────────────────────────────────────────
.week-nav__day {
display: flex;
flex-direction: column;
align-items: center;
padding: $space-2 $space-3;
border-radius: $radius-md;
cursor: pointer;
text-decoration: none;
width: 64px;
flex-shrink: 0;
flex-grow: 0;
transition: background $transition-fast;
user-select: none;

&:hover:not(.week-nav__day--active) {
background: rgba(255, 255, 255, 0.15);
}

&--active {
background: $color-white;

.week-nav__day-name,
.week-nav__day-date { color: $color-text-dark; }
}

&--today:not(&--active) {
.week-nav__day-name {
text-decoration: underline;
text-underline-offset: 3px;
}
}
}

.week-nav__day-name {
font-size: $font-size-sm;
font-weight: $font-weight-bold;
color: $color-white;
line-height: 1.2;
}

.week-nav__day-date {
font-size: $font-size-xs;
color: rgba(255, 255, 255, 0.78);
line-height: 1.3;
}

// ─── Kalender-Icon ───────────────────────────────────────────────────────────
.week-nav__cal {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: $radius-md;
background: rgba(255, 255, 255, 0.2);
color: $color-white;
cursor: pointer;
border: none;
margin-left: $space-1;
flex-shrink: 0;
transition: background $transition-fast;

svg { width: 16px; height: 16px; pointer-events: none; }

&:hover,
&--active { background: rgba(255, 255, 255, 0.35); }
}

+ 42
- 0
httpdocs/assets/styles/main.scss 파일 보기

@@ -0,0 +1,42 @@
// ─── Atoms ────────────────────────────────────────────────────────────────────
@use 'atoms/variables' as *;
@use 'atoms/typography';
@use 'atoms/buttons';
@use 'atoms/inputs';

// ─── Components ───────────────────────────────────────────────────────────────
@use 'components/week-nav';
@use 'components/entry-form';
@use 'components/entry-list';
@use 'components/month-calendar';
@use 'components/duration-help';
@use 'components/main-nav';
@use 'components/greeting';
@use 'components/crud';
@use 'components/login';
@use 'components/register';
@use 'components/account';
@use 'components/team';

// ─── Sections ─────────────────────────────────────────────────────────────────
@use 'sections/timetracking';
@use 'sections/home';

// ─── Reset / Base ─────────────────────────────────────────────────────────────
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}

html {
font-size: 16px;
}

body {
background: $color-bg;
}

@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap');

+ 67
- 0
httpdocs/assets/styles/sections/_home.scss 파일 보기

@@ -0,0 +1,67 @@
@use '../atoms/variables' as *;

.home-body {
min-height: 100vh;
background: $color-bg;
display: flex;
flex-direction: column;
}

// ─── Header ──────────────────────────────────────────────────────────────────
.home-header {
background: linear-gradient(135deg, $color-header-from, $color-header-to);
padding: $space-4 $space-8;
}

.home-header__inner {
max-width: 1000px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
}

.home-header__brand {
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $color-white;

span {
font-weight: $font-weight-regular;
opacity: 0.8;
}
}

// ─── Hero ─────────────────────────────────────────────────────────────────────
.home-hero {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: $space-12 $space-8;
}

.home-hero__inner {
text-align: center;
max-width: 600px;
}

.home-hero__title {
font-size: 2.8rem;
font-weight: $font-weight-bold;
color: $color-text-dark;
line-height: 1.2;
margin-bottom: $space-6;
}

.home-hero__sub {
font-size: $font-size-lg;
color: $color-text-muted;
margin-bottom: $space-10;
line-height: $line-height-base;
}

.home-hero__cta {
font-size: $font-size-md;
padding: $space-4 $space-10;
}

+ 54
- 0
httpdocs/assets/styles/sections/_timetracking.scss 파일 보기

@@ -0,0 +1,54 @@
@use '../atoms/variables' as *;

// ─── Page Wrapper ─────────────────────────────────────────────────────────────
.tt-page {
min-height: 100vh;
background: $color-bg;
display: flex;
flex-direction: column;
}

// ─── Header Section ──────────────────────────────────────────────────────────
.tt-header {
background: linear-gradient(135deg, $color-header-from 0%, $color-header-to 100%);
padding: $space-4 $space-6;
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-6;
min-height: $header-height;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 2px 16px rgba(0, 50, 120, 0.2);
}

.tt-header__meta {
flex-shrink: 0;
}

.tt-header__date {
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $color-white;
line-height: 1.2;
}

.tt-header__kw {
font-size: $font-size-sm;
font-weight: $font-weight-bold;
color: rgba(255, 255, 255, 0.75);
line-height: 1.3;
}

// ─── Main Content ─────────────────────────────────────────────────────────────
.tt-content {
flex: 1;
max-width: $content-max-width;
width: 100%;
margin: 0 auto;
padding: $space-6 $space-6;
display: flex;
flex-direction: column;
gap: $space-4;
}

+ 21
- 0
httpdocs/bin/console 파일 보기

@@ -0,0 +1,21 @@
#!/usr/bin/env php
<?php

use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;

if (!is_dir(dirname(__DIR__).'/vendor')) {
throw new LogicException('Dependencies are missing. Try running "composer install".');
}

if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
}

require_once dirname(__DIR__).'/vendor/autoload_runtime.php';

return function (array $context) {
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);

return new Application($kernel);
};

+ 84
- 0
httpdocs/composer.json 파일 보기

@@ -0,0 +1,84 @@
{
"name": "symfony/skeleton",
"type": "project",
"license": "MIT",
"description": "A minimal Symfony project recommended to create bare bones applications",
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.2",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/doctrine-bundle": "^3.2.2",
"doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6.6",
"symfony/console": "7.4.*",
"symfony/dotenv": "7.4.*",
"symfony/flex": "^2.10",
"symfony/form": "7.4.*",
"symfony/framework-bundle": "7.4.*",
"symfony/mailer": "7.4.*",
"symfony/monolog-bundle": "^4.0.2",
"symfony/runtime": "7.4.*",
"symfony/security-bundle": "7.4.*",
"symfony/translation": "7.4.*",
"symfony/twig-bundle": "7.4.*",
"symfony/validator": "7.4.*",
"symfony/webpack-encore-bundle": "^2.4",
"symfony/yaml": "7.4.*"
},
"config": {
"allow-plugins": {
"php-http/discovery": true,
"symfony/flex": true,
"symfony/runtime": true
},
"bump-after-update": true,
"sort-packages": true
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
"symfony/polyfill-php72": "*",
"symfony/polyfill-php73": "*",
"symfony/polyfill-php74": "*",
"symfony/polyfill-php80": "*",
"symfony/polyfill-php81": "*",
"symfony/polyfill-php82": "*"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
],
"post-update-cmd": [
"@auto-scripts"
]
},
"conflict": {
"symfony/symfony": "*"
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.4.*",
"docker": false
}
},
"require-dev": {
"symfony/maker-bundle": "^1.67"
}
}

+ 6634
- 0
httpdocs/composer.lock
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
파일 보기


+ 12
- 0
httpdocs/config/bundles.php 파일 보기

@@ -0,0 +1,12 @@
<?php

return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
];

+ 19
- 0
httpdocs/config/packages/cache.yaml 파일 보기

@@ -0,0 +1,19 @@
framework:
cache:
# Unique name of your app: used to compute stable namespaces for cache keys.
#prefix_seed: your_vendor_name/app_name

# The "app" cache stores to the filesystem by default.
# The data in this cache should persist between deploys.
# Other options include:

# Redis
#app: cache.adapter.redis
#default_redis_provider: redis://localhost

# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu

# Namespaced pools use the above "app" backend by default
#pools:
#my.dedicated.cache: null

+ 11
- 0
httpdocs/config/packages/csrf.yaml 파일 보기

@@ -0,0 +1,11 @@
# Enable stateless CSRF protection for forms and logins/logouts
framework:
form:
csrf_protection:
token_id: submit

csrf_protection:
stateless_token_ids:
- submit
- authenticate
- logout

+ 76
- 0
httpdocs/config/packages/doctrine.yaml 파일 보기

@@ -0,0 +1,76 @@
doctrine:
dbal:
default_connection: central
connections:
central:
url: '%env(resolve:DATABASE_URL)%'
profiling_collect_backtrace: '%kernel.debug%'
tenant:
# Basis-URL zeigt auf central – TenantConnectionMiddleware
# ersetzt dbname zur Laufzeit mit 'db_{slug}'
url: '%env(resolve:DATABASE_URL)%'
profiling_collect_backtrace: '%kernel.debug%'
# middlewares:
# - App\Doctrine\TenantConnectionMiddleware

orm:
default_entity_manager: central
entity_managers:
central:
connection: central
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
mappings:
Central:
is_bundle: false
type: attribute
dir: '%kernel.project_dir%/src/Entity/Central'
prefix: 'App\Entity\Central'
alias: Central
tenant:
connection: tenant
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
mappings:
Tenant:
is_bundle: false
type: attribute
dir: '%kernel.project_dir%/src/Entity/Tenant'
prefix: 'App\Entity\Tenant'
alias: Tenant

when@test:
doctrine:
dbal:
connections:
central:
url: '%env(resolve:DATABASE_URL)%'
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
tenant:
url: '%env(resolve:DATABASE_URL)%'
dbname_suffix: '_test%env(default::TEST_TOKEN)%'

when@prod:
doctrine:
orm:
entity_managers:
central:
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool
tenant:
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool

framework:
cache:
pools:
doctrine.result_cache_pool:
adapter: cache.app
doctrine.system_cache_pool:
adapter: cache.system

+ 5
- 0
httpdocs/config/packages/doctrine_migrations.yaml 파일 보기

@@ -0,0 +1,5 @@
doctrine_migrations:
migrations_paths:
'DoctrineMigrations': '%kernel.project_dir%/migrations/central'
# 'DoctrineMigrationsTenant': '%kernel.project_dir%/migrations/tenant'
enable_profiler: false

+ 19
- 0
httpdocs/config/packages/framework.yaml 파일 보기

@@ -0,0 +1,19 @@
# see https://symfony.com/doc/current/reference/configuration/framework.html
framework:
secret: '%env(APP_SECRET)%'

# Note that the session will be started ONLY if you read or write from it.
session:
gc_maxlifetime: 14400 # 4 Stunden – serverseitige Sliding-Session
cookie_lifetime: 14400 # Cookie-Expiry beim ersten Setzen; Subscriber verlängert bei jedem Request
cookie_secure: auto
cookie_samesite: lax

#esi: true
#fragments: true

when@test:
framework:
test: true
session:
storage_factory_id: session.storage.factory.mock_file

+ 5
- 0
httpdocs/config/packages/mailer.yaml 파일 보기

@@ -0,0 +1,5 @@
framework:
mailer:
dsn: '%env(MAILER_DSN)%'
headers:
from: 'spawntree Timetracker <noreply@spawntree.de>'

+ 20
- 0
httpdocs/config/packages/monolog.yaml 파일 보기

@@ -0,0 +1,20 @@
# config/packages/monolog.yaml

when@dev:
monolog:
handlers:
main:
type: stream
path: '%kernel.logs_dir%/%kernel.environment%.log'
level: warning
channels: ['!event']

when@prod:
monolog:
handlers:
main:
type: rotating_file
path: '%kernel.logs_dir%/%kernel.environment%.log'
level: warning
max_files: 10
channels: ['!event']

+ 3
- 0
httpdocs/config/packages/property_info.yaml 파일 보기

@@ -0,0 +1,3 @@
framework:
property_info:
with_constructor_extractor: true

+ 10
- 0
httpdocs/config/packages/routing.yaml 파일 보기

@@ -0,0 +1,10 @@
framework:
router:
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
default_uri: '%env(DEFAULT_URI)%'

when@prod:
framework:
router:
strict_requirements: null

+ 65
- 0
httpdocs/config/packages/security.yaml 파일 보기

@@ -0,0 +1,65 @@
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
App\Entity\Central\User:
algorithm: auto

# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
app_user_provider:
entity:
class: App\Entity\Central\User
property: email
# users_in_memory: { memory: null }

firewalls:
dev:
# Ensure dev tools and static assets are always allowed
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_user_provider
access_denied_handler: App\Security\AccessDeniedHandler
form_login:
login_path: app_login
check_path: app_login
default_target_path: /week
username_parameter: email
password_parameter: password
enable_csrf: true
logout:
path: app_logout
target: app_login
remember_me:
secret: '%kernel.secret%'
lifetime: 2592000 # 30 Tage
path: /
name: REMEMBERME

# Activate different ways to authenticate:
# https://symfony.com/doc/current/security.html#the-firewall

# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true

# Note: Only the *first* matching rule is applied
access_control:
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/register, roles: PUBLIC_ACCESS }
- { path: ^/api/register, roles: PUBLIC_ACCESS }
- { path: ^/verify/, roles: PUBLIC_ACCESS }
- { path: ^/invite/, roles: PUBLIC_ACCESS }
- { path: ^/$, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER }

when@test:
security:
password_hashers:
# Password hashers are resource-intensive by design to ensure security.
# In tests, it's safe to reduce their cost to improve performance.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon

+ 7
- 0
httpdocs/config/packages/translation.yaml 파일 보기

@@ -0,0 +1,7 @@
framework:
default_locale: de
translator:
default_path: '%kernel.project_dir%/translations'
fallbacks:
- de
providers:

+ 6
- 0
httpdocs/config/packages/twig.yaml 파일 보기

@@ -0,0 +1,6 @@
twig:
file_name_pattern: '*.twig'

when@test:
twig:
strict_variables: true

+ 11
- 0
httpdocs/config/packages/validator.yaml 파일 보기

@@ -0,0 +1,11 @@
framework:
validation:
# Enables validator auto-mapping support.
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
#auto_mapping:
# App\Entity\: []

when@test:
framework:
validation:
not_compromised_password: false

+ 45
- 0
httpdocs/config/packages/webpack_encore.yaml 파일 보기

@@ -0,0 +1,45 @@
webpack_encore:
# The path where Encore is building the assets - i.e. Encore.setOutputPath()
output_path: '%kernel.project_dir%/public/build'
# If multiple builds are defined (as shown below), you can disable the default build:
# output_path: false

# Set attributes that will be rendered on all script and link tags
script_attributes:
defer: true
# Uncomment (also under link_attributes) if using Turbo Drive
# https://turbo.hotwired.dev/handbook/drive#reloading-when-assets-change
# 'data-turbo-track': reload
# link_attributes:
# Uncomment if using Turbo Drive
# 'data-turbo-track': reload

# If using Encore.enableIntegrityHashes() and need the crossorigin attribute (default: false, or use 'anonymous' or 'use-credentials')
# crossorigin: 'anonymous'

# Preload all rendered script and link tags automatically via the HTTP/2 Link header
# preload: true

# Throw an exception if the entrypoints.json file is missing or an entry is missing from the data
# strict_mode: false

# If you have multiple builds:
# builds:
# frontend: '%kernel.project_dir%/public/frontend/build'

# pass the build name as the 3rd argument to the Twig functions
# {{ encore_entry_script_tags('entry1', null, 'frontend') }}

framework:
assets:
json_manifest_path: '%kernel.project_dir%/public/build/manifest.json'

#when@prod:
# webpack_encore:
# # Cache the entrypoints.json (rebuild Symfony's cache when entrypoints.json changes)
# # Available in version 1.2
# cache: true

#when@test:
# webpack_encore:
# strict_mode: false

+ 5
- 0
httpdocs/config/preload.php 파일 보기

@@ -0,0 +1,5 @@
<?php

if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
}

+ 1550
- 0
httpdocs/config/reference.php
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
파일 보기


+ 11
- 0
httpdocs/config/routes.yaml 파일 보기

@@ -0,0 +1,11 @@
# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json

# This file is the entry point to configure the routes of your app.
# Methods with the #[Route] attribute are automatically imported.
# See also https://symfony.com/doc/current/routing.html

# To list all registered routes, run the following command:
# bin/console debug:router

controllers:
resource: routing.controllers

+ 4
- 0
httpdocs/config/routes/framework.yaml 파일 보기

@@ -0,0 +1,4 @@
when@dev:
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
prefix: /_error

+ 3
- 0
httpdocs/config/routes/security.yaml 파일 보기

@@ -0,0 +1,3 @@
_security_logout:
resource: security.route_loader.logout
type: service

+ 60
- 0
httpdocs/config/services.yaml 파일 보기

@@ -0,0 +1,60 @@
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json

parameters:
app.domain: '%env(APP_DOMAIN)%'

services:
_defaults:
autowire: true
autoconfigure: true

App\:
resource: '../src/'

# ── Tenant EM explizit binden ──────────────────────────────────────────────
# Symfony kann bei mehreren EMs nicht automatisch wissen welcher gemeint ist.
# Alle Services/Controller die Tenant-Entities anfassen, brauchen den tenant EM.

App\Controller\TimeTrackingController:
arguments:
$tenantEm: '@doctrine.orm.tenant_entity_manager'

App\Controller\ClientController:
arguments:
$em: '@doctrine.orm.tenant_entity_manager'

App\Controller\ProjectController:
arguments:
$em: '@doctrine.orm.tenant_entity_manager'

App\Controller\ServiceController:
arguments:
$em: '@doctrine.orm.tenant_entity_manager'

App\Command\SeedCommand:
arguments:
$centralEm: '@doctrine.orm.central_entity_manager'
$tenantEm: '@doctrine.orm.tenant_entity_manager'

# ── app.domain in Subscriber injizieren ───────────────────────────────────
App\EventSubscriber\TenantRequestSubscriber:
arguments:
$appDomain: '%app.domain%'

App\Controller\RegistrationController:
arguments:
$appDomain: '%app.domain%'

App\Service\RegistrationService:
arguments:
$centralEm: '@doctrine.orm.central_entity_manager'
$tenantEm: '@doctrine.orm.tenant_entity_manager'
$appDomain: '%app.domain%'
$notifyEmail: '%env(REGISTRATION_NOTIFY_EMAIL)%'

App\Controller\TeamController:
arguments:
$appDomain: '%app.domain%'

App\Controller\InviteController:
arguments: ~

+ 0
- 0
httpdocs/migrations/.gitignore 파일 보기


+ 0
- 0
httpdocs/migrations/central/.gitkeep 파일 보기


+ 39
- 0
httpdocs/migrations/central/Version20260523122322.php 파일 보기

@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260523122322 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}

public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE account (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, slug VARCHAR(63) NOT NULL, created_at DATETIME NOT NULL, UNIQUE INDEX UNIQ_7D3656A4989D9B62 (slug), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE account_user (id INT AUTO_INCREMENT NOT NULL, role VARCHAR(20) NOT NULL, account_id INT NOT NULL, user_id INT NOT NULL, INDEX IDX_10051E39B6B5FBA (account_id), INDEX IDX_10051E3A76ED395 (user_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE `user` (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(180) NOT NULL, first_name VARCHAR(100) NOT NULL, last_name VARCHAR(100) NOT NULL, password VARCHAR(255) DEFAULT NULL, note LONGTEXT DEFAULT NULL, UNIQUE INDEX UNIQ_8D93D649E7927C74 (email), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE account_user ADD CONSTRAINT FK_10051E39B6B5FBA FOREIGN KEY (account_id) REFERENCES account (id)');
$this->addSql('ALTER TABLE account_user ADD CONSTRAINT FK_10051E3A76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id)');
}

public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE account_user DROP FOREIGN KEY FK_10051E39B6B5FBA');
$this->addSql('ALTER TABLE account_user DROP FOREIGN KEY FK_10051E3A76ED395');
$this->addSql('DROP TABLE account');
$this->addSql('DROP TABLE account_user');
$this->addSql('DROP TABLE `user`');
}
}

+ 31
- 0
httpdocs/migrations/central/Version20260523190211.php 파일 보기

@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260523190211 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}

public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE registration_token (id INT AUTO_INCREMENT NOT NULL, token VARCHAR(64) NOT NULL, company_name VARCHAR(255) NOT NULL, slug VARCHAR(63) NOT NULL, email VARCHAR(180) NOT NULL, first_name VARCHAR(100) NOT NULL, last_name VARCHAR(100) NOT NULL, password_hash VARCHAR(255) NOT NULL, created_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, UNIQUE INDEX UNIQ_D09D01D35F37A13B (token), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
}

public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE registration_token');
}
}

+ 31
- 0
httpdocs/migrations/central/Version20260523203200.php 파일 보기

@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260523203200 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}

public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE account ADD tracking_interval SMALLINT DEFAULT 1 NOT NULL');
}

public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE account DROP tracking_interval');
}
}

+ 33
- 0
httpdocs/migrations/central/Version20260523212725.php 파일 보기

@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260523212725 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}

public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE invite_token (id INT AUTO_INCREMENT NOT NULL, token VARCHAR(64) NOT NULL, email VARCHAR(180) NOT NULL, first_name VARCHAR(100) NOT NULL, last_name VARCHAR(100) NOT NULL, role VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, account_id INT NOT NULL, UNIQUE INDEX UNIQ_5242FFC45F37A13B (token), INDEX IDX_5242FFC49B6B5FBA (account_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE invite_token ADD CONSTRAINT FK_5242FFC49B6B5FBA FOREIGN KEY (account_id) REFERENCES account (id)');
}

public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE invite_token DROP FOREIGN KEY FK_5242FFC49B6B5FBA');
$this->addSql('DROP TABLE invite_token');
}
}

+ 31
- 0
httpdocs/migrations/central/Version20260523221257.php 파일 보기

@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260523221257 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}

public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE account_user ADD archived_at DATETIME DEFAULT NULL');
}

public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE account_user DROP archived_at');
}
}

+ 0
- 0
httpdocs/migrations/tenant/.gitkeep 파일 보기


+ 5812
- 0
httpdocs/package-lock.json
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
파일 보기


+ 21
- 0
httpdocs/package.json 파일 보기

@@ -0,0 +1,21 @@
{
"devDependencies": {
"@babel/core": "^7.17.0",
"@babel/preset-env": "^7.16.0",
"@symfony/webpack-encore": "^6.0.0",
"core-js": "^3.38.0",
"regenerator-runtime": "^0.13.9",
"sass": "^1.99.0",
"sass-loader": "^16.0.8",
"webpack": "^5.72",
"webpack-cli": "^6.0.0"
},
"license": "UNLICENSED",
"private": true,
"scripts": {
"dev-server": "encore dev-server",
"dev": "encore dev",
"watch": "encore dev --watch",
"build": "encore production --progress"
}
}

+ 9
- 0
httpdocs/public/index.php 파일 보기

@@ -0,0 +1,9 @@
<?php

use App\Kernel;

require_once dirname(__DIR__).'/vendor/autoload_runtime.php';

return static function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

+ 198
- 0
httpdocs/src/Command/SeedCommand.php 파일 보기

@@ -0,0 +1,198 @@
<?php

namespace App\Command;

use App\Entity\Central\Account;
use App\Entity\Central\AccountUser;
use App\Entity\Central\User;
use App\Entity\Tenant\Client;
use App\Entity\Tenant\Project;
use App\Entity\Tenant\Service;
use App\Service\TenantContext;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Tools\SchemaTool;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

#[AsCommand(name: 'app:seed', description: 'Testdaten einspielen')]
class SeedCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $centralEm,
private readonly EntityManagerInterface $tenantEm,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly TenantContext $tenantContext,
) {
parent::__construct();
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);

// ── Central: User ─────────────────────────────────────────────────────
$user = new User();
$user->setEmail('f.eisenmenger@spawntree.de');
$user->setFirstName('Flo');
$user->setLastName('Eisenmenger');
$user->setPassword($this->passwordHasher->hashPassword($user, '12spawntree345'));
$this->centralEm->persist($user);

// ── Central: Account ──────────────────────────────────────────────────
$account = new Account();
$account->setName('spawntree GmbH');
$account->setSlug('spawntree');
$this->centralEm->persist($account);

// ── Central: AccountUser (Flo als Admin) ──────────────────────────────
$accountUser = new AccountUser();
$accountUser->setAccount($account);
$accountUser->setUser($user);
$accountUser->setRole(AccountUser::ROLE_ADMIN);
$this->centralEm->persist($accountUser);

$this->centralEm->flush();
$io->text('✓ Central-DB: User, Account, AccountUser angelegt');

// ── Tenant-Context setzen → Middleware switcht auf db_spawntree ───────
$this->tenantContext->setAccount($account);

// Bestehende Connection schließen, damit Middleware greift
$this->tenantEm->getConnection()->close();

// ── Tenant-Schema erstellen ───────────────────────────────────────────
$schemaTool = new SchemaTool($this->tenantEm);
$metadata = $this->tenantEm->getMetadataFactory()->getAllMetadata();
$schemaTool->createSchema($metadata);
$io->text('✓ Tenant-Schema erstellt');

// ── Tenant: Leistungen ────────────────────────────────────────────────
$serviceData = [
['name' => 'Frontend-Entwicklung', 'billable' => true],
['name' => 'Software-Entwicklung', 'billable' => true],
['name' => 'Meeting', 'billable' => true],
['name' => 'Design', 'billable' => true],
['name' => 'Intern', 'billable' => false],
];
foreach ($serviceData as $data) {
$service = new Service();
$service->setName($data['name']);
$service->setBillable($data['billable']);
$this->tenantEm->persist($service);
}

// ── Tenant: Kunden + Projekte ─────────────────────────────────────────
$clientsData = [
['name' => 'AKN GmbH', 'hourlyRate' => '95.00', 'projects' => ['Maintenance', 'Relaunch Website']],
['name' => 'Altoelankauf.de', 'hourlyRate' => '100.00', 'projects' => ['Shop', 'SEO-Optimierung', 'Wartungsvertrag']],
['name' => 'André Firmenich', 'hourlyRate' => '110.00', 'projects' => ['German Health Tech', 'Webumed']],
['name' => 'Angelika Ballosch', 'hourlyRate' => '85.00', 'projects' => ['Maintenance']],
['name' => 'Annika Teerling', 'hourlyRate' => '90.00', 'projects' => ['Website', 'Fotografie-Portfolio']],
['name' => 'Bauunternehmen Krause', 'hourlyRate' => '100.00', 'projects' => ['Firmenwebsite', 'Stellenbörse']],
['name' => 'Digitalagentur Nord', 'hourlyRate' => '120.00', 'projects' => ['Whitelabel CMS', 'API-Integration', 'Support']],
['name' => 'Eventhaus Hamburg', 'hourlyRate' => '95.00', 'projects' => ['Ticketsystem', 'Landingpages']],
['name' => 'Kanzlei Meier & Partner', 'hourlyRate' => '100.00', 'projects' => ['Kanzleiwebsite']],
['name' => 'Spawntree (intern)', 'hourlyRate' => null, 'projects' => ['Futbase', 'Timetracker', 'Akquise']],
];
foreach ($clientsData as $data) {
$client = new Client();
$client->setName($data['name']);
$client->setHourlyRate($data['hourlyRate']);
$this->tenantEm->persist($client);

foreach ($data['projects'] as $projectName) {
$project = new Project();
$project->setName($projectName);
$project->setClient($client);
$this->tenantEm->persist($project);
}
}

$this->tenantEm->flush();
$io->text('✓ Tenant-DB: Leistungen, Kunden, Projekte angelegt');

// ══ Zweiter Tenant: Nova-Sign ══════════════════════════════════════════

// ── Central: User ─────────────────────────────────────────────────────
$user2 = new User();
$user2->setEmail('dirktietze@nova-sign.de');
$user2->setFirstName('Dirk');
$user2->setLastName('Tietze');
$user2->setPassword($this->passwordHasher->hashPassword($user2, '12spawntree345'));
$this->centralEm->persist($user2);

// ── Central: Account ──────────────────────────────────────────────────
$account2 = new Account();
$account2->setName('Nova-Sign');
$account2->setSlug('nova-sign');
$this->centralEm->persist($account2);

// ── Central: AccountUser ──────────────────────────────────────────────
$accountUser2 = new AccountUser();
$accountUser2->setAccount($account2);
$accountUser2->setUser($user2);
$accountUser2->setRole(AccountUser::ROLE_ADMIN);
$this->centralEm->persist($accountUser2);

$this->centralEm->flush();
$io->text('✓ Central-DB: User, Account, AccountUser (Nova-Sign) angelegt');

// ── Tenant-Context umschalten auf db_nova_sign ────────────────────────
$this->tenantContext->setAccount($account2);
$this->tenantEm->clear();
$this->tenantEm->getConnection()->close();

// ── Tenant-Schema erstellen ───────────────────────────────────────────
$schemaTool2 = new SchemaTool($this->tenantEm);
$metadata2 = $this->tenantEm->getMetadataFactory()->getAllMetadata();
$schemaTool2->createSchema($metadata2);
$io->text('✓ Tenant-Schema (Nova-Sign) erstellt');

// ── Tenant: Leistungen ────────────────────────────────────────────────
$serviceData2 = [
['name' => 'Beratung', 'billable' => true],
['name' => 'Projektmanagement', 'billable' => true],
['name' => 'Design', 'billable' => true],
['name' => 'Produktion', 'billable' => true],
['name' => 'Intern', 'billable' => false],
];
foreach ($serviceData2 as $data) {
$service = new Service();
$service->setName($data['name']);
$service->setBillable($data['billable']);
$this->tenantEm->persist($service);
}

// ── Tenant: Kunden + Projekte ─────────────────────────────────────────
$clientsData2 = [
['name' => 'Messe Stuttgart', 'hourlyRate' => '110.00', 'projects' => ['Messestand 2025', 'Leitsystem']],
['name' => 'Autohaus Brenner', 'hourlyRate' => '95.00', 'projects' => ['Showroom-Beschriftung']],
['name' => 'Hotel Kronsberg', 'hourlyRate' => '100.00', 'projects' => ['Wegeleitsystem', 'Zimmerbeschilderung']],
['name' => 'Klinik am See', 'hourlyRate' => '105.00', 'projects' => ['Orientierungssystem']],
['name' => 'Nova-Sign (intern)', 'hourlyRate' => null, 'projects' => ['Website', 'Buchhaltung']],
];
foreach ($clientsData2 as $data) {
$client = new Client();
$client->setName($data['name']);
$client->setHourlyRate($data['hourlyRate']);
$this->tenantEm->persist($client);

foreach ($data['projects'] as $projectName) {
$project = new Project();
$project->setName($projectName);
$project->setClient($client);
$this->tenantEm->persist($project);
}
}

$this->tenantEm->flush();
$io->text('✓ Tenant-DB Nova-Sign: Leistungen, Kunden, Projekte angelegt');

$io->success('Seeding abgeschlossen. Login: f.eisenmenger@spawntree.de / 12spawntree345');
return Command::SUCCESS;
}
}

+ 0
- 0
httpdocs/src/Controller/.gitignore 파일 보기


+ 125
- 0
httpdocs/src/Controller/AccountController.php 파일 보기

@@ -0,0 +1,125 @@
<?php

namespace App\Controller;

use App\Entity\Central\User;
use App\Repository\Central\AccountUserRepository;
use App\Repository\Central\UserRepository;
use App\Service\TenantContext;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;

class AccountController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly TenantContext $tenantContext,
private readonly AccountUserRepository $accountUserRepo,
private readonly UserRepository $userRepo,
private readonly UserPasswordHasherInterface $passwordHasher,
) {}

#[Route('/account', name: 'account_index')]
public function index(Request $request): Response
{
$account = $this->tenantContext->getAccount();
$user = $this->getUser();
$accountUser = $this->accountUserRepo->findOneBy(['account' => $account, 'user' => $user]);
$isAdmin = $accountUser?->isAdmin() ?? false;

$tab = $request->query->get('tab', $isAdmin ? 'account' : 'user');
if (!$isAdmin && $tab === 'account') {
$tab = 'user';
}

return $this->render('account/index.html.twig', [
'account' => $account,
'user' => $user,
'isAdmin' => $isAdmin,
'tab' => $tab,
'intervalOptions' => [
1 => 'Minuten',
15 => 'Viertelstunde',
30 => 'Halbe Stunde',
60 => 'Stunde',
],
]);
}

#[Route('/api/account', name: 'api_account_update', methods: ['PATCH'])]
public function updateAccount(Request $request): JsonResponse
{
$account = $this->tenantContext->getAccount();
$user = $this->getUser();
$accountUser = $this->accountUserRepo->findOneBy(['account' => $account, 'user' => $user]);

if (!$accountUser?->isAdmin()) {
return $this->json(['error' => 'Zugriff verweigert'], 403);
}

$data = json_decode($request->getContent(), true) ?? [];

if (!empty($data['name'])) {
$account->setName(trim($data['name']));
}

if (isset($data['trackingInterval'])) {
$interval = (int) $data['trackingInterval'];
if (in_array($interval, [1, 15, 30, 60], true)) {
$account->setTrackingInterval($interval);
}
}

$this->em->flush();

return $this->json(['ok' => true, 'name' => $account->getName()]);
}

#[Route('/api/account/user', name: 'api_account_user_update', methods: ['PATCH'])]
public function updateUser(Request $request): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$data = json_decode($request->getContent(), true) ?? [];

if (!empty($data['firstName'])) {
$user->setFirstName(trim($data['firstName']));
}
if (!empty($data['lastName'])) {
$user->setLastName(trim($data['lastName']));
}

if (!empty($data['email'])) {
$newEmail = trim($data['email']);
if ($newEmail !== $user->getEmail()) {
$existing = $this->userRepo->findOneBy(['email' => $newEmail]);
if ($existing !== null && $existing->getId() !== $user->getId()) {
return $this->json(['error' => 'Diese E-Mail-Adresse wird bereits verwendet.'], 409);
}
$user->setEmail($newEmail);
}
}

if (!empty($data['newPassword'])) {
if (empty($data['currentPassword'])) {
return $this->json(['error' => 'Aktuelles Passwort ist erforderlich.'], 400);
}
if (!$this->passwordHasher->isPasswordValid($user, $data['currentPassword'])) {
return $this->json(['error' => 'Das aktuelle Passwort ist falsch.'], 400);
}
if (strlen($data['newPassword']) < 8) {
return $this->json(['error' => 'Das neue Passwort muss mindestens 8 Zeichen haben.'], 400);
}
$user->setPassword($this->passwordHasher->hashPassword($user, $data['newPassword']));
}

$this->em->flush();

return $this->json(['ok' => true]);
}
}

+ 143
- 0
httpdocs/src/Controller/ClientController.php 파일 보기

@@ -0,0 +1,143 @@
<?php

namespace App\Controller;

use App\Entity\Tenant\Client;
use App\Repository\Tenant\ClientRepository;
use App\Repository\Tenant\TimeEntryRepository;
use App\Service\AccountRoleHelper;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class ClientController extends AbstractController
{
public function __construct(
private EntityManagerInterface $em,
private ClientRepository $clientRepo,
private TimeEntryRepository $timeEntryRepo,
private readonly AccountRoleHelper $roleHelper,
) {}

#[Route('/clients', name: 'client_index')]
public function index(): Response
{
if ($this->roleHelper->isTracker()) {
throw $this->createAccessDeniedException();
}
return $this->render('client/index.html.twig', [
'clients' => $this->clientRepo->findAllOrderedByName(),
]);
}

#[Route('/api/clients', name: 'api_client_create', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
if ($this->roleHelper->isTracker()) {
return $this->json(['error' => 'Zugriff verweigert'], 403);
}
$data = json_decode($request->getContent(), true);

if (empty($data['name'])) {
return $this->json(['error' => 'Name ist erforderlich'], 400);
}

$client = new Client();
$client->setName(trim($data['name']));
$client->setHourlyRate(!empty($data['hourlyRate']) ? $data['hourlyRate'] : null);
$client->setNote(!empty($data['note']) ? $data['note'] : null);

$this->em->persist($client);
$this->em->flush();

return $this->json($this->clientToArray($client), 201);
}

#[Route('/api/clients/{id}', name: 'api_client_update', methods: ['PATCH'])]
public function update(int $id, Request $request): JsonResponse
{
if ($this->roleHelper->isTracker()) {
return $this->json(['error' => 'Zugriff verweigert'], 403);
}
$client = $this->clientRepo->find($id);
if (!$client) return $this->json(['error' => 'Nicht gefunden'], 404);

$data = json_decode($request->getContent(), true);

if (empty($data['name'])) {
return $this->json(['error' => 'Name ist erforderlich'], 400);
}

$client->setName(trim($data['name']));
$client->setHourlyRate(!empty($data['hourlyRate']) ? $data['hourlyRate'] : null);
$client->setNote(!empty($data['note']) ? $data['note'] : null);

$this->em->flush();

return $this->json($this->clientToArray($client));
}

#[Route('/api/clients/{id}', name: 'api_client_delete', methods: ['DELETE'])]
public function delete(int $id): JsonResponse
{
if ($this->roleHelper->isTracker()) {
return $this->json(['error' => 'Zugriff verweigert'], 403);
}
$client = $this->clientRepo->find($id);
if (!$client) return $this->json(['error' => 'Nicht gefunden'], 404);

if ($this->timeEntryRepo->countByClient($client) > 0) {
return $this->json(['error' => 'has_dependencies', 'canArchive' => true], 409);
}

$this->em->remove($client);
$this->em->flush();

return $this->json(['success' => true]);
}

#[Route('/api/clients/{id}/archive', name: 'api_client_archive', methods: ['PATCH'])]
public function archive(int $id): JsonResponse
{
if ($this->roleHelper->isTracker()) {
return $this->json(['error' => 'Zugriff verweigert'], 403);
}
$client = $this->clientRepo->find($id);
if (!$client) return $this->json(['error' => 'Nicht gefunden'], 404);

$client->setArchivedAt(new \DateTimeImmutable());
$this->em->flush();

return $this->json($this->clientToArray($client));
}

#[Route('/api/clients/{id}/unarchive', name: 'api_client_unarchive', methods: ['PATCH'])]
public function unarchive(int $id): JsonResponse
{
if ($this->roleHelper->isTracker()) {
return $this->json(['error' => 'Zugriff verweigert'], 403);
}
$client = $this->clientRepo->find($id);
if (!$client) return $this->json(['error' => 'Nicht gefunden'], 404);

$client->setArchivedAt(null);
$this->em->flush();

return $this->json($this->clientToArray($client));
}

private function clientToArray(Client $client): array
{
return [
'id' => $client->getId(),
'name' => $client->getName(),
'hourlyRate' => $client->getHourlyRate(),
'note' => $client->getNote(),
'projectCount' => $client->getProjects()->count(),
'archived' => $client->isArchived(),
];
}
}

+ 26
- 0
httpdocs/src/Controller/HomeController.php 파일 보기

@@ -0,0 +1,26 @@
<?php

namespace App\Controller;

use App\Service\TenantContext;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class HomeController extends AbstractController
{
public function __construct(private readonly TenantContext $tenantContext) {}

#[Route('/', name: 'app_home')]
public function index(): Response
{
if ($this->tenantContext->hasAccount()) {
if ($this->getUser()) {
return $this->redirectToRoute('timetracking_week');
}
return $this->redirectToRoute('app_login');
}

return $this->render('home/index.html.twig');
}
}

+ 101
- 0
httpdocs/src/Controller/InviteController.php 파일 보기

@@ -0,0 +1,101 @@
<?php

namespace App\Controller;

use App\Entity\Central\AccountUser;
use App\Entity\Central\User;
use App\Repository\Central\InviteTokenRepository;
use App\Repository\Central\UserRepository;
use App\Service\TenantContext;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;

class InviteController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly InviteTokenRepository $inviteTokenRepo,
private readonly UserRepository $userRepo,
private readonly TenantContext $tenantContext,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly Security $security,
) {}

#[Route('/invite/{token}', name: 'app_invite')]
public function setPassword(string $token, Request $request): Response
{
$invite = $this->inviteTokenRepo->findOneBy(['token' => $token]);

if ($invite === null) {
return $this->render('invite/error.html.twig', [
'error' => 'Dieser Einladungslink ist ungültig.',
]);
}

if ($invite->isExpired()) {
return $this->render('invite/error.html.twig', [
'error' => 'Dieser Einladungslink ist abgelaufen (gültig 7 Tage).',
]);
}

// Account-Kontext prüfen (Sicherheit: Link muss auf richtigem Subdomain geöffnet werden)
$account = $this->tenantContext->getAccount();
if ($account === null || $account->getId() !== $invite->getAccount()?->getId()) {
return $this->render('invite/error.html.twig', [
'error' => 'Dieser Einladungslink gehört zu einem anderen Account.',
]);
}

$error = null;

if ($request->isMethod('POST')) {
$password = $request->request->get('password', '');
$passwordRepeat = $request->request->get('passwordRepeat', '');

if (strlen($password) < 8) {
$error = 'Das Passwort muss mindestens 8 Zeichen haben.';
} elseif ($password !== $passwordRepeat) {
$error = 'Die Passwörter stimmen nicht überein.';
} else {
// User anlegen (oder existierenden finden, falls E-Mail schon vorhanden)
$user = $this->userRepo->findOneBy(['email' => $invite->getEmail()]);

if ($user === null) {
$user = new User();
$user->setEmail($invite->getEmail());
$user->setFirstName($invite->getFirstName());
$user->setLastName($invite->getLastName());
$this->em->persist($user);
}

$user->setPassword($this->passwordHasher->hashPassword($user, $password));

// AccountUser anlegen
$accountUser = new AccountUser();
$accountUser->setAccount($invite->getAccount());
$accountUser->setUser($user);
$accountUser->setRole($invite->getRole());
$this->em->persist($accountUser);

// Token löschen
$this->em->remove($invite);
$this->em->flush();

// Direkt einloggen
$this->security->login($user, 'form_login', 'main');

return $this->redirectToRoute('timetracking_week');
}
}

return $this->render('invite/set_password.html.twig', [
'invite' => $invite,
'error' => $error,
]);
}
}

+ 146
- 0
httpdocs/src/Controller/ProjectController.php 파일 보기

@@ -0,0 +1,146 @@
<?php

namespace App\Controller;

use App\Entity\Tenant\Project;
use App\Repository\Tenant\ClientRepository;
use App\Repository\Tenant\ProjectRepository;
use App\Repository\Tenant\TimeEntryRepository;
use App\Service\AccountRoleHelper;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class ProjectController extends AbstractController
{
public function __construct(
private EntityManagerInterface $em,
private ProjectRepository $projectRepo,
private ClientRepository $clientRepo,
private TimeEntryRepository $timeEntryRepo,
private readonly AccountRoleHelper $roleHelper,
) {}

#[Route('/projects', name: 'project_index')]
public function index(): Response
{
if ($this->roleHelper->isTracker()) {
throw $this->createAccessDeniedException();
}
return $this->render('project/index.html.twig', [
'projects' => $this->projectRepo->findAllWithClient(),
'clients' => $this->clientRepo->findAllOrderedByName(),
]);
}

#[Route('/api/projects', name: 'api_project_create', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
if ($this->roleHelper->isTracker()) {
return $this->json(['error' => 'Zugriff verweigert'], 403);
}
$data = json_decode($request->getContent(), true);
$client = $this->clientRepo->find($data['clientId'] ?? 0);

if (empty($data['name'])) return $this->json(['error' => 'Name ist erforderlich'], 400);
if (!$client) return $this->json(['error' => 'Kunde nicht gefunden'], 400);

$project = new Project();
$project->setName(trim($data['name']));
$project->setClient($client);
$project->setNote(!empty($data['note']) ? $data['note'] : null);

$this->em->persist($project);
$this->em->flush();

return $this->json($this->projectToArray($project), 201);
}

#[Route('/api/projects/{id}', name: 'api_project_update', methods: ['PATCH'])]
public function update(int $id, Request $request): JsonResponse
{
if ($this->roleHelper->isTracker()) {
return $this->json(['error' => 'Zugriff verweigert'], 403);
}
$project = $this->projectRepo->find($id);
if (!$project) return $this->json(['error' => 'Nicht gefunden'], 404);

$data = json_decode($request->getContent(), true);
$client = $this->clientRepo->find($data['clientId'] ?? 0);

if (empty($data['name'])) return $this->json(['error' => 'Name ist erforderlich'], 400);
if (!$client) return $this->json(['error' => 'Kunde nicht gefunden'], 400);

$project->setName(trim($data['name']));
$project->setClient($client);
$project->setNote(!empty($data['note']) ? $data['note'] : null);

$this->em->flush();

return $this->json($this->projectToArray($project));
}

#[Route('/api/projects/{id}', name: 'api_project_delete', methods: ['DELETE'])]
public function delete(int $id): JsonResponse
{
if ($this->roleHelper->isTracker()) {
return $this->json(['error' => 'Zugriff verweigert'], 403);
}
$project = $this->projectRepo->find($id);
if (!$project) return $this->json(['error' => 'Nicht gefunden'], 404);

if ($this->timeEntryRepo->countByProject($project) > 0) {
return $this->json(['error' => 'has_dependencies', 'canArchive' => true], 409);
}

$this->em->remove($project);
$this->em->flush();

return $this->json(['success' => true]);
}

#[Route('/api/projects/{id}/archive', name: 'api_project_archive', methods: ['PATCH'])]
public function archive(int $id): JsonResponse
{
if ($this->roleHelper->isTracker()) {
return $this->json(['error' => 'Zugriff verweigert'], 403);
}
$project = $this->projectRepo->find($id);
if (!$project) return $this->json(['error' => 'Nicht gefunden'], 404);

$project->setArchivedAt(new \DateTimeImmutable());
$this->em->flush();

return $this->json($this->projectToArray($project));
}

#[Route('/api/projects/{id}/unarchive', name: 'api_project_unarchive', methods: ['PATCH'])]
public function unarchive(int $id): JsonResponse
{
if ($this->roleHelper->isTracker()) {
return $this->json(['error' => 'Zugriff verweigert'], 403);
}
$project = $this->projectRepo->find($id);
if (!$project) return $this->json(['error' => 'Nicht gefunden'], 404);

$project->setArchivedAt(null);
$this->em->flush();

return $this->json($this->projectToArray($project));
}

private function projectToArray(Project $project): array
{
return [
'id' => $project->getId(),
'name' => $project->getName(),
'clientId' => $project->getClient()->getId(),
'clientName' => $project->getClient()->getName(),
'note' => $project->getNote(),
'archived' => $project->isArchived(),
];
}
}

+ 103
- 0
httpdocs/src/Controller/RegistrationController.php 파일 보기

@@ -0,0 +1,103 @@
<?php

namespace App\Controller;

use App\Service\RegistrationService;
use App\Service\SlugGenerator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class RegistrationController extends AbstractController
{
public function __construct(
private readonly RegistrationService $registrationService,
private readonly SlugGenerator $slugGenerator,
private readonly string $appDomain,
) {}

#[Route('/register', name: 'app_register')]
public function register(): Response
{
return $this->render('registration/register.html.twig', [
'appDomain' => $this->appDomain,
]);
}

/**
* Live-Vorschau des Slugs während der User tippt.
*/
#[Route('/api/register/preview-slug', name: 'api_register_preview_slug', methods: ['POST'])]
public function previewSlug(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
$companyName = trim($data['companyName'] ?? '');

if ($companyName === '') {
return $this->json(['slug' => '']);
}

try {
$slug = $this->slugGenerator->previewFromCompanyName($companyName);
return $this->json(['slug' => $slug]);
} catch (\Throwable) {
return $this->json(['slug' => '']);
}
}

#[Route('/api/register', name: 'api_register', methods: ['POST'])]
public function submit(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];

$companyName = trim($data['companyName'] ?? '');
$email = trim($data['email'] ?? '');
$firstName = trim($data['firstName'] ?? '');
$lastName = trim($data['lastName'] ?? '');
$password = $data['password'] ?? '';
$passwordRepeat = $data['passwordRepeat'] ?? '';

$errors = [];
if ($companyName === '') { $errors[] = 'Firmenname ist erforderlich.'; }
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = 'Keine gültige E-Mail-Adresse.'; }
if ($firstName === '') { $errors[] = 'Vorname ist erforderlich.'; }
if ($lastName === '') { $errors[] = 'Nachname ist erforderlich.'; }
if (strlen($password) < 8) { $errors[] = 'Passwort muss mindestens 8 Zeichen lang sein.'; }
if ($password !== $passwordRepeat) { $errors[] = 'Passwörter stimmen nicht überein.'; }

if (!empty($errors)) {
return $this->json(['errors' => $errors], Response::HTTP_UNPROCESSABLE_ENTITY);
}

try {
$this->registrationService->startRegistration(
$companyName, $email, $firstName, $lastName, $password,
);
return $this->json(['success' => true]);
} catch (\DomainException $e) {
return $this->json(['errors' => [$e->getMessage()]], Response::HTTP_UNPROCESSABLE_ENTITY);
} catch (\Throwable $e) {
return $this->json(['errors' => ['Ein Fehler ist aufgetreten. Bitte versuche es erneut.']], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}

#[Route('/verify/{token}', name: 'app_verify')]
public function verify(string $token): Response
{
try {
$account = $this->registrationService->confirm($token);
$redirectUrl = 'https://' . $account->getSlug() . '.' . $this->appDomain;

return $this->render('registration/confirmed.html.twig', [
'account' => $account,
'redirectUrl' => $redirectUrl,
]);
} catch (\InvalidArgumentException $e) {
return $this->render('registration/confirm_error.html.twig', [
'error' => $e->getMessage(),
]);
}
}
}

+ 38
- 0
httpdocs/src/Controller/SecurityController.php 파일 보기

@@ -0,0 +1,38 @@
<?php

namespace App\Controller;

use App\Service\TenantContext;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;

class SecurityController extends AbstractController
{
public function __construct(private readonly TenantContext $tenantContext) {}

#[Route('/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
if ($this->getUser()) {
return $this->redirectToRoute('timetracking_week');
}

if (!$this->tenantContext->hasAccount()) {
return $this->redirectToRoute('app_home');
}

return $this->render('security/login.html.twig', [
'lastUsername' => $authenticationUtils->getLastUsername(),
'error' => $authenticationUtils->getLastAuthenticationError(),
'accountName' => $this->tenantContext->getAccount()?->getName() ?? 'spawntree',
]);
}

#[Route('/logout', name: 'app_logout')]
public function logout(): void
{
throw new \LogicException('This method should never be reached.');
}
}

+ 138
- 0
httpdocs/src/Controller/ServiceController.php 파일 보기

@@ -0,0 +1,138 @@
<?php

namespace App\Controller;

use App\Entity\Tenant\Service;
use App\Repository\Tenant\ServiceRepository;
use App\Repository\Tenant\TimeEntryRepository;
use App\Service\AccountRoleHelper;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class ServiceController extends AbstractController
{
public function __construct(
private EntityManagerInterface $em,
private ServiceRepository $serviceRepo,
private TimeEntryRepository $timeEntryRepo,
private readonly AccountRoleHelper $roleHelper,
) {}

#[Route('/services', name: 'service_index')]
public function index(): Response
{
if ($this->roleHelper->isTracker()) {
throw $this->createAccessDeniedException();
}
return $this->render('service/index.html.twig', [
'services' => $this->serviceRepo->findAllOrderedByBillable(),
]);
}

#[Route('/api/services', name: 'api_service_create', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
if ($this->roleHelper->isTracker()) {
return $this->json(['error' => 'Zugriff verweigert'], 403);
}
$data = json_decode($request->getContent(), true);

if (empty($data['name'])) return $this->json(['error' => 'Name ist erforderlich'], 400);

$service = new Service();
$service->setName(trim($data['name']));
$service->setBillable((bool) ($data['billable'] ?? true));
$service->setNote(!empty($data['note']) ? $data['note'] : null);

$this->em->persist($service);
$this->em->flush();

return $this->json($this->serviceToArray($service), 201);
}

#[Route('/api/services/{id}', name: 'api_service_update', methods: ['PATCH'])]
public function update(int $id, Request $request): JsonResponse
{
if ($this->roleHelper->isTracker()) {
return $this->json(['error' => 'Zugriff verweigert'], 403);
}
$service = $this->serviceRepo->find($id);
if (!$service) return $this->json(['error' => 'Nicht gefunden'], 404);

$data = json_decode($request->getContent(), true);

if (empty($data['name'])) return $this->json(['error' => 'Name ist erforderlich'], 400);

$service->setName(trim($data['name']));
$service->setBillable((bool) ($data['billable'] ?? true));
$service->setNote(!empty($data['note']) ? $data['note'] : null);

$this->em->flush();

return $this->json($this->serviceToArray($service));
}

#[Route('/api/services/{id}', name: 'api_service_delete', methods: ['DELETE'])]
public function delete(int $id): JsonResponse
{
if ($this->roleHelper->isTracker()) {
return $this->json(['error' => 'Zugriff verweigert'], 403);
}
$service = $this->serviceRepo->find($id);
if (!$service) return $this->json(['error' => 'Nicht gefunden'], 404);

if ($this->timeEntryRepo->countByService($service) > 0) {
return $this->json(['error' => 'has_dependencies', 'canArchive' => true], 409);
}

$this->em->remove($service);
$this->em->flush();

return $this->json(['success' => true]);
}

#[Route('/api/services/{id}/archive', name: 'api_service_archive', methods: ['PATCH'])]
public function archive(int $id): JsonResponse
{
if ($this->roleHelper->isTracker()) {
return $this->json(['error' => 'Zugriff verweigert'], 403);
}
$service = $this->serviceRepo->find($id);
if (!$service) return $this->json(['error' => 'Nicht gefunden'], 404);

$service->setArchivedAt(new \DateTimeImmutable());
$this->em->flush();

return $this->json($this->serviceToArray($service));
}

#[Route('/api/services/{id}/unarchive', name: 'api_service_unarchive', methods: ['PATCH'])]
public function unarchive(int $id): JsonResponse
{
if ($this->roleHelper->isTracker()) {
return $this->json(['error' => 'Zugriff verweigert'], 403);
}
$service = $this->serviceRepo->find($id);
if (!$service) return $this->json(['error' => 'Nicht gefunden'], 404);

$service->setArchivedAt(null);
$this->em->flush();

return $this->json($this->serviceToArray($service));
}

private function serviceToArray(Service $service): array
{
return [
'id' => $service->getId(),
'name' => $service->getName(),
'billable' => $service->isBillable(),
'note' => $service->getNote(),
'archived' => $service->isArchived(),
];
}
}

+ 313
- 0
httpdocs/src/Controller/TeamController.php 파일 보기

@@ -0,0 +1,313 @@
<?php

namespace App\Controller;

use App\Entity\Central\AccountUser;
use App\Entity\Central\InviteToken;
use App\Entity\Central\User;
use App\Repository\Central\AccountUserRepository;
use App\Repository\Central\InviteTokenRepository;
use App\Repository\Central\UserRepository;
use App\Repository\Tenant\TimeEntryRepository;
use App\Service\AccountRoleHelper;
use App\Service\TenantContext;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

class TeamController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly TenantContext $tenantContext,
private readonly AccountUserRepository $accountUserRepo,
private readonly InviteTokenRepository $inviteTokenRepo,
private readonly UserRepository $userRepo,
private readonly TimeEntryRepository $timeEntryRepo,
private readonly AccountRoleHelper $roleHelper,
private readonly MailerInterface $mailer,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly string $appDomain,
) {}

#[Route('/team', name: 'team_index')]
public function index(): Response
{
if (!$this->roleHelper->isAdmin()) {
throw $this->createAccessDeniedException();
}

$account = $this->tenantContext->getAccount();
$allUsers = $this->accountUserRepo->findBy(['account' => $account]);
$pendingInvites = $this->inviteTokenRepo->findBy(['account' => $account]);

$activeUsers = array_values(array_filter($allUsers, fn($au) => !$au->isArchived()));
$archivedUsers = array_values(array_filter($allUsers, fn($au) => $au->isArchived()));

return $this->render('team/index.html.twig', [
'activeUsers' => $activeUsers,
'archivedUsers' => $archivedUsers,
'pendingInvites' => $pendingInvites,
'currentUserId' => $this->getUser()?->getId(),
]);
}

#[Route('/api/team/invite', name: 'api_team_invite', methods: ['POST'])]
public function invite(Request $request): JsonResponse
{
if (!$this->roleHelper->isAdmin()) {
return $this->json(['error' => 'Zugriff verweigert'], 403);
}

$data = json_decode($request->getContent(), true) ?? [];
$email = trim($data['email'] ?? '');
$firstName = trim($data['firstName'] ?? '');
$lastName = trim($data['lastName'] ?? '');
$role = $data['role'] ?? AccountUser::ROLE_MEMBER;

$errors = [];
if ($email === '') { $errors[] = 'E-Mail ist erforderlich.'; }
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = 'Keine gültige E-Mail-Adresse.'; }
if ($firstName === '') { $errors[] = 'Vorname ist erforderlich.'; }
if ($lastName === '') { $errors[] = 'Nachname ist erforderlich.'; }
if (!in_array($role, [AccountUser::ROLE_ADMIN, AccountUser::ROLE_MEMBER, AccountUser::ROLE_TRACKER], true)) {
$errors[] = 'Ungültige Rolle.';
}

if (!empty($errors)) {
return $this->json(['errors' => $errors], 422);
}

$account = $this->tenantContext->getAccount();

$existingUser = $this->userRepo->findOneBy(['email' => $email]);
if ($existingUser !== null) {
$alreadyMember = $this->accountUserRepo->findOneBy([
'account' => $account,
'user' => $existingUser,
]);
if ($alreadyMember !== null) {
return $this->json(['errors' => ['Diese Person ist bereits Mitglied dieses Accounts.']], 409);
}
}

$existing = $this->inviteTokenRepo->findOneBy(['account' => $account, 'email' => $email]);
if ($existing !== null) {
$this->em->remove($existing);
$this->em->flush();
}

$invite = new InviteToken();
$invite->setToken(bin2hex(random_bytes(32)));
$invite->setAccount($account);
$invite->setEmail($email);
$invite->setFirstName($firstName);
$invite->setLastName($lastName);
$invite->setRole($role);

$this->em->persist($invite);
$this->em->flush();

$this->sendInviteMail($invite);

return $this->json(['ok' => true]);
}

#[Route('/api/team/{id}/archive', name: 'api_team_archive', methods: ['PATCH'])]
public function archive(int $id): JsonResponse
{
if (!$this->roleHelper->isAdmin()) {
return $this->json(['error' => 'Zugriff verweigert'], 403);
}

$account = $this->tenantContext->getAccount();
$accountUser = $this->accountUserRepo->find($id);

if ($accountUser === null || $accountUser->getAccount()?->getId() !== $account?->getId()) {
return $this->json(['error' => 'Nicht gefunden'], 404);
}

if ($accountUser->getUser() === $this->getUser()) {
return $this->json(['error' => 'Du kannst dich nicht selbst archivieren.'], 400);
}

$accountUser->setArchivedAt(new \DateTimeImmutable());
$this->em->flush();

return $this->json(['ok' => true]);
}

#[Route('/api/team/{id}/unarchive', name: 'api_team_unarchive', methods: ['PATCH'])]
public function unarchive(int $id): JsonResponse
{
if (!$this->roleHelper->isAdmin()) {
return $this->json(['error' => 'Zugriff verweigert'], 403);
}

$account = $this->tenantContext->getAccount();
$accountUser = $this->accountUserRepo->find($id);

if ($accountUser === null || $accountUser->getAccount()?->getId() !== $account?->getId()) {
return $this->json(['error' => 'Nicht gefunden'], 404);
}

$accountUser->setArchivedAt(null);
$this->em->flush();

return $this->json(['ok' => true]);
}

#[Route('/api/team/{id}', name: 'api_team_edit', methods: ['PATCH'])]
public function edit(int $id, Request $request): JsonResponse
{
if (!$this->roleHelper->isAdmin()) {
return $this->json(['error' => 'Zugriff verweigert'], 403);
}

$account = $this->tenantContext->getAccount();
$accountUser = $this->accountUserRepo->find($id);

if ($accountUser === null || $accountUser->getAccount()?->getId() !== $account?->getId()) {
return $this->json(['error' => 'Nicht gefunden'], 404);
}

$data = json_decode($request->getContent(), true) ?? [];
$firstName = trim($data['firstName'] ?? '');
$lastName = trim($data['lastName'] ?? '');
$email = trim($data['email'] ?? '');
$note = $data['note'] !== '' ? ($data['note'] ?? null) : null;
$role = $data['role'] ?? null;

$errors = [];
if ($firstName === '') { $errors[] = 'Vorname ist erforderlich.'; }
if ($lastName === '') { $errors[] = 'Nachname ist erforderlich.'; }
if ($email === '') { $errors[] = 'E-Mail ist erforderlich.'; }
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = 'Keine gültige E-Mail-Adresse.'; }
if ($role !== null && !in_array($role, [AccountUser::ROLE_ADMIN, AccountUser::ROLE_MEMBER, AccountUser::ROLE_TRACKER], true)) {
$errors[] = 'Ungültige Rolle.';
}

if (!empty($errors)) {
return $this->json(['errors' => $errors], 422);
}

$user = $accountUser->getUser();

// E-Mail-Änderung: Duplikat prüfen
if ($email !== $user->getEmail()) {
$existing = $this->userRepo->findOneBy(['email' => $email]);
if ($existing !== null) {
return $this->json(['errors' => ['Diese E-Mail-Adresse wird bereits verwendet.']], 409);
}
}

// Eigene Rolle: Admin darf sich nicht selbst degradieren
$isSelf = ($user === $this->getUser());
if ($isSelf && $accountUser->isAdmin() && $role !== null && $role !== AccountUser::ROLE_ADMIN) {
return $this->json(['errors' => ['Du kannst deine eigene Administratoren-Rolle nicht ändern.']], 400);
}

$user->setFirstName($firstName);
$user->setLastName($lastName);
$user->setEmail($email);
$user->setNote($note !== '' ? $note : null);

if ($role !== null && !($isSelf && $accountUser->isAdmin())) {
$accountUser->setRole($role);
}

$this->em->flush();

return $this->json($this->accountUserToArray($accountUser));
}

#[Route('/api/team/{id}', name: 'api_team_delete', methods: ['DELETE'])]
public function delete(int $id): JsonResponse
{
if (!$this->roleHelper->isAdmin()) {
return $this->json(['error' => 'Zugriff verweigert'], 403);
}

$account = $this->tenantContext->getAccount();
$accountUser = $this->accountUserRepo->find($id);

if ($accountUser === null || $accountUser->getAccount()?->getId() !== $account?->getId()) {
return $this->json(['error' => 'Nicht gefunden'], 404);
}

if ($accountUser->getUser() === $this->getUser()) {
return $this->json(['error' => 'Du kannst dich nicht selbst entfernen.'], 400);
}

$userId = $accountUser->getUser()->getId();
if ($this->timeEntryRepo->countByUserId($userId) > 0) {
return $this->json(['error' => 'has_dependencies', 'canArchive' => true], 409);
}

$this->em->remove($accountUser);
$this->em->flush();

return $this->json(['ok' => true]);
}

#[Route('/api/team/invite/{id}', name: 'api_team_invite_delete', methods: ['DELETE'])]
public function deleteInvite(int $id): JsonResponse
{
if (!$this->roleHelper->isAdmin()) {
return $this->json(['error' => 'Zugriff verweigert'], 403);
}

$account = $this->tenantContext->getAccount();
$invite = $this->inviteTokenRepo->find($id);

if ($invite === null || $invite->getAccount()?->getId() !== $account?->getId()) {
return $this->json(['error' => 'Nicht gefunden'], 404);
}

$this->em->remove($invite);
$this->em->flush();

return $this->json(['ok' => true]);
}

private function accountUserToArray(AccountUser $au): array
{
return [
'id' => $au->getId(),
'firstName' => $au->getUser()->getFirstName(),
'lastName' => $au->getUser()->getLastName(),
'fullName' => $au->getUser()->getFullName(),
'email' => $au->getUser()->getEmail(),
'note' => $au->getUser()->getNote(),
'role' => $au->getRole(),
'roleLabel' => $au->getRoleLabel(),
];
}

private function sendInviteMail(InviteToken $invite): void
{
$inviteUrl = 'https://'
. $invite->getAccount()->getSlug()
. '.'
. $this->appDomain
. $this->urlGenerator->generate('app_invite', ['token' => $invite->getToken()]);

$email = (new TemplatedEmail())
->to(new Address($invite->getEmail(), $invite->getFirstName() . ' ' . $invite->getLastName()))
->subject('Einladung zu ' . $invite->getAccount()->getName())
->htmlTemplate('email/team_invite.html.twig')
->context([
'invite' => $invite,
'inviteUrl' => $inviteUrl,
]);

$this->mailer->send($email);
}
}

+ 265
- 0
httpdocs/src/Controller/TimeTrackingController.php 파일 보기

@@ -0,0 +1,265 @@
<?php

namespace App\Controller;

use App\Entity\Central\User;
use App\Entity\Tenant\TimeEntry;
use App\Repository\Tenant\ProjectRepository;
use App\Repository\Tenant\ServiceRepository;
use App\Repository\Tenant\TimeEntryRepository;
use App\Service\TenantContext;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class TimeTrackingController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $tenantEm,
private readonly TimeEntryRepository $timeEntryRepo,
private readonly ProjectRepository $projectRepo,
private readonly ServiceRepository $serviceRepo,
private readonly TenantContext $tenantContext,
) {}

// ── Hauptseite ────────────────────────────────────────────────────────────

#[Route('/week', name: 'timetracking_week')]
#[Route('/week/{date}', name: 'timetracking_week_date')]
public function week(?string $date = null): Response
{
$tz = new \DateTimeZone('Europe/Berlin');

try {
$activeDate = $date
? new \DateTimeImmutable($date, $tz)
: new \DateTimeImmutable('today', $tz);
} catch (\Exception) {
$activeDate = new \DateTimeImmutable('today', $tz);
}

$today = new \DateTimeImmutable('today', $tz);

/** @var User $user */
$user = $this->getUser();
$entries = $this->timeEntryRepo->findByDateAndUserId($activeDate, $user->getId());
$totalMin = $this->timeEntryRepo->sumDurationByDateAndUserId($activeDate, $user->getId());

$monday = $activeDate->modify('monday this week');
$prevMonday = $monday->modify('-7 days');
$nextMonday = $monday->modify('+7 days');

return $this->render('timetracking/week.html.twig', [
'currentDate' => $activeDate,
'today' => $today,
'todayStr' => $today->format('Y-m-d'),
'tomorrowStr' => $today->modify('+1 day')->format('Y-m-d'),
'yesterdayStr' => $today->modify('-1 day')->format('Y-m-d'),
'currentWeekNumber' => (int) $activeDate->format('W'),
'weekDays' => $this->buildWeekDays($activeDate, $today),
'prevWeekUrl' => $this->generateUrl('timetracking_week_date', ['date' => $prevMonday->format('Y-m-d')]),
'nextWeekUrl' => $this->generateUrl('timetracking_week_date', ['date' => $nextMonday->format('Y-m-d')]),
'timeEntries' => $entries,
'totalDuration' => $this->formatMinutes($totalMin),
'projects' => $this->projectRepo->findAllWithClient(),
'services' => $this->serviceRepo->findAllOrderedByBillable(),
'greeting' => $this->getGreeting(),
'firstName' => $user->getFirstName(),
'trackingInterval' => $this->tenantContext->getAccount()?->getTrackingInterval() ?? 1,
]);
}

// ── API: Einträge für einen Tag laden ─────────────────────────────────────

#[Route('/api/entries', name: 'api_entries_list', methods: ['GET'])]
public function apiList(Request $request): JsonResponse
{
$tz = new \DateTimeZone('Europe/Berlin');
$date = new \DateTimeImmutable($request->query->get('date', 'today'), $tz);

/** @var User $user */
$user = $this->getUser();
$entries = $this->timeEntryRepo->findByDateAndUserId($date, $user->getId());
$totalMin = $this->timeEntryRepo->sumDurationByDateAndUserId($date, $user->getId());

return $this->json([
'entries' => array_map(fn(TimeEntry $e) => $e->toArray(), $entries),
'totalDuration' => $this->formatMinutes($totalMin),
]);
}

// ── API: Eintrag erstellen ────────────────────────────────────────────────

#[Route('/api/entries', name: 'api_entries_create', methods: ['POST'])]
public function apiCreate(Request $request): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$data = json_decode($request->getContent(), true);
$project = $this->projectRepo->find($data['projectId'] ?? 0);

if (!$project) {
return $this->json(['error' => 'Projekt nicht gefunden'], 400);
}

$tz = new \DateTimeZone('Europe/Berlin');
$date = new \DateTimeImmutable($data['date'] ?? 'today', $tz);

$service = null;
if (!empty($data['serviceId'])) {
$service = $this->serviceRepo->find($data['serviceId']);
}

$entry = new TimeEntry();
$entry->setUserId($user->getId());
$entry->setProject($project);
$entry->setService($service);
$entry->setDate($date);
$entry->setDuration($this->parseDuration($data['duration'] ?? '0'));
$entry->setNote(!empty($data['note']) ? $data['note'] : null);

$this->tenantEm->persist($entry);
$this->tenantEm->flush();

$totalMin = $this->timeEntryRepo->sumDurationByDateAndUserId($date, $user->getId());

return $this->json([
'entry' => $entry->toArray(),
'totalDuration' => $this->formatMinutes($totalMin),
], 201);
}

// ── API: Eintrag bearbeiten ───────────────────────────────────────────────

#[Route('/api/entries/{id}', name: 'api_entries_update', methods: ['PATCH'])]
public function apiUpdate(int $id, Request $request): JsonResponse
{
$entry = $this->timeEntryRepo->find($id);
if (!$entry) {
return $this->json(['error' => 'Nicht gefunden'], 404);
}

$data = json_decode($request->getContent(), true);
$project = $this->projectRepo->find($data['projectId'] ?? 0);

if (!$project) {
return $this->json(['error' => 'Projekt nicht gefunden'], 400);
}

$service = null;
if (!empty($data['serviceId'])) {
$service = $this->serviceRepo->find($data['serviceId']);
}

$entry->setProject($project);
$entry->setService($service);
$entry->setDuration($this->parseDuration($data['duration'] ?? '0'));
$entry->setNote(!empty($data['note']) ? $data['note'] : null);

$this->tenantEm->flush();

$totalMin = $this->timeEntryRepo->sumDurationByDateAndUserId(
$entry->getDate(),
$entry->getUserId(),
);

return $this->json([
'entry' => $entry->toArray(),
'totalDuration' => $this->formatMinutes($totalMin),
]);
}

// ── API: Eintrag löschen ──────────────────────────────────────────────────

#[Route('/api/entries/{id}', name: 'api_entries_delete', methods: ['DELETE'])]
public function apiDelete(int $id): JsonResponse
{
$entry = $this->timeEntryRepo->find($id);
if (!$entry) {
return $this->json(['error' => 'Nicht gefunden'], 404);
}

$date = $entry->getDate();
$userId = $entry->getUserId();

$this->tenantEm->remove($entry);
$this->tenantEm->flush();

$totalMin = $this->timeEntryRepo->sumDurationByDateAndUserId($date, $userId);

return $this->json(['totalDuration' => $this->formatMinutes($totalMin)]);
}

// ── Legacy-Routen ─────────────────────────────────────────────────────────

#[Route('/day/{date}', name: 'timetracking_day')]
public function day(string $date): Response
{
return $this->redirectToRoute('timetracking_week_date', ['date' => $date]);
}

#[Route('/copy-last/{date}', name: 'timetracking_copy_last')]
public function copyLast(): Response
{
return $this->redirectToRoute('timetracking_week');
}

// ── Hilfsfunktionen ───────────────────────────────────────────────────────

private function buildWeekDays(\DateTimeImmutable $activeDate, \DateTimeImmutable $today): array
{
$monday = $activeDate->modify('monday this week');
$dayNames = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
$days = [];

for ($i = 0; $i < 7; $i++) {
$day = $monday->modify("+{$i} days");
$days[] = [
'date' => $day,
'label' => $dayNames[$i],
'short' => $dayNames[$i],
'isToday' => $day->format('Y-m-d') === $today->format('Y-m-d'),
'isActive' => $day->format('Y-m-d') === $activeDate->format('Y-m-d'),
];
}

return $days;
}

private function getGreeting(): string
{
$hour = (int) (new \DateTimeImmutable('now', new \DateTimeZone('Europe/Berlin')))->format('H');

return match(true) {
$hour >= 5 && $hour < 11 => 'Guten Morgen',
$hour >= 11 && $hour < 14 => 'Mahlzeit',
$hour >= 14 && $hour < 18 => 'Guten Tag',
$hour >= 18 && $hour < 22 => 'Guten Abend',
default => 'Gute Nacht',
};
}

private function parseDuration(string $input): int
{
$input = trim($input);

if (str_contains($input, ':')) {
[$h, $m] = explode(':', $input, 2);
return (int) $h * 60 + (int) $m;
}

if (str_contains($input, '.') || str_contains($input, ',')) {
return (int) round((float) str_replace(',', '.', $input) * 60);
}

return (int) $input * 60;
}

private function formatMinutes(int $minutes): string
{
return sprintf('%d:%02d', intdiv($minutes, 60), $minutes % 60);
}
}

+ 38
- 0
httpdocs/src/Doctrine/TenantConnectionMiddleware.php 파일 보기

@@ -0,0 +1,38 @@
<?php

namespace App\Doctrine;

use App\Service\TenantContext;
use Doctrine\DBAL\Driver;
use Doctrine\DBAL\Driver\Connection as DriverConnection;
use Doctrine\DBAL\Driver\Middleware;
use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware;

/**
* DBAL-Middleware: ersetzt den DB-Namen beim connect() mit dem Tenant-DB-Namen.
* Wird nur für die 'tenant'-Connection registriert.
*/
class TenantConnectionMiddleware implements Middleware
{
public function __construct(private readonly TenantContext $tenantContext) {}

public function wrap(Driver $driver): Driver
{
return new class($driver, $this->tenantContext) extends AbstractDriverMiddleware {
public function __construct(
Driver $driver,
private readonly TenantContext $tenantContext,
) {
parent::__construct($driver);
}

public function connect(array $params): DriverConnection
{
if ($this->tenantContext->hasAccount()) {
$params['dbname'] = $this->tenantContext->getAccount()->getTenantDbName();
}
return parent::connect($params);
}
};
}
}

+ 0
- 0
httpdocs/src/Entity/.gitignore 파일 보기


+ 70
- 0
httpdocs/src/Entity/Central/Account.php 파일 보기

@@ -0,0 +1,70 @@
<?php

namespace App\Entity\Central;

use App\Repository\Central\AccountRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: AccountRepository::class)]
#[ORM\Table(name: 'account')]
class Account
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\Column(length: 255)]
private string $name = '';

/** Wird zur Subdomain: spawntree → spawntree.domain.de */
#[ORM\Column(length: 63, unique: true)]
private string $slug = '';

#[ORM\Column(type: 'smallint', options: ['default' => 1])]
private int $trackingInterval = 1;

#[ORM\Column]
private \DateTimeImmutable $createdAt;

#[ORM\OneToMany(targetEntity: AccountUser::class, mappedBy: 'account', cascade: ['persist', 'remove'])]
private Collection $accountUsers;

public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
$this->accountUsers = new ArrayCollection();
}

public function getId(): ?int { return $this->id; }

public function getName(): string { return $this->name; }
public function setName(string $name): static { $this->name = $name; return $this; }

public function getSlug(): string { return $this->slug; }
public function setSlug(string $slug): static { $this->slug = $slug; return $this; }

public function getTrackingInterval(): int { return $this->trackingInterval; }
public function setTrackingInterval(int $v): static { $this->trackingInterval = $v; return $this; }

public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }

public function getAccountUsers(): Collection { return $this->accountUsers; }

/** Gibt alle User zurück, die Admin dieses Accounts sind */
public function getAdmins(): Collection
{
return $this->accountUsers->filter(
fn(AccountUser $au) => $au->getRole() === AccountUser::ROLE_ADMIN
);
}

public function getTenantDbName(): string
{
return 'db_' . str_replace('-', '_', $this->slug);
}

public function __toString(): string { return $this->name; }
}

+ 65
- 0
httpdocs/src/Entity/Central/AccountUser.php 파일 보기

@@ -0,0 +1,65 @@
<?php

namespace App\Entity\Central;

use App\Repository\Central\AccountUserRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: AccountUserRepository::class)]
#[ORM\Table(name: 'account_user')]
class AccountUser
{
public const ROLE_ADMIN = 'admin';
public const ROLE_MEMBER = 'member';
public const ROLE_TRACKER = 'tracker';

#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\ManyToOne(targetEntity: Account::class, inversedBy: 'accountUsers')]
#[ORM\JoinColumn(nullable: false)]
private ?Account $account = null;

#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;

/** 'admin', 'member' oder 'tracker' */
#[ORM\Column(length: 20)]
private string $role = self::ROLE_MEMBER;

#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $archivedAt = null;

public function getId(): ?int { return $this->id; }

public function getAccount(): ?Account { return $this->account; }
public function setAccount(?Account $account): static { $this->account = $account; return $this; }

public function getUser(): ?User { return $this->user; }
public function setUser(?User $user): static { $this->user = $user; return $this; }

public function getRole(): string { return $this->role; }
public function setRole(string $role): static { $this->role = $role; return $this; }

public function getArchivedAt(): ?\DateTimeImmutable { return $this->archivedAt; }
public function setArchivedAt(?\DateTimeImmutable $archivedAt): static { $this->archivedAt = $archivedAt; return $this; }
public function isArchived(): bool { return $this->archivedAt !== null; }

public function isAdmin(): bool { return $this->role === self::ROLE_ADMIN; }
public function isMember(): bool { return $this->role === self::ROLE_MEMBER; }
public function isTracker(): bool { return $this->role === self::ROLE_TRACKER; }
public function isMemberOrAdmin(): bool { return $this->isAdmin() || $this->isMember(); }

public function getRoleLabel(): string
{
return match ($this->role) {
self::ROLE_ADMIN => 'Administrator',
self::ROLE_MEMBER => 'Standard',
self::ROLE_TRACKER => 'Zeiterfasser',
default => $this->role,
};
}
}

+ 66
- 0
httpdocs/src/Entity/Central/InviteToken.php 파일 보기

@@ -0,0 +1,66 @@
<?php

namespace App\Entity\Central;

use App\Repository\Central\InviteTokenRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: InviteTokenRepository::class)]
#[ORM\Table(name: 'invite_token')]
class InviteToken
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\Column(length: 64, unique: true)]
private string $token = '';

#[ORM\ManyToOne(targetEntity: Account::class)]
#[ORM\JoinColumn(nullable: false)]
private ?Account $account = null;

#[ORM\Column(length: 180)]
private string $email = '';

#[ORM\Column(length: 100)]
private string $firstName = '';

#[ORM\Column(length: 100)]
private string $lastName = '';

/** 'admin', 'member' oder 'tracker' */
#[ORM\Column(length: 20)]
private string $role = AccountUser::ROLE_MEMBER;

#[ORM\Column]
private \DateTimeImmutable $createdAt;

#[ORM\Column]
private \DateTimeImmutable $expiresAt;

public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
$this->expiresAt = new \DateTimeImmutable('+7 days');
}

public function isExpired(): bool { return $this->expiresAt < new \DateTimeImmutable(); }

public function getId(): ?int { return $this->id; }
public function getToken(): string { return $this->token; }
public function setToken(string $token): static { $this->token = $token; return $this; }
public function getAccount(): ?Account { return $this->account; }
public function setAccount(?Account $account): static { $this->account = $account; return $this; }
public function getEmail(): string { return $this->email; }
public function setEmail(string $email): static { $this->email = $email; return $this; }
public function getFirstName(): string { return $this->firstName; }
public function setFirstName(string $firstName): static { $this->firstName = $firstName; return $this; }
public function getLastName(): string { return $this->lastName; }
public function setLastName(string $lastName): static { $this->lastName = $lastName; return $this; }
public function getRole(): string { return $this->role; }
public function setRole(string $role): static { $this->role = $role; return $this; }
public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
public function getExpiresAt(): \DateTimeImmutable { return $this->expiresAt; }
}

+ 69
- 0
httpdocs/src/Entity/Central/RegistrationToken.php 파일 보기

@@ -0,0 +1,69 @@
<?php

namespace App\Entity\Central;

use App\Repository\Central\RegistrationTokenRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: RegistrationTokenRepository::class)]
#[ORM\Table(name: 'registration_token')]
class RegistrationToken
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\Column(length: 64, unique: true)]
private string $token = '';

#[ORM\Column(length: 255)]
private string $companyName = '';

#[ORM\Column(length: 63)]
private string $slug = '';

#[ORM\Column(length: 180)]
private string $email = '';

#[ORM\Column(length: 100)]
private string $firstName = '';

#[ORM\Column(length: 100)]
private string $lastName = '';

#[ORM\Column(length: 255)]
private string $passwordHash = '';

#[ORM\Column]
private \DateTimeImmutable $createdAt;

#[ORM\Column]
private \DateTimeImmutable $expiresAt;

public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
$this->expiresAt = new \DateTimeImmutable('+24 hours');
}

public function isExpired(): bool { return $this->expiresAt < new \DateTimeImmutable(); }

public function getId(): ?int { return $this->id; }
public function getToken(): string { return $this->token; }
public function setToken(string $token): static { $this->token = $token; return $this; }
public function getCompanyName(): string { return $this->companyName; }
public function setCompanyName(string $companyName): static { $this->companyName = $companyName; return $this; }
public function getSlug(): string { return $this->slug; }
public function setSlug(string $slug): static { $this->slug = $slug; return $this; }
public function getEmail(): string { return $this->email; }
public function setEmail(string $email): static { $this->email = $email; return $this; }
public function getFirstName(): string { return $this->firstName; }
public function setFirstName(string $firstName): static { $this->firstName = $firstName; return $this; }
public function getLastName(): string { return $this->lastName; }
public function setLastName(string $lastName): static { $this->lastName = $lastName; return $this; }
public function getPasswordHash(): string { return $this->passwordHash; }
public function setPasswordHash(string $passwordHash): static { $this->passwordHash = $passwordHash; return $this; }
public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
public function getExpiresAt(): \DateTimeImmutable { return $this->expiresAt; }
}

+ 59
- 0
httpdocs/src/Entity/Central/User.php 파일 보기

@@ -0,0 +1,59 @@
<?php

namespace App\Entity\Central;

use App\Repository\Central\UserRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\Column(length: 180, unique: true)]
private string $email = '';

#[ORM\Column(length: 100)]
private string $firstName = '';

#[ORM\Column(length: 100)]
private string $lastName = '';

#[ORM\Column(length: 255, nullable: true)]
private ?string $password = null;

#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $note = null;

public function getUserIdentifier(): string { return $this->email; }
public function getRoles(): array { return ['ROLE_USER']; }
public function eraseCredentials(): void {}

public function getId(): ?int { return $this->id; }

public function getEmail(): string { return $this->email; }
public function setEmail(string $email): static { $this->email = $email; return $this; }

public function getFirstName(): string { return $this->firstName; }
public function setFirstName(string $firstName): static { $this->firstName = $firstName; return $this; }

public function getLastName(): string { return $this->lastName; }
public function setLastName(string $lastName): static { $this->lastName = $lastName; return $this; }

public function getFullName(): string { return $this->firstName . ' ' . $this->lastName; }

public function getPassword(): ?string { return $this->password; }
public function setPassword(?string $password): static { $this->password = $password; return $this; }

public function getNote(): ?string { return $this->note; }
public function setNote(?string $note): static { $this->note = $note; return $this; }

public function __toString(): string { return $this->getFullName(); }
}

+ 71
- 0
httpdocs/src/Entity/Tenant/Client.php 파일 보기

@@ -0,0 +1,71 @@
<?php

namespace App\Entity\Tenant;

use App\Repository\Tenant\ClientRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: ClientRepository::class)]
#[ORM\Table(name: 'client')]
class Client
{
// Inhalt identisch zu bisherigem Client.php
// Nur namespace + repositoryClass-Referenz geändert
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\Column(length: 255)]
private string $name = '';

#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true)]
private ?string $hourlyRate = null;

#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $note = null;

#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $archivedAt = null;

#[ORM\OneToMany(targetEntity: Project::class, mappedBy: 'client', cascade: ['persist', 'remove'])]
#[ORM\OrderBy(['name' => 'ASC'])]
private Collection $projects;

public function __construct()
{
$this->projects = new ArrayCollection();
}

public function getId(): ?int { return $this->id; }

public function getName(): string { return $this->name; }
public function setName(string $name): static { $this->name = $name; return $this; }

public function getHourlyRate(): ?string { return $this->hourlyRate; }
public function setHourlyRate(?string $hourlyRate): static { $this->hourlyRate = $hourlyRate; return $this; }

public function getNote(): ?string { return $this->note; }
public function setNote(?string $note): static { $this->note = $note; return $this; }

public function getArchivedAt(): ?\DateTimeImmutable { return $this->archivedAt; }
public function setArchivedAt(?\DateTimeImmutable $archivedAt): static { $this->archivedAt = $archivedAt; return $this; }
public function isArchived(): bool { return $this->archivedAt !== null; }


public function getProjects(): Collection { return $this->projects; }

public function addProject(Project $project): static
{
if (!$this->projects->contains($project)) {
$this->projects->add($project);
$project->setClient($this);
}
return $this;
}

public function __toString(): string { return $this->name; }
}

+ 47
- 0
httpdocs/src/Entity/Tenant/Project.php 파일 보기

@@ -0,0 +1,47 @@
<?php

namespace App\Entity\Tenant;

use App\Repository\Tenant\ProjectRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: ProjectRepository::class)]
#[ORM\Table(name: 'project')]
class Project
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\Column(length: 255)]
private string $name = '';

#[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'projects')]
#[ORM\JoinColumn(nullable: false)]
private ?Client $client = null;

#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $note = null;

#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $archivedAt = null;

public function getId(): ?int { return $this->id; }

public function getName(): string { return $this->name; }
public function setName(string $name): static { $this->name = $name; return $this; }

public function getClient(): ?Client { return $this->client; }
public function setClient(?Client $client): static { $this->client = $client; return $this; }

public function getNote(): ?string { return $this->note; }
public function setNote(?string $note): static { $this->note = $note; return $this; }

public function getArchivedAt(): ?\DateTimeImmutable { return $this->archivedAt; }
public function setArchivedAt(?\DateTimeImmutable $archivedAt): static { $this->archivedAt = $archivedAt; return $this; }
public function isArchived(): bool { return $this->archivedAt !== null; }

public function __toString(): string { return $this->name; }
}

+ 46
- 0
httpdocs/src/Entity/Tenant/Service.php 파일 보기

@@ -0,0 +1,46 @@
<?php

namespace App\Entity\Tenant;

use App\Repository\Tenant\ServiceRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: ServiceRepository::class)]
#[ORM\Table(name: 'service')]
class Service
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\Column(length: 255)]
private string $name = '';

#[ORM\Column]
private bool $billable = true;

#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $note = null;

#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $archivedAt = null;

public function getId(): ?int { return $this->id; }

public function getName(): string { return $this->name; }
public function setName(string $name): static { $this->name = $name; return $this; }

public function isBillable(): bool { return $this->billable; }
public function setBillable(bool $billable): static { $this->billable = $billable; return $this; }

public function getNote(): ?string { return $this->note; }
public function setNote(?string $note): static { $this->note = $note; return $this; }

public function getArchivedAt(): ?\DateTimeImmutable { return $this->archivedAt; }
public function setArchivedAt(?\DateTimeImmutable $archivedAt): static { $this->archivedAt = $archivedAt; return $this; }
public function isArchived(): bool { return $this->archivedAt !== null; }

public function __toString(): string { return $this->name; }
}

+ 102
- 0
httpdocs/src/Entity/Tenant/TimeEntry.php 파일 보기

@@ -0,0 +1,102 @@
<?php

namespace App\Entity\Tenant;

use App\Repository\Tenant\TimeEntryRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: TimeEntryRepository::class)]
#[ORM\Table(name: 'time_entry')]
#[ORM\HasLifecycleCallbacks]
class TimeEntry
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\Column(type: Types::DATE_IMMUTABLE)]
private \DateTimeImmutable $date;

#[ORM\Column]
private int $duration = 0;

/** User-ID aus der Central-DB – kein FK-Join über DBs möglich */
#[ORM\Column]
private int $userId = 0;

#[ORM\ManyToOne(targetEntity: Project::class)]
#[ORM\JoinColumn(nullable: false)]
private ?Project $project = null;

#[ORM\ManyToOne(targetEntity: Service::class)]
#[ORM\JoinColumn(nullable: true)]
private ?Service $service = null;

#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $note = null;

#[ORM\Column]
private \DateTimeImmutable $createdAt;

#[ORM\Column]
private \DateTimeImmutable $updatedAt;

public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
$this->updatedAt = new \DateTimeImmutable();
$this->date = new \DateTimeImmutable('today');
}

#[ORM\PreUpdate]
public function onPreUpdate(): void
{
$this->updatedAt = new \DateTimeImmutable();
}

public function getId(): ?int { return $this->id; }

public function getDate(): \DateTimeImmutable { return $this->date; }
public function setDate(\DateTimeImmutable $date): static { $this->date = $date; return $this; }

public function getDuration(): int { return $this->duration; }
public function setDuration(int $duration): static { $this->duration = $duration; return $this; }

public function getDurationFormatted(): string
{
return sprintf('%d:%02d', intdiv($this->duration, 60), $this->duration % 60);
}

public function getUserId(): int { return $this->userId; }
public function setUserId(int $userId): static { $this->userId = $userId; return $this; }

public function getProject(): ?Project { return $this->project; }
public function setProject(?Project $project): static { $this->project = $project; return $this; }

public function getService(): ?Service { return $this->service; }
public function setService(?Service $service): static { $this->service = $service; return $this; }

public function getNote(): ?string { return $this->note; }
public function setNote(?string $note): static { $this->note = $note; return $this; }

public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
public function getUpdatedAt(): \DateTimeImmutable { return $this->updatedAt; }

public function toArray(): array
{
return [
'id' => $this->id,
'duration' => $this->duration,
'durationFormatted' => $this->getDurationFormatted(),
'projectId' => $this->project?->getId(),
'projectName' => $this->project?->getName(),
'clientName' => $this->project?->getClient()?->getName(),
'serviceId' => $this->service?->getId(),
'serviceName' => $this->service?->getName(),
'serviceBillable' => $this->service?->isBillable(),
'note' => $this->note,
];
}
}

+ 57
- 0
httpdocs/src/EventSubscriber/SlidingSessionSubscriber.php 파일 보기

@@ -0,0 +1,57 @@
<?php

namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

/**
* Verlängert den Session-Cookie bei jedem Request um 4 Stunden.
* Ohne das hätte der Cookie eine feste Ablaufzeit ab Login.
*/
class SlidingSessionSubscriber implements EventSubscriberInterface
{
private const LIFETIME = 14400; // 4 Stunden in Sekunden

public function onKernelResponse(ResponseEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}

$request = $event->getRequest();

if (!$request->hasSession()) {
return;
}

$session = $request->getSession();

if (!$session->isStarted()) {
return;
}

$params = session_get_cookie_params();

$event->getResponse()->headers->setCookie(new Cookie(
session_name(),
$session->getId(),
time() + self::LIFETIME,
$params['path'] ?: '/',
$params['domain'] ?: null,
$params['secure'],
$params['httponly'],
false,
$params['samesite'] ?: 'lax',
));
}

public static function getSubscribedEvents(): array
{
return [
KernelEvents::RESPONSE => 'onKernelResponse',
];
}
}

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.

불러오는 중...
취소
저장