| @@ -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', | |||
| ]; | |||
| } | |||
| } | |||