| @@ -0,0 +1 @@ | |||||
| .idea | |||||
| @@ -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: [] | |||||
| @@ -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 | |||||
| @@ -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: | |||||
| @@ -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 | |||||
| @@ -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: {} | |||||
| @@ -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----- | |||||
| @@ -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----- | |||||
| @@ -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 | |||||
| @@ -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 | |||||
| @@ -0,0 +1,4 @@ | |||||
| ###> symfony/framework-bundle ### | |||||
| APP_SECRET=f19f2bcb34a48e20e66302a0e88408a9 | |||||
| ###< symfony/framework-bundle ### | |||||
| @@ -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 ### | |||||
| @@ -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!" | |||||
| @@ -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. | |||||
| @@ -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 | |||||
| @@ -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'; | |||||
| @@ -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(); }); | |||||
| @@ -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(); | |||||
| }); | |||||
| @@ -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' }; | |||||
| } | |||||
| @@ -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, '"')}"> | |||||
| <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(); | |||||
| }); | |||||
| @@ -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'; | |||||
| } | |||||
| }); | |||||
| }); | |||||
| @@ -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); | |||||
| } | |||||
| }); | |||||
| @@ -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); | |||||
| } | |||||
| } | |||||
| @@ -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; | |||||
| } | |||||
| } | |||||
| @@ -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; | |||||
| } | |||||
| @@ -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; | |||||
| @@ -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; } | |||||
| } | |||||
| @@ -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; | |||||
| } | |||||
| } | |||||
| @@ -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; | |||||
| } | |||||
| } | |||||
| @@ -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; | |||||
| } | |||||
| @@ -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; | |||||
| } | |||||
| @@ -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; | |||||
| } | |||||
| } | |||||
| @@ -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; | |||||
| } | |||||
| @@ -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; | |||||
| } | |||||
| } | |||||
| @@ -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; | |||||
| } | |||||
| } | |||||
| @@ -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; | |||||
| } | |||||
| @@ -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; | |||||
| } | |||||
| @@ -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); } | |||||
| } | |||||
| @@ -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'); | |||||
| @@ -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; | |||||
| } | |||||
| @@ -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; | |||||
| } | |||||
| @@ -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); | |||||
| }; | |||||
| @@ -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" | |||||
| } | |||||
| } | |||||
| @@ -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], | |||||
| ]; | |||||
| @@ -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 | |||||
| @@ -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 | |||||
| @@ -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 | |||||
| @@ -0,0 +1,5 @@ | |||||
| doctrine_migrations: | |||||
| migrations_paths: | |||||
| 'DoctrineMigrations': '%kernel.project_dir%/migrations/central' | |||||
| # 'DoctrineMigrationsTenant': '%kernel.project_dir%/migrations/tenant' | |||||
| enable_profiler: false | |||||
| @@ -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 | |||||
| @@ -0,0 +1,5 @@ | |||||
| framework: | |||||
| mailer: | |||||
| dsn: '%env(MAILER_DSN)%' | |||||
| headers: | |||||
| from: 'spawntree Timetracker <noreply@spawntree.de>' | |||||
| @@ -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'] | |||||
| @@ -0,0 +1,3 @@ | |||||
| framework: | |||||
| property_info: | |||||
| with_constructor_extractor: true | |||||
| @@ -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 | |||||
| @@ -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 | |||||
| @@ -0,0 +1,7 @@ | |||||
| framework: | |||||
| default_locale: de | |||||
| translator: | |||||
| default_path: '%kernel.project_dir%/translations' | |||||
| fallbacks: | |||||
| - de | |||||
| providers: | |||||
| @@ -0,0 +1,6 @@ | |||||
| twig: | |||||
| file_name_pattern: '*.twig' | |||||
| when@test: | |||||
| twig: | |||||
| strict_variables: true | |||||
| @@ -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 | |||||
| @@ -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 | |||||
| @@ -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'; | |||||
| } | |||||
| @@ -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 | |||||
| @@ -0,0 +1,4 @@ | |||||
| when@dev: | |||||
| _errors: | |||||
| resource: '@FrameworkBundle/Resources/config/routing/errors.php' | |||||
| prefix: /_error | |||||
| @@ -0,0 +1,3 @@ | |||||
| _security_logout: | |||||
| resource: security.route_loader.logout | |||||
| type: service | |||||
| @@ -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 +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`'); | |||||
| } | |||||
| } | |||||
| @@ -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'); | |||||
| } | |||||
| } | |||||
| @@ -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'); | |||||
| } | |||||
| } | |||||
| @@ -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'); | |||||
| } | |||||
| } | |||||
| @@ -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 +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" | |||||
| } | |||||
| } | |||||
| @@ -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']); | |||||
| }; | |||||
| @@ -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 +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]); | |||||
| } | |||||
| } | |||||
| @@ -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(), | |||||
| ]; | |||||
| } | |||||
| } | |||||
| @@ -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'); | |||||
| } | |||||
| } | |||||
| @@ -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, | |||||
| ]); | |||||
| } | |||||
| } | |||||
| @@ -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(), | |||||
| ]; | |||||
| } | |||||
| } | |||||
| @@ -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(), | |||||
| ]); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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.'); | |||||
| } | |||||
| } | |||||
| @@ -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(), | |||||
| ]; | |||||
| } | |||||
| } | |||||
| @@ -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); | |||||
| } | |||||
| } | |||||
| @@ -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); | |||||
| } | |||||
| } | |||||
| @@ -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 +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; } | |||||
| } | |||||
| @@ -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, | |||||
| }; | |||||
| } | |||||
| } | |||||
| @@ -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; } | |||||
| } | |||||
| @@ -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; } | |||||
| } | |||||
| @@ -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(); } | |||||
| } | |||||
| @@ -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; } | |||||
| } | |||||
| @@ -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; } | |||||
| } | |||||
| @@ -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; } | |||||
| } | |||||
| @@ -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, | |||||
| ]; | |||||
| } | |||||
| } | |||||
| @@ -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', | |||||
| ]; | |||||
| } | |||||
| } | |||||