[
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\nindent_size = 4\nindent_style = space\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n\n[*.md]\ntrim_trailing_whitespace = false\n\n[*.{yml,yaml}]\nindent_size = 2\n\n[docker-compose.yml]\nindent_size = 4\n"
  },
  {
    "path": ".gitattributes",
    "content": "* text=auto eol=lf\n\n*.blade.php diff=html\n*.css diff=css\n*.html diff=html\n*.md diff=markdown\n*.php diff=php\n\n/.github export-ignore\nCHANGELOG.md export-ignore\n.styleci.yml export-ignore\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "github: solidtime-io\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/1_bug_report.yml",
    "content": "name: Bug Report\ndescription: \"Report a bug\"\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Before creating a new bug report, please check that there isn't already a similar issue.\n\n  - type: textarea\n    attributes:\n      label: Description\n      description: A clear and concise description of what the bug is.\n    validations:\n      required: true\n\n  - type: textarea\n    attributes:\n      label: \"Steps To Reproduce\"\n      description: How do you trigger this bug? Please walk us through it step by step.\n      value: |\n        1.\n        2.\n        3.\n        ...\n    validations:\n      required: false\n\n  - type: dropdown\n    attributes:\n      label: \"Self-hosted or Cloud?\"\n      options:\n        - Self-Hosted\n        - solidtime Cloud\n        - Both\n\n  - type: input\n    attributes:\n      label: \"Version of solidtime: (for self-hosted)\"\n    validations:\n      required: false\n\n  - type: input\n    attributes:\n      label: \"solidtime self-hosting guide: (for self-hosted)\"\n      description: \"Did you use the official guide to self-host solidtime? If yes, which one?\"\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: 🚀 Feature Request\n    url: https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests\n    about: Share ideas for new features\n  - name: ❓ Ask a Question\n    url: https://github.com/solidtime-io/solidtime/discussions/new?category=general\n    about: Ask the community for help\n"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "content": "## What does this PR do?\n\n<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->\n\n- Fixes #XXXX (GitHub issue number)\n\n## Checklist (DO NOT REMOVE)\n\n- [ ] I read the [contributing guide](https://github.com/solidtime-io/solidtime/blob/main/CONTRIBUTING.md)\n- [ ] I signed the [Contributor License Agreement](https://cla-assistant.io/solidtime-io/solidtime).\n- [ ] I commented my code, particularly in hard-to-understand areas\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"daily\"\n    target-branch: \"main\"\n  - package-ecosystem: \"docker\"\n    directory: \"/\"\n    schedule:\n      interval: \"daily\"\n    target-branch: \"main\"\n  - package-ecosystem: \"composer\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n    target-branch: \"main\"\n    groups:\n      major-updates:\n        update-types:\n          - \"major\"\n      minor-updates:\n        update-types:\n          - \"minor\"\n          - \"patch\"\n      security-updates:\n        applies-to: version-updates\n        update-types:\n          - \"minor\"\n          - \"patch\"\n  - package-ecosystem: \"npm\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n    target-branch: \"main\"\n    groups:\n      major-updates:\n        update-types:\n          - \"major\"\n      minor-updates:\n        update-types:\n          - \"minor\"\n          - \"patch\"\n      security-updates:\n        applies-to: version-updates\n        update-types:\n          - \"minor\"\n          - \"patch\"\n"
  },
  {
    "path": ".github/workflows/build-onpremise.yml",
    "content": "on:\n  push:\n    branches:\n      - main\n      - develop\n    tags:\n      - '*'\n  pull_request:\n    paths:\n      - '.github/workflows/build-onpremise.yml'\n      - 'docker/prod/**'\n  workflow_dispatch:\n\npermissions:\n  packages: write\n  contents: read\n  attestations: write\n  id-token: write\n\nenv:\n  DOCKER_REPO: registry.on-premise.solidtime.io/solidtime/solidtime\n\nname: Build - On Premise\njobs:\n  build:\n    strategy:\n      matrix:\n        include:\n          - runs-on: \"ubuntu-24.04-arm\"\n            platform: \"linux/arm64\"\n          - runs-on: \"ubuntu-24.04\"\n            platform: \"linux/amd64\"\n    runs-on: ${{ matrix.runs-on }}\n    timeout-minutes: 90\n\n    steps:\n      - name: \"Check out code\"\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag\n\n      - name: \"Get build\"\n        id: release-build\n        run: echo \"build=$(git rev-parse --short=8 HEAD)\" >> \"$GITHUB_OUTPUT\"\n\n      - name: \"Get Previous tag (normal push)\"\n        id: previoustag\n        if: ${{ !startsWith(github.ref, 'refs/tags/v') }}\n        uses: \"WyriHaximus/github-action-get-previous-tag@v1\"\n        with:\n          prefix: \"v\"\n\n      - name: \"Get version\"\n        id: release-version\n        run: |\n          if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then\n            if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then\n              version=$(echo \"${{ steps.previoustag.outputs.tag }}\" | cut -c 2-)\n              echo \"app_version=${version}\" >> \"$GITHUB_OUTPUT\"\n            else\n              echo \"ERROR: No previous tag found\";\n              exit 1;\n            fi\n          else\n            version=$(echo \"${{ github.ref }}\" | cut -c 12-)\n            echo \"app_version=${version}\" >> \"$GITHUB_OUTPUT\"\n          fi\n\n      - name: \"Copy .env template for production\"\n        run: |\n          cp .env.production .env\n          rm .env.production .env.ci .env.example\n\n      - name: \"Add version to .env\"\n        run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.release-version.outputs.app_version }}/g' .env\n\n      - name: \"Add build to .env\"\n        run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.release-build.outputs.build }}/g' .env\n\n      - name: \"Output .env\"\n        run: cat .env\n\n      - name: \"Setup PHP with PECL extension\"\n        uses: shivammathur/setup-php@v2\n        with:\n          php-version: '8.3'\n          extensions: mbstring, dom, fileinfo, pgsql\n\n      - name: \"Install dependencies\"\n        run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative\n        if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit\n\n      - name: \"Use Node.js\"\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20.x'\n\n      - name: \"Checkout invoicing extension\"\n        uses: actions/checkout@v4\n        with:\n          repository: solidtime-io/extension-invoicing\n          path: extensions/Invoicing\n          ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}\n\n      - name: \"Install composer dependencies in invoicing extension\"\n        run: cd extensions/Invoicing && composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative\n\n      - name: \"Install npm dependencies in invoicing extension\"\n        run: cd extensions/Invoicing && npm ci\n\n      - name: \"Activate invoicing extension\"\n        run: php artisan module:enable Invoicing\n\n      - name: \"Install npm dependencies\"\n        run: npm ci\n\n      - name: \"Build\"\n        run: npm run build\n\n      - name: \"Prepare\"\n        run: |\n          platform=${{ matrix.platform }}\n          echo \"PLATFORM_PAIR=${platform//\\//-}\" >> $GITHUB_ENV\n\n      - name: \"Docker meta\"\n        id: \"meta\"\n        uses: docker/metadata-action@v5\n        with:\n          images: |\n            ${{ env.DOCKER_REPO }}\n\n      - name: \"Login to solidtime OnPremise Registry\"\n        uses: docker/login-action@v3\n        with:\n          registry: registry.on-premise.solidtime.io\n          username: ${{ secrets.ONPREMISE_USERNAME }}\n          password: ${{ secrets.ONPREMISE_TOKEN }}\n\n      - name: \"Set up QEMU\"\n        uses: docker/setup-qemu-action@v3\n\n      - name: \"Set up Docker Buildx\"\n        uses: docker/setup-buildx-action@v3\n\n      - name: \"Build and push by digest\"\n        id: build\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          file: docker/prod/Dockerfile\n          build-args: |\n            DOCKER_FILES_BASE_PATH=docker/prod/\n          platforms: ${{ matrix.platform }}\n          labels: ${{ steps.meta.outputs.labels }}\n          outputs: type=image,\"name=${{ env.DOCKER_REPO }}\",push-by-digest=true,name-canonical=true,push=true\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n\n      - name: \"Export digest\"\n        run: |\n          mkdir -p ${{ runner.temp }}/digests\n          digest=\"${{ steps.build.outputs.digest }}\"\n          touch \"${{ runner.temp }}/digests/${digest#sha256:}\"\n\n      - name: \"Upload digest\"\n        uses: actions/upload-artifact@v4\n        with:\n          name: digests-${{ env.PLATFORM_PAIR }}\n          path: ${{ runner.temp }}/digests/*\n          if-no-files-found: error\n          retention-days: 1\n\n  merge:\n    runs-on: ubuntu-latest\n    timeout-minutes: 90\n    needs:\n      - build\n    steps:\n      - name: \"Download digests\"\n        uses: actions/download-artifact@v4\n        with:\n          path: ${{ runner.temp }}/digests\n          pattern: digests-*\n          merge-multiple: true\n\n      - name: \"Login to solidtime OnPremise Registry\"\n        uses: docker/login-action@v3\n        with:\n          registry: registry.on-premise.solidtime.io\n          username: ${{ secrets.ONPREMISE_USERNAME }}\n          password: ${{ secrets.ONPREMISE_TOKEN }}\n\n      - name: \"Set up Docker Buildx\"\n        uses: docker/setup-buildx-action@v3\n\n      - name: \"Docker meta\"\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: |\n            ${{ env.DOCKER_REPO }}\n          tags: |\n            type=ref,event=branch\n            type=ref,event=pr\n            type=semver,pattern={{version}}\n            type=semver,pattern={{major}}.{{minor}}\n\n      - name: \"Create manifest list and push\"\n        working-directory: ${{ runner.temp }}/digests\n        run: |\n          docker buildx imagetools create $(jq -cr '.tags | map(\"-t \" + .) | join(\" \")' <<< \"$DOCKER_METADATA_OUTPUT_JSON\") \\\n            $(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)\n\n      - name: \"Inspect image\"\n        run: |\n          docker buildx imagetools inspect ${{ env.DOCKER_REPO }}:${{ steps.meta.outputs.version }}\n"
  },
  {
    "path": ".github/workflows/build-private.yml",
    "content": "on:\n  push:\n    branches:\n      - main\n      - develop\n    tags:\n      - '*'\n  pull_request:\n    paths:\n      - '.github/workflows/build-private.yml'\n      - 'docker/prod/**'\n  workflow_dispatch:\npermissions:\n  contents: read\n\nname: Build - Private\njobs:\n  build:\n    runs-on: ubuntu-latest\n    timeout-minutes: 20\n\n\n    steps:\n      - name: \"Check out code\"\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag\n\n      - name: \"Get build\"\n        id: build\n        run: echo \"build=$(git rev-parse --short=8 HEAD)\" >> \"$GITHUB_OUTPUT\"\n\n      - name: \"Get Previous tag (normal push)\"\n        id: previoustag\n        if: ${{ !startsWith(github.ref, 'refs/tags/v') }}\n        uses: \"WyriHaximus/github-action-get-previous-tag@v1\"\n        with:\n          prefix: \"v\"\n\n      - name: \"Get version\"\n        id: version\n        run: |\n          if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then\n            if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then\n              version=$(echo \"${{ steps.previoustag.outputs.tag }}\" | cut -c 2-)\n              echo \"app_version=${version}\" >> \"$GITHUB_OUTPUT\"\n            else\n              echo \"ERROR: No previous tag found\";\n              exit 1;\n            fi\n          else\n            version=$(echo \"${{ github.ref }}\" | cut -c 12-)\n            echo \"app_version=${version}\" >> \"$GITHUB_OUTPUT\"\n          fi\n\n      - name: \"Copy .env template for production\"\n        run: |\n          cp .env.production .env\n          rm .env.production .env.ci .env.example\n\n      - name: \"Add version to .env\"\n        run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.version.outputs.app_version }}/g' .env\n\n      - name: \"Add build to .env\"\n        run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.build.outputs.build }}/g' .env\n\n      - name: \"Output .env\"\n        run: cat .env\n\n      - name: \"Use Node.js\"\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20.x'\n\n      - name: \"Checkout billing extension\"\n        uses: actions/checkout@v4\n        with:\n          repository: solidtime-io/extension-billing\n          path: extensions/Billing\n          ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}\n\n      - name: \"Install dependencies in billing extension\"\n        uses: php-actions/composer@v6\n        env:\n          COMPOSER_AUTH: '{\"http-basic\": {\"spark.laravel.com\": {\"username\": \"gregor@vostrak.at\", \"password\": \"${{ secrets.LARAVEL_SPARK_API_KEY }}\"}}}'\n        with:\n          working_dir: \"extensions/Billing\"\n          command: install\n          only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative\n          php_version: 8.3\n\n      - name: \"Install npm dependencies in billing extension\"\n        run: cd extensions/Billing && npm ci\n\n      - name: \"Checkout services extension\"\n        uses: actions/checkout@v4\n        with:\n          repository: solidtime-io/extension-services\n          path: extensions/Services\n          ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}\n\n      - name: \"Install composer dependencies in services extension\"\n        uses: php-actions/composer@v6\n        with:\n          working_dir: \"extensions/Services\"\n          command: install\n          only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative\n          php_version: 8.3\n\n      - name: \"Install npm dependencies in services extension\"\n        run: cd extensions/Services && npm ci\n\n      - name: \"Checkout invoicing extension\"\n        uses: actions/checkout@v4\n        with:\n          repository: solidtime-io/extension-invoicing\n          path: extensions/Invoicing\n          ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}\n\n      - name: \"Install composer dependencies in invoicing extension\"\n        uses: php-actions/composer@v6\n        with:\n          working_dir: \"extensions/Invoicing\"\n          command: install\n          only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative\n          php_version: 8.3\n\n      - name: \"Install npm dependencies in invoicing extension\"\n        run: cd extensions/Invoicing && npm ci\n\n      - name: \"Setup PHP with PECL extension\"\n        uses: shivammathur/setup-php@v2\n        with:\n          php-version: '8.3'\n          extensions: mbstring, dom, fileinfo, pgsql\n\n      - name: \"Install dependencies\"\n        uses: php-actions/composer@v6\n        if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit\n        with:\n          command: install\n          only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative\n          php_version: 8.3\n\n      - name: \"Activate billing extension\"\n        run: php artisan module:enable Billing\n\n      - name: \"Activate services extension\"\n        run: php artisan module:enable Services\n\n      - name: \"Activate invoicing extension\"\n        run: php artisan module:enable Invoicing\n\n      - name: \"Install npm dependencies\"\n        run: npm ci\n\n      - name: \"Build\"\n        run: npm run build\n        env:\n          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}\n\n      - name: \"Login to GitHub Container Registry\"\n        uses: docker/login-action@v3\n        with:\n          registry: rg.fr-par.scw.cloud/solidtime\n          username: nologin\n          password: ${{ secrets.SCALEWAY_REGISTRY_TOKEN }}\n\n      - name: \"Docker meta\"\n        id: \"meta\"\n        uses: docker/metadata-action@v5\n        with:\n          images: rg.fr-par.scw.cloud/solidtime/solidtime\n          tags: |\n            type=ref,event=branch\n            type=ref,event=pr\n            type=semver,pattern={{version}}\n            type=semver,pattern={{major}}.{{minor}}\n            type=sha,format=long\n\n      - name: \"Set up QEMU\"\n        uses: docker/setup-qemu-action@v3\n\n      - name: \"Set up Docker Buildx\"\n        uses: docker/setup-buildx-action@v3\n\n      - name: \"Build and push\"\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          build-args: |\n            DOCKER_FILES_BASE_PATH=docker/prod/\n          file: docker/prod/Dockerfile\n          push: true\n          platforms: linux/amd64\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n"
  },
  {
    "path": ".github/workflows/build-public.yml",
    "content": "on:\n  push:\n    branches:\n      - main\n      - develop\n    tags:\n      - '*'\n  pull_request:\n    paths:\n      - '.github/workflows/build-public.yml'\n      - 'docker/prod/**'\n  workflow_dispatch:\n\npermissions:\n  packages: write\n  contents: read\n  attestations: write\n  id-token: write\n\nenv:\n  DOCKERHUB_REPO: solidtime/solidtime\n  GHCR_REPO: ghcr.io/solidtime-io/solidtime\n\nname: Build - Public\njobs:\n  build:\n    strategy:\n      matrix:\n        include:\n          - runs-on: \"ubuntu-24.04-arm\"\n            platform: \"linux/arm64\"\n          - runs-on: \"ubuntu-24.04\"\n            platform: \"linux/amd64\"\n    runs-on: ${{ matrix.runs-on }}\n    timeout-minutes: 90\n\n    steps:\n      - name: \"Check out code\"\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag\n\n      - name: \"Get build\"\n        id: release-build\n        run: echo \"build=$(git rev-parse --short=8 HEAD)\" >> \"$GITHUB_OUTPUT\"\n\n      - name: \"Get Previous tag (normal push)\"\n        id: previoustag\n        if: ${{ !startsWith(github.ref, 'refs/tags/v') }}\n        uses: \"WyriHaximus/github-action-get-previous-tag@v1\"\n        with:\n          prefix: \"v\"\n\n      - name: \"Get version\"\n        id: release-version\n        run: |\n          if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then\n            if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then\n              version=$(echo \"${{ steps.previoustag.outputs.tag }}\" | cut -c 2-)\n              echo \"app_version=${version}\" >> \"$GITHUB_OUTPUT\"\n            else\n              echo \"ERROR: No previous tag found\";\n              exit 1;\n            fi\n          else\n            version=$(echo \"${{ github.ref }}\" | cut -c 12-)\n            echo \"app_version=${version}\" >> \"$GITHUB_OUTPUT\"\n          fi\n\n      - name: \"Copy .env template for production\"\n        run: |\n          cp .env.production .env\n          rm .env.production .env.ci .env.example\n\n      - name: \"Add version to .env\"\n        run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.release-version.outputs.app_version }}/g' .env\n\n      - name: \"Add build to .env\"\n        run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.release-build.outputs.build }}/g' .env\n\n      - name: \"Output .env\"\n        run: cat .env\n\n      - name: \"Setup PHP with PECL extension\"\n        uses: shivammathur/setup-php@v2\n        with:\n          php-version: '8.3'\n          extensions: mbstring, dom, fileinfo, pgsql\n\n      - name: \"Install dependencies\"\n        run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative\n        if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit\n\n      - name: \"Use Node.js\"\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20.x'\n\n      - name: \"Install npm dependencies\"\n        run: npm ci\n\n      - name: \"Build\"\n        run: npm run build\n\n      - name: \"Prepare\"\n        run: |\n          platform=${{ matrix.platform }}\n          echo \"PLATFORM_PAIR=${platform//\\//-}\" >> $GITHUB_ENV\n\n      - name: \"Docker meta\"\n        id: \"meta\"\n        uses: docker/metadata-action@v5\n        with:\n          images: |\n            ${{ env.DOCKERHUB_REPO }}\n            ${{ env.GHCR_REPO }}\n\n      - name: \"Login to Docker Hub Container Registry\"\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n\n      - name: \"Login to GitHub Container Registry\"\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: \"Set up QEMU\"\n        uses: docker/setup-qemu-action@v3\n\n      - name: \"Set up Docker Buildx\"\n        uses: docker/setup-buildx-action@v3\n\n      - name: \"Build and push by digest\"\n        id: build\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          file: docker/prod/Dockerfile\n          build-args: |\n            DOCKER_FILES_BASE_PATH=docker/prod/\n          platforms: ${{ matrix.platform }}\n          labels: ${{ steps.meta.outputs.labels }}\n          outputs: type=image,\"name=${{ env.DOCKERHUB_REPO }},${{ env.GHCR_REPO }}\",push-by-digest=true,name-canonical=true,push=true\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n\n      - name: \"Export digest\"\n        run: |\n          mkdir -p ${{ runner.temp }}/digests\n          digest=\"${{ steps.build.outputs.digest }}\"\n          touch \"${{ runner.temp }}/digests/${digest#sha256:}\"\n\n      - name: \"Upload digest\"\n        uses: actions/upload-artifact@v4\n        with:\n          name: digests-${{ env.PLATFORM_PAIR }}\n          path: ${{ runner.temp }}/digests/*\n          if-no-files-found: error\n          retention-days: 1\n\n  merge:\n    runs-on: ubuntu-latest\n    timeout-minutes: 90\n    needs:\n      - build\n    steps:\n      - name: \"Download digests\"\n        uses: actions/download-artifact@v4\n        with:\n          path: ${{ runner.temp }}/digests\n          pattern: digests-*\n          merge-multiple: true\n\n      - name: \"Login to Docker Hub\"\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n\n      - name: \"Login to GHCR\"\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: \"Set up Docker Buildx\"\n        uses: docker/setup-buildx-action@v3\n\n      - name: \"Docker meta\"\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: |\n            ${{ env.DOCKERHUB_REPO }}\n            ${{ env.GHCR_REPO }}\n          tags: |\n            type=ref,event=branch\n            type=ref,event=pr\n            type=semver,pattern={{version}}\n            type=semver,pattern={{major}}.{{minor}}\n\n      - name: \"Create manifest list and push\"\n        working-directory: ${{ runner.temp }}/digests\n        run: |\n          docker buildx imagetools create $(jq -cr '.tags | map(\"-t \" + .) | join(\" \")' <<< \"$DOCKER_METADATA_OUTPUT_JSON\") \\\n            $(printf '${{ env.DOCKERHUB_REPO }}@sha256:%s ' *)\n          docker buildx imagetools create $(jq -cr '.tags | map(\"-t \" + .) | join(\" \")' <<< \"$DOCKER_METADATA_OUTPUT_JSON\") \\\n            $(printf '${{ env.GHCR_REPO }}@sha256:%s ' *)\n\n      - name: \"Inspect image\"\n        run: |\n          docker buildx imagetools inspect ${{ env.DOCKERHUB_REPO }}:${{ steps.meta.outputs.version }}\n          docker buildx imagetools inspect ${{ env.GHCR_REPO }}:${{ steps.meta.outputs.version }}\n"
  },
  {
    "path": ".github/workflows/generate-api-docs.yml",
    "content": "name: Generate API docs\non:\n  push:\n    branches:\n      - main\npermissions:\n  contents: read\n\njobs:\n  api_docs:\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n\n    services:\n      pgsql_test:\n        image: postgres:15\n        env:\n          PGPASSWORD: 'root'\n          POSTGRES_DB: 'laravel'\n          POSTGRES_USER: 'root'\n          POSTGRES_PASSWORD: 'root'\n        ports:\n          - 5432:5432\n        options: >-\n          --health-cmd pg_isready\n          --health-interval 10s\n          --health-timeout 5s\n          --health-retries 5\n\n    steps:\n      - name: \"Checkout code\"\n        uses: actions/checkout@v4\n\n      - name: \"Setup PHP\"\n        uses: shivammathur/setup-php@v2\n        with:\n          php-version: '8.3'\n          extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv\n\n      - name: \"Run composer install\"\n        run: composer install -n --prefer-dist\n\n      - name: \"Create build directory\"\n        run: mkdir build\n\n      - name: Prepare Laravel Application\n        run: |\n          cp .env.ci .env\n          php artisan migrate\n\n      - name: \"Export API docs\"\n        run: php artisan scramble:export --path=build/api-docs.json\n\n      - name: \"Upload API docs to GitHub\"\n        uses: actions/upload-artifact@v4\n        with:\n          name: api-docs.json\n          path: build/api-docs.json\n\n      - name: \"Download Fastfront CLI\"\n        run: curl https://fastfront-cli.s3.fr-par.scw.cloud/fastfront-cli.phar -o fastfront-cli.phar\n\n      - name: \"Deploy with Fastfront\"\n        run: php fastfront-cli.phar deploy 9beab6cf-f459-446b-85f1-38ec007cf457 ./build\n        env:\n          FASTFRONT_API_KEY: ${{ secrets.FASTFRONT_API_DOCS_API_KEY }}\n"
  },
  {
    "path": ".github/workflows/npm-build.yml",
    "content": "name: NPM Build\n\non: [push]\npermissions:\n  contents: read\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n\n    steps:\n      - name: \"Checkout code\"\n        uses: actions/checkout@v4\n\n      - name: \"Setup PHP (for Ziggy)\"\n        uses: shivammathur/setup-php@v2\n        with:\n          php-version: '8.3'\n          extensions: intl, zip\n          coverage: none\n\n      - name: \"Run composer install (for Ziggy)\"\n        run: composer install -n --prefer-dist\n\n      - name: \"Use Node.js\"\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20.x'\n\n      - name: \"Install npm dependencies\"\n        run: npm ci\n\n      - name: \"Build\"\n        run: npm run build\n"
  },
  {
    "path": ".github/workflows/npm-format-check.yml",
    "content": "name: NPM Format Check\n\non: [push]\n\njobs:\n  format-check:\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n\n    steps:\n      - name: \"Checkout code\"\n        uses: actions/checkout@v4\n\n      - name: \"Use Node.js\"\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20.x'\n\n      - name: \"Install npm dependencies\"\n        run: npm ci\n\n      - name: \"Check code formatting\"\n        run: npm run format:check "
  },
  {
    "path": ".github/workflows/npm-lint.yml",
    "content": "name: NPM Lint\n\non: [push]\npermissions:\n  contents: read\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n\n    steps:\n      - name: \"Checkout code\"\n        uses: actions/checkout@v4\n\n      - name: \"Use Node.js\"\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20.x'\n\n      - name: \"Install npm dependencies\"\n        run: npm ci\n\n      - name: \"Run linter\"\n        run: npm run lint\n"
  },
  {
    "path": ".github/workflows/npm-publish-api.yml",
    "content": "name: Publish API package to NPM\non:\n  workflow_dispatch\npermissions:\n  contents: read\njobs:\n  build:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      id-token: write\n    steps:\n      - name: \"Checkout code\"\n        uses: actions/checkout@v4\n      # Setup .npmrc file to publish to npm\n      - name: Install root project dependencies\n        run: npm ci\n      - uses: actions/setup-node@v4\n        with:\n          node-version: '20.x'\n          registry-url: 'https://registry.npmjs.org'\n      - name: Install dependencies\n        run: npm ci\n        working-directory: ./resources/js/packages/api\n      - name: Build package\n        run: npm run build\n        working-directory: ./resources/js/packages/api\n      - name: Publish Package\n        run: npm publish --provenance --access public\n        working-directory: ./resources/js/packages/api\n        env:\n          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/npm-publish-ui.yml",
    "content": "name: Publish UI package to NPM\non:\n  workflow_dispatch\npermissions:\n  contents: read\njobs:\n  build:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      id-token: write\n    steps:\n      - name: \"Checkout code\"\n        uses: actions/checkout@v4\n      # Setup .npmrc file to publish to npm\n      - uses: actions/setup-node@v4\n        with:\n          node-version: '20.x'\n          registry-url: 'https://registry.npmjs.org'\n      - name: Install root project dependencies\n        run: npm ci\n      - name: Install package dependencies\n        run: npm ci\n        working-directory: ./resources/js/packages/ui\n      - name: Build package\n        run: npm run build\n        working-directory: ./resources/js/packages/ui\n      - name: Publish Package\n        run: npm publish --provenance --access public\n        working-directory: ./resources/js/packages/ui\n        env:\n          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/npm-typecheck.yml",
    "content": "name: NPM Typecheck\n\non: [push]\npermissions:\n  contents: read\njobs:\n  build:\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n\n    steps:\n      - name: \"Checkout code\"\n        uses: actions/checkout@v4\n\n      - name: \"Setup PHP (for Ziggy)\"\n        uses: shivammathur/setup-php@v2\n        with:\n          php-version: '8.3'\n          extensions: intl, zip\n          coverage: none\n\n      - name: \"Run composer install (for Ziggy)\"\n        run: composer install -n --prefer-dist\n\n      - name: \"Use Node.js\"\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20.x'\n\n      - name: \"Install npm dependencies\"\n        run: npm ci\n\n      - name: \"Run type check\"\n        run: npm run type-check\n"
  },
  {
    "path": ".github/workflows/phpstan.yml",
    "content": "name: Static code analysis (PHPStan)\non: push\npermissions:\n  contents: read\njobs:\n  phpstan:\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n\n    steps:\n      - name: \"Checkout code\"\n        uses: actions/checkout@v4\n\n      - name: \"Setup PHP\"\n        uses: shivammathur/setup-php@v2\n        with:\n          php-version: '8.3'\n          extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv\n          coverage: none\n\n      - name: \"Run composer install\"\n        run: composer install -n --prefer-dist\n\n      - name: \"Run PHPStan\"\n        run: composer analyse\n\n\n"
  },
  {
    "path": ".github/workflows/phpunit.yml",
    "content": "name: PHPUnit Tests\non: push\npermissions:\n  contents: read\njobs:\n  phpunit:\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n    strategy:\n      matrix:\n        postgres_version: [ 15, 16, 17 ]\n\n    services:\n      pgsql_test:\n        image: postgres:${{ matrix.postgres_version }}\n        env:\n          PGPASSWORD: 'root'\n          POSTGRES_DB: 'laravel'\n          POSTGRES_USER: 'root'\n          POSTGRES_PASSWORD: 'root'\n        ports:\n          - 5432:5432\n        options: >-\n          --health-cmd pg_isready\n          --health-interval 10s\n          --health-timeout 5s\n          --health-retries 5\n      gotenberg:\n        image: gotenberg/gotenberg:8\n        ports:\n          - 3000:3000\n        options: >-\n          --health-cmd \"curl --silent --fail http://localhost:3000/health\"\n          --health-interval 10s\n          --health-timeout 5s\n          --health-retries 5\n    steps:\n      - name: \"Checkout code\"\n        uses: actions/checkout@v4\n\n      - name: \"Setup PHP\"\n        uses: shivammathur/setup-php@v2\n        with:\n          php-version: '8.3'\n          extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv\n          coverage: pcov\n\n      - name: \"Run composer install\"\n        run: composer install -n --prefer-dist\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: '20.x'\n\n      - name: \"Install dependencies\"\n        run: npm ci\n\n      - name: \"Build Frontend\"\n        run: npm run build\n\n      - name: \"Prepare Laravel Application\"\n        run: |\n          cp .env.ci .env\n          php artisan key:generate\n          php artisan passport:keys\n\n      - name: \"Run PHPUnit\"\n        run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml\n\n      - name: \"Upload coverage reports to Codecov\"\n        uses: codecov/codecov-action@v5.4.3\n        with:\n          token: ${{ secrets.CODECOV_TOKEN }}\n          slug: solidtime-io/solidtime\n"
  },
  {
    "path": ".github/workflows/pint.yml",
    "content": "name: PHP Linting\non: push\npermissions:\n  contents: read\njobs:\n  pint:\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n\n    steps:\n      - name: \"Checkout code\"\n        uses: actions/checkout@v4\n\n      - name: \"Check code style\"\n        uses: aglipanci/laravel-pint-action@2.5\n        with:\n          configPath: \"pint.json\"\n"
  },
  {
    "path": ".github/workflows/playwright.yml",
    "content": "name: Playwright Tests\non: [push]\npermissions:\n  contents: read\njobs:\n  test:\n    runs-on: ubuntu-latest\n    timeout-minutes: 60\n    strategy:\n      fail-fast: false\n      matrix:\n        shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]\n        shardTotal: [8]\n\n    services:\n      mailpit:\n        image: 'axllent/mailpit:latest'\n        ports:\n          - 1025:1025\n          - 8025:8025\n      pgsql_test:\n        image: postgres:15\n        env:\n          PGPASSWORD: 'root'\n          POSTGRES_DB: 'laravel'\n          POSTGRES_USER: 'root'\n          POSTGRES_PASSWORD: 'root'\n        ports:\n          - 5432:5432\n        options: >-\n          --health-cmd pg_isready\n          --health-interval 10s\n          --health-timeout 5s\n          --health-retries 5\n\n    steps:\n      - name: \"Checkout code\"\n        uses: actions/checkout@v4\n\n      - name: \"Setup node\"\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20.x'\n\n      - name: \"Setup PHP\"\n        uses: shivammathur/setup-php@v2\n        with:\n          php-version: '8.3'\n          extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv\n          coverage: none\n\n      - name: \"Run composer install\"\n        run: composer install -n --prefer-dist\n\n      - name: \"Prepare Laravel Application\"\n        run: |\n          cp .env.ci .env\n          php artisan key:generate\n          php artisan passport:keys\n          php artisan migrate --seed\n\n      - name: \"Install dependencies\"\n        run: npm ci\n\n      - name: \"Build Frontend\"\n        run: npm run build\n\n      - name: \"Install FrankenPHP\"\n        run: |\n          ARCH=\"$(uname -m)\"\n          curl -fsSL \"https://github.com/dunglas/frankenphp/releases/latest/download/frankenphp-linux-${ARCH}\" -o /usr/local/bin/frankenphp\n          chmod +x /usr/local/bin/frankenphp\n\n      - name: \"Run Laravel Octane Server\"\n        run: php artisan octane:start --server=frankenphp --host=127.0.0.1 --port=8000 --workers=4 --max-requests=500 > /dev/null 2>&1 &\n        env:\n          OCTANE_SERVER: frankenphp\n\n      - name: \"Install Playwright Browsers\"\n        run: npx playwright install --with-deps\n\n      - name: \"Run Playwright tests\"\n        run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}\n        env:\n          PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000'\n          MAILPIT_BASE_URL: 'http://localhost:8025'\n\n      - name: \"Upload blob report\"\n        uses: actions/upload-artifact@v4\n        if: always()\n        with:\n          name: blob-report-${{ matrix.shardIndex }}\n          path: blob-report/\n          retention-days: 7\n\n  merge-reports:\n    if: always()\n    needs: [test]\n    runs-on: ubuntu-latest\n    steps:\n      - name: \"Checkout code\"\n        uses: actions/checkout@v4\n\n      - name: \"Setup node\"\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20.x'\n\n      - name: \"Install dependencies\"\n        run: npm ci\n\n      - name: \"Download blob reports\"\n        uses: actions/download-artifact@v4\n        with:\n          path: all-blob-reports\n          pattern: blob-report-*\n          merge-multiple: true\n\n      - name: \"Merge reports\"\n        run: npx playwright merge-reports --reporter html ./all-blob-reports\n\n      - name: \"Upload merged HTML report\"\n        uses: actions/upload-artifact@v4\n        with:\n          name: playwright-report\n          path: playwright-report/\n          retention-days: 30\n"
  },
  {
    "path": ".gitignore",
    "content": "/.phpunit.cache\nnode_modules\ndist\n/public/build\n/public/hot\n/public/storage\n/public/css\n/public/js\n/public/vendor\n/lang/vendor\n/storage/*.key\n/vendor\n.env\n.env.backup\n.phpunit.result.cache\nHomestead.json\nHomestead.yaml\nauth.json\nnpm-debug.log\nyarn-error.log\n/.fleet\n/.idea\n/.vscode\n/test-results/\n/playwright-report/\n/blob-report/\n/playwright/.cache/\n/coverage\n/extensions\n!/extensions/.gitkeep\n!/extensions/extensions_autoload.php\n/auth.json\n/modules_statuses.json\n/k8s\n/_ide_helper.php\n/.phpstorm.meta.php\n/.rnd\n\n/caddy\n/frankenphp\n/public/frankenphp-worker.php\n/data\n/config/caddy\n/config/composer\n"
  },
  {
    "path": ".prettierignore",
    "content": "# Ignore build outputs\nnode_modules/\nvendor/\nstorage/\nbootstrap/cache/\npublic/build/\npublic/hot/\n\n# Ignore lock files\npackage-lock.json\ncomposer.lock\n\n# Ignore generated files\n*.min.js\n*.min.css\n\n# Ignore test results\ntest-results/\nplaywright-report/\n\n# Ignore IDE files\n.idea/\n.vscode/\n\n# Ignore OS files\n.DS_Store\nThumbs.db "
  },
  {
    "path": ".prettierrc.json",
    "content": "{\n    \"trailingComma\": \"es5\",\n    \"tabWidth\": 4,\n    \"singleQuote\": true,\n    \"bracketSameLine\": true,\n    \"quoteProps\": \"preserve\",\n    \"printWidth\": 100\n}\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Code of Conduct\n\nThe goal is to create a community that is open and welcoming to all individuals.\nTo achieve this, we have developed a code of conduct that outlines the expectations for behavior of all members of our community.\n\n## Pledge\n\nThis community is founded on respect and understanding.\nAll members are expected to treat others with respect and empathy, and to not tolerate any form of discrimination,\nharassment, or attacks.\n\n## Expectations\n\nExamples of behavior that contributes to creating a positive environment include:\n\n- Using welcoming and inclusive language\n- Being respectful of differing viewpoints and experiences\n- Gracefully accepting constructive criticism\n- Focusing on what is best for the community\n- Showing empathy towards other community members\n\nExamples of unacceptable behavior by participants include:\n\n- The use of sexualized language or imagery and sexual attention or advances\n- Trolling, insulting/derogatory comments, and personal or political attacks\n- Public or private harassment\n- Publishing others' private information, such as a physical or electronic address, without explicit permission\n- Other conduct which could reasonably be considered inappropriate in a professional setting\n\n## Responsibilities\n\nProject maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate\nand fair corrective action in response to any instances of unacceptable behavior.\n\nProject maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits,\nissues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily\nor permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.\n\n## Contact\n\nIf you feel uncomfortable or believe that someone has violated the code of conduct, please contact us at [hello@solidtime.io](mailto:hello@solidtime.io).\nWe will thoroughly investigate the incident and aim for the best possible outcome.\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to solidtime\n\nContributions are greatly apprecited, please make sure to read the rules and vision for solidtime before contributing. \n\n## Rules\n\n### Issues for Bugs, Discussions for Feature requests\n\nIn order to keep the issues of the repository clean we decided to only use them for bugs. Feature Requests and enhancement are handled in discussions. This also helps us to see which feature requests are popular as they can be upvoted. \n\n### Only work on approved issues\n\nTo respect your time and help us manage contributions effectively, please open an issue or start a discussion and wait for approval before submitting a pull request (PR). This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons. \n\n### Contributor License Agreement\n\nYou'll also notice that we’ve set up a [Contributor License Agreement (CLA)](https://cla-assistant.io/solidtime-io/solidtime), which must be signed before any PR can be merged. Don’t worry - the process is quick and only takes a few clicks.\n\nWe want to be transparent about why we require the CLA and what it means for your contributions and the codebase. That’s why we’ve written a few paragraphs below outlining our plans and vision for solidtime in the **Vision** part of this document. \n\n### Prevent Duplicate Work\n\nBefore you submit a new PR, make sure that none exists already. If you plan to work on an issue, make sure to let us and others know by commenting on the issue/discussion. \n\n### Give context\n\nTell us what you thinking was behind the decisions you made while drafting the PR. Treat the PR itself as documentation for everyone who wants to go back and understand why certain decisions were made. \n\n### Summarize your PR\n\nPlease make sure to include a short summary at the top of your PR to make it easy for us to quickly check what the PR is about, without looking at the code changes. \n\n### Use Github Keywords and Auto-Link Issues\n\nUse phrases like \"Closes #123\" or \"Fixes #123\" in the PR description to link the PR with the issue that you are adressing. \n\n### Mention what you tested and how\n\nExplain how you tested and validated the implementation. \n\n### Keep Naming consistent\n\nLook at existing code patterns and use naming conventions that already exist in the code base. \n\n### Testing\n\nWe have an exhaustive test-suite of PHPUnit (Backend) and Playwright (Frontend) testing. Whereever applicable please make sure to write add tests to the codebase. \n\n### Linting & Formatting\n\nMake sure to run linting and formatting commands before you commit the changes. \n\nFor backend changes:\n\n```\ncomposer fix\ncomposer analyse\n```\n\nFor frontend changes: \n\n```\nnpm run lint:fix\nnpm run format\n```\n\n## Vision\n\nWe started solidtime to provide an open infrastructure solution for time tracking—one that empowers teams and individuals to fully own their data, instead of depending on proprietary platforms. We believe infrastructure software should be open, accessible, and built to last. However, competing with established market leaders in this space requires long-term financial sustainability.\n\nsolidtime is licensed under the AGPL, which we believe is the best available license to strike a balance between openness and financial viability. The AGPL gives us, as the copyright holders, certain exclusive rights that we plan to leverage to fund development. To ensure we retain those rights across the entire codebase, we've put a CLA in place that contributors must sign before submitting code.\n\nOne of solidtime’s key advantages is that it's built to be self-hostable. This makes it a great solution for organizations like governments, healthcare providers, and enterprises that are required to keep data on their own infrastructure due to regulations or internal policies. These organizations may need custom licenses, integrations, or modifications that aren't suitable for the open-source version. To support them, we offer relicensed versions of solidtime along with support plans.\n\nWe’ll also provide proprietary extensions for solidtime. These will be available to enterprise customers with support plans, but also to individual users or teams who don’t need support, at much more accessible price points. For companies running solidtime on their own infrastructure, this is the easiest way to support the project while gaining additional functionality. While we plan to make it easier to build custom extensions in the future, our current APIs are still highly experimental.\n\nFinally - and perhaps most importantly - we offer a hosted SaaS version called solidtime Cloud, for users who can’t or don’t want to run the software themselves. This version includes proprietary extensions, always runs the latest commit, and includes monitoring and billing features available exclusively on this hosted instance. We expect solidtime Cloud to play a critical role in funding the project long-term.\n\nHaving full control over the source code’s licensing also gives us the ability to change the license of the main project in the future. That said, we have no plans to do so and would only consider it in extreme cases - for example, if a malicious actor were to directly compete with our hosted service in a way that threatens the sustainability of the project, the legal interpretation of AGPL changes in a way that would make it unreasonable to use for certain companies, or a new similar license gains wide-spread adoption. Regardless, solidtime will always remain free to self-host for individuals and companies who use it as part of their work, and all previous releases will remain licensed under AGPL.\n\nIf you are using the open-source version of solidtime and want to support us, the best way to do so is to spread the word. \n"
  },
  {
    "path": "LICENSE.md",
    "content": "GNU Affero General Public License\n=================================\n\n_Version 3, 19 November 2007_\n_Copyright © 2007 Free Software Foundation, Inc. &lt;<http://fsf.org/>&gt;_\n\nEveryone is permitted to copy and distribute verbatim copies\nof this license document, but changing it is not allowed.\n\n## Preamble\n\nThe GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\nThe licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\nWhen we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\nDevelopers that use our General Public Licenses protect your rights\nwith two steps: **(1)** assert copyright on the software, and **(2)** offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\nA secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\nThe GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\nAn older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\nThe precise terms and conditions for copying, distribution and\nmodification follow.\n\n## TERMS AND CONDITIONS\n\n### 0. Definitions\n\n“This License” refers to version 3 of the GNU Affero General Public License.\n\n“Copyright” also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n“The Program” refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as “you”.  “Licensees” and\n“recipients” may be individuals or organizations.\n\nTo “modify” a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a “modified version” of the\nearlier work or a work “based on” the earlier work.\n\nA “covered work” means either the unmodified Program or a work based\non the Program.\n\nTo “propagate” a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\nTo “convey” a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\nAn interactive user interface displays “Appropriate Legal Notices”\nto the extent that it includes a convenient and prominently visible\nfeature that **(1)** displays an appropriate copyright notice, and **(2)**\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n### 1. Source Code\n\nThe “source code” for a work means the preferred form of the work\nfor making modifications to it.  “Object code” means any non-source\nform of a work.\n\nA “Standard Interface” means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\nThe “System Libraries” of an executable work include anything, other\nthan the work as a whole, that **(a)** is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and **(b)** serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n“Major Component”, in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\nThe “Corresponding Source” for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\nThe Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\nThe Corresponding Source for a work in source code form is that\nsame work.\n\n### 2. Basic Permissions\n\nAll rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\nYou may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\nConveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n### 3. Protecting Users' Legal Rights From Anti-Circumvention Law\n\nNo covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\nWhen you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n### 4. Conveying Verbatim Copies\n\nYou may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\nYou may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n### 5. Conveying Modified Source Versions\n\nYou may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n* **a)** The work must carry prominent notices stating that you modified\nit, and giving a relevant date.\n* **b)** The work must carry prominent notices stating that it is\nreleased under this License and any conditions added under section 7.\nThis requirement modifies the requirement in section 4 to\n“keep intact all notices”.\n* **c)** You must license the entire work, as a whole, under this\nLicense to anyone who comes into possession of a copy.  This\nLicense will therefore apply, along with any applicable section 7\nadditional terms, to the whole of the work, and all its parts,\nregardless of how they are packaged.  This License gives no\npermission to license the work in any other way, but it does not\ninvalidate such permission if you have separately received it.\n* **d)** If the work has interactive user interfaces, each must display\nAppropriate Legal Notices; however, if the Program has interactive\ninterfaces that do not display Appropriate Legal Notices, your\nwork need not make them do so.\n\nA compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n“aggregate” if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n### 6. Conveying Non-Source Forms\n\nYou may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n* **a)** Convey the object code in, or embodied in, a physical product\n(including a physical distribution medium), accompanied by the\nCorresponding Source fixed on a durable physical medium\ncustomarily used for software interchange.\n* **b)** Convey the object code in, or embodied in, a physical product\n(including a physical distribution medium), accompanied by a\nwritten offer, valid for at least three years and valid for as\nlong as you offer spare parts or customer support for that product\nmodel, to give anyone who possesses the object code either **(1)** a\ncopy of the Corresponding Source for all the software in the\nproduct that is covered by this License, on a durable physical\nmedium customarily used for software interchange, for a price no\nmore than your reasonable cost of physically performing this\nconveying of source, or **(2)** access to copy the\nCorresponding Source from a network server at no charge.\n* **c)** Convey individual copies of the object code with a copy of the\nwritten offer to provide the Corresponding Source.  This\nalternative is allowed only occasionally and noncommercially, and\nonly if you received the object code with such an offer, in accord\nwith subsection 6b.\n* **d)** Convey the object code by offering access from a designated\nplace (gratis or for a charge), and offer equivalent access to the\nCorresponding Source in the same way through the same place at no\nfurther charge.  You need not require recipients to copy the\nCorresponding Source along with the object code.  If the place to\ncopy the object code is a network server, the Corresponding Source\nmay be on a different server (operated by you or a third party)\nthat supports equivalent copying facilities, provided you maintain\nclear directions next to the object code saying where to find the\nCorresponding Source.  Regardless of what server hosts the\nCorresponding Source, you remain obligated to ensure that it is\navailable for as long as needed to satisfy these requirements.\n* **e)** Convey the object code using peer-to-peer transmission, provided\nyou inform other peers where the object code and Corresponding\nSource of the work are being offered to the general public at no\ncharge under subsection 6d.\n\nA separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\nA “User Product” is either **(1)** a “consumer product”, which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or **(2)** anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, “normally used” refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n“Installation Information” for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\nIf you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\nThe requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\nCorresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n### 7. Additional Terms\n\n“Additional permissions” are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\nWhen you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\nNotwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n* **a)** Disclaiming warranty or limiting liability differently from the\nterms of sections 15 and 16 of this License; or\n* **b)** Requiring preservation of specified reasonable legal notices or\nauthor attributions in that material or in the Appropriate Legal\nNotices displayed by works containing it; or\n* **c)** Prohibiting misrepresentation of the origin of that material, or\nrequiring that modified versions of such material be marked in\nreasonable ways as different from the original version; or\n* **d)** Limiting the use for publicity purposes of names of licensors or\nauthors of the material; or\n* **e)** Declining to grant rights under trademark law for use of some\ntrade names, trademarks, or service marks; or\n* **f)** Requiring indemnification of licensors and authors of that\nmaterial by anyone who conveys the material (or modified versions of\nit) with contractual assumptions of liability to the recipient, for\nany liability that these contractual assumptions directly impose on\nthose licensors and authors.\n\nAll other non-permissive additional terms are considered “further\nrestrictions” within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\nIf you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\nAdditional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n### 8. Termination\n\nYou may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\nHowever, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated **(a)**\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and **(b)** permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\nMoreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\nTermination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n### 9. Acceptance Not Required for Having Copies\n\nYou are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n### 10. Automatic Licensing of Downstream Recipients\n\nEach time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\nAn “entity transaction” is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\nYou may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n### 11. Patents\n\nA “contributor” is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's “contributor version”.\n\nA contributor's “essential patent claims” are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, “control” includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\nEach contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\nIn the following three paragraphs, a “patent license” is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To “grant” such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\nIf you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either **(1)** cause the Corresponding Source to be so\navailable, or **(2)** arrange to deprive yourself of the benefit of the\npatent license for this particular work, or **(3)** arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  “Knowingly relying” means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\nIf, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\nA patent license is “discriminatory” if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license **(a)** in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or **(b)** primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\nNothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n### 12. No Surrender of Others' Freedom\n\nIf conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n### 13. Remote Network Interaction; Use with the GNU General Public License\n\nNotwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\nNotwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n### 14. Revised Versions of this License\n\nThe Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\nEach version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License “or any later version” applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\nIf the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\nLater license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n### 15. Disclaimer of Warranty\n\nTHERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n### 16. Limitation of Liability\n\nIN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n### 17. Interpretation of Sections 15 and 16\n\nIf the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n_END OF TERMS AND CONDITIONS_\n\n## How to Apply These Terms to Your New Programs\n\nIf you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\nTo do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe “copyright” line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) 2024 Gregor Vostrak & Constantin Graf\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <http://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\nIf your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a “Source” link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\nYou should also get your employer (if you work as a programmer) or school,\nif any, to sign a “copyright disclaimer” for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n&lt;<http://www.gnu.org/licenses/>&gt;.\n"
  },
  {
    "path": "README.md",
    "content": "# solidtime - The modern Open-Source Time Tracker\n\n[![GitHub License](https://img.shields.io/github/license/solidtime-io/solidtime?style=flat-square)](https://github.com/solidtime-io/solidtime/blob/main/LICENSE.md)\n[![Codecov](https://img.shields.io/codecov/c/github/solidtime-io/solidtime?style=flat-square&logo=codecov)](https://codecov.io/gh/solidtime-io/solidtime)\n![GitHub Actions Unit Tests Status](https://img.shields.io/github/actions/workflow/status/solidtime-io/solidtime/phpunit.yml?style=flat-square)\n![PHPStan badge](https://img.shields.io/badge/PHPStan-Level_7-blue?style=flat-square&color=blue)\n\n![Screenshot of the solidtime application with header: solidtime - The modern Open-Source Time Tracker](docs/solidtime-banner.png \"solidtime Banner\")\n\nsolidtime is a modern open-source time tracking application for Freelancers and Agencies.\n\n## Features\n\n - Time tracking: Track your time with a modern and easy-to-use interface\n - Projects: Create and manage projects and assign project members\n - Tasks: Create and manage tasks and assign tasks to projects\n - Clients: Create and manage clients and assign clients to projects\n - Billable rates: Set billable rates for projects, project members, organization members and organizations \n - Multiple organizations: Create and manage multiple organizations with one account\n - Roles and permissions: Create and manage organizations\n - Import: Import your time tracking data from other time tracking applications (Supported: Toggl, Clockify, Timeentry CSV)\n\n## Self Hosting\n\nIf you are looking into self-hosting solidtime, you can find the guides [here](https://docs.solidtime.io/self-hosting/intro)\n\nWe also have an examples repository [here](https://github.com/solidtime-io/self-hosting-examples)\n\nIf you do not want to self-host solidtime or try it out you can sign up for [solidtime cloud](https://www.solidtime.io/)\n\n## Issues & Feature Requests\n\nIf you find any **bugs in solidtime**, please feel free to [**open an issue**](https://github.com/solidtime-io/solidtime/issues/new) in this repository, with instructions on how to reproduce the bug. \nIf you have a **feature request**, please [**create a discussion**](https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests) in this repository.\n\n## Contributing\n\nPlease open an issue or start a discussion and wait for approval before submitting a pull request. This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons. \n\n**If you submit an AI slop pull request (especially without following the proper procedure), you will be banned from future contributions to solidtime.**\n\nPlease read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.\n\nWe do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.\n\n## Security\n\nLooking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file.\n\n## License\n\nThis project is open-source and available under the GNU Affero General Public License v3.0 (AGPL v3). Please see the [license file](LICENSE.md) for more information.\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Reporting a Vulnerability\n\nIf you discover a security vulnerability regarding this project, please e-mail me to [security@solidtime.io](mailto:security@solidtime.io)!\n"
  },
  {
    "path": "app/Actions/Fortify/CreateNewUser.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Actions\\Fortify;\n\nuse App\\Enums\\Weekday;\nuse App\\Events\\NewsletterRegistered;\nuse App\\Models\\User;\nuse App\\Service\\IpLookup\\IpLookupServiceContract;\nuse App\\Service\\TimezoneService;\nuse App\\Service\\UserService;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Support\\Facades\\Validator;\nuse Illuminate\\Validation\\ValidationException;\nuse Korridor\\LaravelModelValidationRules\\Rules\\UniqueEloquent;\nuse Laravel\\Fortify\\Contracts\\CreatesNewUsers;\nuse Laravel\\Jetstream\\Jetstream;\nuse Log;\n\nclass CreateNewUser implements CreatesNewUsers\n{\n    use PasswordValidationRules;\n\n    /**\n     * Create a newly registered user.\n     *\n     * @param  array<string, mixed>  $input\n     *\n     * @throws ValidationException\n     */\n    public function create(array $input): User\n    {\n        if (! config('app.enable_registration')) {\n            throw ValidationException::withMessages([\n                'email' => [__('Registration is disabled.')],\n            ]);\n        }\n\n        Validator::make($input, [\n            'name' => [\n                'required',\n                'string',\n                'max:255',\n            ],\n            'email' => [\n                'required',\n                'string',\n                'email:rfc,strict',\n                'max:255',\n                UniqueEloquent::make(User::class, 'email', function (Builder $builder): Builder {\n                    /** @var Builder<User> $builder */\n                    return $builder->where('is_placeholder', '=', false);\n                }),\n            ],\n            'password' => $this->passwordRules(),\n            'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',\n            'newsletter_consent' => [\n                'boolean',\n            ],\n        ])->validate();\n\n        $timezone = null;\n        if (array_key_exists('timezone', $input) && is_string($input['timezone'])) {\n            if (app(TimezoneService::class)->isValid($input['timezone'])) {\n                $timezone = $input['timezone'];\n            } else {\n                $timezone = app(TimezoneService::class)->mapLegacyTimezone($input['timezone']);\n                if ($timezone === null) {\n                    Log::debug('Invalid timezone', ['timezone' => $input['timezone']]);\n                }\n            }\n        }\n\n        $ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip());\n\n        $startOfWeek = Weekday::Monday;\n        $numberFormat = null;\n        $currencyFormat = null;\n        $dateFormat = null;\n        $intervalFormat = null;\n        $timeFormat = null;\n        $currency = null;\n        if ($ipLookupResponse !== null) {\n            $startOfWeek = $ipLookupResponse->startOfWeek ?? Weekday::Monday;\n            if ($timezone === null) {\n                $timezone = $ipLookupResponse->timezone;\n            }\n            $currency = $ipLookupResponse->currency;\n        }\n        $user = null;\n        DB::transaction(function () use (&$user, $input, $timezone, $startOfWeek, $currency, $numberFormat, $currencyFormat, $dateFormat, $intervalFormat, $timeFormat): void {\n            $userService = app(UserService::class);\n            $user = $userService->createUser(\n                $input['name'],\n                $input['email'],\n                $input['password'],\n                $timezone ?? 'UTC',\n                $startOfWeek,\n                $currency,\n                $numberFormat,\n                $currencyFormat,\n                $dateFormat,\n                $intervalFormat,\n                $timeFormat\n            );\n        });\n\n        $newsletterConsent = isset($input['newsletter_consent']) && (bool) $input['newsletter_consent'];\n        if ($newsletterConsent) {\n            NewsletterRegistered::dispatch($input['name'], $input['email'], $user->getKey());\n        }\n\n        return $user;\n    }\n}\n"
  },
  {
    "path": "app/Actions/Fortify/PasswordValidationRules.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Actions\\Fortify;\n\nuse Illuminate\\Contracts\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\Password;\n\ntrait PasswordValidationRules\n{\n    /**\n     * Get the validation rules used to validate passwords.\n     *\n     * @return array<int, Rule|string>\n     */\n    protected function passwordRules(): array\n    {\n        return ['required', 'string', Password::default(), 'confirmed'];\n    }\n}\n"
  },
  {
    "path": "app/Actions/Fortify/ResetUserPassword.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Actions\\Fortify;\n\nuse App\\Models\\User;\nuse Illuminate\\Support\\Facades\\Hash;\nuse Illuminate\\Support\\Facades\\Validator;\nuse Laravel\\Fortify\\Contracts\\ResetsUserPasswords;\n\nclass ResetUserPassword implements ResetsUserPasswords\n{\n    use PasswordValidationRules;\n\n    /**\n     * Validate and reset the user's forgotten password.\n     *\n     * @param  array<string, string>  $input\n     */\n    public function reset(User $user, array $input): void\n    {\n        Validator::make($input, [\n            'password' => $this->passwordRules(),\n        ])->validate();\n\n        $user->forceFill([\n            'password' => Hash::make($input['password']),\n        ])->save();\n    }\n}\n"
  },
  {
    "path": "app/Actions/Fortify/UpdateUserPassword.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Actions\\Fortify;\n\nuse App\\Models\\User;\nuse Illuminate\\Support\\Facades\\Hash;\nuse Illuminate\\Support\\Facades\\Validator;\nuse Laravel\\Fortify\\Contracts\\UpdatesUserPasswords;\n\nclass UpdateUserPassword implements UpdatesUserPasswords\n{\n    use PasswordValidationRules;\n\n    /**\n     * Validate and update the user's password.\n     *\n     * @param  array<string, string>  $input\n     */\n    public function update(User $user, array $input): void\n    {\n        Validator::make($input, [\n            'current_password' => ['required', 'string', 'current_password:web'],\n            'password' => $this->passwordRules(),\n        ], [\n            'current_password.current_password' => __('The provided password does not match your current password.'),\n        ])->validateWithBag('updatePassword');\n\n        $user->forceFill([\n            'password' => Hash::make($input['password']),\n        ])->save();\n    }\n}\n"
  },
  {
    "path": "app/Actions/Fortify/UpdateUserProfileInformation.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Actions\\Fortify;\n\nuse App\\Enums\\Weekday;\nuse App\\Models\\User;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Support\\Facades\\Validator;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\ValidationException;\nuse Korridor\\LaravelModelValidationRules\\Rules\\UniqueEloquent;\nuse Laravel\\Fortify\\Contracts\\UpdatesUserProfileInformation;\n\nclass UpdateUserProfileInformation implements UpdatesUserProfileInformation\n{\n    /**\n     * Validate and update the given user's profile information.\n     *\n     * @param  array<string, mixed>  $input\n     *\n     * @throws ValidationException\n     */\n    public function update(User $user, array $input): void\n    {\n        Validator::make($input, [\n            'name' => [\n                'required',\n                'string',\n                'max:255',\n            ],\n            'email' => [\n                'required',\n                'email',\n                'max:255',\n                UniqueEloquent::make(User::class, 'email')->ignore($user->id)->query(function (Builder $query) {\n                    /** @var Builder<User> $query */\n                    return $query->where('is_placeholder', '=', false);\n                }),\n            ],\n            'photo' => [\n                'nullable',\n                'mimes:jpg,jpeg,png',\n                'max:1024',\n            ],\n            'timezone' => [\n                'required',\n                'timezone:all',\n            ],\n            'week_start' => [\n                'required',\n                Rule::enum(Weekday::class),\n            ],\n        ])->validateWithBag('updateProfileInformation');\n\n        if (isset($input['photo'])) {\n            $user->updateProfilePhoto($input['photo']);\n        }\n\n        if ($input['email'] !== $user->email) {\n            $user->forceFill([\n                'name' => $input['name'],\n                'email' => $input['email'],\n                'email_verified_at' => null,\n                'timezone' => $input['timezone'],\n                'week_start' => $input['week_start'],\n            ])->save();\n\n            $user->sendEmailVerificationNotification();\n        } else {\n            $user->forceFill([\n                'name' => $input['name'],\n                'timezone' => $input['timezone'],\n                'week_start' => $input['week_start'],\n            ])->save();\n        }\n    }\n}\n"
  },
  {
    "path": "app/Actions/Jetstream/AddOrganizationMember.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Actions\\Jetstream;\n\nuse App\\Enums\\Role;\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse App\\Service\\MemberService;\nuse Closure;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Support\\Facades\\Gate;\nuse Illuminate\\Support\\Facades\\Validator;\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\In;\nuse Korridor\\LaravelModelValidationRules\\Rules\\ExistsEloquent;\nuse Laravel\\Jetstream\\Contracts\\AddsTeamMembers;\n\nclass AddOrganizationMember implements AddsTeamMembers\n{\n    /**\n     * Add a new team member to the given team.\n     */\n    public function add(User $owner, Organization $organization, string $email, ?string $role = null): void\n    {\n        Gate::forUser($owner)->authorize('addTeamMember', $organization); // TODO: refactor after owner refactoring\n\n        $this->validate($organization, $email, $role);\n\n        $newOrganizationMember = User::query()\n            ->where('email', $email)\n            ->where('is_placeholder', '=', false)\n            ->firstOrFail();\n\n        app(MemberService::class)->addMember($newOrganizationMember, $organization, Role::from($role));\n    }\n\n    /**\n     * Validate the add member operation.\n     */\n    protected function validate(Organization $organization, string $email, ?string $role): void\n    {\n        Validator::make([\n            'email' => $email,\n            'role' => $role,\n        ], $this->rules())->after(\n            $this->ensureUserIsNotAlreadyOnTeam($organization, $email)\n        )->validateWithBag('addTeamMember');\n    }\n\n    /**\n     * Get the validation rules for adding a team member.\n     *\n     * @return array<string, array<ValidationRule|Rule|string|In>>\n     */\n    protected function rules(): array\n    {\n        return [\n            'email' => [\n                'required',\n                'email',\n                ExistsEloquent::make(User::class, 'email', function (Builder $builder) {\n                    /** @var Builder<User> $builder */\n                    return $builder->where('is_placeholder', '=', false);\n                })->withMessage(__('We were unable to find a registered user with this email address.')),\n            ],\n            'role' => [\n                'required',\n                'string',\n                Rule::in([\n                    Role::Admin->value,\n                    Role::Manager->value,\n                    Role::Employee->value,\n                ]),\n            ],\n        ];\n    }\n\n    /**\n     * Ensure that the user is not already on the team.\n     */\n    protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure\n    {\n        return function ($validator) use ($team, $email): void {\n            $validator->errors()->addIf(\n                $team->hasRealUserWithEmail($email),\n                'email',\n                __('This user already belongs to the team.')\n            );\n        };\n    }\n}\n"
  },
  {
    "path": "app/Actions/Jetstream/CreateOrganization.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Actions\\Jetstream;\n\nuse App\\Events\\AfterCreateOrganization;\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse App\\Service\\IpLookup\\IpLookupServiceContract;\nuse App\\Service\\OrganizationService;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Support\\Facades\\Gate;\nuse Illuminate\\Support\\Facades\\Validator;\nuse Illuminate\\Validation\\ValidationException;\nuse Laravel\\Jetstream\\Contracts\\CreatesTeams;\nuse Laravel\\Jetstream\\Jetstream;\n\nclass CreateOrganization implements CreatesTeams\n{\n    /**\n     * Validate and create a new team for the given user.\n     *\n     * @param  array<string, string>  $input\n     *\n     * @throws AuthorizationException\n     * @throws ValidationException\n     */\n    public function create(User $user, array $input): Organization\n    {\n        Gate::forUser($user)->authorize('create', Jetstream::newTeamModel());\n\n        Validator::make($input, [\n            'name' => ['required', 'string', 'max:255'],\n        ])->validateWithBag('createTeam');\n\n        $ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip());\n\n        $currency = null;\n        if ($ipLookupResponse !== null) {\n            $currency = $ipLookupResponse->currency;\n        }\n\n        $organization = app(OrganizationService::class)->createOrganization(\n            $input['name'],\n            $user,\n            false,\n            $currency\n        );\n\n        $user->switchTeam($organization);\n\n        // Note: The refresh is necessary for currently unknown reasons. Do not remove it.\n        $organization = $organization->refresh();\n        AfterCreateOrganization::dispatch($organization);\n\n        return $organization;\n    }\n}\n"
  },
  {
    "path": "app/Actions/Jetstream/DeleteOrganization.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Actions\\Jetstream;\n\nuse App\\Models\\Organization;\nuse App\\Service\\DeletionService;\nuse Laravel\\Jetstream\\Contracts\\DeletesTeams;\n\nclass DeleteOrganization implements DeletesTeams\n{\n    /**\n     * Delete the given team.\n     */\n    public function delete(Organization $organization): void\n    {\n        /** @see ValidateOrganizationDeletion */\n        app(DeletionService::class)->deleteOrganization($organization);\n    }\n}\n"
  },
  {
    "path": "app/Actions/Jetstream/DeleteUser.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Actions\\Jetstream;\n\nuse App\\Exceptions\\Api\\ApiException;\nuse App\\Models\\User;\nuse App\\Service\\DeletionService;\nuse Illuminate\\Validation\\ValidationException;\nuse Laravel\\Jetstream\\Contracts\\DeletesUsers;\n\nclass DeleteUser implements DeletesUsers\n{\n    /**\n     * Delete the given user.\n     *\n     * @throws ValidationException\n     */\n    public function delete(User $user): void\n    {\n        try {\n            app(DeletionService::class)->deleteUser($user);\n        } catch (ApiException $exception) {\n            throw ValidationException::withMessages([\n                'password' => $exception->getTranslatedMessage(),\n            ]);\n        }\n    }\n}\n"
  },
  {
    "path": "app/Actions/Jetstream/InviteOrganizationMember.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Actions\\Jetstream;\n\nuse App\\Exceptions\\MovedToApiException;\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse Exception;\nuse Laravel\\Jetstream\\Contracts\\InvitesTeamMembers;\n\nclass InviteOrganizationMember implements InvitesTeamMembers\n{\n    /**\n     * Invite a new team member to the given team.\n     *\n     * @throws Exception\n     */\n    public function invite(User $user, Organization $organization, string $email, ?string $role = null): void\n    {\n        throw new MovedToApiException;\n    }\n}\n"
  },
  {
    "path": "app/Actions/Jetstream/RemoveOrganizationMember.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Actions\\Jetstream;\n\nuse App\\Exceptions\\MovedToApiException;\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse Exception;\nuse Laravel\\Jetstream\\Contracts\\RemovesTeamMembers;\n\nclass RemoveOrganizationMember implements RemovesTeamMembers\n{\n    /**\n     * Remove the team member from the given team.\n     *\n     * @throws Exception\n     */\n    public function remove(User $user, Organization $organization, User $teamMember): void\n    {\n        throw new MovedToApiException;\n    }\n}\n"
  },
  {
    "path": "app/Actions/Jetstream/UpdateMemberRole.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Actions\\Jetstream;\n\nuse App\\Enums\\Role;\nuse App\\Exceptions\\MovedToApiException;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse Exception;\n\nclass UpdateMemberRole\n{\n    /**\n     * Update the role for the given team member.\n     *\n     * @throws Exception\n     */\n    public function update(User $actingUser, Organization $organization, string $userId, string $role): void\n    {\n        throw new MovedToApiException;\n    }\n}\n"
  },
  {
    "path": "app/Actions/Jetstream/UpdateOrganization.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Actions\\Jetstream;\n\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse App\\Rules\\CurrencyRule;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Support\\Facades\\Gate;\nuse Illuminate\\Support\\Facades\\Validator;\nuse Illuminate\\Validation\\ValidationException;\nuse Laravel\\Jetstream\\Contracts\\UpdatesTeamNames;\n\nclass UpdateOrganization implements UpdatesTeamNames\n{\n    /**\n     * Validate and update the given team's name.\n     *\n     * @param  array<string, string>  $input\n     *\n     * @throws AuthorizationException\n     * @throws ValidationException\n     */\n    public function update(User $user, Organization $organization, array $input): void\n    {\n        Gate::forUser($user)->authorize('update', $organization);\n\n        Validator::make($input, [\n            'name' => [\n                'required',\n                'string',\n                'max:255',\n            ],\n            'currency' => [\n                'required',\n                'string',\n                new CurrencyRule,\n            ],\n        ])->validateWithBag('updateTeamName');\n\n        $organization->forceFill([\n            'name' => $input['name'],\n            'currency' => $input['currency'],\n        ])->save();\n    }\n}\n"
  },
  {
    "path": "app/Actions/Jetstream/ValidateOrganizationDeletion.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Actions\\Jetstream;\n\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse App\\Service\\PermissionStore;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\n\nclass ValidateOrganizationDeletion\n{\n    /**\n     * Validate that the team can be deleted by the given user.\n     *\n     * @param  User  $user  Authenticated user\n     * @param  Organization  $organization  Organization to be deleted\n     *\n     * @throws AuthorizationException\n     */\n    public function validate(User $user, Organization $organization): void\n    {\n        if (! app(PermissionStore::class)->userHas($organization, $user, 'organizations:delete')) {\n            throw new AuthorizationException;\n        }\n    }\n}\n"
  },
  {
    "path": "app/Console/Commands/Admin/OrganizationDeleteCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Console\\Commands\\Admin;\n\nuse App\\Models\\Organization;\nuse App\\Service\\DeletionService;\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Support\\Str;\n\nclass OrganizationDeleteCommand extends Command\n{\n    /**\n     * The name and signature of the console command.\n     *\n     * @var string\n     */\n    protected $signature = 'admin:organization:delete\n                { organization : The ID of the organization to delete }';\n\n    /**\n     * The console command description.\n     *\n     * @var string\n     */\n    protected $description = 'Delete a organization';\n\n    /**\n     * Execute the console command.\n     */\n    public function handle(DeletionService $deletionService): int\n    {\n        $organizationId = $this->argument('organization');\n\n        if (! Str::isUuid($organizationId)) {\n            $this->error('Organization ID must be a valid UUID.');\n\n            return self::FAILURE;\n\n        }\n\n        /** @var Organization|null $organization */\n        $organization = Organization::find($organizationId);\n        if ($organization === null) {\n            $this->error('Organization with ID '.$organizationId.' not found.');\n\n            return self::FAILURE;\n        }\n\n        $this->info('Deleting organization with ID '.$organization->getKey());\n\n        $deletionService->deleteOrganization($organization);\n\n        $this->info('Organization with ID '.$organization->getKey().' has been deleted.');\n\n        return self::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "app/Console/Commands/Admin/UserCreateCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Console\\Commands\\Admin;\n\nuse App\\Enums\\Weekday;\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse App\\Service\\UserService;\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Support\\Facades\\DB;\nuse LogicException;\n\nclass UserCreateCommand extends Command\n{\n    /**\n     * The name and signature of the console command.\n     *\n     * @var string\n     */\n    protected $signature = 'admin:user:create\n                { name : The name of the user }\n                { email : The email of the user }\n                { --ask-for-password : Ask for the password, otherwise the command will generate a random one }\n                { --verify-email : Verify the email address of the user }';\n\n    /**\n     * The console command description.\n     *\n     * @var string\n     */\n    protected $description = 'Create a new user';\n\n    /**\n     * Execute the console command.\n     */\n    public function handle(): int\n    {\n        $name = $this->argument('name');\n        $email = $this->argument('email');\n        $askForPassword = (bool) $this->option('ask-for-password');\n        $verifyEmail = (bool) $this->option('verify-email');\n\n        if (User::query()->where('email', $email)->where('is_placeholder', '=', false)->exists()) {\n            $this->error('User with email \"'.$email.'\" already exists.');\n\n            return self::FAILURE;\n        }\n\n        if ($askForPassword) {\n            $outputPassword = false;\n            $password = $this->secret('Enter the password');\n        } else {\n            $outputPassword = true;\n            $password = bin2hex(random_bytes(16));\n        }\n\n        $user = null;\n        DB::transaction(function () use (&$user, $name, $email, $password, $verifyEmail): void {\n            $user = app(UserService::class)->createUser(\n                $name,\n                $email,\n                $password,\n                'UTC',\n                Weekday::Monday,\n                null,\n                verifyEmail: $verifyEmail\n            );\n        });\n        /** @var Organization|null $organization */\n        $organization = $user->ownedTeams->first();\n        if ($organization === null) {\n            throw new LogicException('User does not have an organization');\n        }\n\n        $this->info('Created user \"'.$name.'\" (\"'.$email.'\")');\n        $this->line('ID: '.$user->getKey());\n        $this->line('Name: '.$name);\n        $this->line('Email: '.$email);\n        if ($outputPassword) {\n            $this->line('Password: '.$password);\n        }\n        $this->line('Timezone: '.$user->timezone);\n        $this->line('Week start: '.$user->week_start->value);\n\n        // Organization\n        $this->line('Currency: '.$organization->currency);\n\n        return self::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "app/Console/Commands/Admin/UserVerifyCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Console\\Commands\\Admin;\n\nuse App\\Models\\User;\nuse Illuminate\\Auth\\Events\\Verified;\nuse Illuminate\\Console\\Command;\n\nclass UserVerifyCommand extends Command\n{\n    /**\n     * The name and signature of the console command.\n     *\n     * @var string\n     */\n    protected $signature = 'admin:user:verify\n                { email : The email of the user to verify }';\n\n    /**\n     * The console command description.\n     *\n     * @var string\n     */\n    protected $description = 'Verify the email address of an user';\n\n    /**\n     * Execute the console command.\n     */\n    public function handle(): int\n    {\n        $email = $this->argument('email');\n\n        $this->info('Start verifying user with email \"'.$email.'\"');\n\n        /** @var User|null $user */\n        $user = User::query()->where('email', $email)\n            ->where('is_placeholder', '=', false)\n            ->first();\n\n        if ($user === null) {\n            $this->error('User with email \"'.$email.'\" not found.');\n\n            return self::FAILURE;\n        }\n\n        if ($user->hasVerifiedEmail()) {\n            $this->info('User with email \"'.$email.'\" already verified.');\n\n            return self::FAILURE;\n        }\n\n        $user->markEmailAsVerified();\n        event(new Verified($user));\n\n        $this->info('User with email \"'.$email.'\" has been verified.');\n\n        return self::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "app/Console/Commands/Auth/AuthSendReminderForExpiringApiTokensCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Console\\Commands\\Auth;\n\nuse App\\Mail\\AuthApiTokenExpirationReminderMail;\nuse App\\Mail\\AuthApiTokenExpiredMail;\nuse App\\Models\\Passport\\Token;\nuse App\\Models\\User;\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Database\\Eloquent\\Collection;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Facades\\Mail;\n\nclass AuthSendReminderForExpiringApiTokensCommand extends Command\n{\n    /**\n     * The name and signature of the console command.\n     *\n     * @var string\n     */\n    protected $signature = 'auth:send-mails-expiring-api-tokens '.\n        ' { --dry-run : Do not actually send emails or save anything to the database, just output what would happen }';\n\n    /**\n     * The console command description.\n     *\n     * @var string\n     */\n    protected $description = 'Sends emails about expiring API tokens, one week before and when they expired.';\n\n    /**\n     * Execute the console command.\n     */\n    public function handle(): int\n    {\n        $dryRun = (bool) $this->option('dry-run');\n        if ($dryRun) {\n            $this->comment('Running in dry-run mode. No emails will be sent and nothing will be saved to the database.');\n        }\n\n        $this->comment('Sending reminder emails about expiring API tokens...');\n        $sentMails = 0;\n        Token::query()\n            ->where('expires_at', '<=', Carbon::now()->addDays(7))\n            ->whereNull('reminder_sent_at')\n            ->with([\n                'client',\n                'user',\n            ])\n            ->whereHas('user', function (Builder $query): void {\n                /** @var Builder<User> $query */\n                $query->where('is_placeholder', '=', false);\n            })\n            ->isApiToken(true)\n            ->orderBy('created_at', 'asc')\n            ->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void {\n                /** @var Collection<int, Token> $tokens */\n                foreach ($tokens as $token) {\n                    $user = $token->user;\n                    $this->info('Start sending email to user \"'.$user->email.'\" ('.$user->getKey().') reminding about API token '.$token->getKey());\n                    $sentMails++;\n                    if (! $dryRun) {\n                        Mail::to($user->email)\n                            ->queue(new AuthApiTokenExpirationReminderMail($token, $user));\n                        $token->reminder_sent_at = Carbon::now();\n                        $token->save();\n                    }\n                }\n            });\n        $this->comment('Finished sending '.$sentMails.' expiring API token emails...');\n\n        $this->comment('Sent emails about expired API tokens');\n        $sentMails = 0;\n        Token::query()\n            ->where('expires_at', '<=', Carbon::now())\n            ->whereNull('expired_info_sent_at')\n            ->with([\n                'client',\n                'user',\n            ])\n            ->whereHas('user', function (Builder $query): void {\n                /** @var Builder<User> $query */\n                $query->where('is_placeholder', '=', false);\n            })\n            ->isApiToken(true)\n            ->orderBy('created_at', 'asc')\n            ->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void {\n                /** @var Collection<int, Token> $tokens */\n                foreach ($tokens as $token) {\n                    $user = $token->user;\n                    $this->info('Start sending email to user \"'.$user->email.'\" ('.$user->getKey().') about expired API token '.$token->getKey());\n                    $sentMails++;\n                    if (! $dryRun) {\n                        Mail::to($user->email)\n                            ->queue(new AuthApiTokenExpiredMail($token, $user));\n                        $token->expired_info_sent_at = Carbon::now();\n                        $token->save();\n                    }\n                }\n            });\n        $this->comment('Finished sending '.$sentMails.' expired API token emails...');\n\n        return self::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "app/Console/Commands/Correction/CorrectionPlaceholderMembersCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Console\\Commands\\Correction;\n\nuse App\\Enums\\Role;\nuse App\\Models\\Member;\nuse App\\Models\\User;\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Database\\Eloquent\\Builder;\n\nclass CorrectionPlaceholderMembersCommand extends Command\n{\n    /**\n     * The name and signature of the console command.\n     *\n     * @var string\n     */\n    protected $signature = 'correction:placeholder-members '.\n        ' { --dry-run : Do not actually save anything to the database, just output what would happen }';\n\n    /**\n     * The console command description.\n     *\n     * @var string\n     */\n    protected $description = 'Sets all members who belong to a placeholder user to role placeholder';\n\n    /**\n     * Execute the console command.\n     */\n    public function handle(): int\n    {\n        $this->comment('Sets all members who belong to a placeholder user to role placeholder...');\n        $dryRun = (bool) $this->option('dry-run');\n        if ($dryRun) {\n            $this->comment('Running in dry-run mode. Nothing will be saved to the database.');\n        }\n\n        $members = Member::query()\n            ->where('role', '!=', Role::Placeholder->value)\n            ->whereHas('user', function (Builder $builder): void {\n                /** @var Builder<User> $builder */\n                $builder->where('is_placeholder', '=', true);\n            })\n            ->get();\n        foreach ($members as $member) {\n            /** @var Member $member */\n            $member->role = Role::Placeholder->value;\n            if (! $dryRun) {\n                $member->save();\n            }\n            $this->line('Set role of member (id='.$member->getKey().') to placeholder');\n        }\n\n        return self::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "app/Console/Commands/Report/ReportSetExpiredToPrivateCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Console\\Commands\\Report;\n\nuse App\\Models\\Report;\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Database\\Eloquent\\Collection;\nuse Illuminate\\Support\\Carbon;\nuse LogicException;\n\nclass ReportSetExpiredToPrivateCommand extends Command\n{\n    /**\n     * The name and signature of the console command.\n     *\n     * @var string\n     */\n    protected $signature = 'report:set-expired-to-private '.\n        ' { --dry-run : Do not actually save anything to the database, just output what would happen }';\n\n    /**\n     * The console command description.\n     *\n     * @var string\n     */\n    protected $description = 'Makes public reports private if the public_until date has passed.';\n\n    /**\n     * Execute the console command.\n     */\n    public function handle(): int\n    {\n        $this->comment('Makes public reports private if the public_until date has passed...');\n        $dryRun = (bool) $this->option('dry-run');\n        if ($dryRun) {\n            $this->comment('Running in dry-run mode. Nothing will be saved to the database.');\n        }\n\n        $resetReports = 0;\n        Report::query()\n            ->where('public_until', '<', Carbon::now())\n            ->orderBy('created_at', 'asc')\n            ->chunk(500, function (Collection $reports) use ($dryRun, &$resetReports): void {\n                /** @var Collection<int, Report> $reports */\n                foreach ($reports as $report) {\n                    $publicUntil = $report->public_until;\n                    if ($publicUntil === null) {\n                        throw new LogicException('public_until should not be null');\n                    }\n                    $this->info('Make report \"'.$report->name.'\" ('.$report->getKey().') private, expired: '.\n                        $publicUntil->toIso8601ZuluString().' ('.$publicUntil->diffForHumans().')');\n                    $resetReports++;\n                    if (! $dryRun) {\n                        $report->is_public = false;\n                        $report->share_secret = null;\n                        $report->save();\n                    }\n                }\n            });\n\n        $this->comment('Finished setting '.$resetReports.' expired reports to private...');\n\n        return self::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "app/Console/Commands/SelfHost/SelfHostCheckForUpdateCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Console\\Commands\\SelfHost;\n\nuse App\\Service\\ApiService;\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Support\\Facades\\Cache;\n\nclass SelfHostCheckForUpdateCommand extends Command\n{\n    /**\n     * The name and signature of the console command.\n     *\n     * @var string\n     */\n    protected $signature = 'self-host:check-for-update';\n\n    /**\n     * The console command description.\n     *\n     * @var string\n     */\n    protected $description = '';\n\n    /**\n     * Execute the console command.\n     */\n    public function handle(): int\n    {\n        $apiService = app(ApiService::class);\n\n        $latestVersion = $apiService->checkForUpdate();\n        if ($latestVersion === null) {\n            $this->error('Failed to check for update, check the logs for more information.');\n\n            return self::FAILURE;\n        }\n\n        // Note: Cache for 13 hours, because the command runs twice daily (every 12 hours).\n        Cache::put('latest_version', $latestVersion, 60 * 60 * 12);\n\n        return self::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "app/Console/Commands/SelfHost/SelfHostDatabaseConsistency.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Console\\Commands\\SelfHost;\n\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Database\\Query\\Builder;\nuse Illuminate\\Database\\Query\\JoinClause;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Support\\Facades\\Log;\n\nclass SelfHostDatabaseConsistency extends Command\n{\n    /**\n     * The name and signature of the console command.\n     *\n     * @var string\n     */\n    protected $signature = 'self-host:database-consistency';\n\n    /**\n     * The console command description.\n     *\n     * @var string\n     */\n    protected $description = '';\n\n    /**\n     * Execute the console command.\n     */\n    public function handle(): int\n    {\n        $hadAProblem = false;\n\n        // Task need to be part of project in time entries\n        $problems = DB::table('time_entries')\n            ->select(['time_entries.id as id'])\n            ->join('tasks', 'time_entries.task_id', '=', 'tasks.id')\n            ->where('tasks.project_id', '!=', DB::raw('time_entries.project_id'))\n            ->get();\n        $this->logProblems($problems, 'Time entries have a task that does not belong to the project of the time entry', $hadAProblem);\n\n        // Client id is the client id of the project\n        $problems = DB::table('time_entries')\n            ->select(['time_entries.id as id'])\n            ->join('projects', 'time_entries.project_id', '=', 'projects.id')\n            ->where(DB::raw('coalesce(projects.client_id::varchar, \\'\\')'), '!=', DB::raw('coalesce(time_entries.client_id::varchar, \\'\\')'))\n            ->get();\n        $this->logProblems($problems, 'Time entries have a client that does not match the client of the project', $hadAProblem);\n\n        // Client id can only be not null if the project id is not null\n        $problems = DB::table('time_entries')\n            ->select(['time_entries.id as id'])\n            ->whereNotNull('client_id')\n            ->whereNull('project_id')\n            ->get();\n        $this->logProblems($problems, 'Time entries have a client but no project', $hadAProblem);\n\n        // Every user needs to be a member of at least one organization\n        $problems = DB::table('users')\n            ->select(['users.id as id'])\n            ->leftJoin('members', 'users.id', '=', 'members.user_id')\n            ->whereNull('members.id')\n            ->get();\n        $this->logProblems($problems, 'Users are not member of any organization', $hadAProblem);\n\n        // Every organization needs at least an owner\n        $problems = DB::table('organizations')\n            ->select(['organizations.id as id'])\n            ->leftJoin('members', function (JoinClause $join): void {\n                $join->on('organizations.id', '=', 'members.organization_id')\n                    ->where('members.role', '=', 'owner');\n            })\n            ->whereNull('members.id')\n            ->get();\n        $this->logProblems($problems, 'Organizations without an owner', $hadAProblem);\n\n        // Every member can only have one running time entry\n        $problems = DB::table('time_entries')\n            ->select(['user_id as id'])\n            ->whereNull('end')\n            ->groupBy('user_id')\n            ->havingRaw('count(*) > 1')\n            ->get(['user_id', DB::raw('count(*) as count')]);\n        $this->logProblems($problems, 'Users with more than one running time entry', $hadAProblem);\n\n        // Users have a current organization that they are not a member of\n        $problems = DB::table('users')\n            ->select(['users.id as id'])\n            ->whereNotNull('current_team_id')\n            ->whereNotIn('current_team_id', function (Builder $query): void {\n                $query->select('organization_id')\n                    ->from('members')\n                    ->whereColumn('members.user_id', 'users.id');\n            })->get();\n        $this->logProblems($problems, 'Users have a current organization that they are not a member of', $hadAProblem);\n\n        return $hadAProblem ? self::FAILURE : self::SUCCESS;\n    }\n\n    /**\n     * @param  Collection<int, \\stdClass>  $problems\n     */\n    private function logProblems(Collection $problems, string $message, bool &$hadAProblem): void\n    {\n        $message = 'Consistency problem: '.$message;\n        if ($problems->isNotEmpty()) {\n            $ids = $problems->pluck('id');\n            $hadAProblem = true;\n            Log::error($message, [\n                'ids' => $ids,\n            ]);\n\n            $error = $message;\n            foreach ($ids as $id) {\n                $error .= \"\\n  - \".$id;\n            }\n            $this->error($error);\n        }\n    }\n}\n"
  },
  {
    "path": "app/Console/Commands/SelfHost/SelfHostGenerateKeysCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Console\\Commands\\SelfHost;\n\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Encryption\\Encrypter;\nuse Illuminate\\Support\\Str;\nuse phpseclib3\\Crypt\\RSA;\n\nclass SelfHostGenerateKeysCommand extends Command\n{\n    /**\n     * The name and signature of the console command.\n     *\n     * @var string\n     */\n    protected $signature = 'self-host:generate-keys\n                { --length=4096 : The length of the passport private key }\n                { --multi-line : Whether to output the keys in multiple lines }\n                { --format=env : The format of the output (env, yaml) }';\n\n    /**\n     * The console command description.\n     *\n     * @var string\n     */\n    protected $description = 'Generate random keys for the env variables.';\n\n    /**\n     * Execute the console command.\n     */\n    public function handle(): int\n    {\n        $format = $this->option('format');\n        $key = RSA::createKey((int) $this->option('length'));\n        $multiLine = (bool) $this->option('multi-line');\n\n        $publicKey = (string) $key->getPublicKey();\n        $privateKey = (string) $key;\n        $appKey = 'base64:'.base64_encode(Encrypter::generateKey(config('app.cipher')));\n\n        if ($format === 'env') {\n            $this->line('APP_KEY=\"'.$appKey.'\"');\n            if ($multiLine) {\n                $this->line('PASSPORT_PRIVATE_KEY=\"'.Str::replace(\"\\r\\n\", \"\\n\", $privateKey).'\"');\n                $this->line('PASSPORT_PUBLIC_KEY=\"'.Str::replace(\"\\r\\n\", \"\\n\", $publicKey).'\"');\n            } else {\n                $this->line('PASSPORT_PRIVATE_KEY=\"'.Str::replace(\"\\r\\n\", '\\n', $privateKey).'\"');\n                $this->line('PASSPORT_PUBLIC_KEY=\"'.Str::replace(\"\\r\\n\", '\\n', $publicKey).'\"');\n            }\n        } elseif ($format === 'yaml') {\n            $this->line('APP_KEY: \"'.$appKey.'\"');\n            $this->line(\"PASSPORT_PRIVATE_KEY: |\\n  \".Str::replace(\"\\r\\n\", \"\\n  \", $privateKey));\n            $this->line(\"PASSPORT_PUBLIC_KEY: |\\n  \".Str::replace(\"\\r\\n\", \"\\n  \", $publicKey));\n        } else {\n            $this->error('Invalid format');\n\n            return self::FAILURE;\n        }\n\n        return self::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "app/Console/Commands/SelfHost/SelfHostTelemetryCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Console\\Commands\\SelfHost;\n\nuse App\\Service\\ApiService;\nuse Illuminate\\Console\\Command;\n\nclass SelfHostTelemetryCommand extends Command\n{\n    /**\n     * The name and signature of the console command.\n     *\n     * @var string\n     */\n    protected $signature = 'self-host:telemetry';\n\n    /**\n     * The console command description.\n     *\n     * @var string\n     */\n    protected $description = '';\n\n    /**\n     * Execute the console command.\n     */\n    public function handle(): int\n    {\n        $apiService = app(ApiService::class);\n\n        $success = $apiService->telemetry();\n\n        if (! $success) {\n            $this->error('Failed to send telemetry data, check the logs for more information.');\n\n            return self::FAILURE;\n\n        }\n\n        return self::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "app/Console/Commands/Test/TestEmailCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Console\\Commands\\Test;\n\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Mail\\Message;\nuse Illuminate\\Support\\Facades\\Mail;\n\nclass TestEmailCommand extends Command\n{\n    /**\n     * The name and signature of the console command.\n     *\n     * @var string\n     */\n    protected $signature = 'test:email { email : Email address to send the email to }';\n\n    /**\n     * The console command description.\n     *\n     * @var string\n     */\n    protected $description = 'This test command sends an email.';\n\n    /**\n     * Execute the console command.\n     */\n    public function handle(): int\n    {\n        $email = $this->argument('email');\n        Mail::raw('Hello World!', function (Message $message) use ($email): void {\n            $message->to($email)\n                ->subject('Test Email')\n                ->html('<h1>Hello World!</h1>');\n        });\n\n        return self::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "app/Console/Commands/Test/TestJobCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Console\\Commands\\Test;\n\nuse App\\Jobs\\Test\\TestJob;\nuse App\\Models\\User;\nuse Illuminate\\Console\\Command;\n\nclass TestJobCommand extends Command\n{\n    /**\n     * The name and signature of the console command.\n     *\n     * @var string\n     */\n    protected $signature = 'test:job {--fail}';\n\n    /**\n     * The console command description.\n     *\n     * @var string\n     */\n    protected $description = 'This test command start an async job.';\n\n    /**\n     * Execute the console command.\n     */\n    public function handle(): int\n    {\n        $user = User::firstOrFail();\n        $fail = (bool) $this->option('fail');\n\n        TestJob::dispatch($user, 'Test job message.', $fail);\n\n        return self::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "app/Console/Commands/Test/TestOutputCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Console\\Commands\\Test;\n\nuse Illuminate\\Console\\Command;\n\nclass TestOutputCommand extends Command\n{\n    /**\n     * The name and signature of the console command.\n     *\n     * @var string\n     */\n    protected $signature = 'test:output';\n\n    /**\n     * The console command description.\n     *\n     * @var string\n     */\n    protected $description = 'This test command outputs some text.';\n\n    /**\n     * Execute the console command.\n     */\n    public function handle(): int\n    {\n        $this->info('Test command output');\n        $this->error('Test command output error');\n\n        return self::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "app/Console/Commands/TimeEntry/TimeEntrySendStillRunningMailsCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Console\\Commands\\TimeEntry;\n\nuse App\\Mail\\TimeEntryStillRunningMail;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Database\\Eloquent\\Collection;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Facades\\Mail;\n\nclass TimeEntrySendStillRunningMailsCommand extends Command\n{\n    /**\n     * The name and signature of the console command.\n     *\n     * @var string\n     */\n    protected $signature = 'time-entry:send-still-running-mails '.\n        ' { --dry-run : Do not actually send emails or save anything to the database, just output what would happen }';\n\n    /**\n     * The console command description.\n     *\n     * @var string\n     */\n    protected $description = 'Sends emails to users who have running time entries for more than 8 hours.';\n\n    /**\n     * Execute the console command.\n     */\n    public function handle(): int\n    {\n        $this->comment('Sending still running time entry emails...');\n        $dryRun = (bool) $this->option('dry-run');\n        if ($dryRun) {\n            $this->comment('Running in dry-run mode. No emails will be sent and nothing will be saved to the database.');\n        }\n\n        $sentMails = 0;\n        TimeEntry::query()\n            ->whereNull('end')\n            ->where('start', '<', now()->subHours(8))\n            ->whereNull('still_active_email_sent_at')\n            ->with([\n                'user',\n            ])\n            ->whereHas('user', function (Builder $query): void {\n                /** @var Builder<User> $query */\n                $query->where('is_placeholder', '=', false);\n            })\n            ->orderBy('created_at', 'asc')\n            ->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void {\n                /** @var Collection<int, TimeEntry> $timeEntries */\n                foreach ($timeEntries as $timeEntry) {\n                    $user = $timeEntry->user;\n                    $this->info('Start sending email to user \"'.$user->email.'\" ('.$user->getKey().') for time entry '.$timeEntry->getKey());\n                    $sentMails++;\n                    if (! $dryRun) {\n                        Mail::to($user->email)\n                            ->queue(new TimeEntryStillRunningMail($timeEntry, $user));\n                        $timeEntry->still_active_email_sent_at = Carbon::now();\n                        $timeEntry->save();\n                    }\n                }\n            });\n\n        $this->comment('Finished sending '.$sentMails.' still running time entry emails...');\n\n        return self::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "app/Console/Kernel.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Console;\n\nuse Illuminate\\Console\\Scheduling\\Schedule;\nuse Illuminate\\Foundation\\Console\\Kernel as ConsoleKernel;\n\nclass Kernel extends ConsoleKernel\n{\n    /**\n     * Define the application's command schedule.\n     */\n    protected function schedule(Schedule $schedule): void\n    {\n        $schedule->command('time-entry:send-still-running-mails')\n            ->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails'))\n            ->everyTenMinutes();\n\n        $schedule->command('auth:send-mails-expiring-api-tokens')\n            ->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens'))\n            ->everyTenMinutes();\n\n        if (config('app.key') && (config('scheduling.tasks.self_hosting_check_for_update') || config('scheduling.tasks.self_hosting_telemetry'))) {\n            // Convert string to a stable integer for seeding\n            /** @var int $seed Take the first 8 hex chars → 32-bit int */\n            $seed = hexdec(substr(hash('md5', config('app.key')), 0, 8));\n            $seed = abs($seed); // Ensure it's positive\n            mt_srand($seed);\n            $firstHour = mt_rand(0, 23);\n            $secondHour = ($firstHour + 12) % 24;\n            $minuteOffset = mt_rand(0, 59);\n            mt_srand(null); // Reset the random number generator\n\n            if (config('scheduling.tasks.self_hosting_check_for_update')) {\n                $schedule->command('self-host:check-for-update')\n                    ->twiceDailyAt($firstHour, $secondHour, $minuteOffset);\n            }\n\n            if (config('scheduling.tasks.self_hosting_telemetry')) {\n                $schedule->command('self-host:telemetry')\n                    ->twiceDailyAt($firstHour, $secondHour, $minuteOffset);\n            }\n        }\n\n        $schedule->command('self-host:database-consistency')\n            ->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))\n            ->everySixHours();\n    }\n\n    /**\n     * Register the commands for the application.\n     */\n    protected function commands(): void\n    {\n        $this->load(__DIR__.'/Commands');\n    }\n}\n"
  },
  {
    "path": "app/Enums/CurrencyFormat.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Enums;\n\nuse Datomatic\\LaravelEnumHelper\\LaravelEnumHelper;\n\nenum CurrencyFormat: string\n{\n    use LaravelEnumHelper;\n\n    case ISOCodeBeforeWithSpace = 'iso-code-before-with-space';\n    case ISOCodeAfterWithSpace = 'iso-code-after-with-space';\n\n    case SymbolBefore = 'symbol-before';\n\n    case SymbolAfter = 'symbol-after';\n\n    case SymbolBeforeWithSpace = 'symbol-before-with-space';\n\n    case SymbolAfterWithSpace = 'symbol-after-with-space';\n\n    /**\n     * @return array<string, string>\n     */\n    public static function toSelectArray(): array\n    {\n        $selectArray = [];\n        foreach (self::values() as $value) {\n            $selectArray[(string) $value] = (string) __('enum.currency_format.'.$value);\n        }\n\n        return $selectArray;\n    }\n}\n"
  },
  {
    "path": "app/Enums/DateFormat.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Enums;\n\nuse Datomatic\\LaravelEnumHelper\\LaravelEnumHelper;\n\nenum DateFormat: string\n{\n    use LaravelEnumHelper;\n\n    case PointSeparatedDMYYYY = 'point-separated-d-m-yyyy';\n    case SlashSeparatedMMDDYYYY = 'slash-separated-mm-dd-yyyy';\n\n    case SlashSeparatedDDMMYYYY = 'slash-separated-dd-mm-yyyy';\n\n    case HyphenSeparatedDDMMYYY = 'hyphen-separated-dd-mm-yyyy';\n\n    case HyphenSeparatedMMDDDYYYY = 'hyphen-separated-mm-dd-yyyy';\n\n    case HyphenSeparatedYYYYMMDD = 'hyphen-separated-yyyy-mm-dd';\n\n    public function toCarbonFormat(): string\n    {\n        return match ($this->value) {\n            self::PointSeparatedDMYYYY->value => 'j.n.Y',\n            self::SlashSeparatedMMDDYYYY->value => 'm/d/Y',\n            self::SlashSeparatedDDMMYYYY->value => 'd/m/Y',\n            self::HyphenSeparatedDDMMYYY->value => 'd-m-Y',\n            self::HyphenSeparatedMMDDDYYYY->value => 'm-d-Y',\n            self::HyphenSeparatedYYYYMMDD->value => 'Y-m-d',\n        };\n    }\n\n    /**\n     * @return array<string, string>\n     */\n    public static function toSelectArray(): array\n    {\n        $selectArray = [];\n        foreach (self::values() as $value) {\n            $selectArray[(string) $value] = (string) __('enum.date_format.'.$value);\n        }\n\n        return $selectArray;\n    }\n}\n"
  },
  {
    "path": "app/Enums/ExportFormat.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Enums;\n\nuse Maatwebsite\\Excel\\Excel;\n\nenum ExportFormat: string\n{\n    case CSV = 'csv';\n    case PDF = 'pdf';\n    case XLSX = 'xlsx';\n    case ODS = 'ods';\n\n    public function getFileExtension(): string\n    {\n        return match ($this) {\n            self::CSV => 'csv',\n            self::PDF => 'pdf',\n            self::XLSX => 'xlsx',\n            self::ODS => 'ods',\n        };\n    }\n\n    public function getExportPackageType(): string\n    {\n        return match ($this) {\n            self::CSV => Excel::CSV,\n            self::PDF => Excel::MPDF,\n            self::XLSX => Excel::XLSX,\n            self::ODS => Excel::ODS,\n        };\n    }\n}\n"
  },
  {
    "path": "app/Enums/IntervalFormat.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Enums;\n\nuse Datomatic\\LaravelEnumHelper\\LaravelEnumHelper;\n\nenum IntervalFormat: string\n{\n    use LaravelEnumHelper;\n\n    case Decimal = 'decimal';\n    case HoursMinutes = 'hours-minutes';\n\n    case HoursMinutesColonSeparated = 'hours-minutes-colon-separated';\n\n    case HoursMinutesSecondsColonSeparated = 'hours-minutes-seconds-colon-separated';\n\n    /**\n     * @return array<string, string>\n     */\n    public static function toSelectArray(): array\n    {\n        $selectArray = [];\n        foreach (self::values() as $value) {\n            $selectArray[(string) $value] = (string) __('enum.interval_format.'.$value);\n        }\n\n        return $selectArray;\n    }\n}\n"
  },
  {
    "path": "app/Enums/NumberFormat.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Enums;\n\nuse Datomatic\\LaravelEnumHelper\\LaravelEnumHelper;\n\n/**\n * @info https://en.wikipedia.org/wiki/Decimal_separator\n */\nenum NumberFormat: string\n{\n    use LaravelEnumHelper;\n\n    case ThousandsPointDecimalComma = 'point-comma';\n\n    case ThousandsCommaDecimalPoint = 'comma-point';\n    case ThousandsSpaceDecimalComma = 'space-comma';\n\n    case ThousandsSpaceDecimalPoint = 'space-point';\n\n    case ThousandsApostropheDecimalPoint = 'apostrophe-point';\n\n    /**\n     * @return array<string, string>\n     */\n    public static function toSelectArray(): array\n    {\n        $selectArray = [];\n        foreach (self::values() as $value) {\n            $selectArray[(string) $value] = (string) __('enum.number_format.'.$value);\n        }\n\n        return $selectArray;\n    }\n}\n"
  },
  {
    "path": "app/Enums/Role.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Enums;\n\nenum Role: string\n{\n    case Owner = 'owner';\n    case Admin = 'admin';\n    case Manager = 'manager';\n    case Employee = 'employee';\n    case Placeholder = 'placeholder';\n}\n"
  },
  {
    "path": "app/Enums/TimeEntryAggregationType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Enums;\n\nuse Datomatic\\LaravelEnumHelper\\LaravelEnumHelper;\n\nenum TimeEntryAggregationType: string\n{\n    use LaravelEnumHelper;\n\n    case Day = 'day';\n    case Week = 'week';\n    case Month = 'month';\n    case Year = 'year';\n    case User = 'user';\n    case Project = 'project';\n    case Task = 'task';\n    case Client = 'client';\n    case Billable = 'billable';\n    case Description = 'description';\n    case Tag = 'tag';\n\n    public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType\n    {\n        return match ($timeEntryAggregationTypeInterval) {\n            TimeEntryAggregationTypeInterval::Day => TimeEntryAggregationType::Day,\n            TimeEntryAggregationTypeInterval::Week => TimeEntryAggregationType::Week,\n            TimeEntryAggregationTypeInterval::Month => TimeEntryAggregationType::Month,\n            TimeEntryAggregationTypeInterval::Year => TimeEntryAggregationType::Year,\n        };\n    }\n\n    public function toInterval(): ?TimeEntryAggregationTypeInterval\n    {\n        return match ($this) {\n            TimeEntryAggregationType::Day => TimeEntryAggregationTypeInterval::Day,\n            TimeEntryAggregationType::Week => TimeEntryAggregationTypeInterval::Week,\n            TimeEntryAggregationType::Month => TimeEntryAggregationTypeInterval::Month,\n            TimeEntryAggregationType::Year => TimeEntryAggregationTypeInterval::Year,\n            default => null\n        };\n    }\n}\n"
  },
  {
    "path": "app/Enums/TimeEntryAggregationTypeInterval.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Enums;\n\nenum TimeEntryAggregationTypeInterval: string\n{\n    case Day = 'day';\n    case Week = 'week';\n    case Month = 'month';\n    case Year = 'year';\n}\n"
  },
  {
    "path": "app/Enums/TimeEntryRoundingType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Enums;\n\nuse Datomatic\\LaravelEnumHelper\\LaravelEnumHelper;\n\nenum TimeEntryRoundingType: string\n{\n    use LaravelEnumHelper;\n\n    case Up = 'up';\n    case Down = 'down';\n    case Nearest = 'nearest';\n}\n"
  },
  {
    "path": "app/Enums/TimeFormat.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Enums;\n\nuse Datomatic\\LaravelEnumHelper\\LaravelEnumHelper;\n\nenum TimeFormat: string\n{\n    use LaravelEnumHelper;\n\n    case TwelveHours = '12-hours';\n    case TwentyFourHours = '24-hours';\n\n    /**\n     * @return array<string, string>\n     */\n    public static function toSelectArray(): array\n    {\n        $selectArray = [];\n        foreach (self::values() as $value) {\n            $selectArray[(string) $value] = (string) __('enum.time_format.'.$value);\n        }\n\n        return $selectArray;\n    }\n}\n"
  },
  {
    "path": "app/Enums/Weekday.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Enums;\n\nuse Datomatic\\LaravelEnumHelper\\LaravelEnumHelper;\nuse Illuminate\\Support\\Carbon;\n\nenum Weekday: string\n{\n    use LaravelEnumHelper;\n\n    case Monday = 'monday';\n    case Tuesday = 'tuesday';\n    case Wednesday = 'wednesday';\n    case Thursday = 'thursday';\n    case Friday = 'friday';\n    case Saturday = 'saturday';\n    case Sunday = 'sunday';\n\n    public function toEndOfWeek(): self\n    {\n        return match ($this) {\n            Weekday::Monday => Weekday::Sunday,\n            Weekday::Tuesday => Weekday::Monday,\n            Weekday::Wednesday => Weekday::Tuesday,\n            Weekday::Thursday => Weekday::Wednesday,\n            Weekday::Friday => Weekday::Thursday,\n            Weekday::Saturday => Weekday::Friday,\n            Weekday::Sunday => Weekday::Saturday,\n        };\n    }\n\n    public function carbonWeekDay(): int\n    {\n        return match ($this) {\n            Weekday::Monday => Carbon::MONDAY,\n            Weekday::Tuesday => Carbon::TUESDAY,\n            Weekday::Wednesday => Carbon::WEDNESDAY,\n            Weekday::Thursday => Carbon::THURSDAY,\n            Weekday::Friday => Carbon::FRIDAY,\n            Weekday::Saturday => Carbon::SATURDAY,\n            Weekday::Sunday => Carbon::SUNDAY,\n        };\n    }\n\n    /**\n     * @return array<string, string>\n     */\n    public static function toSelectArray(): array\n    {\n        return [\n            Weekday::Monday->value => __('enum.weekday.'.Weekday::Monday->value),\n            Weekday::Tuesday->value => __('enum.weekday.'.Weekday::Tuesday->value),\n            Weekday::Wednesday->value => __('enum.weekday.'.Weekday::Wednesday->value),\n            Weekday::Thursday->value => __('enum.weekday.'.Weekday::Thursday->value),\n            Weekday::Friday->value => __('enum.weekday.'.Weekday::Friday->value),\n            Weekday::Saturday->value => __('enum.weekday.'.Weekday::Saturday->value),\n            Weekday::Sunday->value => __('enum.weekday.'.Weekday::Sunday->value),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Events/AfterCreateOrganization.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Events;\n\nuse App\\Models\\Organization;\nuse Illuminate\\Foundation\\Events\\Dispatchable;\nuse Illuminate\\Queue\\SerializesModels;\n\n/**\n * This event is fired after an organization has been created.\n * This event does NOT fire when an organization is created as part of a registration.\n */\nclass AfterCreateOrganization\n{\n    use Dispatchable;\n    use SerializesModels;\n\n    public Organization $organization;\n\n    public function __construct(Organization $organization)\n    {\n        $this->organization = $organization;\n    }\n}\n"
  },
  {
    "path": "app/Events/BeforeOrganizationDeletion.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Events;\n\nuse App\\Models\\Organization;\nuse Illuminate\\Foundation\\Events\\Dispatchable;\n\nclass BeforeOrganizationDeletion\n{\n    use Dispatchable;\n\n    public Organization $organization;\n\n    public function __construct(Organization $organization)\n    {\n        $this->organization = $organization;\n    }\n}\n"
  },
  {
    "path": "app/Events/DatabaseSeederAfterSeed.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Events;\n\nuse Illuminate\\Foundation\\Events\\Dispatchable;\n\nclass DatabaseSeederAfterSeed\n{\n    use Dispatchable;\n\n    public function __construct() {}\n}\n"
  },
  {
    "path": "app/Events/DatabaseSeederBeforeDelete.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Events;\n\nuse Illuminate\\Foundation\\Events\\Dispatchable;\n\nclass DatabaseSeederBeforeDelete\n{\n    use Dispatchable;\n\n    public function __construct() {}\n}\n"
  },
  {
    "path": "app/Events/MemberMadeToPlaceholder.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Events;\n\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse Illuminate\\Foundation\\Events\\Dispatchable;\n\nclass MemberMadeToPlaceholder\n{\n    use Dispatchable;\n\n    public Organization $organization;\n\n    public Member $member;\n\n    public function __construct(Member $member, Organization $organization)\n    {\n        $this->member = $member;\n        $this->organization = $organization;\n    }\n}\n"
  },
  {
    "path": "app/Events/MemberRemoved.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Events;\n\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse Illuminate\\Foundation\\Events\\Dispatchable;\n\nclass MemberRemoved\n{\n    use Dispatchable;\n\n    public Organization $organization;\n\n    public Member $member;\n\n    public function __construct(Member $member, Organization $organization)\n    {\n        $this->member = $member;\n        $this->organization = $organization;\n    }\n}\n"
  },
  {
    "path": "app/Events/NewsletterRegistered.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Events;\n\nuse Illuminate\\Foundation\\Events\\Dispatchable;\n\nclass NewsletterRegistered\n{\n    use Dispatchable;\n\n    public string $name;\n\n    public string $email;\n\n    public string $id;\n\n    /**\n     * Create a new event instance.\n     */\n    public function __construct(string $name, string $email, string $id)\n    {\n        $this->name = $name;\n        $this->email = $email;\n        $this->id = $id;\n    }\n}\n"
  },
  {
    "path": "app/Exceptions/Api/ApiException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exceptions\\Api;\n\nuse Exception;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse LogicException;\n\nabstract class ApiException extends Exception\n{\n    public const string KEY = 'api_exception';\n\n    public function __construct()\n    {\n        parent::__construct(static::KEY);\n    }\n\n    /**\n     * Render the exception into an HTTP response.\n     */\n    public function render(Request $request): JsonResponse\n    {\n        return response()\n            ->json([\n                'error' => true,\n                'key' => $this->getKey(),\n                'message' => $this->getTranslatedMessage(),\n            ], 400);\n    }\n\n    /**\n     * Get the key for the exception.\n     */\n    public function getKey(): string\n    {\n        $key = static::KEY;\n\n        if ($key === ApiException::KEY) {\n            throw new LogicException('API exceptions need the KEY constant defined.');\n        }\n\n        return $key;\n    }\n\n    /**\n     * Get the translated message for the exception.\n     */\n    public function getTranslatedMessage(): string\n    {\n        return __('exceptions.api.'.$this->getKey());\n    }\n\n    /**\n     * Report the exception.\n     *\n     * @return bool true means the exception handler will not report it again\n     */\n    public function report(): bool\n    {\n        // TODO: temporary activated\n        return false;\n    }\n}\n"
  },
  {
    "path": "app/Exceptions/Api/CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exceptions\\Api;\n\nclass CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers extends ApiException\n{\n    public const string KEY = 'can_not_delete_user_who_is_owner_of_organization_with_multiple_members';\n}\n"
  },
  {
    "path": "app/Exceptions/Api/CanNotRemoveOwnerFromOrganization.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exceptions\\Api;\n\nclass CanNotRemoveOwnerFromOrganization extends ApiException\n{\n    public const string KEY = 'can_not_remove_owner_from_organization';\n}\n"
  },
  {
    "path": "app/Exceptions/Api/ChangingRoleOfPlaceholderIsNotAllowed.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exceptions\\Api;\n\nclass ChangingRoleOfPlaceholderIsNotAllowed extends ApiException\n{\n    public const string KEY = 'changing_role_of_placeholder_is_not_allowed';\n}\n"
  },
  {
    "path": "app/Exceptions/Api/ChangingRoleToPlaceholderIsNotAllowed.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exceptions\\Api;\n\nclass ChangingRoleToPlaceholderIsNotAllowed extends ApiException\n{\n    public const string KEY = 'changing_role_to_placeholder_is_not_allowed';\n}\n"
  },
  {
    "path": "app/Exceptions/Api/EntityStillInUseApiException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exceptions\\Api;\n\nclass EntityStillInUseApiException extends ApiException\n{\n    private string $modelToDelete;\n\n    private string $modelInUse;\n\n    public function __construct(string $modelToDelete, string $modelInUse)\n    {\n        parent::__construct();\n        $this->modelToDelete = $modelToDelete;\n        $this->modelInUse = $modelInUse;\n    }\n\n    public const string KEY = 'entity_still_in_use';\n\n    /**\n     * Get the translated message for the exception.\n     */\n    #[\\Override]\n    public function getTranslatedMessage(): string\n    {\n        return __('exceptions.api.'.$this->getKey(), [\n            'modelToDelete' => __('validation.entities.'.$this->modelToDelete),\n            'modelInUse' => __('validation.entities.'.$this->modelInUse),\n        ]);\n    }\n}\n"
  },
  {
    "path": "app/Exceptions/Api/FeatureIsNotAvailableInFreePlanApiException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exceptions\\Api;\n\nclass FeatureIsNotAvailableInFreePlanApiException extends ApiException\n{\n    public const string KEY = 'feature_is_not_available_in_free_plan';\n}\n"
  },
  {
    "path": "app/Exceptions/Api/InactiveUserCanNotBeUsedApiException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exceptions\\Api;\n\nclass InactiveUserCanNotBeUsedApiException extends ApiException\n{\n    public const string KEY = 'inactive_user_can_not_be_used';\n}\n"
  },
  {
    "path": "app/Exceptions/Api/InvitationForTheEmailAlreadyExistsApiException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exceptions\\Api;\n\nclass InvitationForTheEmailAlreadyExistsApiException extends ApiException\n{\n    public const string KEY = 'invitation_for_the_email_already_exists';\n}\n"
  },
  {
    "path": "app/Exceptions/Api/OnlyOwnerCanChangeOwnership.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exceptions\\Api;\n\nclass OnlyOwnerCanChangeOwnership extends ApiException\n{\n    public const string KEY = 'only_owner_can_change_ownership';\n}\n"
  },
  {
    "path": "app/Exceptions/Api/OnlyPlaceholdersCanBeMergedIntoAnotherMember.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exceptions\\Api;\n\nclass OnlyPlaceholdersCanBeMergedIntoAnotherMember extends ApiException\n{\n    public const string KEY = 'only_placeholders_can_be_merged_into_another_member';\n}\n"
  },
  {
    "path": "app/Exceptions/Api/OrganizationHasNoSubscriptionButMultipleMembersException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exceptions\\Api;\n\nclass OrganizationHasNoSubscriptionButMultipleMembersException extends ApiException\n{\n    public const string KEY = 'organization_has_no_subscription_but_multiple_members';\n}\n"
  },
  {
    "path": "app/Exceptions/Api/OrganizationNeedsAtLeastOneOwner.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exceptions\\Api;\n\nclass OrganizationNeedsAtLeastOneOwner extends ApiException\n{\n    public const string KEY = 'organization_needs_at_least_one_owner';\n}\n"
  },
  {
    "path": "app/Exceptions/Api/OverlappingTimeEntryApiException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exceptions\\Api;\n\nclass OverlappingTimeEntryApiException extends ApiException\n{\n    public const string KEY = 'overlapping_time_entry';\n}\n"
  },
  {
    "path": "app/Exceptions/Api/PdfRendererIsNotConfiguredException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exceptions\\Api;\n\nclass PdfRendererIsNotConfiguredException extends ApiException\n{\n    public const string KEY = 'pdf_renderer_is_not_configured';\n}\n"
  },
  {
    "path": "app/Exceptions/Api/PersonalAccessClientIsNotConfiguredException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exceptions\\Api;\n\nclass PersonalAccessClientIsNotConfiguredException extends ApiException\n{\n    public const string KEY = 'personal_access_client_is_not_configured';\n}\n"
  },
  {
    "path": "app/Exceptions/Api/ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exceptions\\Api;\n\nclass ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException extends ApiException\n{\n    public const string KEY = 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception';\n}\n"
  },
  {
    "path": "app/Exceptions/Api/TimeEntryCanNotBeRestartedApiException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exceptions\\Api;\n\nclass TimeEntryCanNotBeRestartedApiException extends ApiException\n{\n    public const string KEY = 'time_entry_can_not_be_restarted';\n}\n"
  },
  {
    "path": "app/Exceptions/Api/TimeEntryStillRunningApiException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exceptions\\Api;\n\nclass TimeEntryStillRunningApiException extends ApiException\n{\n    public const string KEY = 'time_entry_still_running';\n}\n"
  },
  {
    "path": "app/Exceptions/Api/UserIsAlreadyMemberOfOrganizationApiException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exceptions\\Api;\n\nclass UserIsAlreadyMemberOfOrganizationApiException extends ApiException\n{\n    public const string KEY = 'user_is_already_member_of_organization';\n}\n"
  },
  {
    "path": "app/Exceptions/Api/UserIsAlreadyMemberOfProjectApiException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exceptions\\Api;\n\nclass UserIsAlreadyMemberOfProjectApiException extends ApiException\n{\n    public const string KEY = 'user_is_already_member_of_project';\n}\n"
  },
  {
    "path": "app/Exceptions/Api/UserNotPlaceholderApiException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exceptions\\Api;\n\nclass UserNotPlaceholderApiException extends ApiException\n{\n    public const string KEY = 'user_not_placeholder';\n}\n"
  },
  {
    "path": "app/Exceptions/Handler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exceptions;\n\nuse Illuminate\\Foundation\\Exceptions\\Handler as ExceptionHandler;\nuse Illuminate\\Http\\RedirectResponse;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Throwable;\n\nclass Handler extends ExceptionHandler\n{\n    /**\n     * The list of the inputs that are never flashed to the session on validation exceptions.\n     *\n     * @var array<int, string>\n     */\n    protected $dontFlash = [\n        'current_password',\n        'password',\n        'password_confirmation',\n    ];\n\n    /**\n     * Register the exception handling callbacks for the application.\n     */\n    public function register(): void\n    {\n        $this->reportable(function (Throwable $e): void {\n            //\n        });\n    }\n\n    public function render($request, Throwable $e): Response|RedirectResponse\n    {\n        $response = parent::render($request, $e);\n\n        if ($response->getStatusCode() === 419) {\n            return back()->with([\n                'message' => 'The page expired, please try again.',\n            ]);\n        }\n\n        return $response;\n    }\n}\n"
  },
  {
    "path": "app/Exceptions/MovedToApiException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exceptions;\n\nuse Symfony\\Component\\HttpKernel\\Exception\\HttpException;\n\nclass MovedToApiException extends HttpException\n{\n    public function __construct()\n    {\n        parent::__construct(403, 'Moved to API');\n    }\n}\n"
  },
  {
    "path": "app/Extensions/Auditing/Resolvers/CustomIpAddressResolver.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Extensions\\Auditing\\Resolvers;\n\nuse Illuminate\\Support\\Facades\\Request;\nuse OwenIt\\Auditing\\Contracts\\Auditable;\nuse OwenIt\\Auditing\\Contracts\\Resolver;\n\nclass CustomIpAddressResolver implements Resolver\n{\n    private static function anonymizeIpAddress(string $ipAddress): string\n    {\n        /** @source https://stackoverflow.com/a/48777412 */\n        return preg_replace(\n            ['/\\.\\d*$/', '/[\\da-f]*:[\\da-f]*$/'],\n            ['.0', '0:0'],\n            $ipAddress\n        );\n    }\n\n    public static function resolve(Auditable $auditable): string\n    {\n        $ip = $auditable->preloadedResolverData['ip_address'] ?? Request::ip();\n\n        if ($ip !== null) {\n            $ip = self::anonymizeIpAddress($ip);\n        }\n\n        return $ip;\n    }\n}\n"
  },
  {
    "path": "app/Extensions/Fortify/CustomLoginResponse.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Extensions\\Fortify;\n\nuse Illuminate\\Http\\Request;\nuse Inertia\\Inertia;\nuse Laravel\\Fortify\\Http\\Responses\\LoginResponse;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass CustomLoginResponse extends LoginResponse\n{\n    /**\n     * Create an HTTP response that represents the object.\n     *\n     * @param  Request  $request\n     */\n    public function toResponse($request): Response\n    {\n        $redirectPath = session()->pull('url.intended', route('dashboard', [], false));\n\n        return $request->wantsJson()\n            ? response()->json(['two_factor' => false])\n            : Inertia::location($redirectPath);\n    }\n}\n"
  },
  {
    "path": "app/Extensions/Fortify/CustomTwoFactorLoginResponse.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Extensions\\Fortify;\n\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Inertia\\Inertia;\nuse Laravel\\Fortify\\Contracts\\TwoFactorLoginResponse as TwoFactorLoginResponseContract;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass CustomTwoFactorLoginResponse implements TwoFactorLoginResponseContract\n{\n    /**\n     * Create an HTTP response that represents the object.\n     *\n     * @param  Request  $request\n     */\n    public function toResponse($request): Response\n    {\n        $redirectPath = session()->pull('url.intended', route('dashboard', [], false));\n\n        return $request->wantsJson()\n            ? new JsonResponse('', 204)\n            : Inertia::location($redirectPath);\n    }\n}\n"
  },
  {
    "path": "app/Extensions/Scramble/ApiExceptionTypeToSchema.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Extensions\\Scramble;\n\nuse App\\Exceptions\\Api\\ApiException;\nuse Dedoc\\Scramble\\Extensions\\ExceptionToResponseExtension;\nuse Dedoc\\Scramble\\Support\\Generator\\Reference;\nuse Dedoc\\Scramble\\Support\\Generator\\Response;\nuse Dedoc\\Scramble\\Support\\Generator\\Schema;\nuse Dedoc\\Scramble\\Support\\Generator\\Types as OpenApiTypes;\nuse Dedoc\\Scramble\\Support\\Type\\ObjectType;\nuse Dedoc\\Scramble\\Support\\Type\\Type;\nuse Illuminate\\Support\\Str;\n\nclass ApiExceptionTypeToSchema extends ExceptionToResponseExtension\n{\n    public function shouldHandle(Type $type): bool\n    {\n        return $type instanceof ObjectType\n            && $type->isInstanceOf(ApiException::class);\n    }\n\n    public function toResponse(Type $type): Response\n    {\n        $validationResponseBodyType = (new OpenApiTypes\\ObjectType)\n            ->addProperty(\n                'error',\n                (new OpenApiTypes\\BooleanType)\n                    ->setDescription('Whether the response is an error.')\n            )\n            ->addProperty(\n                'key',\n                (new OpenApiTypes\\StringType)\n                    ->setDescription('Error key.')\n            )\n            ->addProperty(\n                'message',\n                (new OpenApiTypes\\StringType)\n                    ->setDescription('Error message.')\n            )\n            ->setRequired(['error', 'key', 'message']);\n\n        return Response::make(400)\n            ->description('API exception')\n            ->setContent(\n                'application/json',\n                Schema::fromType($validationResponseBodyType)\n            );\n    }\n\n    public function reference(ObjectType $type): Reference\n    {\n        return new Reference('responses', Str::start($type->name, '\\\\'), $this->components);\n    }\n}\n"
  },
  {
    "path": "app/Extensions/Scramble/PaginatedResourceCollectionTypeToSchema.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Extensions\\Scramble;\n\nuse App\\Http\\Resources\\PaginatedResourceCollection;\nuse App\\Http\\Resources\\V1\\TimeEntry\\TimeEntryCollection;\nuse Dedoc\\Scramble\\Extensions\\TypeToSchemaExtension;\nuse Dedoc\\Scramble\\Support\\Generator\\Response;\nuse Dedoc\\Scramble\\Support\\Generator\\Schema;\nuse Dedoc\\Scramble\\Support\\Generator\\Types\\ArrayType;\nuse Dedoc\\Scramble\\Support\\Generator\\Types\\BooleanType;\nuse Dedoc\\Scramble\\Support\\Generator\\Types\\IntegerType;\nuse Dedoc\\Scramble\\Support\\Generator\\Types\\ObjectType as OpenApiObjectType;\nuse Dedoc\\Scramble\\Support\\Generator\\Types\\StringType;\nuse Dedoc\\Scramble\\Support\\Type\\Generic;\nuse Dedoc\\Scramble\\Support\\Type\\ObjectType;\nuse Dedoc\\Scramble\\Support\\Type\\Type;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Http\\Resources\\Json\\JsonResource;\n\nclass PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension\n{\n    public function shouldHandle(Type $type): bool\n    {\n        return $type instanceof ObjectType\n            && $type->isInstanceOf(PaginatedResourceCollection::class);\n    }\n\n    public function toSchema(Type $type): ?OpenApiObjectType\n    {\n        /** @var Type|null $collectingClassType */\n        $collectingClassType = $type->templateTypes[0] ?? null;\n\n        if (! $collectingClassType instanceof ObjectType) {\n            return null;\n        }\n\n        if (! $collectingClassType->isInstanceOf(JsonResource::class) && ! $collectingClassType->isInstanceOf(Model::class)) {\n            return null;\n        }\n\n        $collectingType = $this->openApiTransformer->transform($collectingClassType);\n\n        $newType = new OpenApiObjectType;\n        $newType->addProperty('data', (new ArrayType)->setItems($collectingType));\n        if ($type instanceof ObjectType && $type->isInstanceOf(TimeEntryCollection::class)) {\n            $newType->addProperty(\n                'meta',\n                (new OpenApiObjectType)\n                    ->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))\n                    ->setRequired(['total'])\n            );\n            $newType->setRequired(['data', 'meta']);\n        } else {\n            $newType->addProperty(\n                'links',\n                (new OpenApiObjectType)\n                    ->addProperty('first', (new StringType)->nullable(true))\n                    ->addProperty('last', (new StringType)->nullable(true))\n                    ->addProperty('prev', (new StringType)->nullable(true))\n                    ->addProperty('next', (new StringType)->nullable(true))\n                    ->setRequired(['first', 'last', 'prev', 'next'])\n            );\n            $newType->addProperty(\n                'meta',\n                (new OpenApiObjectType)\n                    ->addProperty('current_page', new IntegerType)\n                    ->addProperty('from', (new IntegerType)->nullable(true))\n                    ->addProperty('last_page', new IntegerType)\n                    ->addProperty('links', (new ArrayType)->setItems(\n                        (new OpenApiObjectType)\n                            ->addProperty('url', (new StringType)->nullable(true))\n                            ->addProperty('label', new StringType)\n                            ->addProperty('active', new BooleanType)\n                            ->setRequired(['url', 'label', 'active'])\n                    )->setDescription('Generated paginator links.'))\n                    ->addProperty('path', (new StringType)->nullable(true)->setDescription('Base path for paginator generated URLs.'))\n                    ->addProperty('per_page', (new IntegerType)->setDescription('Number of items shown per page.'))\n                    ->addProperty('to', (new IntegerType)->nullable(true)->setDescription('Number of the last item in the slice.'))\n                    ->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))\n                    ->setRequired(['current_page', 'from', 'last_page', 'links', 'path', 'per_page', 'to', 'total'])\n            );\n            $newType->setRequired(['data', 'links', 'meta']);\n        }\n\n        return $newType;\n    }\n\n    /**\n     * @param  Generic  $type\n     */\n    public function toResponse(Type $type): ?Response\n    {\n        /** @var ObjectType|null $collectingClassType */\n        $collectingClassType = $type->templateTypes[0] ?? null;\n        if (! $collectingClassType instanceof ObjectType) {\n            return null;\n        }\n        $type = $this->toSchema($type);\n\n        return Response::make(200)\n            ->description('Paginated set of `'.$this->components->uniqueSchemaName($collectingClassType->name).'`')\n            ->setContent('application/json', Schema::fromType($type));\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/AuditResource/Pages/CreateAudit.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\AuditResource\\Pages;\n\nuse App\\Filament\\Resources\\AuditResource;\nuse Filament\\Resources\\Pages\\CreateRecord;\n\nclass CreateAudit extends CreateRecord\n{\n    protected static string $resource = AuditResource::class;\n}\n"
  },
  {
    "path": "app/Filament/Resources/AuditResource/Pages/ListAudits.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\AuditResource\\Pages;\n\nuse App\\Filament\\Resources\\AuditResource;\nuse Filament\\Resources\\Pages\\ListRecords;\n\nclass ListAudits extends ListRecords\n{\n    protected static string $resource = AuditResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/AuditResource/Pages/ViewAudit.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\AuditResource\\Pages;\n\nuse App\\Filament\\Resources\\AuditResource;\nuse Filament\\Resources\\Pages\\ViewRecord;\n\nclass ViewAudit extends ViewRecord\n{\n    protected static string $resource = AuditResource::class;\n}\n"
  },
  {
    "path": "app/Filament/Resources/AuditResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources;\n\nuse App\\Filament\\Resources\\AuditResource\\Pages;\nuse App\\Models\\Audit;\nuse Filament\\Forms;\nuse Filament\\Forms\\Form;\nuse Filament\\Resources\\Resource;\nuse Filament\\Tables;\nuse Filament\\Tables\\Columns\\IconColumn;\nuse Filament\\Tables\\Table;\nuse Illuminate\\Support\\Str;\nuse Novadaemon\\FilamentPrettyJson\\Form\\PrettyJsonField;\n\nclass AuditResource extends Resource\n{\n    protected static ?string $model = Audit::class;\n\n    protected static ?string $navigationIcon = 'heroicon-o-archive-box';\n\n    protected static ?string $navigationGroup = 'System';\n\n    public static function form(Form $form): Form\n    {\n        return $form\n            ->schema([\n                Forms\\Components\\TextInput::make('user_type')\n                    ->maxLength(255),\n                Forms\\Components\\TextInput::make('user_id'),\n                Forms\\Components\\TextInput::make('event')\n                    ->required()\n                    ->maxLength(255),\n                Forms\\Components\\TextInput::make('auditable_type')\n                    ->required()\n                    ->maxLength(255),\n                Forms\\Components\\TextInput::make('auditable_id')\n                    ->required(),\n                PrettyJsonField::make('old_values'),\n                PrettyJsonField::make('new_values'),\n                Forms\\Components\\Textarea::make('url'),\n                Forms\\Components\\TextInput::make('ip_address'),\n                Forms\\Components\\TextInput::make('user_agent')\n                    ->maxLength(1023),\n                Forms\\Components\\TextInput::make('tags')\n                    ->maxLength(255),\n            ]);\n    }\n\n    public static function table(Table $table): Table\n    {\n        return $table\n            ->columns([\n                Tables\\Columns\\TextColumn::make('user.name'),\n                Tables\\Columns\\TextColumn::make('event'),\n                Tables\\Columns\\TextColumn::make('auditable_type'),\n                Tables\\Columns\\TextColumn::make('auditable_id'),\n                IconColumn::make('was_command')\n                    ->getStateUsing(fn (Audit $record) => Str::startsWith($record->url, 'artisan '))\n                    ->boolean(),\n                Tables\\Columns\\TextColumn::make('created_at')\n                    ->sortable()\n                    ->dateTime(),\n                Tables\\Columns\\TextColumn::make('updated_at')\n                    ->sortable()\n                    ->dateTime(),\n            ])\n            ->filters([\n                //\n            ])\n            ->actions([\n                Tables\\Actions\\ViewAction::make(),\n            ])\n            ->bulkActions([\n            ])\n            ->defaultSort('created_at', 'desc');\n    }\n\n    public static function getRelations(): array\n    {\n        return [\n        ];\n    }\n\n    public static function getPages(): array\n    {\n        return [\n            'index' => Pages\\ListAudits::route('/'),\n            'create' => Pages\\CreateAudit::route('/create'),\n            'view' => Pages\\ViewAudit::route('/{record}'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/ClientResource/Pages/CreateClient.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\ClientResource\\Pages;\n\nuse App\\Filament\\Resources\\ClientResource;\nuse Filament\\Resources\\Pages\\CreateRecord;\n\nclass CreateClient extends CreateRecord\n{\n    protected static string $resource = ClientResource::class;\n}\n"
  },
  {
    "path": "app/Filament/Resources/ClientResource/Pages/EditClient.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\ClientResource\\Pages;\n\nuse App\\Filament\\Resources\\ClientResource;\nuse Filament\\Actions;\nuse Filament\\Resources\\Pages\\EditRecord;\n\nclass EditClient extends EditRecord\n{\n    protected static string $resource = ClientResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n            Actions\\DeleteAction::make()\n                ->icon('heroicon-m-trash'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/ClientResource/Pages/ListClients.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\ClientResource\\Pages;\n\nuse App\\Filament\\Resources\\ClientResource;\nuse Filament\\Actions;\nuse Filament\\Resources\\Pages\\ListRecords;\n\nclass ListClients extends ListRecords\n{\n    protected static string $resource = ClientResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n            Actions\\CreateAction::make()\n                ->icon('heroicon-s-plus'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/ClientResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources;\n\nuse App\\Filament\\Resources\\ClientResource\\Pages;\nuse App\\Models\\Client;\nuse Filament\\Forms\\Components\\Select;\nuse Filament\\Forms\\Components\\TextInput;\nuse Filament\\Forms\\Form;\nuse Filament\\Resources\\Resource;\nuse Filament\\Tables;\nuse Filament\\Tables\\Filters\\SelectFilter;\nuse Filament\\Tables\\Table;\n\nclass ClientResource extends Resource\n{\n    protected static ?string $model = Client::class;\n\n    protected static ?string $navigationIcon = 'heroicon-o-briefcase';\n\n    protected static ?string $navigationGroup = 'Timetracking';\n\n    protected static ?int $navigationSort = 4;\n\n    public static function form(Form $form): Form\n    {\n        return $form\n            ->schema([\n                TextInput::make('name')\n                    ->label('Name')\n                    ->required(),\n                Select::make('organization_id')\n                    ->relationship(name: 'organization', titleAttribute: 'name')\n                    ->label('Organization')\n                    ->searchable(['name'])\n                    ->required(),\n            ]);\n    }\n\n    public static function table(Table $table): Table\n    {\n        return $table\n            ->columns([\n                Tables\\Columns\\TextColumn::make('name')\n                    ->label('Name')\n                    ->searchable()\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('organization.name')\n                    ->sortable()\n                    ->label('Organization'),\n                Tables\\Columns\\TextColumn::make('created_at')\n                    ->label('Created at')\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('updated_at')\n                    ->label('Updated at')\n                    ->sortable(),\n            ])\n            ->defaultSort('created_at', 'desc')\n            ->filters([\n                SelectFilter::make('organization')\n                    ->label('Organization')\n                    ->relationship('organization', 'name')\n                    ->searchable(),\n                SelectFilter::make('organization_id')\n                    ->label('Organization ID')\n                    ->relationship('organization', 'id')\n                    ->searchable(),\n            ])\n            ->actions([\n                Tables\\Actions\\EditAction::make(),\n            ])\n            ->bulkActions([\n                Tables\\Actions\\BulkActionGroup::make([\n                    Tables\\Actions\\DeleteBulkAction::make(),\n                ]),\n            ]);\n    }\n\n    public static function getRelations(): array\n    {\n        return [\n            //\n        ];\n    }\n\n    public static function getPages(): array\n    {\n        return [\n            'index' => Pages\\ListClients::route('/'),\n            'create' => Pages\\CreateClient::route('/create'),\n            'edit' => Pages\\EditClient::route('/{record}/edit'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/FailedJobResource/Pages/ListFailedJobs.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\FailedJobResource\\Pages;\n\nuse App\\Filament\\Resources\\FailedJobResource;\nuse App\\Models\\FailedJob;\nuse Filament\\Actions\\Action;\nuse Filament\\Notifications\\Notification;\nuse Filament\\Resources\\Pages\\ListRecords;\nuse Illuminate\\Support\\Facades\\Artisan;\n\nclass ListFailedJobs extends ListRecords\n{\n    protected static string $resource = FailedJobResource::class;\n\n    public function getHeaderActions(): array\n    {\n        return [\n            Action::make('retry_all')\n                ->icon('heroicon-o-arrow-path')\n                ->label('Retry all')\n                ->requiresConfirmation()\n                ->action(function (): void {\n                    Artisan::call('queue:retry all');\n                    Notification::make()\n                        ->title('All failed jobs have been pushed back onto the queue.')\n                        ->success()\n                        ->send();\n                }),\n\n            Action::make('delete_all')\n                ->icon('heroicon-o-trash')\n                ->label('Delete all')\n                ->requiresConfirmation()\n                ->color('danger')\n                ->action(function (): void {\n                    FailedJob::truncate();\n                    Notification::make()\n                        ->title('All failed jobs have been removed.')\n                        ->success()\n                        ->send();\n                }),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/FailedJobResource/Pages/ViewFailedJobs.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\FailedJobResource\\Pages;\n\nuse App\\Filament\\Resources\\FailedJobResource;\nuse Filament\\Resources\\Pages\\ViewRecord;\n\nclass ViewFailedJobs extends ViewRecord\n{\n    protected static string $resource = FailedJobResource::class;\n}\n"
  },
  {
    "path": "app/Filament/Resources/FailedJobResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources;\n\nuse App\\Filament\\Resources\\FailedJobResource\\Pages\\ListFailedJobs;\nuse App\\Filament\\Resources\\FailedJobResource\\Pages\\ViewFailedJobs;\nuse App\\Models\\FailedJob;\nuse Filament\\Forms\\Components\\Textarea;\nuse Filament\\Forms\\Components\\TextInput;\nuse Filament\\Forms\\Form;\nuse Filament\\Notifications\\Notification;\nuse Filament\\Resources\\Resource;\nuse Filament\\Tables\\Actions\\Action;\nuse Filament\\Tables\\Actions\\BulkAction;\nuse Filament\\Tables\\Actions\\DeleteAction;\nuse Filament\\Tables\\Actions\\DeleteBulkAction;\nuse Filament\\Tables\\Actions\\ViewAction;\nuse Filament\\Tables\\Columns\\TextColumn;\nuse Filament\\Tables\\Table;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Artisan;\nuse Novadaemon\\FilamentPrettyJson\\Form\\PrettyJsonField;\n\n/**\n * @source https://gitlab.com/amvisor/filament-failed-jobs\n */\nclass FailedJobResource extends Resource\n{\n    protected static ?string $model = FailedJob::class;\n\n    protected static ?string $navigationIcon = 'heroicon-o-exclamation-circle';\n\n    protected static ?string $navigationGroup = 'System';\n\n    public static function getNavigationBadge(): ?string\n    {\n        return (string) FailedJob::query()->count();\n    }\n\n    public static function form(Form $form): Form\n    {\n        return $form\n            ->schema([\n                TextInput::make('uuid')->disabled()->columnSpan(4),\n                TextInput::make('failed_at')->disabled(),\n                TextInput::make('id')->disabled(),\n                TextInput::make('connection')->disabled(),\n                TextInput::make('queue')->disabled(),\n\n                // make text a little bit smaller because often a complete Stack Trace is shown:\n                TextArea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']),\n                PrettyJsonField::make('payload')->disabled()->columnSpan(4),\n            ])->columns(4);\n    }\n\n    public static function table(Table $table): Table\n    {\n        return $table\n            ->defaultSort('id', 'desc')\n            ->columns([\n                TextColumn::make('id')->sortable()->searchable()->toggleable(),\n                TextColumn::make('failed_at')->sortable()->searchable(false)->toggleable(),\n                TextColumn::make('exception')\n                    ->sortable()\n                    ->searchable()\n                    ->toggleable()\n                    ->wrap()\n                    ->limit(200)\n                    ->tooltip(fn (FailedJob $record) => \"{$record->failed_at} UUID: {$record->uuid}; Connection: {$record->connection}; Queue: {$record->queue};\"),\n                TextColumn::make('uuid')->sortable()->searchable()->toggleable(isToggledHiddenByDefault: true),\n                TextColumn::make('connection')->sortable()->searchable()->toggleable(isToggledHiddenByDefault: true),\n                TextColumn::make('queue')->sortable()->searchable()->toggleable(isToggledHiddenByDefault: true),\n            ])\n            ->filters([])\n            ->bulkActions([\n                BulkAction::make('retry')\n                    ->icon('heroicon-o-arrow-path')\n                    ->label('Retry selected')\n                    ->requiresConfirmation()\n                    ->action(function (Collection $records): void {\n                        /** @var FailedJob $record */\n                        foreach ($records as $record) {\n                            Artisan::call(\"queue:retry {$record->uuid}\");\n                        }\n                        Notification::make()\n                            ->title(\"{$records->count()} jobs have been pushed back onto the queue.\")\n                            ->success()\n                            ->send();\n                    }),\n                DeleteBulkAction::make(),\n            ])\n            ->actions([\n                DeleteAction::make(),\n                ViewAction::make(),\n                Action::make('retry')\n                    ->icon('heroicon-o-arrow-path')\n                    ->label('Retry')\n                    ->requiresConfirmation()\n                    ->action(function (FailedJob $record): void {\n                        Artisan::call(\"queue:retry {$record->uuid}\");\n                        Notification::make()\n                            ->title(\"The job with uuid '{$record->uuid}' has been pushed back onto the queue.\")\n                            ->success()\n                            ->send();\n                    }),\n            ]);\n    }\n\n    public static function getPages(): array\n    {\n        return [\n            'index' => ListFailedJobs::route('/'),\n            'view' => ViewFailedJobs::route('/{record}'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/OrganizationInvitationResource/Pages/EditOrganizationInvitation.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\OrganizationInvitationResource\\Pages;\n\nuse App\\Filament\\Resources\\OrganizationInvitationResource;\nuse Filament\\Actions;\nuse Filament\\Resources\\Pages\\EditRecord;\n\nclass EditOrganizationInvitation extends EditRecord\n{\n    protected static string $resource = OrganizationInvitationResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n            Actions\\DeleteAction::make()\n                ->icon('heroicon-m-trash'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/OrganizationInvitationResource/Pages/ListOrganizationInvitations.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\OrganizationInvitationResource\\Pages;\n\nuse App\\Filament\\Resources\\OrganizationInvitationResource;\nuse Filament\\Resources\\Pages\\ListRecords;\n\nclass ListOrganizationInvitations extends ListRecords\n{\n    protected static string $resource = OrganizationInvitationResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/OrganizationInvitationResource/Pages/ViewOrganizationInvitation.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\OrganizationInvitationResource\\Pages;\n\nuse App\\Filament\\Resources\\OrganizationInvitationResource;\nuse Filament\\Actions\\EditAction;\nuse Filament\\Resources\\Pages\\ViewRecord;\n\nclass ViewOrganizationInvitation extends ViewRecord\n{\n    protected static string $resource = OrganizationInvitationResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n            EditAction::make('edit')\n                ->icon('heroicon-s-pencil'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/OrganizationInvitationResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources;\n\nuse App\\Enums\\Role;\nuse App\\Filament\\Resources\\OrganizationInvitationResource\\Pages;\nuse App\\Models\\OrganizationInvitation;\nuse App\\Service\\OrganizationInvitationService;\nuse Filament\\Forms;\nuse Filament\\Forms\\Components\\Select;\nuse Filament\\Forms\\Form;\nuse Filament\\Resources\\Resource;\nuse Filament\\Tables;\nuse Filament\\Tables\\Table;\nuse Illuminate\\Support\\Collection;\n\nclass OrganizationInvitationResource extends Resource\n{\n    protected static ?string $model = OrganizationInvitation::class;\n\n    protected static ?string $label = 'Invitations';\n\n    protected static ?string $navigationIcon = 'heroicon-o-user-plus';\n\n    protected static ?string $navigationGroup = 'Users';\n\n    protected static ?int $navigationSort = 9;\n\n    public static function form(Form $form): Form\n    {\n        return $form\n            ->columns(1)\n            ->schema([\n                Forms\\Components\\TextInput::make('email')\n                    ->label('Email')\n                    ->disabledOn(['edit'])\n                    ->required(),\n                Select::make('role')\n                    ->options(Role::class),\n                Forms\\Components\\Select::make('organization_id')\n                    ->label('Organization')\n                    ->relationship(name: 'organization', titleAttribute: 'name')\n                    ->searchable(['name'])\n                    ->disabledOn(['edit'])\n                    ->required(),\n                Forms\\Components\\DateTimePicker::make('created_at')\n                    ->label('Created At')\n                    ->hiddenOn(['create'])\n                    ->disabled(),\n                Forms\\Components\\DateTimePicker::make('updated_at')\n                    ->label('Updated At')\n                    ->hiddenOn(['create'])\n                    ->disabled(),\n            ]);\n    }\n\n    public static function table(Table $table): Table\n    {\n        return $table\n            ->columns([\n                Tables\\Columns\\TextColumn::make('organization.name')\n                    ->searchable()\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('email')\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('role'),\n                Tables\\Columns\\TextColumn::make('created_at')\n                    ->label('Created At')\n                    ->dateTime()\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('updated_at')\n                    ->label('Updated At')\n                    ->dateTime()\n                    ->sortable()\n                    ->toggleable(isToggledHiddenByDefault: true),\n            ])\n            ->defaultSort('created_at', 'desc')\n            ->filters([\n                //\n            ])\n            ->actions([\n                Tables\\Actions\\EditAction::make(),\n                Tables\\Actions\\DeleteAction::make(),\n            ])\n            ->bulkActions([\n                Tables\\Actions\\BulkActionGroup::make([\n                    Tables\\Actions\\BulkAction::make('resend')\n                        ->label('Resend')\n                        ->action(function (Collection $records): void {\n                            foreach ($records as $organizationInvite) {\n                                app(OrganizationInvitationService::class)->resend($organizationInvite);\n                            }\n                        }),\n                ]),\n            ]);\n    }\n\n    public static function getRelations(): array\n    {\n        return [\n        ];\n    }\n\n    public static function getPages(): array\n    {\n        return [\n            'index' => Pages\\ListOrganizationInvitations::route('/'),\n            'edit' => Pages\\EditOrganizationInvitation::route('/{record}/edit'),\n            'view' => Pages\\ViewOrganizationInvitation::route('/{record}'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/OrganizationResource/Actions/DeleteOrganization.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\OrganizationResource\\Actions;\n\nuse App\\Exceptions\\Api\\ApiException;\nuse App\\Models\\Organization;\nuse App\\Service\\DeletionService;\nuse Filament\\Actions\\DeleteAction;\nuse Throwable;\n\nclass DeleteOrganization extends DeleteAction\n{\n    protected function setUp(): void\n    {\n        parent::setUp();\n        $this->icon('heroicon-m-trash');\n        $this->action(function (): void {\n            $result = $this->process(function (Organization $record): bool {\n                try {\n                    $deletionService = app(DeletionService::class);\n                    $deletionService->deleteOrganization($record);\n\n                    return true;\n                } catch (ApiException $exception) {\n                    $this->failureNotificationTitle($exception->getTranslatedMessage());\n                    report($exception);\n                } catch (Throwable $exception) {\n                    $this->failureNotificationTitle(__('exceptions.unknown_error_in_admin_panel'));\n                    report($exception);\n                }\n\n                return false;\n            });\n\n            if (! $result) {\n                $this->failure();\n\n                return;\n            }\n\n            $this->success();\n        });\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/OrganizationResource/Pages/CreateOrganization.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\OrganizationResource\\Pages;\n\nuse App\\Enums\\Role;\nuse App\\Filament\\Resources\\OrganizationResource;\nuse App\\Models\\Organization;\nuse Filament\\Resources\\Pages\\CreateRecord;\n\nclass CreateOrganization extends CreateRecord\n{\n    protected static string $resource = OrganizationResource::class;\n\n    protected function mutateFormDataBeforeCreate(array $data): array\n    {\n        $data['personal_team'] = false;\n\n        return $data;\n    }\n\n    protected function afterCreate(): void\n    {\n        /** @var Organization $organization */\n        $organization = $this->record;\n\n        $user = $organization->owner;\n\n        $organization->users()->attach(\n            $user, [\n                'role' => Role::Owner->value,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/OrganizationResource/Pages/EditOrganization.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\OrganizationResource\\Pages;\n\nuse App\\Filament\\Resources\\OrganizationResource;\nuse Filament\\Resources\\Pages\\EditRecord;\n\nclass EditOrganization extends EditRecord\n{\n    protected static string $resource = OrganizationResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n            OrganizationResource\\Actions\\DeleteOrganization::make(),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/OrganizationResource/Pages/ListOrganizations.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\OrganizationResource\\Pages;\n\nuse App\\Filament\\Resources\\OrganizationResource;\nuse Filament\\Actions;\nuse Filament\\Resources\\Pages\\ListRecords;\n\nclass ListOrganizations extends ListRecords\n{\n    protected static string $resource = OrganizationResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n            Actions\\CreateAction::make()\n                ->icon('heroicon-s-plus'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/OrganizationResource/Pages/ViewOrganization.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\OrganizationResource\\Pages;\n\nuse App\\Filament\\Resources\\OrganizationResource;\nuse Filament\\Actions\\EditAction;\nuse Filament\\Resources\\Pages\\ViewRecord;\n\nclass ViewOrganization extends ViewRecord\n{\n    protected static string $resource = OrganizationResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n            EditAction::make('edit')\n                ->icon('heroicon-s-pencil'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/OrganizationResource/RelationManagers/InvitationsRelationManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\OrganizationResource\\RelationManagers;\n\nuse App\\Enums\\Role;\nuse App\\Filament\\Resources\\OrganizationInvitationResource;\nuse App\\Models\\Organization;\nuse App\\Models\\OrganizationInvitation;\nuse App\\Service\\InvitationService;\nuse Filament\\Forms\\Components\\Select;\nuse Filament\\Forms\\Components\\TextInput;\nuse Filament\\Forms\\Form;\nuse Filament\\Resources\\RelationManagers\\RelationManager;\nuse Filament\\Tables;\nuse Filament\\Tables\\Actions\\Action;\nuse Filament\\Tables\\Table;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Validation\\Rule;\n\nclass InvitationsRelationManager extends RelationManager\n{\n    protected static string $relationship = 'teamInvitations';\n\n    protected static ?string $title = 'Invitations';\n\n    public function form(Form $form): Form\n    {\n        return $form\n            ->schema([\n                TextInput::make('email')\n                    ->label('Email')\n                    ->disabledOn(['edit'])\n                    ->required(),\n                Select::make('role')\n                    ->options(Role::class)\n                    ->label('Role')\n                    ->rules([\n                        'required',\n                        'string',\n                        Rule::enum(Role::class)\n                            ->except([Role::Owner, Role::Placeholder]),\n                    ])\n                    ->required(),\n            ]);\n    }\n\n    public function table(Table $table): Table\n    {\n        return $table\n            ->recordTitleAttribute('email')\n            ->modelLabel('Invitation')\n            ->pluralModelLabel('Invitations')\n            ->columns([\n                Tables\\Columns\\TextColumn::make('email'),\n                Tables\\Columns\\TextColumn::make('role'),\n            ])\n            ->headerActions([\n                Tables\\Actions\\CreateAction::make()\n                    ->icon('heroicon-s-plus')\n                    ->using(function (array $data, string $model): Model {\n                        /** @var Organization $ownerRecord */\n                        $ownerRecord = $this->getOwnerRecord();\n\n                        return app(InvitationService::class)\n                            ->inviteUser($ownerRecord, $data['email'], Role::from($data['role']));\n                    }),\n            ])\n            ->actions([\n                Action::make('view')\n                    ->icon('heroicon-o-eye')\n                    ->color('gray')\n                    ->url(fn (OrganizationInvitation $record): string => OrganizationInvitationResource::getUrl('view', [\n                        'record' => $record->getKey(),\n                    ])),\n                Tables\\Actions\\EditAction::make(),\n                Tables\\Actions\\DeleteAction::make(),\n            ])\n            ->bulkActions([\n                Tables\\Actions\\BulkActionGroup::make([\n                    Tables\\Actions\\DetachBulkAction::make(),\n                ]),\n            ]);\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/OrganizationResource/RelationManagers/UsersRelationManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\OrganizationResource\\RelationManagers;\n\nuse App\\Enums\\Role;\nuse App\\Exceptions\\Api\\ApiException;\nuse App\\Filament\\Resources\\UserResource;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse App\\Service\\BillableRateService;\nuse App\\Service\\MemberService;\nuse Filament\\Forms\\Components\\Select;\nuse Filament\\Forms\\Components\\TextInput;\nuse Filament\\Forms\\Form;\nuse Filament\\Notifications\\Notification;\nuse Filament\\Resources\\RelationManagers\\RelationManager;\nuse Filament\\Tables;\nuse Filament\\Tables\\Actions\\Action;\nuse Filament\\Tables\\Actions\\AttachAction;\nuse Filament\\Tables\\Columns\\TextColumn;\nuse Filament\\Tables\\Table;\nuse Illuminate\\Validation\\Rule;\n\nclass UsersRelationManager extends RelationManager\n{\n    protected static string $relationship = 'users';\n\n    public function form(Form $form): Form\n    {\n        return $form\n            ->schema([\n                Select::make('role')\n                    ->options(Role::class),\n                TextInput::make('billable_rate')\n                    ->label('Billable rate (in Cents)')\n                    ->nullable()\n                    ->numeric(),\n            ]);\n    }\n\n    public function table(Table $table): Table\n    {\n        /** @var Organization $organization */\n        $organization = $this->getOwnerRecord();\n\n        return $table\n            ->recordTitleAttribute('name')\n            ->columns([\n                Tables\\Columns\\TextColumn::make('name'),\n                Tables\\Columns\\TextColumn::make('role'),\n                TextColumn::make('billable_rate')\n                    ->money($organization->currency, divideBy: 100),\n            ])\n            ->headerActions([\n                Tables\\Actions\\AttachAction::make()\n                    ->recordTitle(fn (User $record): string => \"{$record->name} ({$record->email})\")\n                    ->form(fn (AttachAction $action): array => [\n                        $action->getRecordSelect(),\n                        Select::make('role')\n                            ->required()\n                            ->options(Role::class)\n                            ->rule([\n                                'required',\n                                'string',\n                                Rule::enum(Role::class)\n                                    ->except([Role::Owner, Role::Placeholder]),\n                            ]),\n                    ])\n                    ->label('Add user')\n                    ->modalHeading('Add user')\n                    ->icon('heroicon-s-plus')\n                    ->using(function (User $record, array $data): void {\n                        /** @var Organization $organization */\n                        $organization = $this->getOwnerRecord();\n                        app(MemberService::class)->addMember($record, $organization, Role::from($data['role']), true);\n                    }),\n            ])\n            ->actions([\n                Action::make('view')\n                    ->icon('heroicon-o-eye')\n                    ->color('gray')\n                    ->url(fn (User $record): string => UserResource::getUrl('view', [\n                        'record' => $record->getKey(),\n                    ])),\n                Tables\\Actions\\EditAction::make()\n                    ->using(function (User $record, array $data): User {\n                        /** @var Organization $organization */\n                        $organization = $this->getOwnerRecord();\n                        /** @var Member $member */\n                        $member = $record->getRelation('membership');\n\n                        if ($data['billable_rate'] !== $member->billable_rate) {\n                            $member->billable_rate = $data['billable_rate'];\n                            app(BillableRateService::class)->updateTimeEntriesBillableRateForMember($member);\n                        }\n\n                        if ($data['role'] !== $member->role) {\n                            try {\n                                app(MemberService::class)->changeRole($member, $organization, Role::from($data['role']), true);\n                            } catch (ApiException $exception) {\n                                Notification::make()\n                                    ->danger()\n                                    ->title('Update failed')\n                                    ->body($exception->getTranslatedMessage())\n                                    ->persistent()\n                                    ->send();\n                            }\n                        }\n                        $member->save();\n\n                        return $record;\n                    }),\n                Tables\\Actions\\DetachAction::make()\n                    ->using(function (User $record): void {\n                        /** @var Organization $organization */\n                        $organization = $this->getOwnerRecord();\n                        $member = Member::query()\n                            ->whereBelongsTo($record, 'user')\n                            ->whereBelongsTo($organization, 'organization')\n                            ->firstOrFail();\n                        try {\n                            app(MemberService::class)->removeMember($member, $organization);\n                        } catch (ApiException $exception) {\n                            Notification::make()\n                                ->danger()\n                                ->title('Delete failed')\n                                ->body($exception->getTranslatedMessage())\n                                ->persistent()\n                                ->send();\n                        }\n                    }),\n            ])\n            ->bulkActions([\n            ]);\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/OrganizationResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources;\n\nuse App\\Enums\\CurrencyFormat;\nuse App\\Enums\\DateFormat;\nuse App\\Enums\\IntervalFormat;\nuse App\\Enums\\NumberFormat;\nuse App\\Enums\\TimeFormat;\nuse App\\Filament\\Resources\\OrganizationResource\\Pages;\nuse App\\Filament\\Resources\\OrganizationResource\\RelationManagers\\InvitationsRelationManager;\nuse App\\Filament\\Resources\\OrganizationResource\\RelationManagers\\UsersRelationManager;\nuse App\\Models\\Organization;\nuse App\\Service\\DeletionService;\nuse App\\Service\\Export\\ExportService;\nuse App\\Service\\Import\\Importers\\ImporterProvider;\nuse App\\Service\\Import\\Importers\\ImportException;\nuse App\\Service\\Import\\Importers\\ReportDto;\nuse App\\Service\\Import\\ImportService;\nuse App\\Service\\TimezoneService;\nuse Brick\\Money\\ISOCurrencyProvider;\nuse Filament\\Forms;\nuse Filament\\Forms\\Components\\Select;\nuse Filament\\Forms\\Form;\nuse Filament\\Notifications\\Notification;\nuse Filament\\Resources\\Resource;\nuse Filament\\Tables;\nuse Filament\\Tables\\Actions\\Action;\nuse Filament\\Tables\\Columns\\TextColumn;\nuse Filament\\Tables\\Table;\nuse Illuminate\\Support\\Facades\\Storage;\n\nclass OrganizationResource extends Resource\n{\n    protected static ?string $model = Organization::class;\n\n    protected static ?string $navigationIcon = 'heroicon-o-building-office-2';\n\n    protected static ?string $navigationGroup = 'Users';\n\n    protected static ?int $navigationSort = 7;\n\n    public static function form(Form $form): Form\n    {\n        return $form\n            ->columns(1)\n            ->schema([\n                Forms\\Components\\TextInput::make('name')\n                    ->label('Name')\n                    ->required()\n                    ->maxLength(255),\n                Forms\\Components\\Toggle::make('personal_team')\n                    ->label('Is personal?')\n                    ->hiddenOn(['create'])\n                    ->required(),\n                Forms\\Components\\Select::make('user_id')\n                    ->label('Owner')\n                    ->relationship(name: 'owner', titleAttribute: 'email')\n                    ->searchable(['name', 'email'])\n                    ->disabledOn(['edit'])\n                    ->required(),\n                Select::make('date_format')\n                    ->options(DateFormat::toSelectArray())\n                    ->required(),\n                Select::make('currency_format')\n                    ->options(CurrencyFormat::toSelectArray())\n                    ->required(),\n                Select::make('interval_format')\n                    ->options(IntervalFormat::toSelectArray())\n                    ->required(),\n                Select::make('number_format')\n                    ->options(NumberFormat::toSelectArray())\n                    ->required(),\n                Select::make('time_format')\n                    ->options(TimeFormat::toSelectArray())\n                    ->required(),\n                Forms\\Components\\Select::make('currency')\n                    ->label('Currency')\n                    ->options(function (): array {\n                        $currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();\n                        $select = [];\n                        foreach ($currencies as $currency) {\n                            $select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')';\n                        }\n\n                        return $select;\n                    })\n                    ->required()\n                    ->searchable(),\n                Forms\\Components\\TextInput::make('billable_rate')\n                    ->label('Billable rate (in Cents)')\n                    ->nullable()\n                    ->rules([\n                        'nullable',\n                        'integer',\n                        'gt:0',\n                        'max:2147483647',\n                    ])\n                    ->numeric(),\n                Forms\\Components\\DateTimePicker::make('created_at')\n                    ->label('Created At')\n                    ->hiddenOn(['create'])\n                    ->disabled(),\n                Forms\\Components\\DateTimePicker::make('updated_at')\n                    ->label('Updated At')\n                    ->hiddenOn(['create'])\n                    ->disabled(),\n            ]);\n    }\n\n    public static function table(Table $table): Table\n    {\n        return $table\n            ->columns([\n                Tables\\Columns\\TextColumn::make('name')\n                    ->searchable()\n                    ->sortable(),\n                Tables\\Columns\\IconColumn::make('personal_team')\n                    ->boolean()\n                    ->label('Is personal?')\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('owner.email')\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('currency'),\n                TextColumn::make('billable_rate')\n                    ->money(fn (Organization $resource) => $resource->currency, divideBy: 100),\n                Tables\\Columns\\TextColumn::make('created_at')\n                    ->dateTime()\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('updated_at')\n                    ->dateTime()\n                    ->sortable()\n                    ->toggleable(isToggledHiddenByDefault: true),\n            ])\n            ->defaultSort('created_at', 'desc')\n            ->filters([\n                //\n            ])\n            ->actions([\n                Tables\\Actions\\EditAction::make(),\n                Tables\\Actions\\DeleteAction::make()\n                    ->using(function (Organization $record): void {\n                        app(DeletionService::class)->deleteOrganization($record);\n                    }),\n                Action::make('Export')\n                    ->icon('heroicon-o-arrow-down-tray')\n                    ->action(function (Organization $record) {\n                        try {\n                            $file = app(ExportService::class)->export($record);\n                            Notification::make()\n                                ->title('Export successful')\n                                ->success()\n                                ->persistent()\n                                ->send();\n\n                            return response()->streamDownload(function () use ($file): void {\n                                echo Storage::disk(config('filesystems.private'))->get($file);\n                            }, 'export.zip');\n                        } catch (\\Exception $exception) {\n                            report($exception);\n                            Notification::make()\n                                ->title('Export failed')\n                                ->danger()\n                                ->body('Message: '.$exception->getMessage())\n                                ->persistent()\n                                ->send();\n                        }\n                    }),\n                Action::make('Import')\n                    ->icon('heroicon-o-inbox-arrow-down')\n                    ->action(function (Organization $record, array $data): void {\n                        try {\n                            $file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']);\n                            if ($file === null) {\n                                throw new \\Exception('File not found');\n                            }\n                            /** @var string $timezone */\n                            $timezone = $data['timezone'];\n                            /** @var ReportDto $report */\n                            $report = app(ImportService::class)->import(\n                                $record,\n                                $data['type'],\n                                $file,\n                                $timezone\n                            );\n                            Notification::make()\n                                ->title('Import successful')\n                                ->success()\n                                ->body(\n                                    'Imported time entries: '.$report->timeEntriesCreated.'<br>'.\n                                    'Imported clients: '.$report->clientsCreated.'<br>'.\n                                    'Imported projects: '.$report->projectsCreated.'<br>'.\n                                    'Imported tasks: '.$report->tasksCreated.'<br>'.\n                                    'Imported tags: '.$report->tagsCreated.'<br>'.\n                                    'Imported users: '.$report->usersCreated\n                                )\n                                ->persistent()\n                                ->send();\n                        } catch (ImportException $exception) {\n                            report($exception);\n                            Notification::make()\n                                ->title('Import failed, changes rolled back')\n                                ->danger()\n                                ->body('Message: '.$exception->getMessage())\n                                ->persistent()\n                                ->send();\n                        }\n                    })\n                    ->tooltip(fn (Organization $record): string => 'Import into '.$record->name)\n                    ->form([\n                        Forms\\Components\\FileUpload::make('file')\n                            ->label('File')\n                            ->required(),\n                        Select::make('type')\n                            ->required()\n                            ->options(function (): array {\n                                $select = [];\n                                foreach (app(ImporterProvider::class)->getImporterKeys() as $key) {\n                                    $select[$key] = $key;\n                                }\n\n                                return $select;\n                            }),\n                        Forms\\Components\\Select::make('timezone')\n                            ->label('Timezone')\n                            ->options(fn (): array => app(TimezoneService::class)->getSelectOptions())\n                            ->searchable()\n                            ->required(),\n                    ]),\n            ])\n            ->bulkActions([\n            ]);\n    }\n\n    public static function getRelations(): array\n    {\n        return [\n            UsersRelationManager::class,\n            InvitationsRelationManager::class,\n        ];\n    }\n\n    public static function getPages(): array\n    {\n        return [\n            'index' => Pages\\ListOrganizations::route('/'),\n            'create' => Pages\\CreateOrganization::route('/create'),\n            'edit' => Pages\\EditOrganization::route('/{record}/edit'),\n            'view' => Pages\\ViewOrganization::route('/{record}'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/ProjectMemberResource/Pages/CreateProjectMember.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\ProjectMemberResource\\Pages;\n\nuse App\\Filament\\Resources\\ProjectMemberResource;\nuse Filament\\Resources\\Pages\\CreateRecord;\n\nclass CreateProjectMember extends CreateRecord\n{\n    protected static string $resource = ProjectMemberResource::class;\n}\n"
  },
  {
    "path": "app/Filament/Resources/ProjectMemberResource/Pages/EditProjectMember.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\ProjectMemberResource\\Pages;\n\nuse App\\Filament\\Resources\\ProjectMemberResource;\nuse Filament\\Actions;\nuse Filament\\Resources\\Pages\\EditRecord;\n\nclass EditProjectMember extends EditRecord\n{\n    protected static string $resource = ProjectMemberResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n            Actions\\DeleteAction::make()\n                ->icon('heroicon-m-trash'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/ProjectMemberResource/Pages/ListProjectMembers.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\ProjectMemberResource\\Pages;\n\nuse App\\Filament\\Resources\\ProjectMemberResource;\nuse Filament\\Actions;\nuse Filament\\Resources\\Pages\\ListRecords;\n\nclass ListProjectMembers extends ListRecords\n{\n    protected static string $resource = ProjectMemberResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n            Actions\\CreateAction::make()\n                ->icon('heroicon-s-plus'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/ProjectMemberResource/Pages/ViewProjectMembers.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\ProjectMemberResource\\Pages;\n\nuse App\\Filament\\Resources\\ProjectMemberResource;\nuse Filament\\Actions\\EditAction;\nuse Filament\\Resources\\Pages\\ViewRecord;\n\nclass ViewProjectMembers extends ViewRecord\n{\n    protected static string $resource = ProjectMemberResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n            EditAction::make('edit')\n                ->icon('heroicon-s-pencil'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/ProjectMemberResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources;\n\nuse App\\Filament\\Resources\\ProjectMemberResource\\Pages;\nuse App\\Models\\ProjectMember;\nuse Filament\\Forms;\nuse Filament\\Forms\\Form;\nuse Filament\\Resources\\Resource;\nuse Filament\\Tables;\nuse Filament\\Tables\\Table;\n\nclass ProjectMemberResource extends Resource\n{\n    protected static ?string $model = ProjectMember::class;\n\n    protected static bool $shouldRegisterNavigation = false;\n\n    public static function form(Form $form): Form\n    {\n        return $form\n            ->schema([\n                Forms\\Components\\TextInput::make('billable_rate')\n                    ->label('Billable rate (in Cents)')\n                    ->nullable()\n                    ->rules([\n                        'nullable',\n                        'integer',\n                        'gt:0',\n                        'max:2147483647',\n                    ])\n                    ->numeric(),\n                Forms\\Components\\Select::make('user_id')\n                    ->relationship('user', 'name')\n                    ->required(),\n                Forms\\Components\\Select::make('member_id')\n                    ->relationship('member', 'id')\n                    ->required(),\n            ]);\n    }\n\n    public static function table(Table $table): Table\n    {\n        return $table\n            ->columns([\n                Tables\\Columns\\TextColumn::make('id')\n                    ->label('ID'),\n                Tables\\Columns\\TextColumn::make('billable_rate')\n                    ->numeric()\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('project.name'),\n                Tables\\Columns\\TextColumn::make('user.name'),\n                Tables\\Columns\\TextColumn::make('created_at')\n                    ->dateTime()\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('updated_at')\n                    ->dateTime()\n                    ->sortable()\n                    ->toggleable(isToggledHiddenByDefault: true),\n            ])\n            ->filters([\n                //\n            ])\n            ->actions([\n                Tables\\Actions\\EditAction::make(),\n            ])\n            ->bulkActions([\n                Tables\\Actions\\BulkActionGroup::make([\n                    Tables\\Actions\\DeleteBulkAction::make(),\n                ]),\n            ]);\n    }\n\n    public static function getRelations(): array\n    {\n        return [\n            //\n        ];\n    }\n\n    public static function getPages(): array\n    {\n        return [\n            'index' => Pages\\ListProjectMembers::route('/'),\n            'create' => Pages\\CreateProjectMember::route('/create'),\n            'edit' => Pages\\EditProjectMember::route('/{record}/edit'),\n            'view' => Pages\\ViewProjectMembers::route('/{record}'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/ProjectResource/Pages/CreateProject.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\ProjectResource\\Pages;\n\nuse App\\Filament\\Resources\\ProjectResource;\nuse Filament\\Resources\\Pages\\CreateRecord;\n\nclass CreateProject extends CreateRecord\n{\n    protected static string $resource = ProjectResource::class;\n}\n"
  },
  {
    "path": "app/Filament/Resources/ProjectResource/Pages/EditProject.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\ProjectResource\\Pages;\n\nuse App\\Filament\\Resources\\ProjectResource;\nuse Filament\\Actions;\nuse Filament\\Resources\\Pages\\EditRecord;\n\nclass EditProject extends EditRecord\n{\n    protected static string $resource = ProjectResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n            Actions\\DeleteAction::make()\n                ->icon('heroicon-m-trash'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/ProjectResource/Pages/ListProjects.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\ProjectResource\\Pages;\n\nuse App\\Filament\\Resources\\ProjectResource;\nuse Filament\\Actions;\nuse Filament\\Resources\\Pages\\ListRecords;\n\nclass ListProjects extends ListRecords\n{\n    protected static string $resource = ProjectResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n            Actions\\CreateAction::make()\n                ->icon('heroicon-s-plus'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/ProjectResource/RelationManagers/ProjectMembersRelationManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\ProjectResource\\RelationManagers;\n\nuse App\\Filament\\Resources\\ProjectMemberResource;\nuse App\\Models\\ProjectMember;\nuse Filament\\Forms\\Form;\nuse Filament\\Resources\\RelationManagers\\RelationManager;\nuse Filament\\Tables;\nuse Filament\\Tables\\Actions\\Action;\nuse Filament\\Tables\\Table;\n\nclass ProjectMembersRelationManager extends RelationManager\n{\n    protected static ?string $title = 'Project Members';\n\n    protected static string $relationship = 'members';\n\n    public function form(Form $form): Form\n    {\n        return $form\n            ->schema([\n            ]);\n    }\n\n    public function table(Table $table): Table\n    {\n        return $table\n            ->recordTitleAttribute('name')\n            ->columns([\n                Tables\\Columns\\TextColumn::make('user.name'),\n                Tables\\Columns\\TextColumn::make('billable_rate')\n                    ->numeric()\n                    ->sortable(),\n            ])\n            ->filters([\n                //\n            ])\n            ->headerActions([\n            ])\n            ->actions([\n                Action::make('view')\n                    ->icon('heroicon-o-eye')\n                    ->color('gray')\n                    ->url(fn (ProjectMember $record): string => ProjectMemberResource::getUrl('view', [\n                        'record' => $record->getKey(),\n                    ])),\n                Action::make('edit')\n                    ->icon('heroicon-o-pencil')\n                    ->url(fn (ProjectMember $record): string => ProjectMemberResource::getUrl('edit', [\n                        'record' => $record->getKey(),\n                    ]))\n                    ->openUrlInNewTab(),\n            ])\n            ->bulkActions([\n            ]);\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/ProjectResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources;\n\nuse App\\Filament\\Resources\\ProjectResource\\Pages;\nuse App\\Filament\\Resources\\ProjectResource\\RelationManagers\\ProjectMembersRelationManager;\nuse App\\Models\\Project;\nuse Filament\\Forms;\nuse Filament\\Forms\\Components\\ColorPicker;\nuse Filament\\Forms\\Form;\nuse Filament\\Resources\\Resource;\nuse Filament\\Tables;\nuse Filament\\Tables\\Columns\\ColorColumn;\nuse Filament\\Tables\\Columns\\TextColumn;\nuse Filament\\Tables\\Filters\\SelectFilter;\nuse Filament\\Tables\\Table;\n\nclass ProjectResource extends Resource\n{\n    protected static ?string $model = Project::class;\n\n    protected static ?string $navigationIcon = 'heroicon-o-folder';\n\n    protected static ?string $navigationGroup = 'Timetracking';\n\n    protected static ?int $navigationSort = 2;\n\n    public static function form(Form $form): Form\n    {\n        return $form\n            ->schema([\n                Forms\\Components\\TextInput::make('name')\n                    ->label('Name')\n                    ->required()\n                    ->maxLength(255),\n                ColorPicker::make('color')\n                    ->label('Color')\n                    ->required(),\n                Forms\\Components\\TextInput::make('billable_rate')\n                    ->label('Billable rate (in Cents)')\n                    ->nullable()\n                    ->rules([\n                        'nullable',\n                        'integer',\n                        'gt:0',\n                        'max:2147483647',\n                    ])\n                    ->numeric(),\n                Forms\\Components\\Select::make('organization_id')\n                    ->relationship(name: 'organization', titleAttribute: 'name')\n                    ->searchable(['name'])\n                    ->required(),\n            ]);\n    }\n\n    public static function table(Table $table): Table\n    {\n        return $table\n            ->columns([\n                ColorColumn::make('color'),\n                TextColumn::make('name')\n                    ->searchable()\n                    ->sortable(),\n                TextColumn::make('organization.name')\n                    ->sortable(),\n                TextColumn::make('created_at')\n                    ->sortable(),\n                TextColumn::make('updated_at')\n                    ->sortable(),\n            ])\n            ->filters([\n                SelectFilter::make('organization')\n                    ->label('Organization')\n                    ->relationship('organization', 'name')\n                    ->searchable(),\n                SelectFilter::make('organization_id')\n                    ->label('Organization ID')\n                    ->relationship('organization', 'id')\n                    ->searchable(),\n            ])\n            ->defaultSort('created_at', 'desc')\n            ->actions([\n                Tables\\Actions\\EditAction::make(),\n            ])\n            ->bulkActions([\n                Tables\\Actions\\BulkActionGroup::make([\n                    Tables\\Actions\\DeleteBulkAction::make(),\n                ]),\n            ]);\n    }\n\n    public static function getRelations(): array\n    {\n        return [\n            ProjectMembersRelationManager::make(),\n        ];\n    }\n\n    public static function getPages(): array\n    {\n        return [\n            'index' => Pages\\ListProjects::route('/'),\n            'create' => Pages\\CreateProject::route('/create'),\n            'edit' => Pages\\EditProject::route('/{record}/edit'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/ReportResource/Pages/EditReport.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\ReportResource\\Pages;\n\nuse App\\Filament\\Resources\\ReportResource;\nuse Filament\\Actions;\nuse Filament\\Resources\\Pages\\EditRecord;\n\nclass EditReport extends EditRecord\n{\n    protected static string $resource = ReportResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n            Actions\\DeleteAction::make()\n                ->icon('heroicon-m-trash'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/ReportResource/Pages/ListReports.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\ReportResource\\Pages;\n\nuse App\\Filament\\Resources\\ReportResource;\nuse Filament\\Resources\\Pages\\ListRecords;\n\nclass ListReports extends ListRecords\n{\n    protected static string $resource = ReportResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/ReportResource/Pages/ViewReport.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\ReportResource\\Pages;\n\nuse App\\Filament\\Resources\\ReportResource;\nuse Filament\\Actions\\EditAction;\nuse Filament\\Resources\\Pages\\ViewRecord;\n\nclass ViewReport extends ViewRecord\n{\n    protected static string $resource = ReportResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n            EditAction::make('edit')\n                ->icon('heroicon-s-pencil'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/ReportResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources;\n\nuse App\\Filament\\Resources\\ReportResource\\Pages;\nuse App\\Models\\Report;\nuse App\\Service\\Dto\\ReportPropertiesDto;\nuse Filament\\Forms;\nuse Filament\\Forms\\Components\\DateTimePicker;\nuse Filament\\Forms\\Components\\Toggle;\nuse Filament\\Forms\\Form;\nuse Filament\\Resources\\Resource;\nuse Filament\\Tables;\nuse Filament\\Tables\\Actions\\Action;\nuse Filament\\Tables\\Columns\\TextColumn;\nuse Filament\\Tables\\Columns\\ToggleColumn;\nuse Filament\\Tables\\Filters\\SelectFilter;\nuse Filament\\Tables\\Table;\nuse Novadaemon\\FilamentPrettyJson\\Form\\PrettyJsonField;\n\nclass ReportResource extends Resource\n{\n    protected static ?string $model = Report::class;\n\n    protected static ?string $navigationIcon = 'heroicon-o-document-chart-bar';\n\n    protected static ?string $navigationGroup = 'Timetracking';\n\n    protected static ?int $navigationSort = 7;\n\n    public static function form(Form $form): Form\n    {\n        return $form\n            ->columns(1)\n            ->schema([\n                Forms\\Components\\TextInput::make('name')\n                    ->label('Name')\n                    ->required()\n                    ->maxLength(255),\n                Forms\\Components\\TextInput::make('description')\n                    ->label('Description')\n                    ->nullable()\n                    ->maxLength(255),\n                Toggle::make('is_public')\n                    ->label('Is public?')\n                    ->required(),\n                DateTimePicker::make('public_until')\n                    ->label('Public until')\n                    ->nullable(),\n                Forms\\Components\\Select::make('organization_id')\n                    ->label('Organization')\n                    ->relationship(name: 'organization', titleAttribute: 'name')\n                    ->searchable(['name'])\n                    ->disabled()\n                    ->required(),\n                Forms\\Components\\TextInput::make('share_secret')\n                    ->label('Share Secret')\n                    ->nullable(),\n                PrettyJsonField::make('properties')\n                    ->formatStateUsing(function (ReportPropertiesDto $state, Report $record): string {\n                        return $record->getRawOriginal('properties');\n                    })\n                    ->disabled(),\n                Forms\\Components\\DateTimePicker::make('created_at')\n                    ->label('Created At')\n                    ->hiddenOn(['create'])\n                    ->disabled(),\n                Forms\\Components\\DateTimePicker::make('updated_at')\n                    ->label('Updated At')\n                    ->hiddenOn(['create'])\n                    ->disabled(),\n            ]);\n    }\n\n    public static function table(Table $table): Table\n    {\n        return $table\n            ->columns([\n                Tables\\Columns\\TextColumn::make('name')\n                    ->searchable()\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('description')\n                    ->searchable()\n                    ->sortable(),\n                ToggleColumn::make('is_public')\n                    ->label('Is public?')\n                    ->sortable(),\n                TextColumn::make('organization.name')\n                    ->searchable()\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('created_at')\n                    ->dateTime()\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('updated_at')\n                    ->dateTime()\n                    ->sortable()\n                    ->toggleable(isToggledHiddenByDefault: true),\n            ])\n            ->defaultSort('created_at', 'desc')\n            ->filters([\n                SelectFilter::make('organization')\n                    ->label('Organization')\n                    ->relationship('organization', 'name')\n                    ->searchable(),\n                SelectFilter::make('organization_id')\n                    ->label('Organization ID')\n                    ->relationship('organization', 'id')\n                    ->searchable(),\n            ])\n            ->actions([\n                Action::make('public-view')\n                    ->label('Public')\n                    ->icon('heroicon-o-eye')\n                    ->color('gray')\n                    ->hidden(fn (Report $record): bool => $record->getShareableLink() === null)\n                    ->url(fn (Report $record): string => $record->getShareableLink(), true),\n                Tables\\Actions\\ViewAction::make(),\n                Tables\\Actions\\EditAction::make(),\n                Tables\\Actions\\DeleteAction::make(),\n            ])\n            ->bulkActions([\n            ]);\n    }\n\n    public static function getRelations(): array\n    {\n        return [\n        ];\n    }\n\n    public static function getPages(): array\n    {\n        return [\n            'index' => Pages\\ListReports::route('/'),\n            'edit' => Pages\\EditReport::route('/{record}/edit'),\n            'view' => Pages\\ViewReport::route('/{record}'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/TagResource/Pages/CreateTag.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\TagResource\\Pages;\n\nuse App\\Filament\\Resources\\TagResource;\nuse Filament\\Resources\\Pages\\CreateRecord;\n\nclass CreateTag extends CreateRecord\n{\n    protected static string $resource = TagResource::class;\n}\n"
  },
  {
    "path": "app/Filament/Resources/TagResource/Pages/EditTag.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\TagResource\\Pages;\n\nuse App\\Filament\\Resources\\TagResource;\nuse Filament\\Actions;\nuse Filament\\Resources\\Pages\\EditRecord;\n\nclass EditTag extends EditRecord\n{\n    protected static string $resource = TagResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n            Actions\\DeleteAction::make()\n                ->icon('heroicon-m-trash'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/TagResource/Pages/ListTags.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\TagResource\\Pages;\n\nuse App\\Filament\\Resources\\TagResource;\nuse Filament\\Actions;\nuse Filament\\Resources\\Pages\\ListRecords;\n\nclass ListTags extends ListRecords\n{\n    protected static string $resource = TagResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n            Actions\\CreateAction::make()\n                ->icon('heroicon-s-plus'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/TagResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources;\n\nuse App\\Filament\\Resources\\TagResource\\Pages;\nuse App\\Models\\Tag;\nuse Filament\\Forms;\nuse Filament\\Forms\\Components\\TextInput;\nuse Filament\\Forms\\Form;\nuse Filament\\Resources\\Resource;\nuse Filament\\Tables;\nuse Filament\\Tables\\Filters\\SelectFilter;\nuse Filament\\Tables\\Table;\n\nclass TagResource extends Resource\n{\n    protected static ?string $model = Tag::class;\n\n    protected static ?string $navigationIcon = 'heroicon-o-tag';\n\n    protected static ?string $navigationGroup = 'Timetracking';\n\n    protected static ?int $navigationSort = 5;\n\n    public static function form(Form $form): Form\n    {\n        return $form\n            ->schema([\n                TextInput::make('name')\n                    ->label('Name')\n                    ->required(),\n                Forms\\Components\\Select::make('organization_id')\n                    ->relationship(name: 'organization', titleAttribute: 'name')\n                    ->label('Organization')\n                    ->searchable(['name'])\n                    ->required(),\n            ]);\n    }\n\n    public static function table(Table $table): Table\n    {\n        return $table\n            ->columns([\n                Tables\\Columns\\TextColumn::make('name')\n                    ->label('Name')\n                    ->searchable()\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('organization.name')\n                    ->sortable()\n                    ->label('Organization'),\n                Tables\\Columns\\TextColumn::make('created_at')\n                    ->label('Created at')\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('updated_at')\n                    ->label('Updated at')\n                    ->sortable(),\n            ])\n            ->defaultSort('created_at', 'desc')\n            ->filters([\n                SelectFilter::make('organization')\n                    ->label('Organization')\n                    ->relationship('organization', 'name')\n                    ->searchable(),\n                SelectFilter::make('organization_id')\n                    ->label('Organization ID')\n                    ->relationship('organization', 'id')\n                    ->searchable(),\n            ])\n            ->actions([\n                Tables\\Actions\\EditAction::make(),\n            ])\n            ->bulkActions([\n                Tables\\Actions\\BulkActionGroup::make([\n                    Tables\\Actions\\DeleteBulkAction::make(),\n                ]),\n            ]);\n    }\n\n    public static function getRelations(): array\n    {\n        return [\n            //\n        ];\n    }\n\n    public static function getPages(): array\n    {\n        return [\n            'index' => Pages\\ListTags::route('/'),\n            'create' => Pages\\CreateTag::route('/create'),\n            'edit' => Pages\\EditTag::route('/{record}/edit'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/TaskResource/Pages/CreateTask.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\TaskResource\\Pages;\n\nuse App\\Filament\\Resources\\TaskResource;\nuse Filament\\Resources\\Pages\\CreateRecord;\n\nclass CreateTask extends CreateRecord\n{\n    protected static string $resource = TaskResource::class;\n}\n"
  },
  {
    "path": "app/Filament/Resources/TaskResource/Pages/EditTask.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\TaskResource\\Pages;\n\nuse App\\Filament\\Resources\\TaskResource;\nuse Filament\\Actions;\nuse Filament\\Resources\\Pages\\EditRecord;\n\nclass EditTask extends EditRecord\n{\n    protected static string $resource = TaskResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n            Actions\\DeleteAction::make()\n                ->icon('heroicon-m-trash'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/TaskResource/Pages/ListTasks.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\TaskResource\\Pages;\n\nuse App\\Filament\\Resources\\TaskResource;\nuse Filament\\Actions;\nuse Filament\\Resources\\Pages\\ListRecords;\n\nclass ListTasks extends ListRecords\n{\n    protected static string $resource = TaskResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n            Actions\\CreateAction::make()\n                ->icon('heroicon-s-plus'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/TaskResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources;\n\nuse App\\Filament\\Resources\\TaskResource\\Pages;\nuse App\\Models\\Task;\nuse Filament\\Forms;\nuse Filament\\Forms\\Components\\Select;\nuse Filament\\Forms\\Form;\nuse Filament\\Resources\\Resource;\nuse Filament\\Tables;\nuse Filament\\Tables\\Filters\\SelectFilter;\nuse Filament\\Tables\\Table;\n\nclass TaskResource extends Resource\n{\n    protected static ?string $model = Task::class;\n\n    protected static ?string $navigationIcon = 'heroicon-o-list-bullet';\n\n    protected static ?string $navigationGroup = 'Timetracking';\n\n    protected static ?int $navigationSort = 3;\n\n    public static function form(Form $form): Form\n    {\n        return $form\n            ->schema([\n                Forms\\Components\\TextInput::make('name')\n                    ->label('Name')\n                    ->required()\n                    ->maxLength(255),\n                Select::make('project_id')\n                    ->relationship(name: 'project', titleAttribute: 'name')\n                    ->searchable(['name'])\n                    ->required(),\n                Select::make('organization_id')\n                    ->relationship(name: 'organization', titleAttribute: 'name')\n                    ->searchable(['name'])\n                    ->required(),\n            ]);\n    }\n\n    public static function table(Table $table): Table\n    {\n        return $table\n            ->columns([\n                Tables\\Columns\\TextColumn::make('name')\n                    ->searchable()\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('project.name')\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('organization.name')\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('created_at')\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('updated_at')\n                    ->sortable(),\n            ])\n            ->filters([\n                SelectFilter::make('organization')\n                    ->label('Organization')\n                    ->relationship('organization', 'name')\n                    ->searchable(),\n                SelectFilter::make('organization_id')\n                    ->label('Organization ID')\n                    ->relationship('organization', 'id')\n                    ->searchable(),\n            ])\n            ->defaultSort('created_at', 'desc')\n            ->actions([\n                Tables\\Actions\\EditAction::make(),\n            ])\n            ->bulkActions([\n                Tables\\Actions\\BulkActionGroup::make([\n                    Tables\\Actions\\DeleteBulkAction::make(),\n                ]),\n            ]);\n    }\n\n    public static function getRelations(): array\n    {\n        return [\n            //\n        ];\n    }\n\n    public static function getPages(): array\n    {\n        return [\n            'index' => Pages\\ListTasks::route('/'),\n            'create' => Pages\\CreateTask::route('/create'),\n            'edit' => Pages\\EditTask::route('/{record}/edit'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/TimeEntryResource/Pages/CreateTimeEntry.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\TimeEntryResource\\Pages;\n\nuse App\\Filament\\Resources\\TimeEntryResource;\nuse App\\Models\\Member;\nuse Filament\\Resources\\Pages\\CreateRecord;\n\nclass CreateTimeEntry extends CreateRecord\n{\n    protected static string $resource = TimeEntryResource::class;\n\n    /**\n     * @param  array<string, mixed>  $data\n     * @return array<string, mixed>\n     */\n    protected function mutateFormDataBeforeCreate(array $data): array\n    {\n        if (isset($data['member_id'])) {\n            /** @var Member|null $member */\n            $member = Member::query()->find($data['member_id']);\n            if ($member !== null) {\n                $data['user_id'] = $member->user_id;\n                $data['organization_id'] = $member->organization_id;\n            }\n        }\n\n        return $data;\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/TimeEntryResource/Pages/EditTimeEntry.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\TimeEntryResource\\Pages;\n\nuse App\\Filament\\Resources\\TimeEntryResource;\nuse App\\Models\\Member;\nuse Filament\\Actions;\nuse Filament\\Resources\\Pages\\EditRecord;\n\nclass EditTimeEntry extends EditRecord\n{\n    protected static string $resource = TimeEntryResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n            Actions\\DeleteAction::make()\n                ->icon('heroicon-m-trash'),\n        ];\n    }\n\n    /**\n     * @param  array<string, mixed>  $data\n     * @return array<string, mixed>\n     */\n    protected function mutateFormDataBeforeSave(array $data): array\n    {\n        if (isset($data['member_id'])) {\n            /** @var Member|null $member */\n            $member = Member::query()->find($data['member_id']);\n            if ($member !== null) {\n                $data['user_id'] = $member->user_id;\n                $data['organization_id'] = $member->organization_id;\n            }\n        }\n\n        return $data;\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/TimeEntryResource/Pages/ListTimeEntries.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\TimeEntryResource\\Pages;\n\nuse App\\Filament\\Resources\\TimeEntryResource;\nuse Filament\\Actions;\nuse Filament\\Resources\\Pages\\ListRecords;\n\nclass ListTimeEntries extends ListRecords\n{\n    protected static string $resource = TimeEntryResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n            Actions\\CreateAction::make()\n                ->icon('heroicon-s-plus'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/TimeEntryResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources;\n\nuse App\\Filament\\Resources\\TimeEntryResource\\Pages;\nuse App\\Models\\Member;\nuse App\\Models\\TimeEntry;\nuse Filament\\Forms\\Components\\DateTimePicker;\nuse Filament\\Forms\\Components\\Select;\nuse Filament\\Forms\\Components\\TextInput;\nuse Filament\\Forms\\Components\\Toggle;\nuse Filament\\Forms\\Form;\nuse Filament\\Resources\\Resource;\nuse Filament\\Tables;\nuse Filament\\Tables\\Columns\\TextColumn;\nuse Filament\\Tables\\Filters\\SelectFilter;\nuse Filament\\Tables\\Table;\nuse Illuminate\\Database\\Eloquent\\Builder;\n\nclass TimeEntryResource extends Resource\n{\n    protected static ?string $model = TimeEntry::class;\n\n    protected static ?string $navigationIcon = 'heroicon-o-clock';\n\n    protected static ?string $navigationGroup = 'Timetracking';\n\n    protected static ?int $navigationSort = 1;\n\n    public static function form(Form $form): Form\n    {\n        return $form\n            ->schema([\n                TextInput::make('id')\n                    ->label('ID')\n                    ->readOnly()\n                    ->disabled(),\n                TextInput::make('description')\n                    ->label('Description')\n                    ->required()\n                    ->maxLength(255),\n                Toggle::make('billable')\n                    ->label('Is Billable?')\n                    ->required(),\n                DateTimePicker::make('start')\n                    ->label('Start')\n                    ->required(),\n                DateTimePicker::make('end')\n                    ->label('End')\n                    ->nullable()\n                    ->rules([\n                        'after_or_equal:start',\n                    ]),\n                Select::make('member_id')\n                    ->relationship(\n                        name: 'member',\n                        titleAttribute: 'id',\n                        modifyQueryUsing: fn (Builder $query) => $query->with(['user', 'organization'])\n                    )\n                    ->getOptionLabelFromRecordUsing(fn (Member $record): string => $record->user->email.' ('.$record->organization->name.')')\n                    ->searchable()\n                    ->required(),\n                Select::make('project_id')\n                    ->relationship(name: 'project', titleAttribute: 'name')\n                    ->searchable(['name'])\n                    ->nullable(),\n                Select::make('task_id')\n                    ->relationship(name: 'task', titleAttribute: 'name')\n                    ->searchable(['name'])\n                    ->nullable(),\n            ]);\n    }\n\n    public static function table(Table $table): Table\n    {\n        return $table\n            ->columns([\n                TextColumn::make('description')\n                    ->searchable()\n                    ->label('Description'),\n                TextColumn::make('user.email')\n                    ->label('User'),\n                TextColumn::make('project.name')\n                    ->label('Project'),\n                TextColumn::make('task.name')\n                    ->label('Task'),\n                TextColumn::make('time')\n                    ->getStateUsing(function (TimeEntry $record): string {\n                        return ($record->getDuration()?->cascade()?->forHumans() ?? '-').' '.\n                            ' ('.$record->start->toDateTimeString('minute').' - '.\n                            ($record->end?->toDateTimeString('minute') ?? '...').')';\n                    })\n                    ->label('Time'),\n                Tables\\Columns\\TextColumn::make('organization.name')\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('created_at')\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('updated_at')\n                    ->sortable(),\n            ])\n            ->filters([\n                SelectFilter::make('organization')\n                    ->label('Organization')\n                    ->relationship('organization', 'name')\n                    ->searchable(),\n                SelectFilter::make('organization_id')\n                    ->label('Organization ID')\n                    ->relationship('organization', 'id')\n                    ->searchable(),\n            ])\n            ->defaultSort('created_at', 'desc')\n            ->actions([\n                Tables\\Actions\\EditAction::make(),\n            ])\n            ->bulkActions([\n                Tables\\Actions\\BulkActionGroup::make([\n                    Tables\\Actions\\DeleteBulkAction::make(),\n                ]),\n            ]);\n    }\n\n    public static function getRelations(): array\n    {\n        return [\n            //\n        ];\n    }\n\n    public static function getPages(): array\n    {\n        return [\n            'index' => Pages\\ListTimeEntries::route('/'),\n            'create' => Pages\\CreateTimeEntry::route('/create'),\n            'edit' => Pages\\EditTimeEntry::route('/{record}/edit'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/TokenResource/Pages/ListTokens.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\TokenResource\\Pages;\n\nuse App\\Filament\\Resources\\TokenResource;\nuse Filament\\Resources\\Pages\\ListRecords;\n\nclass ListTokens extends ListRecords\n{\n    protected static string $resource = TokenResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/TokenResource/Pages/ViewToken.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\TokenResource\\Pages;\n\nuse App\\Filament\\Resources\\TokenResource;\nuse Filament\\Resources\\Pages\\ViewRecord;\n\nclass ViewToken extends ViewRecord\n{\n    protected static string $resource = TokenResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/TokenResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources;\n\nuse App\\Filament\\Resources\\TokenResource\\Pages;\nuse App\\Models\\Passport\\Token;\nuse Filament\\Forms;\nuse Filament\\Forms\\Form;\nuse Filament\\Resources\\Resource;\nuse Filament\\Tables;\nuse Filament\\Tables\\Filters\\TernaryFilter;\nuse Filament\\Tables\\Table;\nuse Illuminate\\Database\\Eloquent\\Builder;\n\nclass TokenResource extends Resource\n{\n    protected static ?string $model = Token::class;\n\n    protected static ?string $navigationIcon = 'heroicon-o-key';\n\n    protected static ?string $navigationGroup = 'Auth';\n\n    protected static ?int $navigationSort = 6;\n\n    public static function form(Form $form): Form\n    {\n        return $form\n            ->columns(1)\n            ->schema([\n                Forms\\Components\\TextInput::make('id')\n                    ->label('ID')\n                    ->disabled()\n                    ->visibleOn(['update', 'show'])\n                    ->readOnly()\n                    ->maxLength(255),\n                Forms\\Components\\TextInput::make('name')\n                    ->label('Name')\n                    ->required()\n                    ->maxLength(255),\n                Forms\\Components\\Select::make('owner_id')\n                    ->label('User')\n                    ->relationship(name: 'user', titleAttribute: 'name')\n                    ->searchable(['name'])\n                    ->disabled()\n                    ->required(),\n                Forms\\Components\\Select::make('client_id')\n                    ->label('Client')\n                    ->relationship(name: 'client', titleAttribute: 'name')\n                    ->searchable(['name'])\n                    ->required(),\n                Forms\\Components\\Toggle::make('revoked')\n                    ->label('Revoked')\n                    ->required(),\n                Forms\\Components\\DateTimePicker::make('expires_at')\n                    ->label('Expires At')\n                    ->disabled(),\n                Forms\\Components\\DateTimePicker::make('created_at')\n                    ->label('Created At')\n                    ->disabled(),\n                Forms\\Components\\DateTimePicker::make('updated_at')\n                    ->label('Updated At')\n                    ->disabled(),\n            ]);\n    }\n\n    public static function table(Table $table): Table\n    {\n        return $table\n            ->columns([\n                Tables\\Columns\\TextColumn::make('name')\n                    ->searchable()\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('user.name')\n                    ->searchable()\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('client.name')\n                    ->searchable()\n                    ->sortable(),\n                Tables\\Columns\\IconColumn::make('personal_access_client')\n                    ->state(function (Token $token): bool {\n                        return in_array('personal_access', $token->client->grant_types ?? [], true);\n                    })\n                    ->boolean()\n                    ->label('API token?'),\n                Tables\\Columns\\IconColumn::make('revoked')\n                    ->boolean()\n                    ->label('Revoked?')\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('expires_at')\n                    ->dateTime()\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('created_at')\n                    ->dateTime()\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('updated_at')\n                    ->dateTime()\n                    ->sortable()\n                    ->toggleable(isToggledHiddenByDefault: true),\n            ])\n            ->defaultSort('created_at', 'desc')\n            ->filters([\n                TernaryFilter::make('is_personal_access_client')\n                    ->queries(\n                        true: function (Builder $query) {\n                            /** @var Builder<Token> $query */\n                            return $query->isApiToken();\n                        },\n                        false: function (Builder $query) {\n                            /** @var Builder<Token> $query */\n                            return $query->isApiToken(false);\n                        },\n                        blank: function (Builder $query) {\n                            /** @var Builder<Token> $query */\n                            return $query;\n                        },\n                    )\n                    ->label('API token?'),\n                TernaryFilter::make('revoked')\n                    ->label('Revoked?'),\n            ])\n            ->actions([\n                Tables\\Actions\\ViewAction::make(),\n            ])\n            ->bulkActions([\n            ]);\n    }\n\n    public static function getRelations(): array\n    {\n        return [\n        ];\n    }\n\n    public static function getPages(): array\n    {\n        return [\n            'index' => Pages\\ListTokens::route('/'),\n            'view' => Pages\\ViewToken::route('/{record}'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/UserResource/Actions/DeleteUser.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\UserResource\\Actions;\n\nuse App\\Exceptions\\Api\\ApiException;\nuse App\\Models\\User;\nuse App\\Service\\DeletionService;\nuse Filament\\Actions\\DeleteAction;\nuse Throwable;\n\nclass DeleteUser extends DeleteAction\n{\n    protected function setUp(): void\n    {\n        parent::setUp();\n        $this->icon('heroicon-m-trash');\n        $this->action(function (): void {\n            $result = $this->process(function (User $record): bool {\n                try {\n                    $deletionService = app(DeletionService::class);\n                    $deletionService->deleteUser($record);\n\n                    return true;\n                } catch (ApiException $exception) {\n                    $this->failureNotificationTitle($exception->getTranslatedMessage());\n                    report($exception);\n                } catch (Throwable $exception) {\n                    $this->failureNotificationTitle(__('exceptions.unknown_error_in_admin_panel'));\n                    report($exception);\n                }\n\n                return false;\n            });\n\n            if (! $result) {\n                $this->failure();\n\n                return;\n            }\n\n            $this->success();\n        });\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/UserResource/Pages/CreateUser.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\UserResource\\Pages;\n\nuse App\\Enums\\Weekday;\nuse App\\Filament\\Resources\\UserResource;\nuse App\\Models\\User;\nuse App\\Service\\UserService;\nuse Filament\\Resources\\Pages\\CreateRecord;\n\nclass CreateUser extends CreateRecord\n{\n    protected static string $resource = UserResource::class;\n\n    protected function handleRecordCreation(array $data): User\n    {\n        $userService = app(UserService::class);\n        $user = $userService->createUser(\n            $data['name'],\n            $data['email'],\n            $data['password_create'],\n            $data['timezone'],\n            Weekday::from($data['week_start']),\n            $data['currency'],\n            verifyEmail: (bool) $data['is_email_verified']\n        );\n\n        return $user;\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/UserResource/Pages/EditUser.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\UserResource\\Pages;\n\nuse App\\Filament\\Resources\\UserResource;\nuse Filament\\Resources\\Pages\\EditRecord;\nuse STS\\FilamentImpersonate\\Pages\\Actions\\Impersonate;\n\nclass EditUser extends EditRecord\n{\n    protected static string $resource = UserResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n            Impersonate::make()->record($this->getRecord()),\n            UserResource\\Actions\\DeleteUser::make(),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/UserResource/Pages/ListUsers.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\UserResource\\Pages;\n\nuse App\\Filament\\Resources\\UserResource;\nuse Filament\\Actions;\nuse Filament\\Resources\\Pages\\ListRecords;\n\nclass ListUsers extends ListRecords\n{\n    protected static string $resource = UserResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n            Actions\\CreateAction::make()\n                ->icon('heroicon-s-plus'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/UserResource/Pages/ViewUser.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\UserResource\\Pages;\n\nuse App\\Filament\\Resources\\UserResource;\nuse Filament\\Actions\\EditAction;\nuse Filament\\Resources\\Pages\\ViewRecord;\nuse STS\\FilamentImpersonate\\Pages\\Actions\\Impersonate;\n\nclass ViewUser extends ViewRecord\n{\n    protected static string $resource = UserResource::class;\n\n    protected function getHeaderActions(): array\n    {\n        return [\n            Impersonate::make()->record($this->getRecord()),\n            EditAction::make('edit')\n                ->icon('heroicon-s-pencil'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/UserResource/RelationManagers/OrganizationsRelationManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\UserResource\\RelationManagers;\n\nuse App\\Enums\\Role;\nuse App\\Exceptions\\Api\\ApiException;\nuse App\\Filament\\Resources\\OrganizationResource;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse App\\Service\\MemberService;\nuse Filament\\Forms\\Components\\Select;\nuse Filament\\Forms\\Form;\nuse Filament\\Notifications\\Notification;\nuse Filament\\Resources\\RelationManagers\\RelationManager;\nuse Filament\\Tables;\nuse Filament\\Tables\\Actions\\Action;\nuse Filament\\Tables\\Columns\\TextColumn;\nuse Filament\\Tables\\Table;\n\nclass OrganizationsRelationManager extends RelationManager\n{\n    protected static string $relationship = 'organizations';\n\n    public function form(Form $form): Form\n    {\n        return $form\n            ->schema([\n                Select::make('role')\n                    ->options(Role::class),\n            ]);\n    }\n\n    public function table(Table $table): Table\n    {\n        return $table\n            ->recordTitleAttribute('name')\n            ->columns([\n                TextColumn::make('name'),\n                TextColumn::make('role'),\n                TextColumn::make('membership.billable_rate')\n                    ->label('Billable rate')\n                    ->money(fn (Organization $resource) => $resource->currency, divideBy: 100),\n            ])\n            ->headerActions([\n            ])\n            ->actions([\n                Action::make('view')\n                    ->icon('heroicon-o-eye')\n                    ->color('gray')\n                    ->url(fn (Organization $record): string => OrganizationResource::getUrl('view', [\n                        'record' => $record->getKey(),\n                    ])),\n                Tables\\Actions\\EditAction::make()\n                    ->using(function (Organization $record, array $data): Organization {\n                        /** @var Member $member */\n                        $member = $record->getRelation('membership');\n\n                        if ($data['role'] !== $member->role) {\n                            try {\n                                app(MemberService::class)->changeRole($member, $record, Role::from($data['role']), true);\n                            } catch (ApiException $exception) {\n                                Notification::make()\n                                    ->danger()\n                                    ->title('Update failed')\n                                    ->body($exception->getTranslatedMessage())\n                                    ->persistent()\n                                    ->send();\n                            }\n                        }\n                        $member->save();\n\n                        return $record;\n                    }),\n                Tables\\Actions\\DetachAction::make()\n                    ->using(function (Organization $record): void {\n                        /** @var User $user */\n                        $user = $this->getOwnerRecord();\n                        $member = Member::query()\n                            ->whereBelongsTo($user, 'user')\n                            ->whereBelongsTo($record, 'organization')\n                            ->firstOrFail();\n                        try {\n                            app(MemberService::class)->removeMember($member, $record);\n                        } catch (ApiException $exception) {\n                            Notification::make()\n                                ->danger()\n                                ->title('Delete failed')\n                                ->body($exception->getTranslatedMessage())\n                                ->persistent()\n                                ->send();\n                        }\n                    }),\n            ])\n            ->bulkActions([\n            ]);\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/UserResource/RelationManagers/OwnedOrganizationsRelationManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources\\UserResource\\RelationManagers;\n\nuse App\\Filament\\Resources\\OrganizationResource;\nuse App\\Models\\Organization;\nuse Filament\\Forms\\Form;\nuse Filament\\Resources\\RelationManagers\\RelationManager;\nuse Filament\\Tables;\nuse Filament\\Tables\\Actions\\Action;\nuse Filament\\Tables\\Table;\n\nclass OwnedOrganizationsRelationManager extends RelationManager\n{\n    protected static ?string $title = 'Owned Organizations';\n\n    protected static string $relationship = 'ownedTeams';\n\n    public function form(Form $form): Form\n    {\n        return $form\n            ->schema([\n            ]);\n    }\n\n    public function table(Table $table): Table\n    {\n        return $table\n            ->recordTitleAttribute('name')\n            ->columns([\n                Tables\\Columns\\TextColumn::make('name'),\n            ])\n            ->filters([\n                //\n            ])\n            ->headerActions([\n            ])\n            ->actions([\n                Action::make('view')\n                    ->icon('heroicon-o-eye')\n                    ->color('gray')\n                    ->url(fn (Organization $record): string => OrganizationResource::getUrl('view', [\n                        'record' => $record->getKey(),\n                    ])),\n                Action::make('edit')\n                    ->icon('heroicon-o-pencil')\n                    ->url(fn (Organization $record): string => OrganizationResource::getUrl('edit', [\n                        'record' => $record->getKey(),\n                    ]))\n                    ->openUrlInNewTab(),\n            ])\n            ->bulkActions([\n            ]);\n    }\n}\n"
  },
  {
    "path": "app/Filament/Resources/UserResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Resources;\n\nuse App\\Enums\\Weekday;\nuse App\\Exceptions\\Api\\ApiException;\nuse App\\Filament\\Resources\\UserResource\\Pages;\nuse App\\Filament\\Resources\\UserResource\\RelationManagers\\OrganizationsRelationManager;\nuse App\\Filament\\Resources\\UserResource\\RelationManagers\\OwnedOrganizationsRelationManager;\nuse App\\Models\\User;\nuse App\\Service\\DeletionService;\nuse App\\Service\\TimezoneService;\nuse Brick\\Money\\ISOCurrencyProvider;\nuse Exception;\nuse Filament\\Forms;\nuse Filament\\Forms\\Components\\TextInput;\nuse Filament\\Forms\\Form;\nuse Filament\\Notifications\\Notification;\nuse Filament\\Resources\\Resource;\nuse Filament\\Tables;\nuse Filament\\Tables\\Filters\\TernaryFilter;\nuse Filament\\Tables\\Table;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Auth;\nuse Illuminate\\Support\\Facades\\Hash;\nuse Korridor\\LaravelModelValidationRules\\Rules\\UniqueEloquent;\nuse STS\\FilamentImpersonate\\Tables\\Actions\\Impersonate;\n\nclass UserResource extends Resource\n{\n    protected static ?string $model = User::class;\n\n    protected static ?string $navigationIcon = 'heroicon-o-user';\n\n    protected static ?string $navigationGroup = 'Users';\n\n    protected static ?int $navigationSort = 6;\n\n    public static function form(Form $form): Form\n    {\n        /** @var User|null $record */\n        $record = $form->getRecord();\n\n        return $form\n            ->columns(1)\n            ->schema([\n                Forms\\Components\\TextInput::make('id')\n                    ->label('ID')\n                    ->disabled()\n                    ->visibleOn(['update', 'show'])\n                    ->readOnly()\n                    ->maxLength(255),\n                Forms\\Components\\TextInput::make('name')\n                    ->label('Name')\n                    ->required()\n                    ->maxLength(255),\n                Forms\\Components\\TextInput::make('email')\n                    ->label('Email')\n                    ->required()\n                    ->rules($record?->is_placeholder ? [] : [\n                        UniqueEloquent::make(User::class, 'email')\n                            ->ignore($record?->getKey()),\n                    ])\n                    ->rule([\n                        'email',\n                    ])\n                    ->maxLength(255),\n                Forms\\Components\\Toggle::make('is_placeholder')\n                    ->label('Is Placeholder?')\n                    ->hiddenOn(['create'])\n                    ->disabledOn(['edit']),\n                Forms\\Components\\DateTimePicker::make('email_verified_at')\n                    ->label('Email Verified At')\n                    ->hiddenOn(['create'])\n                    ->nullable(),\n                Forms\\Components\\Toggle::make('is_email_verified')\n                    ->label('Email Verified?')\n                    ->visibleOn(['create']),\n                Forms\\Components\\Select::make('timezone')\n                    ->label('Timezone')\n                    ->options(fn (): array => app(TimezoneService::class)->getSelectOptions())\n                    ->searchable()\n                    ->required(),\n                Forms\\Components\\Select::make('week_start')\n                    ->label('Week Start')\n                    ->options(Weekday::class)\n                    ->required(),\n                TextInput::make('password')\n                    ->password()\n                    ->label('Password')\n                    ->dehydrateStateUsing(fn ($state) => Hash::make($state))\n                    ->dehydrated(fn ($state) => filled($state))\n                    ->hiddenOn(['create'])\n                    ->required(fn (string $context): bool => $context === 'create')\n                    ->maxLength(255),\n                TextInput::make('password_create')\n                    ->password()\n                    ->label('Password')\n                    ->visibleOn(['create'])\n                    ->required(fn (string $context): bool => $context === 'create')\n                    ->maxLength(255),\n                Forms\\Components\\Select::make('currency')\n                    ->label('Currency (Personal Organization)')\n                    ->options(function (): array {\n                        $currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();\n                        $select = [];\n                        foreach ($currencies as $currency) {\n                            $select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')';\n                        }\n\n                        return $select;\n                    })\n                    ->required()\n                    ->visibleOn(['create'])\n                    ->searchable(),\n                Forms\\Components\\DateTimePicker::make('created_at')\n                    ->label('Created At')\n                    ->hiddenOn(['create'])\n                    ->disabled(),\n                Forms\\Components\\DateTimePicker::make('updated_at')\n                    ->label('Updated At')\n                    ->hiddenOn(['create'])\n                    ->disabled(),\n            ]);\n    }\n\n    public static function table(Table $table): Table\n    {\n        return $table\n            ->columns([\n                Tables\\Columns\\TextColumn::make('name')\n                    ->searchable()\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('email')\n                    ->icon('heroicon-m-envelope')\n                    ->searchable()\n                    ->sortable(),\n                Tables\\Columns\\IconColumn::make('is_real_user')\n                    ->getStateUsing(fn (User $record): bool => ! $record->is_placeholder)\n                    ->label('Real user?')\n                    ->boolean(),\n                Tables\\Columns\\IconColumn::make('email_verified')\n                    ->getStateUsing(fn (User $record): bool => $record->email_verified_at !== null)\n                    ->label('Email verified?')\n                    ->boolean(),\n                Tables\\Columns\\TextColumn::make('created_at')\n                    ->dateTime()\n                    ->sortable(),\n                Tables\\Columns\\TextColumn::make('updated_at')\n                    ->dateTime()\n                    ->sortable()\n                    ->toggleable(isToggledHiddenByDefault: true),\n            ])\n            ->defaultSort('created_at', 'desc')\n            ->filters([\n                TernaryFilter::make('real_user')\n                    ->queries(\n                        true: function (Builder $query): Builder {\n                            /** @var Builder<User> $query */\n                            return $query->where('is_placeholder', '=', false);\n                        },\n                        false: function (Builder $query): Builder {\n                            /** @var Builder<User> $query */\n                            return $query->where('is_placeholder', '=', true);\n                        },\n                        blank: function (Builder $query): Builder {\n                            /** @var Builder<User> $query */\n                            return $query;\n                        },\n                    )\n                    ->label('Real User?'),\n                TernaryFilter::make('email_verified')\n                    ->label('Email Verified?')\n                    ->attribute('email_verified_at')\n                    ->nullable(),\n            ])\n            ->actions([\n                Impersonate::make()->before(function (User $record): void {\n                    if ($record->currentTeam === null) {\n                        $organization = $record->organizations()->where('personal_team', '=', true)->first();\n                        if ($organization === null) {\n                            $organization = $record->organizations()->first();\n                        }\n                        if ($organization === null) {\n                            throw new Exception('User has no organization');\n                        }\n                        $record->currentTeam()->associate($organization);\n                        $record->save();\n                    }\n                }),\n                Tables\\Actions\\EditAction::make(),\n                Tables\\Actions\\DeleteAction::make()\n                    ->hidden(fn (User $record) => $record->is(Auth::user()))\n                    ->using(function (User $record): void {\n                        try {\n                            app(DeletionService::class)->deleteUser($record);\n                        } catch (ApiException $exception) {\n                            Notification::make()\n                                ->danger()\n                                ->title('Delete failed')\n                                ->body($exception->getTranslatedMessage())\n                                ->persistent()\n                                ->send();\n                        }\n                    }),\n            ])\n            ->bulkActions([\n                Tables\\Actions\\BulkAction::make('Resend verification email')\n                    ->icon('heroicon-o-paper-airplane')\n                    ->action(function (Collection $records): void {\n                        foreach ($records as $user) {\n                            /** @var User $user */\n                            $user->sendEmailVerificationNotification();\n                        }\n                    }),\n            ]);\n    }\n\n    public static function getRelations(): array\n    {\n        return [\n            OrganizationsRelationManager::class,\n            OwnedOrganizationsRelationManager::class,\n        ];\n    }\n\n    public static function getPages(): array\n    {\n        return [\n            'index' => Pages\\ListUsers::route('/'),\n            'create' => Pages\\CreateUser::route('/create'),\n            'edit' => Pages\\EditUser::route('/{record}/edit'),\n            'view' => Pages\\ViewUser::route('/{record}'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Widgets/ActiveUserOverview.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Widgets;\n\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse Filament\\Widgets\\StatsOverviewWidget as BaseWidget;\nuse Filament\\Widgets\\StatsOverviewWidget\\Stat;\nuse Illuminate\\Database\\Eloquent\\Builder;\n\nclass ActiveUserOverview extends BaseWidget\n{\n    protected static ?int $sort = 1;\n\n    protected ?string $heading = 'A Registrations';\n\n    protected function getCards(): array\n    {\n        $usersCount = User::query()->where('is_placeholder', '=', false)->count();\n        $placeholderUserCount = User::query()->where('is_placeholder', '=', true)->count();\n        $activeInLastWeek = User::query()\n            ->where('is_placeholder', '=', false)\n            ->whereHas('timeEntries', function (Builder $query): void {\n                /** @var Builder<TimeEntry> $query */\n                $query->where('created_at', '>=', now()->subWeek())\n                    ->orWhere('updated_at', '>=', now()->subWeek());\n            })\n            ->count();\n\n        return [\n            Stat::make('Total', $usersCount)\n                ->color('primary')\n                ->description('Total real users'),\n\n            Stat::make('Placeholder', $placeholderUserCount)\n                ->color('danger')\n                ->description('Placeholder users'),\n\n            Stat::make('Active', $activeInLastWeek)\n                ->color('success')\n                ->description('Active users in the last seven days'),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Widgets/ServerOverview.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Widgets;\n\nuse Filament\\Widgets\\Widget;\nuse Illuminate\\Support\\Facades\\Cache;\n\nclass ServerOverview extends Widget\n{\n    protected static string $view = 'filament.widgets.server-overview';\n\n    /**\n     * @return array<string, mixed>\n     */\n    protected function getViewData(): array\n    {\n        /** @var string|null $currentVersion */\n        $currentVersion = config('app.version');\n        /** @var string|null $build */\n        $build = config('app.build');\n        $latestVersion = Cache::get('latest_version', null);\n\n        $needsUpdate = false;\n        if ($latestVersion !== null && $currentVersion !== null && version_compare($latestVersion, $currentVersion) > 0) {\n            $needsUpdate = true;\n        }\n\n        return [\n            'version' => $currentVersion,\n            'build' => $build,\n            'environment' => config('app.env'),\n            'currentVersion' => $latestVersion,\n            'needsUpdate' => $needsUpdate,\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Filament/Widgets/TimeEntriesCreated.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Widgets;\n\nuse App\\Models\\TimeEntry;\nuse Filament\\Widgets\\ChartWidget;\nuse Flowframe\\Trend\\Trend;\nuse Flowframe\\Trend\\TrendValue;\n\nclass TimeEntriesCreated extends ChartWidget\n{\n    protected static ?string $heading = 'Time Entries Created';\n\n    public ?string $filter = 'week';\n\n    protected static ?int $sort = 3;\n\n    protected function getData(): array\n    {\n        $filter = $this->filter;\n        if ($filter === 'week') {\n            $start = now()->subWeek();\n        } elseif ($filter === 'month') {\n            $start = now()->subMonth();\n        } elseif ($filter === 'year') {\n            $start = now()->subYear();\n        } else {\n            $start = now()->subWeek();\n        }\n        $trend = Trend::query(\n            TimeEntry::query()->where('is_imported', '=', false)\n        )\n            ->between(\n                start: $start,\n                end: now(),\n            )\n            ->perDay();\n\n        if ($filter === 'week') {\n            $trend->perDay();\n        } elseif ($filter === 'month') {\n            $trend->perDay();\n        } elseif ($filter === 'year') {\n            $trend->perMonth();\n        } else {\n            $trend->perDay();\n        }\n\n        $data = $trend->count();\n\n        return [\n            'datasets' => [\n                [\n                    'label' => self::$heading,\n                    'data' => $data->map(fn (TrendValue $value) => $value->aggregate),\n                ],\n            ],\n            'labels' => $data->map(fn (TrendValue $value) => $value->date),\n        ];\n    }\n\n    protected function getFilters(): ?array\n    {\n        return [\n            'week' => 'Last week',\n            'month' => 'Last month',\n            'year' => 'Last year',\n        ];\n    }\n\n    protected function getType(): string\n    {\n        return 'line';\n    }\n}\n"
  },
  {
    "path": "app/Filament/Widgets/TimeEntriesImported.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Widgets;\n\nuse App\\Models\\TimeEntry;\nuse Filament\\Widgets\\ChartWidget;\nuse Flowframe\\Trend\\Trend;\nuse Flowframe\\Trend\\TrendValue;\n\nclass TimeEntriesImported extends ChartWidget\n{\n    protected static ?string $heading = 'Time Entries Imported';\n\n    public ?string $filter = 'week';\n\n    protected static ?int $sort = 4;\n\n    protected function getData(): array\n    {\n        $filter = $this->filter;\n        if ($filter === 'week') {\n            $start = now()->subWeek();\n        } elseif ($filter === 'month') {\n            $start = now()->subMonth();\n        } elseif ($filter === 'year') {\n            $start = now()->subYear();\n        } else {\n            $start = now()->subWeek();\n        }\n        $trend = Trend::query(\n            TimeEntry::query()->where('is_imported', '=', true)\n        )\n            ->between(\n                start: $start,\n                end: now(),\n            )\n            ->perDay();\n\n        if ($filter === 'week') {\n            $trend->perDay();\n        } elseif ($filter === 'month') {\n            $trend->perDay();\n        } elseif ($filter === 'year') {\n            $trend->perMonth();\n        } else {\n            $trend->perDay();\n        }\n\n        $data = $trend->count();\n\n        return [\n            'datasets' => [\n                [\n                    'label' => self::$heading,\n                    'data' => $data->map(fn (TrendValue $value) => $value->aggregate),\n                ],\n            ],\n            'labels' => $data->map(fn (TrendValue $value) => $value->date),\n        ];\n    }\n\n    protected function getFilters(): ?array\n    {\n        return [\n            'week' => 'Last week',\n            'month' => 'Last month',\n            'year' => 'Last year',\n        ];\n    }\n\n    protected function getType(): string\n    {\n        return 'line';\n    }\n}\n"
  },
  {
    "path": "app/Filament/Widgets/UserRegistrations.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Filament\\Widgets;\n\nuse App\\Models\\User;\nuse Filament\\Widgets\\ChartWidget;\nuse Flowframe\\Trend\\Trend;\nuse Flowframe\\Trend\\TrendValue;\n\nclass UserRegistrations extends ChartWidget\n{\n    protected static ?string $heading = 'User Registrations';\n\n    public ?string $filter = 'week';\n\n    protected static ?int $sort = 2;\n\n    protected function getData(): array\n    {\n        $filter = $this->filter;\n        if ($filter === 'week') {\n            $start = now()->subWeek();\n        } elseif ($filter === 'month') {\n            $start = now()->subMonth();\n        } elseif ($filter === 'year') {\n            $start = now()->subYear();\n        } else {\n            $start = now()->subWeek();\n        }\n        $trend = Trend::query(\n            User::query()\n                ->where('is_placeholder', '=', false)\n        )\n            ->between(\n                start: $start,\n                end: now(),\n            )\n            ->perDay();\n\n        if ($filter === 'week') {\n            $trend->perDay();\n        } elseif ($filter === 'month') {\n            $trend->perDay();\n        } elseif ($filter === 'year') {\n            $trend->perMonth();\n        } else {\n            $trend->perDay();\n        }\n\n        $data = $trend->count();\n\n        return [\n            'datasets' => [\n                [\n                    'label' => self::$heading,\n                    'data' => $data->map(fn (TrendValue $value) => $value->aggregate),\n                ],\n            ],\n            'labels' => $data->map(fn (TrendValue $value) => $value->date),\n        ];\n    }\n\n    protected function getFilters(): ?array\n    {\n        return [\n            'week' => 'Last week',\n            'month' => 'Last month',\n            'year' => 'Last year',\n        ];\n    }\n\n    protected function getType(): string\n    {\n        return 'line';\n    }\n}\n"
  },
  {
    "path": "app/Http/Controllers/Api/V1/ApiTokenController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Controllers\\Api\\V1;\n\nuse App\\Exceptions\\Api\\PersonalAccessClientIsNotConfiguredException;\nuse App\\Http\\Requests\\V1\\ApiToken\\ApiTokenStoreRequest;\nuse App\\Http\\Resources\\V1\\ApiToken\\ApiTokenCollection;\nuse App\\Http\\Resources\\V1\\ApiToken\\ApiTokenWithAccessTokenResource;\nuse App\\Models\\Passport\\Client;\nuse App\\Models\\Passport\\Token;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Support\\Str;\n\nclass ApiTokenController extends Controller\n{\n    /**\n     * List all api token of the currently authenticated user\n     *\n     * This endpoint is independent of organization.\n     *\n     * @operationId getApiTokens\n     *\n     * @throws AuthorizationException\n     */\n    public function index(): ApiTokenCollection\n    {\n        $user = $this->user();\n\n        $tokens = $user->tokens()\n            ->whereHas('client', function (Builder $query): void {\n                /** @var Builder<Client> $query */\n                $query->whereJsonContains('grant_types', 'personal_access');\n            })\n            ->orderBy('created_at', 'desc')\n            ->get();\n\n        return new ApiTokenCollection($tokens);\n    }\n\n    /**\n     * Create a new api token for the currently authenticated user\n     *\n     * The response will contain the access token that can be used to send authenticated API requests.\n     * Please note that the access token is only shown in this response and cannot be retrieved later.\n     *\n     * @operationId createApiToken\n     *\n     * @throws AuthorizationException|PersonalAccessClientIsNotConfiguredException\n     */\n    public function store(ApiTokenStoreRequest $request): ApiTokenWithAccessTokenResource\n    {\n        $user = $this->user();\n\n        try {\n            $token = $user->createToken($request->getName(), ['*']);\n\n            /** @var Token $tokenModel */\n            $tokenModel = $token->getToken();\n\n            return new ApiTokenWithAccessTokenResource($tokenModel, $token->accessToken);\n        } catch (\\RuntimeException $exception) {\n            report($exception);\n            if (Str::contains($exception->getMessage(), ['Personal access client not found'])) {\n                throw new PersonalAccessClientIsNotConfiguredException;\n            }\n\n            throw $exception;\n        }\n    }\n\n    /**\n     * Revoke an api token\n     *\n     * @operationId revokeApiToken\n     *\n     * @throws AuthorizationException\n     * @throws PersonalAccessClientIsNotConfiguredException\n     */\n    public function revoke(Token $apiToken): JsonResponse\n    {\n        $user = $this->user();\n\n        if ($apiToken->user_id !== $user->getKey()) {\n            throw new AuthorizationException('API token does not belong to user');\n        }\n        if (! ($apiToken->client?->hasGrantType('personal_access') ?? false)) {\n            throw new AuthorizationException('API token is not a personal access token');\n        }\n\n        $apiToken->revoke();\n\n        return response()->json(null, 204);\n    }\n\n    /**\n     * Delete an api token\n     *\n     * @operationId deleteApiToken\n     *\n     * @throws AuthorizationException|PersonalAccessClientIsNotConfiguredException\n     */\n    public function destroy(Token $apiToken): JsonResponse\n    {\n        $user = $this->user();\n\n        if ($apiToken->user_id !== $user->getKey()) {\n            throw new AuthorizationException('API token does not belong to user');\n        }\n        if (! ($apiToken->client?->hasGrantType('personal_access') ?? false)) {\n            throw new AuthorizationException('API token is not a personal access token');\n        }\n\n        $apiToken->delete();\n\n        return response()->json(null, 204);\n    }\n}\n"
  },
  {
    "path": "app/Http/Controllers/Api/V1/ChartController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Controllers\\Api\\V1;\n\nuse App\\Enums\\Role;\nuse App\\Models\\Organization;\nuse App\\Service\\DashboardService;\nuse App\\Service\\PermissionStore;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Http\\JsonResponse;\n\nclass ChartController extends Controller\n{\n    /**\n     * Get chart data for the weekly project overview.\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId weeklyProjectOverview\n     *\n     * @response array<int, array{value: int, name: string, color: string}>\n     */\n    public function weeklyProjectOverview(Organization $organization, DashboardService $dashboardService): JsonResponse\n    {\n        $this->checkPermission($organization, 'charts:view:own');\n        $user = $this->user();\n\n        $weeklyProjectOverview = $dashboardService->weeklyProjectOverview($user, $organization);\n\n        return response()->json($weeklyProjectOverview);\n    }\n\n    /**\n     * Get chart data for the latest tasks.\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId latestTasks\n     *\n     * @response array<int, array{task_id: string, name: string, description: string|null, status: bool, time_entry_id: string|null}>\n     */\n    public function latestTasks(Organization $organization, DashboardService $dashboardService): JsonResponse\n    {\n        $this->checkPermission($organization, 'charts:view:own');\n        $user = $this->user();\n\n        $latestTasks = $dashboardService->latestTasks($user, $organization);\n\n        return response()->json($latestTasks);\n    }\n\n    /**\n     * Get chart data for the last seven days.\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId lastSevenDays\n     *\n     * @response array<int, array{ date: string, duration: int, history: array<int> }>\n     */\n    public function lastSevenDays(Organization $organization, DashboardService $dashboardService): JsonResponse\n    {\n        $this->checkPermission($organization, 'charts:view:own');\n        $user = $this->user();\n\n        $lastSevenDays = $dashboardService->lastSevenDays($user, $organization);\n\n        return response()->json($lastSevenDays);\n    }\n\n    /**\n     * Get chart data for the latest team activity.\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId latestTeamActivity\n     *\n     * @response array<int, array{member_id: string, name: string, description: string|null, time_entry_id: string, task_id: string|null, status: bool }>\n     */\n    public function latestTeamActivity(Organization $organization, DashboardService $dashboardService, PermissionStore $permissionStore): JsonResponse\n    {\n        $this->checkPermission($organization, 'charts:view:all');\n\n        $latestTeamActivity = $dashboardService->latestTeamActivity($organization);\n\n        return response()->json($latestTeamActivity);\n    }\n\n    /**\n     * Get chart data for daily tracked hours.\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId dailyTrackedHours\n     *\n     * @response array<int, array{date: string, duration: int}>\n     */\n    public function dailyTrackedHours(Organization $organization, DashboardService $dashboardService): JsonResponse\n    {\n        $this->checkPermission($organization, 'charts:view:own');\n        $user = $this->user();\n\n        $dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 100);\n\n        return response()->json($dailyTrackedHours);\n    }\n\n    /**\n     * Get chart data for total weekly time.\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId totalWeeklyTime\n     *\n     * @response int\n     */\n    public function totalWeeklyTime(Organization $organization, DashboardService $dashboardService): JsonResponse\n    {\n        $this->checkPermission($organization, 'charts:view:own');\n        $user = $this->user();\n\n        $totalWeeklyTime = $dashboardService->totalWeeklyTime($user, $organization);\n\n        return response()->json($totalWeeklyTime);\n    }\n\n    /**\n     * Get chart data for total weekly billable time.\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId totalWeeklyBillableTime\n     *\n     * @response int\n     */\n    public function totalWeeklyBillableTime(Organization $organization, DashboardService $dashboardService): JsonResponse\n    {\n        $this->checkPermission($organization, 'charts:view:own');\n        $user = $this->user();\n\n        $totalWeeklyBillableTime = $dashboardService->totalWeeklyBillableTime($user, $organization);\n\n        return response()->json($totalWeeklyBillableTime);\n    }\n\n    /**\n     * Get chart data for total weekly billable amount.\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId totalWeeklyBillableAmount\n     *\n     * @response array{value: int, currency: string}\n     */\n    public function totalWeeklyBillableAmount(Organization $organization, DashboardService $dashboardService): JsonResponse\n    {\n        $this->checkPermission($organization, 'charts:view:own');\n        $user = $this->user();\n\n        $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;\n        if (! $showBillableRate) {\n            throw new AuthorizationException('You do not have permission to view billable rates.');\n        }\n\n        $totalWeeklyBillableAmount = $dashboardService->totalWeeklyBillableAmount($user, $organization);\n\n        return response()->json($totalWeeklyBillableAmount);\n    }\n\n    /**\n     * Get chart data for weekly history.\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId weeklyHistory\n     *\n     * @response array<int, array{date: string, duration: int}>\n     */\n    public function weeklyHistory(Organization $organization, DashboardService $dashboardService): JsonResponse\n    {\n        $this->checkPermission($organization, 'charts:view:own');\n        $user = $this->user();\n\n        $weeklyHistory = $dashboardService->getWeeklyHistory($user, $organization);\n\n        return response()->json($weeklyHistory);\n    }\n}\n"
  },
  {
    "path": "app/Http/Controllers/Api/V1/ClientController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Controllers\\Api\\V1;\n\nuse App\\Exceptions\\Api\\EntityStillInUseApiException;\nuse App\\Http\\Requests\\V1\\Client\\ClientIndexRequest;\nuse App\\Http\\Requests\\V1\\Client\\ClientStoreRequest;\nuse App\\Http\\Requests\\V1\\Client\\ClientUpdateRequest;\nuse App\\Http\\Resources\\V1\\Client\\ClientCollection;\nuse App\\Http\\Resources\\V1\\Client\\ClientResource;\nuse App\\Models\\Client;\nuse App\\Models\\Organization;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Support\\Carbon;\n\nclass ClientController extends Controller\n{\n    protected function checkPermission(Organization $organization, string $permission, ?Client $client = null): void\n    {\n        parent::checkPermission($organization, $permission);\n        if ($client !== null && $client->organization_id !== $organization->getKey()) {\n            throw new AuthorizationException('Tag does not belong to organization');\n        }\n    }\n\n    /**\n     * Get clients\n     *\n     * @return ClientCollection<ClientResource>\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId getClients\n     */\n    public function index(Organization $organization, ClientIndexRequest $request): ClientCollection\n    {\n        $this->checkPermission($organization, 'clients:view');\n        $canViewAllClients = $this->hasPermission($organization, 'clients:view:all');\n        $user = $this->user();\n\n        $clientsQuery = Client::query()\n            ->whereBelongsTo($organization, 'organization')\n            ->orderBy('created_at', 'desc');\n\n        if (! $canViewAllClients) {\n            $clientsQuery->visibleByEmployee($user);\n        }\n\n        $filterArchived = $request->getFilterArchived();\n        if ($filterArchived === 'true') {\n            $clientsQuery->whereNotNull('archived_at');\n        } elseif ($filterArchived === 'false') {\n            $clientsQuery->whereNull('archived_at');\n        }\n\n        $clients = $clientsQuery->paginate(config('app.pagination_per_page_default'));\n\n        return new ClientCollection($clients);\n    }\n\n    /**\n     * Create client\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId createClient\n     */\n    public function store(Organization $organization, ClientStoreRequest $request): ClientResource\n    {\n        $this->checkPermission($organization, 'clients:create');\n\n        $client = new Client;\n        $client->name = $request->input('name');\n        $client->organization()->associate($organization);\n        $client->save();\n\n        return new ClientResource($client);\n    }\n\n    /**\n     * Update client\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId updateClient\n     */\n    public function update(Organization $organization, Client $client, ClientUpdateRequest $request): ClientResource\n    {\n        $this->checkPermission($organization, 'clients:update', $client);\n\n        $client->name = $request->input('name');\n        if ($request->has('is_archived')) {\n            $client->archived_at = $request->getIsArchived() ? Carbon::now() : null;\n        }\n        $client->save();\n\n        return new ClientResource($client);\n    }\n\n    /**\n     * Delete client\n     *\n     * @throws AuthorizationException|EntityStillInUseApiException\n     *\n     * @operationId deleteClient\n     */\n    public function destroy(Organization $organization, Client $client): JsonResponse\n    {\n        $this->checkPermission($organization, 'clients:delete', $client);\n\n        if ($client->projects()->exists()) {\n            throw new EntityStillInUseApiException('client', 'project');\n        }\n\n        $client->delete();\n\n        return response()->json(null, 204);\n    }\n}\n"
  },
  {
    "path": "app/Http/Controllers/Api/V1/Controller.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Controllers\\Api\\V1;\n\nuse App\\Models\\Organization;\nuse App\\Service\\BillingContract;\nuse App\\Service\\PermissionStore;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\n\nclass Controller extends \\App\\Http\\Controllers\\Controller\n{\n    public function __construct(\n        protected PermissionStore $permissionStore,\n    ) {}\n\n    /**\n     * @throws AuthorizationException\n     */\n    protected function checkPermission(Organization $organization, string $permission): void\n    {\n        if (! $this->permissionStore->has($organization, $permission)) {\n            throw new AuthorizationException;\n        }\n    }\n\n    /**\n     * @param  array<string>  $permissions\n     *\n     * @throws AuthorizationException\n     */\n    protected function checkAnyPermission(Organization $organization, array $permissions): void\n    {\n        foreach ($permissions as $permission) {\n            if ($this->permissionStore->has($organization, $permission)) {\n                return;\n            }\n        }\n        throw new AuthorizationException;\n    }\n\n    protected function hasPermission(Organization $organization, string $permission): bool\n    {\n        return $this->permissionStore->has($organization, $permission);\n    }\n\n    protected function canAccessPremiumFeatures(Organization $organization): bool\n    {\n        return app(BillingContract::class)->hasSubscription($organization) || app(BillingContract::class)->hasTrial($organization);\n    }\n}\n"
  },
  {
    "path": "app/Http/Controllers/Api/V1/CurrencyController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Controllers\\Api\\V1;\n\nuse App\\Http\\Controllers\\Controller;\nuse App\\Service\\CurrencyService;\nuse Brick\\Money\\Currency;\nuse Brick\\Money\\ISOCurrencyProvider;\nuse Illuminate\\Http\\JsonResponse;\n\nclass CurrencyController extends Controller\n{\n    /**\n     * Get all currencies\n     *\n     * @response array{code: string, name: string, symbol: string}[]\n     *\n     * @operationId getCurrencies\n     */\n    public function index(): JsonResponse\n    {\n        $currencyService = app(CurrencyService::class);\n\n        $currencies = array_values(array_map(\n            fn (Currency $currency): array => [\n                'code' => $currency->getCurrencyCode(),\n                'name' => $currency->getName(),\n                'symbol' => $currencyService->getCurrencySymbol($currency->getCurrencyCode()),\n            ],\n            ISOCurrencyProvider::getInstance()->getAvailableCurrencies()\n        ));\n\n        return response()->json($currencies);\n    }\n}\n"
  },
  {
    "path": "app/Http/Controllers/Api/V1/ExportController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Controllers\\Api\\V1;\n\nuse App\\Models\\Organization;\nuse App\\Service\\Export\\ExportException;\nuse App\\Service\\Export\\ExportService;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Facades\\Storage;\n\nclass ExportController extends Controller\n{\n    /**\n     * Export data of an organization\n     *\n     * @throws AuthorizationException\n     * @throws ExportException\n     *\n     * @operationId exportOrganization\n     */\n    public function export(Organization $organization, ExportService $exportService): JsonResponse\n    {\n        $this->checkPermission($organization, 'export');\n\n        $filepath = $exportService->export($organization);\n        $downloadUrl = Storage::disk(config('filesystems.private'))\n            ->temporaryUrl($filepath, Carbon::now()->addMinutes(10));\n\n        return new JsonResponse([\n            'success' => true,\n            'download_url' => $downloadUrl,\n        ], 200);\n    }\n}\n"
  },
  {
    "path": "app/Http/Controllers/Api/V1/ImportController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Controllers\\Api\\V1;\n\nuse App\\Http\\Requests\\V1\\Import\\ImportRequest;\nuse App\\Models\\Organization;\nuse App\\Service\\Import\\Importers\\ImporterContract;\nuse App\\Service\\Import\\Importers\\ImporterProvider;\nuse App\\Service\\Import\\Importers\\ImportException;\nuse App\\Service\\Import\\ImportService;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Http\\JsonResponse;\n\nclass ImportController extends Controller\n{\n    /**\n     * Get information about available importers\n     *\n     * @operationId getImporters\n     *\n     * @throws AuthorizationException\n     *\n     * @response array{data: array<array{ key: string, name: string, description: string }>}\n     */\n    public function index(Organization $organization, ImporterProvider $importerProvider): JsonResponse\n    {\n        $this->checkPermission($organization, 'import');\n\n        $importers = $importerProvider->getImporters();\n\n        /** @var array<array{ key: string, name: string, description: string }> $importersResponse */\n        $importersResponse = [];\n\n        foreach ($importers as $key => $importerClass) {\n            /** @var ImporterContract $importer */\n            $importer = new $importerClass;\n            $importersResponse[] = [\n                'key' => $key,\n                'name' => $importer->getName(),\n                'description' => $importer->getDescription(),\n            ];\n        }\n\n        return new JsonResponse([\n            'data' => $importersResponse,\n        ], 200);\n    }\n\n    /**\n     * Import data into the organization\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId importData\n     */\n    public function import(Organization $organization, ImportRequest $request, ImportService $importService): JsonResponse\n    {\n        $this->checkPermission($organization, 'import');\n\n        try {\n            $importData = base64_decode($request->input('data'), true);\n            if ($importData === false) {\n                return new JsonResponse([\n                    'message' => 'Invalid base64 encoded data',\n                ], 400);\n            }\n\n            $timezone = $this->user()->timezone;\n            $report = $importService->import(\n                $organization,\n                $request->input('type'),\n                $importData,\n                $timezone\n            );\n\n            return new JsonResponse([\n                /** @var array{\n                 *   clients: array{\n                 *     created: int,\n                 *   },\n                 *   projects: array{\n                 *     created: int,\n                 *   },\n                 *   tasks: array{\n                 *     created: int,\n                 *   },\n                 *   time_entries: array{\n                 *     created: int,\n                 *   },\n                 *   tags: array{\n                 *     created: int,\n                 *   },\n                 *   users: array{\n                 *     created: int,\n                 *   }\n                 * } $report Import report */\n                'report' => $report->toArray(),\n            ], 200);\n        } catch (ImportException $exception) {\n            report($exception);\n\n            return new JsonResponse([\n                'message' => $exception->getMessage(),\n            ], 400);\n        }\n    }\n}\n"
  },
  {
    "path": "app/Http/Controllers/Api/V1/InvitationController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Controllers\\Api\\V1;\n\nuse App\\Exceptions\\Api\\InvitationForTheEmailAlreadyExistsApiException;\nuse App\\Exceptions\\Api\\UserIsAlreadyMemberOfOrganizationApiException;\nuse App\\Http\\Requests\\V1\\Invitation\\InvitationIndexRequest;\nuse App\\Http\\Requests\\V1\\Invitation\\InvitationStoreRequest;\nuse App\\Http\\Resources\\V1\\Invitation\\InvitationCollection;\nuse App\\Http\\Resources\\V1\\Invitation\\InvitationResource;\nuse App\\Models\\Organization;\nuse App\\Models\\OrganizationInvitation;\nuse App\\Service\\InvitationService;\nuse App\\Service\\OrganizationInvitationService;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Http\\JsonResponse;\n\nclass InvitationController extends Controller\n{\n    protected function checkPermission(Organization $organization, string $permission, ?OrganizationInvitation $organizationInvitation = null): void\n    {\n        parent::checkPermission($organization, $permission);\n        if ($organizationInvitation !== null && $organizationInvitation->organization_id !== $organization->id) {\n            throw new AuthorizationException('Invitation does not belong to organization');\n        }\n    }\n\n    /**\n     * List all invitations of an organization\n     *\n     * @return InvitationCollection<InvitationResource>\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId getInvitations\n     */\n    public function index(Organization $organization, InvitationIndexRequest $request): InvitationCollection\n    {\n        $this->checkPermission($organization, 'invitations:view');\n\n        $invitations = $organization->teamInvitations()\n            ->orderBy('created_at', 'desc')\n            ->paginate(config('app.pagination_per_page_default'));\n\n        return InvitationCollection::make($invitations);\n    }\n\n    /**\n     * Invite a user to the organization\n     *\n     * @throws AuthorizationException\n     * @throws UserIsAlreadyMemberOfOrganizationApiException\n     * @throws InvitationForTheEmailAlreadyExistsApiException\n     *\n     * @operationId invite\n     */\n    public function store(Organization $organization, InvitationStoreRequest $request, InvitationService $invitationService): JsonResponse\n    {\n        $this->checkPermission($organization, 'invitations:create');\n\n        $email = $request->getEmail();\n        $role = $request->getRole();\n\n        $invitationService->inviteUser($organization, $email, $role);\n\n        return response()->json(null, 204);\n    }\n\n    /**\n     * Resend email for a pending invitation\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId resendInvitationEmail\n     */\n    public function resend(Organization $organization, OrganizationInvitation $invitation, OrganizationInvitationService $organizationInvitationService): JsonResponse\n    {\n        $this->checkPermission($organization, 'invitations:resend', $invitation);\n\n        $organizationInvitationService->resend($invitation);\n\n        return response()->json(null, 204);\n    }\n\n    /**\n     * Remove a pending invitation\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId removeInvitation\n     */\n    public function destroy(Organization $organization, OrganizationInvitation $invitation): JsonResponse\n    {\n        $this->checkPermission($organization, 'invitations:remove', $invitation);\n\n        $invitation->delete();\n\n        return response()->json(null, 204);\n    }\n}\n"
  },
  {
    "path": "app/Http/Controllers/Api/V1/MemberController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Controllers\\Api\\V1;\n\nuse App\\Enums\\Role;\nuse App\\Events\\MemberMadeToPlaceholder;\nuse App\\Exceptions\\Api\\CanNotRemoveOwnerFromOrganization;\nuse App\\Exceptions\\Api\\ChangingRoleOfPlaceholderIsNotAllowed;\nuse App\\Exceptions\\Api\\ChangingRoleToPlaceholderIsNotAllowed;\nuse App\\Exceptions\\Api\\EntityStillInUseApiException;\nuse App\\Exceptions\\Api\\InvitationForTheEmailAlreadyExistsApiException;\nuse App\\Exceptions\\Api\\OnlyOwnerCanChangeOwnership;\nuse App\\Exceptions\\Api\\OnlyPlaceholdersCanBeMergedIntoAnotherMember;\nuse App\\Exceptions\\Api\\OrganizationNeedsAtLeastOneOwner;\nuse App\\Exceptions\\Api\\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;\nuse App\\Exceptions\\Api\\UserIsAlreadyMemberOfOrganizationApiException;\nuse App\\Exceptions\\Api\\UserNotPlaceholderApiException;\nuse App\\Http\\Requests\\V1\\Member\\MemberDestroyRequest;\nuse App\\Http\\Requests\\V1\\Member\\MemberIndexRequest;\nuse App\\Http\\Requests\\V1\\Member\\MemberMergeIntoRequest;\nuse App\\Http\\Requests\\V1\\Member\\MemberUpdateRequest;\nuse App\\Http\\Resources\\V1\\Member\\MemberCollection;\nuse App\\Http\\Resources\\V1\\Member\\MemberResource;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Service\\BillableRateService;\nuse App\\Service\\InvitationService;\nuse App\\Service\\MemberService;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Resources\\Json\\JsonResource;\nuse Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Support\\Str;\n\nclass MemberController extends Controller\n{\n    protected function checkPermission(Organization $organization, string $permission, ?Member $member = null): void\n    {\n        parent::checkPermission($organization, $permission);\n        if ($member !== null && $member->organization_id !== $organization->id) {\n            throw new AuthorizationException('Member does not belong to organization');\n        }\n    }\n\n    /**\n     * List all members of an organization\n     *\n     * @return MemberCollection<MemberResource>\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId getMembers\n     */\n    public function index(Organization $organization, MemberIndexRequest $request): MemberCollection\n    {\n        $this->checkPermission($organization, 'members:view');\n\n        $members = Member::query()\n            ->whereBelongsTo($organization, 'organization')\n            ->with(['user'])\n            ->orderBy('created_at', 'desc')\n            ->paginate(config('app.pagination_per_page_default'));\n\n        return MemberCollection::make($members);\n    }\n\n    /**\n     * Update a member of the organization\n     *\n     * @throws AuthorizationException\n     * @throws OrganizationNeedsAtLeastOneOwner\n     * @throws OnlyOwnerCanChangeOwnership\n     * @throws ChangingRoleToPlaceholderIsNotAllowed\n     * @throws ChangingRoleOfPlaceholderIsNotAllowed\n     *\n     * @operationId updateMember\n     */\n    public function update(Organization $organization, Member $member, MemberUpdateRequest $request, BillableRateService $billableRateService, MemberService $memberService): JsonResource\n    {\n        $this->checkPermission($organization, 'members:update', $member);\n\n        if ($request->has('billable_rate') && $member->billable_rate !== $request->getBillableRate()) {\n            $member->billable_rate = $request->getBillableRate();\n\n            $billableRateService->updateTimeEntriesBillableRateForMember($member);\n        }\n        if ($request->has('role') && $member->role !== $request->getRole()->value) {\n            $newRole = $request->getRole();\n            $allowOwnerChange = $this->hasPermission($organization, 'members:change-ownership');\n            $memberService->changeRole($member, $organization, $newRole, $allowOwnerChange);\n        }\n        $member->save();\n\n        return new MemberResource($member);\n    }\n\n    /**\n     * Remove a member of the organization.\n     *\n     * @throws AuthorizationException|EntityStillInUseApiException|CanNotRemoveOwnerFromOrganization\n     *\n     * @operationId removeMember\n     */\n    public function destroy(MemberDestroyRequest $request, Organization $organization, Member $member, MemberService $memberService): JsonResponse\n    {\n        $this->checkPermission($organization, 'members:delete', $member);\n\n        $deleteRelated = $request->getDeleteRelated();\n\n        $memberService->removeMember($member, $organization, $deleteRelated);\n\n        return response()\n            ->json(null, 204);\n    }\n\n    /**\n     * Make a member a placeholder member\n     *\n     * @throws AuthorizationException|CanNotRemoveOwnerFromOrganization|ChangingRoleOfPlaceholderIsNotAllowed\n     *\n     * @operationId makePlaceholder\n     */\n    public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse\n    {\n        $this->checkPermission($organization, 'members:make-placeholder', $member);\n\n        if ($member->role === Role::Owner->value) {\n            throw new CanNotRemoveOwnerFromOrganization;\n        }\n        if ($member->role === Role::Placeholder->value) {\n            throw new ChangingRoleOfPlaceholderIsNotAllowed;\n        }\n\n        $memberService->makeMemberToPlaceholder($member);\n\n        MemberMadeToPlaceholder::dispatch($member, $organization);\n\n        return response()->json(null, 204);\n    }\n\n    /**\n     * Merge one member into another\n     *\n     * @throws AuthorizationException\n     * @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember\n     * @throws \\Throwable\n     *\n     * @operationId mergeMember\n     */\n    public function mergeInto(Organization $organization, Member $member, MemberMergeIntoRequest $request, MemberService $memberService): JsonResponse\n    {\n        $this->checkPermission($organization, 'members:merge-into', $member);\n\n        $user = $member->user;\n        if ($member->role !== Role::Placeholder->value || ! $user->is_placeholder) {\n            throw new OnlyPlaceholdersCanBeMergedIntoAnotherMember;\n        }\n        $memberTo = Member::findOrFail($request->getMemberId());\n\n        DB::transaction(function () use ($organization, $member, $user, $memberTo, $memberService): void {\n            $memberService->assignOrganizationEntitiesToDifferentMember($organization, $member, $memberTo);\n            $member->delete();\n            $user->delete();\n        });\n\n        return response()->json(null, 204);\n    }\n\n    /**\n     * Invite a placeholder member to become a real member of the organization\n     *\n     * @throws AuthorizationException\n     * @throws UserNotPlaceholderApiException\n     * @throws UserIsAlreadyMemberOfOrganizationApiException\n     * @throws ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException\n     * @throws InvitationForTheEmailAlreadyExistsApiException\n     *\n     * @operationId invitePlaceholder\n     */\n    public function invitePlaceholder(Organization $organization, Member $member, InvitationService $invitationService): JsonResponse\n    {\n        $this->checkPermission($organization, 'members:invite-placeholder', $member);\n        $user = $member->user;\n\n        if (! $user->is_placeholder) {\n            throw new UserNotPlaceholderApiException;\n        }\n\n        if (Str::endsWith($user->email, '@solidtime-import.test')) {\n            throw new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;\n        }\n\n        $invitationService->inviteUser($organization, $user->email, Role::Employee);\n\n        return response()->json(null, 204);\n    }\n}\n"
  },
  {
    "path": "app/Http/Controllers/Api/V1/OrganizationController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Controllers\\Api\\V1;\n\nuse App\\Enums\\Role;\nuse App\\Http\\Requests\\V1\\Organization\\OrganizationUpdateRequest;\nuse App\\Http\\Resources\\V1\\Organization\\OrganizationResource;\nuse App\\Models\\Organization;\nuse App\\Service\\BillableRateService;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\n\nclass OrganizationController extends Controller\n{\n    /**\n     * Get organization\n     *\n     * @operationId getOrganization\n     *\n     * @throws AuthorizationException\n     */\n    public function show(Organization $organization): OrganizationResource\n    {\n        $this->checkPermission($organization, 'organizations:view');\n\n        $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;\n\n        return new OrganizationResource($organization, $showBillableRate);\n    }\n\n    /**\n     * Update organization\n     *\n     * @operationId updateOrganization\n     *\n     * @throws AuthorizationException\n     */\n    public function update(Organization $organization, OrganizationUpdateRequest $request, BillableRateService $billableRateService): OrganizationResource\n    {\n        $this->checkPermission($organization, 'organizations:update');\n\n        if ($request->getName() !== null) {\n            $organization->name = $request->getName();\n        }\n        if ($request->getEmployeesCanSeeBillableRates() !== null) {\n            $organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();\n        }\n        if ($request->getEmployeesCanManageTasks() !== null) {\n            $organization->employees_can_manage_tasks = $request->getEmployeesCanManageTasks();\n        }\n        if ($request->getNumberFormat() !== null) {\n            $organization->number_format = $request->getNumberFormat();\n        }\n        if ($request->getCurrencyFormat() !== null) {\n            $organization->currency_format = $request->getCurrencyFormat();\n        }\n        if ($request->getDateFormat() !== null) {\n            $organization->date_format = $request->getDateFormat();\n        }\n        if ($request->getIntervalFormat() !== null) {\n            $organization->interval_format = $request->getIntervalFormat();\n        }\n        if ($request->getTimeFormat() !== null) {\n            $organization->time_format = $request->getTimeFormat();\n        }\n        if ($request->getPreventOverlappingTimeEntries() !== null) {\n            $organization->prevent_overlapping_time_entries = $request->getPreventOverlappingTimeEntries();\n        }\n        $hasBillableRate = $request->has('billable_rate');\n        if ($hasBillableRate) {\n            $oldBillableRate = $organization->billable_rate;\n            $organization->billable_rate = $request->getBillableRate();\n        }\n        $organization->save();\n\n        if ($hasBillableRate && $oldBillableRate !== $request->getBillableRate()) {\n            $billableRateService->updateTimeEntriesBillableRateForOrganization($organization);\n        }\n\n        return new OrganizationResource($organization, true);\n    }\n}\n"
  },
  {
    "path": "app/Http/Controllers/Api/V1/ProjectController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Controllers\\Api\\V1;\n\nuse App\\Enums\\Role;\nuse App\\Exceptions\\Api\\EntityStillInUseApiException;\nuse App\\Http\\Requests\\V1\\Project\\ProjectIndexRequest;\nuse App\\Http\\Requests\\V1\\Project\\ProjectStoreRequest;\nuse App\\Http\\Requests\\V1\\Project\\ProjectUpdateRequest;\nuse App\\Http\\Resources\\V1\\Project\\ProjectCollection;\nuse App\\Http\\Resources\\V1\\Project\\ProjectResource;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\ProjectMember;\nuse App\\Models\\TimeEntry;\nuse App\\Service\\BillableRateService;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Resources\\Json\\JsonResource;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Facades\\DB;\n\nclass ProjectController extends Controller\n{\n    protected function checkPermission(Organization $organization, string $permission, ?Project $project = null): void\n    {\n        parent::checkPermission($organization, $permission);\n        if ($project !== null && $project->organization_id !== $organization->id) {\n            throw new AuthorizationException('Project does not belong to organization');\n        }\n    }\n\n    /**\n     * Get projects visible to the current user\n     *\n     * @return ProjectCollection<ProjectResource>\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId getProjects\n     */\n    public function index(Organization $organization, ProjectIndexRequest $request): ProjectCollection\n    {\n        $this->checkPermission($organization, 'projects:view');\n        $canViewAllProjects = $this->hasPermission($organization, 'projects:view:all');\n        $user = $this->user();\n\n        $projectsQuery = Project::query()\n            ->whereBelongsTo($organization, 'organization');\n\n        if (! $canViewAllProjects) {\n            $projectsQuery->visibleByEmployee($user);\n        }\n        $filterArchived = $request->getFilterArchived();\n        if ($filterArchived === 'true') {\n            $projectsQuery->whereNotNull('archived_at');\n        } elseif ($filterArchived === 'false') {\n            $projectsQuery->whereNull('archived_at');\n        }\n\n        $projects = $projectsQuery\n            ->orderBy('created_at', 'desc')\n            ->paginate(config('app.pagination_per_page_default'));\n\n        $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;\n\n        return new ProjectCollection($projects, $showBillableRate);\n    }\n\n    /**\n     * Get project\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId getProject\n     */\n    public function show(Organization $organization, Project $project): JsonResource\n    {\n        $this->checkPermission($organization, 'projects:view:all', $project);\n\n        // Note: There is currently no need to check if a user is a member of the project,\n        // since this is only relevant for users with the role \"employee\" and they can not access this endpoint.\n\n        $project->load('organization');\n\n        return new ProjectResource($project, true);\n    }\n\n    /**\n     * Create project\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId createProject\n     */\n    public function store(Organization $organization, ProjectStoreRequest $request): JsonResource\n    {\n        $this->checkPermission($organization, 'projects:create');\n        $project = new Project;\n        $project->name = $request->input('name');\n        $project->color = $request->input('color');\n        $project->is_billable = (bool) $request->input('is_billable');\n        $project->billable_rate = $request->getBillableRate();\n        $project->client_id = $request->input('client_id');\n        $project->is_public = $request->getIsPublic();\n        if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {\n            $project->estimated_time = $request->getEstimatedTime();\n        }\n        $project->organization()->associate($organization);\n        $project->save();\n\n        return new ProjectResource($project, true);\n    }\n\n    /**\n     * Update project\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId updateProject\n     */\n    public function update(Organization $organization, Project $project, ProjectUpdateRequest $request, BillableRateService $billableRateService): JsonResource\n    {\n        $this->checkPermission($organization, 'projects:update', $project);\n        $project->name = $request->input('name');\n        $project->color = $request->input('color');\n        $project->is_billable = (bool) $request->input('is_billable');\n        if ($request->has('is_archived')) {\n            $project->archived_at = $request->getIsArchived() ? Carbon::now() : null;\n        }\n        if ($request->has('is_public')) {\n            $project->is_public = $request->boolean('is_public');\n        }\n        if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {\n            $project->estimated_time = $request->getEstimatedTime();\n        }\n        $oldBillableRate = $project->billable_rate;\n        $clientIdChanged = false;\n        $project->billable_rate = $request->getBillableRate();\n        if ($project->client_id !== $request->input('client_id')) {\n            $project->client_id = $request->input('client_id');\n            $clientIdChanged = true;\n        }\n        $project->save();\n\n        if ($oldBillableRate !== $request->getBillableRate()) {\n            $billableRateService->updateTimeEntriesBillableRateForProject($project);\n        }\n        if ($clientIdChanged) {\n            TimeEntry::query()\n                ->whereBelongsTo($organization, 'organization')\n                ->whereBelongsTo($project, 'project')\n                ->update(['client_id' => $project->client_id]);\n        }\n\n        return new ProjectResource($project, true);\n    }\n\n    /**\n     * Delete project\n     *\n     * @throws AuthorizationException|EntityStillInUseApiException\n     *\n     * @operationId deleteProject\n     */\n    public function destroy(Organization $organization, Project $project): JsonResponse\n    {\n        $this->checkPermission($organization, 'projects:delete', $project);\n\n        if ($project->tasks()->exists()) {\n            throw new EntityStillInUseApiException('project', 'task');\n        }\n        if ($project->timeEntries()->exists()) {\n            throw new EntityStillInUseApiException('project', 'time_entry');\n        }\n\n        DB::transaction(function () use (&$project): void {\n            $project->members->each(function (ProjectMember $member): void {\n                $member->delete();\n            });\n\n            $project->delete();\n        });\n\n        return response()\n            ->json(null, 204);\n    }\n}\n"
  },
  {
    "path": "app/Http/Controllers/Api/V1/ProjectMemberController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Controllers\\Api\\V1;\n\nuse App\\Exceptions\\Api\\InactiveUserCanNotBeUsedApiException;\nuse App\\Exceptions\\Api\\UserIsAlreadyMemberOfProjectApiException;\nuse App\\Http\\Requests\\V1\\ProjectMember\\ProjectMemberIndexRequest;\nuse App\\Http\\Requests\\V1\\ProjectMember\\ProjectMemberStoreRequest;\nuse App\\Http\\Requests\\V1\\ProjectMember\\ProjectMemberUpdateRequest;\nuse App\\Http\\Resources\\V1\\ProjectMember\\ProjectMemberCollection;\nuse App\\Http\\Resources\\V1\\ProjectMember\\ProjectMemberResource;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\ProjectMember;\nuse App\\Service\\BillableRateService;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Resources\\Json\\JsonResource;\n\nclass ProjectMemberController extends Controller\n{\n    protected function checkPermission(Organization $organization, string $permission, ?Project $project = null, ?ProjectMember $projectMember = null): void\n    {\n        parent::checkPermission($organization, $permission);\n        if ($project !== null && $project->organization_id !== $organization->id) {\n            throw new AuthorizationException('Project does not belong to organization');\n        }\n        if ($projectMember !== null && $projectMember->project->organization_id !== $organization->id) {\n            throw new AuthorizationException('Project member does not belong to organization');\n        }\n    }\n\n    /**\n     * Get project members for project\n     *\n     * @return ProjectMemberCollection<ProjectMemberResource>\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId getProjectMembers\n     */\n    public function index(Organization $organization, Project $project, ProjectMemberIndexRequest $request): ProjectMemberCollection\n    {\n        $this->checkPermission($organization, 'project-members:view', $project);\n\n        $projectMembers = ProjectMember::query()\n            ->whereBelongsTo($project, 'project')\n            ->orderBy('created_at', 'desc')\n            ->paginate(config('app.pagination_per_page_default'));\n\n        return new ProjectMemberCollection($projectMembers);\n    }\n\n    /**\n     * Add project member to project\n     *\n     * @throws AuthorizationException|InactiveUserCanNotBeUsedApiException|UserIsAlreadyMemberOfProjectApiException\n     *\n     * @operationId createProjectMember\n     */\n    public function store(Organization $organization, Project $project, ProjectMemberStoreRequest $request, BillableRateService $billableRateService): JsonResource\n    {\n        $this->checkPermission($organization, 'project-members:create', $project);\n\n        $member = Member::findOrFail((string) $request->input('member_id'));\n        if ($member->user->is_placeholder) {\n            throw new InactiveUserCanNotBeUsedApiException;\n        }\n        if (ProjectMember::whereBelongsTo($project, 'project')->whereBelongsTo($member, 'member')->exists()) {\n            throw new UserIsAlreadyMemberOfProjectApiException;\n        }\n\n        $projectMember = new ProjectMember;\n        $projectMember->billable_rate = $request->getBillableRate();\n        $projectMember->member()->associate($member);\n        $projectMember->user()->associate($member->user);\n        $projectMember->project()->associate($project);\n        $projectMember->save();\n\n        if ($request->getBillableRate() !== null) {\n            $billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);\n        }\n\n        return new ProjectMemberResource($projectMember);\n    }\n\n    /**\n     * Update project member\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId updateProjectMember\n     */\n    public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request, BillableRateService $billableRateService): JsonResource\n    {\n        $this->checkPermission($organization, 'project-members:update', projectMember: $projectMember);\n        $oldBillableRate = $projectMember->billable_rate;\n        $projectMember->billable_rate = $request->getBillableRate();\n        $projectMember->save();\n\n        if ($oldBillableRate !== $request->getBillableRate()) {\n            $billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);\n        }\n\n        return new ProjectMemberResource($projectMember);\n    }\n\n    /**\n     * Delete project member\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId deleteProjectMember\n     */\n    public function destroy(Organization $organization, ProjectMember $projectMember, BillableRateService $billableRateService): JsonResponse\n    {\n        $this->checkPermission($organization, 'project-members:delete', projectMember: $projectMember);\n\n        $hadBillableRate = $projectMember->billable_rate !== null;\n        $project = $projectMember->project;\n        $member = $projectMember->member;\n\n        $projectMember->delete();\n\n        if ($hadBillableRate) {\n            $billableRateService->updateTimeEntriesBillableRateForMember($member);\n            $billableRateService->updateTimeEntriesBillableRateForProject($project);\n            $billableRateService->updateTimeEntriesBillableRateForOrganization($organization);\n        }\n\n        return response()\n            ->json(null, 204);\n    }\n}\n"
  },
  {
    "path": "app/Http/Controllers/Api/V1/Public/ReportController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Controllers\\Api\\V1\\Public;\n\nuse App\\Enums\\TimeEntryAggregationType;\nuse App\\Http\\Controllers\\Api\\V1\\Controller;\nuse App\\Http\\Resources\\V1\\Report\\DetailedWithDataReportResource;\nuse App\\Models\\Report;\nuse App\\Models\\TimeEntry;\nuse App\\Service\\Dto\\ReportPropertiesDto;\nuse App\\Service\\TimeEntryAggregationService;\nuse App\\Service\\TimeEntryFilter;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Database\\Eloquent\\ModelNotFoundException;\nuse Illuminate\\Http\\Request;\n\nclass ReportController extends Controller\n{\n    /**\n     * Get report by a share secret\n     *\n     * This endpoint is public and does not require authentication. The report must be public and not expired.\n     * The report is considered expired if the `public_until` field is set and the date is in the past.\n     * The report is considered public if the `is_public` field is set to `true`.\n     *\n     * @operationId getPublicReport\n     */\n    public function show(Request $request, TimeEntryAggregationService $timeEntryAggregationService): DetailedWithDataReportResource\n    {\n        $shareSecret = $request->header('X-Api-Key');\n        if (! is_string($shareSecret)) {\n            throw new ModelNotFoundException;\n        }\n\n        $report = Report::query()\n            ->with([\n                'organization',\n            ])\n            ->where('share_secret', '=', $shareSecret)\n            ->where('is_public', '=', true)\n            ->where(function (Builder $builder): void {\n                /** @var Builder<Report> $builder */\n                $builder->whereNull('public_until')\n                    ->orWhere('public_until', '>', now());\n            })\n            ->firstOrFail();\n        /** @var ReportPropertiesDto $properties */\n        $properties = $report->properties;\n\n        $timeEntriesQuery = TimeEntry::query()\n            ->whereBelongsTo($report->organization, 'organization');\n\n        $filter = new TimeEntryFilter($timeEntriesQuery);\n        $filter->addStart($properties->start);\n        $filter->addEnd($properties->end);\n        $filter->addActive($properties->active);\n        $filter->addBillable($properties->billable);\n        $filter->addMemberIdsFilter($properties->memberIds?->toArray());\n        $filter->addProjectIdsFilter($properties->projectIds?->toArray());\n        $filter->addTagIdsFilter($properties->tagIds?->toArray());\n        $filter->addTaskIdsFilter($properties->taskIds?->toArray());\n        $filter->addClientIdsFilter($properties->clientIds?->toArray());\n        $timeEntriesQuery = $filter->get();\n\n        $data = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(\n            $timeEntriesQuery->clone(),\n            $report->properties->group,\n            $report->properties->subGroup,\n            $report->properties->timezone,\n            $report->properties->weekStart,\n            false,\n            $report->properties->start,\n            $report->properties->end,\n            true,\n            $report->properties->roundingType,\n            $report->properties->roundingMinutes,\n        );\n        $historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(\n            $timeEntriesQuery->clone(),\n            TimeEntryAggregationType::fromInterval($report->properties->historyGroup),\n            null,\n            $report->properties->timezone,\n            $report->properties->weekStart,\n            true,\n            $report->properties->start,\n            $report->properties->end,\n            true,\n            $report->properties->roundingType,\n            $report->properties->roundingMinutes,\n        );\n\n        return new DetailedWithDataReportResource($report, $data, $historyData);\n    }\n}\n"
  },
  {
    "path": "app/Http/Controllers/Api/V1/ReportController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Controllers\\Api\\V1;\n\nuse App\\Enums\\Weekday;\nuse App\\Http\\Requests\\V1\\Report\\ReportIndexRequest;\nuse App\\Http\\Requests\\V1\\Report\\ReportStoreRequest;\nuse App\\Http\\Requests\\V1\\Report\\ReportUpdateRequest;\nuse App\\Http\\Resources\\V1\\Report\\DetailedReportResource;\nuse App\\Http\\Resources\\V1\\Report\\ReportCollection;\nuse App\\Http\\Resources\\V1\\Report\\ReportResource;\nuse App\\Models\\Organization;\nuse App\\Models\\Report;\nuse App\\Service\\Dto\\ReportPropertiesDto;\nuse App\\Service\\ReportService;\nuse App\\Service\\TimezoneService;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Http\\JsonResponse;\n\nclass ReportController extends Controller\n{\n    /**\n     * @throws AuthorizationException\n     */\n    protected function checkPermission(Organization $organization, string $permission, ?Report $report = null): void\n    {\n        parent::checkPermission($organization, $permission);\n        if ($report !== null && $report->organization_id !== $organization->id) {\n            throw new AuthorizationException('Report does not belong to organization');\n        }\n    }\n\n    /**\n     * Get reports\n     *\n     * @return ReportCollection<ReportResource>\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId getReports\n     */\n    public function index(Organization $organization, ReportIndexRequest $request): ReportCollection\n    {\n        $this->checkPermission($organization, 'reports:view');\n\n        $reports = Report::query()\n            ->orderBy('created_at', 'desc')\n            ->whereBelongsTo($organization, 'organization')\n            ->paginate(config('app.pagination_per_page_default'));\n\n        return new ReportCollection($reports);\n    }\n\n    /**\n     * Get report\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId getReport\n     */\n    public function show(Organization $organization, Report $report): DetailedReportResource\n    {\n        $this->checkPermission($organization, 'reports:view', $report);\n\n        return new DetailedReportResource($report);\n    }\n\n    /**\n     * Create report\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId createReport\n     */\n    public function store(Organization $organization, ReportStoreRequest $request, TimezoneService $timezoneService, ReportService $reportService): DetailedReportResource\n    {\n        $this->checkPermission($organization, 'reports:create');\n        $user = $this->user();\n\n        $report = new Report;\n        $report->name = $request->getName();\n        $report->description = $request->getDescription();\n        $isPublic = $request->getIsPublic();\n        $report->is_public = $isPublic;\n        $properties = new ReportPropertiesDto;\n        $properties->group = $request->getPropertyGroup();\n        $properties->subGroup = $request->getPropertySubGroup();\n        $properties->historyGroup = $request->getPropertyHistoryGroup();\n        $properties->start = $request->getPropertyStart();\n        $properties->end = $request->getPropertyEnd();\n        $properties->active = $request->getPropertyActive();\n        $properties->setMemberIds($request->input('properties.member_ids', null));\n        $properties->billable = $request->getPropertyBillable();\n        $properties->setClientIds($request->input('properties.client_ids', null));\n        $properties->setProjectIds($request->input('properties.project_ids', null));\n        $properties->setTagIds($request->input('properties.tag_ids', null));\n        $properties->setTaskIds($request->input('properties.task_ids', null));\n        $properties->weekStart = $request->has('properties.week_start') ? Weekday::from($request->input('properties.week_start')) : $user->week_start;\n        $timezone = $user->timezone;\n        if ($request->has('properties.timezone')) {\n            if ($timezoneService->isValid($request->input('properties.timezone'))) {\n                $timezone = $request->input('properties.timezone');\n            }\n            if ($timezoneService->mapLegacyTimezone($request->input('properties.timezone')) !== null) {\n                $timezone = $timezoneService->mapLegacyTimezone($request->input('properties.timezone'));\n            }\n        }\n        $properties->timezone = $timezone;\n        $properties->roundingType = $request->getPropertyRoundingType();\n        $properties->roundingMinutes = $request->getPropertyRoundingMinutes();\n        $report->properties = $properties;\n        if ($isPublic) {\n            $report->share_secret = $reportService->generateSecret();\n            $report->public_until = $request->getPublicUntil();\n        } else {\n            $report->share_secret = null;\n            $report->public_until = null;\n        }\n        $report->organization()->associate($organization);\n        $report->save();\n\n        return new DetailedReportResource($report);\n    }\n\n    /**\n     * Update report\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId updateReport\n     */\n    public function update(Organization $organization, Report $report, ReportUpdateRequest $request, ReportService $reportService): DetailedReportResource\n    {\n        $this->checkPermission($organization, 'reports:update', $report);\n\n        if ($request->has('name')) {\n            $report->name = $request->getName();\n        }\n        if ($request->has('description')) {\n            $report->description = $request->getDescription();\n        }\n        if ($request->has('is_public') && $request->getIsPublic() !== $report->is_public) {\n            $isPublic = $request->getIsPublic();\n            $report->is_public = $isPublic;\n            if ($isPublic) {\n                $report->share_secret = $reportService->generateSecret();\n                $report->public_until = $request->getPublicUntil();\n            } else {\n                $report->share_secret = null;\n                $report->public_until = null;\n            }\n        } elseif ($report->is_public && $request->has('public_until')) {\n            // Allow updating expiration date on already-public reports\n            $report->public_until = $request->getPublicUntil();\n        }\n        $report->save();\n\n        return new DetailedReportResource($report);\n    }\n\n    /**\n     * Delete report\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId deleteReport\n     */\n    public function destroy(Organization $organization, Report $report): JsonResponse\n    {\n        $this->checkPermission($organization, 'reports:delete', $report);\n\n        $report->delete();\n\n        return response()->json(null, 204);\n    }\n}\n"
  },
  {
    "path": "app/Http/Controllers/Api/V1/TagController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Controllers\\Api\\V1;\n\nuse App\\Exceptions\\Api\\EntityStillInUseApiException;\nuse App\\Http\\Requests\\V1\\Tag\\TagIndexRequest;\nuse App\\Http\\Requests\\V1\\Tag\\TagStoreRequest;\nuse App\\Http\\Requests\\V1\\Tag\\TagUpdateRequest;\nuse App\\Http\\Resources\\V1\\Tag\\TagCollection;\nuse App\\Http\\Resources\\V1\\Tag\\TagResource;\nuse App\\Models\\Organization;\nuse App\\Models\\Tag;\nuse App\\Models\\TimeEntry;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Http\\JsonResponse;\n\nclass TagController extends Controller\n{\n    protected function checkPermission(Organization $organization, string $permission, ?Tag $tag = null): void\n    {\n        parent::checkPermission($organization, $permission);\n        if ($tag !== null && $tag->organization_id !== $organization->getKey()) {\n            throw new AuthorizationException('Tag does not belong to organization');\n        }\n    }\n\n    /**\n     * Get tags\n     *\n     * @return TagCollection<TagResource>\n     *\n     * @operationId getTags\n     *\n     * @throws AuthorizationException\n     */\n    public function index(Organization $organization, TagIndexRequest $request): TagCollection\n    {\n        $this->checkPermission($organization, 'tags:view');\n\n        $tags = Tag::query()\n            ->whereBelongsTo($organization, 'organization')\n            ->orderBy('created_at', 'desc')\n            ->paginate(config('app.pagination_per_page_default'));\n\n        return new TagCollection($tags);\n    }\n\n    /**\n     * Create tag\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId createTag\n     */\n    public function store(Organization $organization, TagStoreRequest $request): TagResource\n    {\n        $this->checkPermission($organization, 'tags:create');\n\n        $tag = new Tag;\n        $tag->name = $request->input('name');\n        $tag->organization()->associate($organization);\n        $tag->save();\n\n        return new TagResource($tag);\n    }\n\n    /**\n     * Update tag\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId updateTag\n     */\n    public function update(Organization $organization, Tag $tag, TagUpdateRequest $request): TagResource\n    {\n        $this->checkPermission($organization, 'tags:update', $tag);\n\n        $tag->name = $request->input('name');\n        $tag->save();\n\n        return new TagResource($tag);\n    }\n\n    /**\n     * Delete tag\n     *\n     * @throws AuthorizationException|EntityStillInUseApiException\n     *\n     * @operationId deleteTag\n     */\n    public function destroy(Organization $organization, Tag $tag): JsonResponse\n    {\n        $this->checkPermission($organization, 'tags:delete', $tag);\n\n        if (TimeEntry::query()->hasTag($tag)->whereBelongsTo($organization, 'organization')->exists()) {\n            throw new EntityStillInUseApiException('tag', 'time_entry');\n        }\n\n        $tag->delete();\n\n        return response()->json(null, 204);\n    }\n}\n"
  },
  {
    "path": "app/Http/Controllers/Api/V1/TaskController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Controllers\\Api\\V1;\n\nuse App\\Exceptions\\Api\\EntityStillInUseApiException;\nuse App\\Http\\Requests\\V1\\Task\\TaskIndexRequest;\nuse App\\Http\\Requests\\V1\\Task\\TaskStoreRequest;\nuse App\\Http\\Requests\\V1\\Task\\TaskUpdateRequest;\nuse App\\Http\\Resources\\V1\\Task\\TaskCollection;\nuse App\\Http\\Resources\\V1\\Task\\TaskResource;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\Task;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Resources\\Json\\JsonResource;\nuse Illuminate\\Support\\Carbon;\n\nclass TaskController extends Controller\n{\n    protected function checkPermission(Organization $organization, string $permission, ?Task $task = null): void\n    {\n        parent::checkPermission($organization, $permission);\n        if ($task !== null && $task->organization_id !== $organization->id) {\n            throw new AuthorizationException('Task does not belong to organization');\n        }\n    }\n\n    /**\n     * Check scoped permission and verify user has access to the project\n     *\n     * @throws AuthorizationException\n     */\n    private function checkScopedPermissionForProject(Organization $organization, Project $project, string $permission): void\n    {\n        $this->checkPermission($organization, $permission);\n\n        $user = $this->user();\n        $hasAccess = Project::query()\n            ->where('id', $project->id)\n            ->visibleByEmployee($user)\n            ->exists();\n\n        if (! $hasAccess) {\n            throw new AuthorizationException('You do not have permission to '.$permission.' in this project.');\n        }\n    }\n\n    /**\n     * Get tasks\n     *\n     * @return TaskCollection<TaskResource>\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId getTasks\n     */\n    public function index(Organization $organization, TaskIndexRequest $request): TaskCollection\n    {\n        $this->checkPermission($organization, 'tasks:view');\n        $canViewAllTasks = $this->hasPermission($organization, 'tasks:view:all');\n        $user = $this->user();\n\n        $projectId = $request->input('project_id');\n\n        $query = Task::query()\n            ->whereBelongsTo($organization, 'organization');\n\n        if ($projectId !== null) {\n            $query->where('project_id', '=', $projectId);\n        }\n\n        if (! $canViewAllTasks) {\n            $query->visibleByEmployee($user);\n        }\n        $doneFilter = $request->getFilterDone();\n        if ($doneFilter === 'true') {\n            $query->whereNotNull('done_at');\n        } elseif ($doneFilter === 'false') {\n            $query->whereNull('done_at');\n        }\n\n        $tasks = $query\n            ->orderBy('created_at', 'desc')\n            ->paginate(config('app.pagination_per_page_default'));\n\n        return new TaskCollection($tasks);\n    }\n\n    /**\n     * Create task\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId createTask\n     */\n    public function store(Organization $organization, TaskStoreRequest $request): JsonResource\n    {\n        /** @var Project $project */\n        $project = Project::query()->findOrFail($request->input('project_id'));\n\n        if ($this->hasPermission($organization, 'tasks:create:all')) {\n            $this->checkPermission($organization, 'tasks:create:all');\n        } else {\n            $this->checkScopedPermissionForProject($organization, $project, 'tasks:create');\n        }\n\n        $task = new Task;\n        $task->name = $request->input('name');\n        $task->project_id = $request->input('project_id');\n        if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {\n            $task->estimated_time = $request->getEstimatedTime();\n        }\n        $task->organization()->associate($organization);\n        $task->save();\n\n        return new TaskResource($task);\n    }\n\n    /**\n     * Update task\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId updateTask\n     */\n    public function update(Organization $organization, Task $task, TaskUpdateRequest $request): JsonResource\n    {\n        // Check task belongs to organization\n        if ($task->organization_id !== $organization->id) {\n            throw new AuthorizationException('Task does not belong to organization');\n        }\n\n        if ($this->hasPermission($organization, 'tasks:update:all')) {\n            $this->checkPermission($organization, 'tasks:update:all');\n        } else {\n            $this->checkScopedPermissionForProject($organization, $task->project, 'tasks:update');\n        }\n\n        $task->name = $request->input('name');\n        if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {\n            $task->estimated_time = $request->getEstimatedTime();\n        }\n        if ($request->has('is_done')) {\n            $task->done_at = $request->getIsDone() ? Carbon::now() : null;\n        }\n        $task->save();\n\n        return new TaskResource($task);\n    }\n\n    /**\n     * Delete task\n     *\n     * @throws AuthorizationException|EntityStillInUseApiException\n     *\n     * @operationId deleteTask\n     */\n    public function destroy(Organization $organization, Task $task): JsonResponse\n    {\n        // Check task belongs to organization\n        if ($task->organization_id !== $organization->id) {\n            throw new AuthorizationException('Task does not belong to organization');\n        }\n\n        if ($this->hasPermission($organization, 'tasks:delete:all')) {\n            $this->checkPermission($organization, 'tasks:delete:all');\n        } else {\n            $this->checkScopedPermissionForProject($organization, $task->project, 'tasks:delete');\n        }\n\n        if ($task->timeEntries()->exists()) {\n            throw new EntityStillInUseApiException('task', 'time_entry');\n        }\n\n        $task->delete();\n\n        return response()\n            ->json(null, 204);\n    }\n}\n"
  },
  {
    "path": "app/Http/Controllers/Api/V1/TimeEntryController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Controllers\\Api\\V1;\n\nuse App\\Enums\\ExportFormat;\nuse App\\Enums\\Role;\nuse App\\Exceptions\\Api\\FeatureIsNotAvailableInFreePlanApiException;\nuse App\\Exceptions\\Api\\OverlappingTimeEntryApiException;\nuse App\\Exceptions\\Api\\PdfRendererIsNotConfiguredException;\nuse App\\Exceptions\\Api\\TimeEntryCanNotBeRestartedApiException;\nuse App\\Exceptions\\Api\\TimeEntryStillRunningApiException;\nuse App\\Http\\Requests\\V1\\TimeEntry\\TimeEntryAggregateExportRequest;\nuse App\\Http\\Requests\\V1\\TimeEntry\\TimeEntryAggregateRequest;\nuse App\\Http\\Requests\\V1\\TimeEntry\\TimeEntryDestroyMultipleRequest;\nuse App\\Http\\Requests\\V1\\TimeEntry\\TimeEntryIndexExportRequest;\nuse App\\Http\\Requests\\V1\\TimeEntry\\TimeEntryIndexRequest;\nuse App\\Http\\Requests\\V1\\TimeEntry\\TimeEntryStoreRequest;\nuse App\\Http\\Requests\\V1\\TimeEntry\\TimeEntryUpdateMultipleRequest;\nuse App\\Http\\Requests\\V1\\TimeEntry\\TimeEntryUpdateRequest;\nuse App\\Http\\Resources\\V1\\TimeEntry\\TimeEntryCollection;\nuse App\\Http\\Resources\\V1\\TimeEntry\\TimeEntryResource;\nuse App\\Jobs\\RecalculateSpentTimeForProject;\nuse App\\Jobs\\RecalculateSpentTimeForTask;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\Task;\nuse App\\Models\\TimeEntry;\nuse App\\Service\\LocalizationService;\nuse App\\Service\\ReportExport\\TimeEntriesDetailedCsvExport;\nuse App\\Service\\ReportExport\\TimeEntriesDetailedExport;\nuse App\\Service\\ReportExport\\TimeEntriesReportExport;\nuse App\\Service\\TimeEntryAggregationService;\nuse App\\Service\\TimeEntryFilter;\nuse App\\Service\\TimeEntryService;\nuse App\\Service\\TimezoneService;\nuse Gotenberg\\Exceptions\\GotenbergApiErrored;\nuse Gotenberg\\Exceptions\\NoOutputFileInResponse;\nuse Gotenberg\\Gotenberg;\nuse Gotenberg\\Stream;\nuse GuzzleHttp\\Client;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Http\\File;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Resources\\Json\\JsonResource;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Auth;\nuse Illuminate\\Support\\Facades\\Blade;\nuse Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Facades\\Storage;\nuse Maatwebsite\\Excel\\Facades\\Excel;\nuse Spatie\\TemporaryDirectory\\TemporaryDirectory;\n\nclass TimeEntryController extends Controller\n{\n    private function assertNoOverlap(Organization $organization, Member $member, \\Illuminate\\Support\\Carbon $start, ?\\Illuminate\\Support\\Carbon $end, ?TimeEntry $exclude = null): void\n    {\n        if (! $organization->prevent_overlapping_time_entries) {\n            return;\n        }\n\n        $query = TimeEntry::query()\n            ->where('organization_id', $organization->getKey())\n            ->where('user_id', $member->user_id)\n            ->when($exclude !== null, function (Builder $q) use ($exclude): void {\n                $q->where('id', '!=', $exclude->getKey());\n            })\n            ->where(function (Builder $q) use ($start, $end): void {\n                $q->where(function (Builder $q2) use ($start): void {\n                    $q2->where('end', '>', $start)\n                        ->where('start', '<', $start);\n                });\n\n                if ($end !== null) {\n                    $q->orWhere(function (Builder $q4) use ($end): void {\n                        $q4->where('start', '<', $end)\n                            ->where('end', '>', $end);\n                    });\n                    // Check if the new entry completely surrounds an existing entry\n                    $q->orWhere(function (Builder $q6) use ($start, $end): void {\n                        $q6->where('start', '>=', $start)\n                            ->where('end', '<=', $end);\n                    });\n                }\n\n            });\n\n        if ($query->exists()) {\n            throw new OverlappingTimeEntryApiException;\n        }\n    }\n\n    protected function checkPermission(Organization $organization, string $permission, ?TimeEntry $timeEntry = null): void\n    {\n        parent::checkPermission($organization, $permission);\n        if ($timeEntry !== null && $timeEntry->organization_id !== $organization->getKey()) {\n            throw new AuthorizationException('Time entry does not belong to organization');\n        }\n    }\n\n    /**\n     * Get time entries in organization\n     *\n     * If you only need time entries for a specific user, you can filter by `user_id`.\n     * Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter.\n     *\n     * @return TimeEntryCollection<TimeEntryResource>\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId getTimeEntries\n     */\n    public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource\n    {\n        /** @var Member|null $member */\n        $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;\n        if ($member !== null && $member->user_id === Auth::id()) {\n            $this->checkPermission($organization, 'time-entries:view:own');\n        } else {\n            $this->checkPermission($organization, 'time-entries:view:all');\n        }\n\n        $canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);\n        $timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);\n\n        $totalCount = $timeEntriesQuery->count();\n\n        $limit = $request->getLimit();\n        if ($limit > 1000) {\n            $limit = 1000;\n        }\n        $timeEntriesQuery->limit($limit);\n        $timeEntriesQuery->skip($request->getOffset());\n\n        $timeEntries = $timeEntriesQuery->get();\n\n        if ($timeEntries->count() === $limit && $request->getOnlyFullDates()) {\n            $user = $this->user();\n            $timezone = app(TimezoneService::class)->getTimezoneFromUser($user);\n            $lastDate = null;\n            /** @var TimeEntry $timeEntry */\n            foreach ($timeEntries as $timeEntry) {\n                if ($lastDate === null || abs($lastDate->diffInDays($timeEntry->start->toImmutable()->timezone($timezone)->startOfDay())) > 0) {\n                    $lastDate = $timeEntry->start->toImmutable()->timezone($timezone)->startOfDay();\n                }\n            }\n\n            $timeEntries = $timeEntries->filter(function (TimeEntry $timeEntry) use ($lastDate, $timezone): bool {\n                return $timeEntry->start->toImmutable()->timezone($timezone)->toDateString() !== $lastDate->toDateString();\n            });\n\n            if ($timeEntries->count() === 0) {\n                Log::warning('User has has more than '.$limit.' time entries on one date', [\n                    'date' => $lastDate->toDateString(),\n                    'user_id' => $request->input('user_id'),\n                    'auth_user_id' => Auth::id(),\n                    'limit' => $limit,\n                ]);\n                $timeEntries = $timeEntriesQuery\n                    ->limit(5000)\n                    ->where('start', '>=', $lastDate->copy()->startOfDay()->utc())\n                    ->where('start', '<=', $lastDate->copy()->endOfDay()->utc())\n                    ->get();\n            }\n        }\n\n        return (new TimeEntryCollection($timeEntries))\n            ->additional([\n                'meta' => [\n                    'total' => $totalCount,\n                ],\n            ]);\n    }\n\n    /**\n     * @return Builder<TimeEntry>\n     */\n    private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member, bool $canAccessPremiumFeatures): Builder\n    {\n        $select = TimeEntry::SELECT_COLUMNS;\n        $roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;\n        $roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;\n        if ($roundingType !== null && $roundingMinutes !== null) {\n            $select = array_diff($select, ['start', 'end']);\n            $select[] = DB::raw(app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes).' as start');\n            $select[] = DB::raw(app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes).' as end');\n        }\n        $timeEntriesQuery = TimeEntry::query()\n            ->whereBelongsTo($organization, 'organization')\n            ->select($select)\n            ->orderBy('start', 'desc');\n\n        $filter = new TimeEntryFilter($timeEntriesQuery);\n        $filter->addStartFilter($request->input('start'));\n        $filter->addEndFilter($request->input('end'));\n        $filter->addActiveFilter($request->input('active'));\n        $filter->addMemberIdFilter($member);\n        $filter->addMemberIdsFilter($request->input('member_ids'));\n        $filter->addProjectIdsFilter($request->input('project_ids'));\n        $filter->addTagIdsFilter($request->input('tag_ids'));\n        $filter->addTaskIdsFilter($request->input('task_ids'));\n        $filter->addClientIdsFilter($request->input('client_ids'));\n        $filter->addBillableFilter($request->input('billable'));\n\n        return $filter->get();\n    }\n\n    /**\n     * Export time entries in organization\n     *\n     * @throws AuthorizationException|PdfRendererIsNotConfiguredException|FeatureIsNotAvailableInFreePlanApiException\n     *\n     * @operationId exportTimeEntries\n     */\n    public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse\n    {\n        /** @var Member|null $member */\n        $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;\n        if ($member !== null && $member->user_id === Auth::id()) {\n            $this->checkPermission($organization, 'time-entries:view:own');\n        } else {\n            $this->checkPermission($organization, 'time-entries:view:all');\n        }\n        $canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);\n        $debug = $request->getDebug();\n        $format = $request->getFormatValue();\n        if ($format === ExportFormat::PDF && ! $canAccessPremiumFeatures) {\n            throw new FeatureIsNotAvailableInFreePlanApiException;\n        }\n        $user = $this->user();\n        $timezone = $user->timezone;\n        $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;\n        $roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;\n        $roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;\n\n        $timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);\n        $timeEntriesQuery->with([\n            'task',\n            'client',\n            'project',\n            'user',\n            'tagsRelation',\n        ]);\n        $filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();\n        $folderPath = 'exports';\n        $path = $folderPath.'/'.$filename;\n        $localizationService = LocalizationService::forOrganization($organization);\n        if ($format === ExportFormat::CSV) {\n            $export = new TimeEntriesDetailedCsvExport(config('filesystems.private'), $folderPath, $filename, $timeEntriesQuery, 1000, $timezone);\n            $export->export();\n        } elseif ($format === ExportFormat::PDF) {\n            if (config('services.gotenberg.url') === null && ! $debug) {\n                throw new PdfRendererIsNotConfiguredException;\n            }\n            $viewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf.blade.php'));\n            if ($viewFile === false) {\n                throw new \\LogicException('View file not found');\n            }\n            $timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);\n            $aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(\n                $timeEntriesAggregateQuery,\n                null,\n                null,\n                $user->timezone,\n                $user->week_start,\n                false,\n                null,\n                null,\n                $showBillableRate,\n                $roundingType,\n                $roundingMinutes,\n            );\n            $html = Blade::render($viewFile, [\n                'timeEntries' => $timeEntriesQuery->get(),\n                'aggregatedData' => $aggregatedData,\n                'timezone' => $timezone,\n                'currency' => $organization->currency,\n                'start' => $request->getStart()->timezone($timezone),\n                'end' => $request->getEnd()->timezone($timezone),\n                'localization' => $localizationService,\n                'showBillableRate' => $showBillableRate,\n            ]);\n            $footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf-footer.blade.php'));\n            if ($footerViewFile === false) {\n                throw new \\LogicException('View file not found');\n            }\n            $footerHtml = Blade::render($footerViewFile);\n            if ($debug) {\n                return response()->json([\n                    'html' => $html,\n                    'footer_html' => $footerHtml,\n                ]);\n            }\n\n            $client = new Client([\n                'auth' => config('services.gotenberg.basic_auth_username') !== null && config('services.gotenberg.basic_auth_password') !== null ? [\n                    config('services.gotenberg.basic_auth_username'),\n                    config('services.gotenberg.basic_auth_password'),\n                ] : null,\n            ]);\n            $request = Gotenberg::chromium(config('services.gotenberg.url'))\n                ->pdf()\n                ->assets(\n                    Stream::path(resource_path('pdf/Outfit-VariableFont_wght.ttf'), 'outfit.ttf'),\n                )\n                ->margins(0.39, 0.78, 0.39, 0.39)\n                ->paperSize('8.27', '11.7') // A4\n                ->footer(Stream::string('footer', $footerHtml))\n                ->html(Stream::string('body', $html));\n            $tempFolder = TemporaryDirectory::make();\n            $filenameTemp = Gotenberg::save($request, $tempFolder->path(), $client);\n            Storage::disk(config('filesystems.private'))\n                ->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);\n        } else {\n            Excel::store(\n                new TimeEntriesDetailedExport($timeEntriesQuery, $format, $timezone, $localizationService),\n                $path,\n                config('filesystems.private'),\n                $format->getExportPackageType(),\n                [\n                    'visibility' => 'private',\n                ]\n            );\n        }\n\n        return response()->json([\n            'download_url' => Storage::disk(config('filesystems.private'))\n                ->temporaryUrl($path, now()->addMinutes(5)),\n        ]);\n    }\n\n    /**\n     * Get aggregated time entries in organization\n     *\n     * This endpoint allows you to filter time entries and aggregate them by different criteria.\n     * The parameters `group` and `sub_group` allow you to group the time entries by different criteria.\n     * If the group parameters are all set to `null` or are all missing, the endpoint will aggregate all filtered time entries.\n     *\n     * @operationId getAggregatedTimeEntries\n     *\n     * @return array{\n     *     data: array{\n     *          grouped_type: string|null,\n     *          grouped_data: null|array<array{\n     *              key: string|null,\n     *              seconds: int,\n     *              cost: int|null,\n     *              grouped_type: string|null,\n     *              grouped_data: null|array<array{\n     *                  key: string|null,\n     *                  seconds: int,\n     *                  cost: int|null,\n     *                  grouped_type: null,\n     *                  grouped_data: null\n     *              }>\n     *          }>,\n     *          seconds: int,\n     *          cost: int|null\n     *      }\n     * }\n     *\n     * @throws AuthorizationException\n     */\n    public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array\n    {\n        /** @var Member|null $member */\n        $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;\n        if ($member !== null && $member->user_id === Auth::id()) {\n            $this->checkPermission($organization, 'time-entries:view:own');\n        } else {\n            $this->checkPermission($organization, 'time-entries:view:all');\n        }\n        $canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);\n        $user = $this->user();\n        $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;\n\n        $group1Type = $request->getGroup();\n        $group2Type = $request->getSubGroup();\n        $timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);\n        $roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;\n        $roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;\n\n        $aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(\n            $timeEntriesAggregateQuery,\n            $group1Type,\n            $group2Type,\n            $user->timezone,\n            $user->week_start,\n            $request->getFillGapsInTimeGroups(),\n            $request->getStart(),\n            $request->getEnd(),\n            $showBillableRate,\n            $roundingType,\n            $roundingMinutes\n        );\n\n        return [\n            'data' => $aggregatedData,\n        ];\n    }\n\n    /**\n     * Export aggregated time entries in organization\n     *\n     * @operationId exportAggregatedTimeEntries\n     *\n     * @throws AuthorizationException\n     * @throws PdfRendererIsNotConfiguredException\n     * @throws GotenbergApiErrored\n     * @throws NoOutputFileInResponse\n     * @throws FeatureIsNotAvailableInFreePlanApiException\n     */\n    public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse\n    {\n        /** @var Member|null $member */\n        $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;\n        if ($member !== null && $member->user_id === Auth::id()) {\n            $this->checkPermission($organization, 'time-entries:view:own');\n        } else {\n            $this->checkPermission($organization, 'time-entries:view:all');\n        }\n        $canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);\n        $format = $request->getFormatValue();\n        if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {\n            throw new FeatureIsNotAvailableInFreePlanApiException;\n        }\n        $debug = $request->getDebug();\n        $user = $this->user();\n        $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;\n\n        $group = $request->getGroup();\n        $subGroup = $request->getSubGroup();\n        $timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);\n        $roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;\n        $roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;\n\n        $aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(\n            $timeEntriesAggregateQuery->clone(),\n            $group,\n            $subGroup,\n            $user->timezone,\n            $user->week_start,\n            false,\n            $request->getStart(),\n            $request->getEnd(),\n            $showBillableRate,\n            $roundingType,\n            $roundingMinutes\n        );\n        $dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(\n            $timeEntriesAggregateQuery->clone(),\n            $request->getHistoryGroup(),\n            null,\n            $user->timezone,\n            $user->week_start,\n            true,\n            $request->getStart(),\n            $request->getEnd(),\n            $showBillableRate,\n            $roundingType,\n            $roundingMinutes\n        );\n        $currency = $organization->currency;\n        $timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());\n        $localizationService = LocalizationService::forOrganization($organization);\n\n        $filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();\n        $folderPath = 'exports';\n        $path = $folderPath.'/'.$filename;\n\n        if ($format === ExportFormat::PDF) {\n            if (config('services.gotenberg.url') === null && ! $debug) {\n                throw new PdfRendererIsNotConfiguredException;\n            }\n            $client = new Client([\n                'auth' => config('services.gotenberg.basic_auth_username') !== null && config('services.gotenberg.basic_auth_password') !== null ? [\n                    config('services.gotenberg.basic_auth_username'),\n                    config('services.gotenberg.basic_auth_password'),\n                ] : null,\n            ]);\n            $viewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf.blade.php'));\n            if ($viewFile === false) {\n                throw new \\LogicException('View file not found');\n            }\n            $html = Blade::render($viewFile, [\n                'aggregatedData' => $aggregatedData,\n                'dataHistoryChart' => $dataHistoryChart,\n                'currency' => $currency,\n                'group' => $group,\n                'subGroup' => $subGroup,\n                'timezone' => $timezone,\n                'start' => $request->getStart()->timezone($timezone),\n                'end' => $request->getEnd()->timezone($timezone),\n                'debug' => $debug,\n                'localization' => $localizationService,\n                'showBillableRate' => $showBillableRate,\n            ]);\n            $footerViewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf-footer.blade.php'));\n            if ($footerViewFile === false) {\n                throw new \\LogicException('View file not found');\n            }\n            $footerHtml = Blade::render($footerViewFile);\n            if ($debug) {\n                return response()->json([\n                    'html' => $html,\n                    'footer_html' => $footerHtml,\n                ]);\n            }\n            $request = Gotenberg::chromium(config('services.gotenberg.url'))\n                ->pdf()\n                ->waitForExpression(\"window.status === 'ready'\")\n                ->margins(0.39, 0.78, 0.39, 0.39)\n                ->paperSize('8.27', '11.7') // A4\n                ->footer(Stream::string('footer', $footerHtml))\n                ->assets(Stream::path(resource_path('pdf/echarts.min.js'), 'echarts.min.js'),\n                    Stream::path(resource_path('pdf/Outfit-VariableFont_wght.ttf'), 'outfit.ttf'),\n                )\n                ->html(Stream::string('body', $html));\n            $tempFolder = TemporaryDirectory::make();\n            $filenameTemp = Gotenberg::save($request, $tempFolder->path(), $client);\n            Storage::disk(config('filesystems.private'))\n                ->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);\n        } else {\n            Excel::store(\n                new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate),\n                $path,\n                config('filesystems.private'),\n                $format->getExportPackageType(),\n                [\n                    'visibility' => 'private',\n                ]\n            );\n        }\n\n        return response()->json([\n            'download_url' => Storage::disk(config('filesystems.private'))\n                ->temporaryUrl($path, now()->addMinutes(5)),\n        ]);\n    }\n\n    /**\n     * @return Builder<TimeEntry>\n     */\n    private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder\n    {\n        $timeEntriesQuery = TimeEntry::query()\n            ->whereBelongsTo($organization, 'organization');\n\n        $filter = new TimeEntryFilter($timeEntriesQuery);\n        $filter->addEndFilter($request->input('end'));\n        $filter->addStartFilter($request->input('start'));\n        $filter->addActiveFilter($request->input('active'));\n        $filter->addMemberIdFilter($member);\n        $filter->addMemberIdsFilter($request->input('member_ids'));\n        $filter->addProjectIdsFilter($request->input('project_ids'));\n        $filter->addTagIdsFilter($request->input('tag_ids'));\n        $filter->addTaskIdsFilter($request->input('task_ids'));\n        $filter->addClientIdsFilter($request->input('client_ids'));\n        $filter->addBillableFilter($request->input('billable'));\n\n        return $filter->get();\n    }\n\n    /**\n     * Create time entry\n     *\n     * @throws AuthorizationException\n     * @throws TimeEntryStillRunningApiException\n     *\n     * @operationId createTimeEntry\n     */\n    public function store(Organization $organization, TimeEntryStoreRequest $request): JsonResource\n    {\n        /** @var Member $member */\n        $member = Member::query()->findOrFail($request->input('member_id'));\n        if ($member->user_id === Auth::id()) {\n            $this->checkPermission($organization, 'time-entries:create:own');\n        } else {\n            $this->checkPermission($organization, 'time-entries:create:all');\n        }\n\n        if ($request->input('end') === null && TimeEntry::query()->whereBelongsTo($member, 'member')->where('end', null)->exists()) {\n            throw new TimeEntryStillRunningApiException;\n        }\n\n        // Overlap check for create\n        $start = Carbon::parse($request->input('start'));\n        $end = $request->input('end') !== null ? Carbon::parse($request->input('end')) : null;\n        $this->assertNoOverlap($organization, $member, $start, $end);\n\n        $project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;\n        $client = $project?->client;\n        $task = $request->input('task_id') !== null ? $project->tasks()->findOrFail((string) $request->input('task_id')) : null;\n\n        $timeEntry = new TimeEntry;\n        $timeEntry->fill($request->validated());\n        $timeEntry->client()->associate($client);\n        $timeEntry->user_id = $member->user_id;\n        $timeEntry->description = $request->input('description') ?? '';\n        $timeEntry->organization()->associate($organization);\n        $timeEntry->setComputedAttributeValue('billable_rate');\n        $timeEntry->save();\n\n        if ($project !== null) {\n            RecalculateSpentTimeForProject::dispatch($project);\n        }\n        if ($task !== null) {\n            RecalculateSpentTimeForTask::dispatch($task);\n        }\n\n        return new TimeEntryResource($timeEntry);\n    }\n\n    /**\n     * Update time entry\n     *\n     * @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException\n     *\n     * @operationId updateTimeEntry\n     */\n    public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource\n    {\n        /** @var Member|null $member */\n        $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;\n        if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) {\n            $this->checkPermission($organization, 'time-entries:update:own');\n        } else {\n            $this->checkPermission($organization, 'time-entries:update:all');\n        }\n\n        if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {\n            throw new TimeEntryCanNotBeRestartedApiException;\n        }\n\n        // Overlap check for update (exclude current)\n        /** @var Member $effectiveMember */\n        $effectiveMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : $timeEntry->member;\n        $effectiveStart = $request->has('start') ? Carbon::parse($request->input('start')) : $timeEntry->start;\n        $effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end;\n        $this->assertNoOverlap($organization, $effectiveMember, $effectiveStart, $effectiveEnd, $timeEntry);\n\n        $oldProject = $timeEntry->project;\n        $oldTask = $timeEntry->task;\n\n        $project = null;\n        if ($request->has('project_id')) {\n            $project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;\n            $client = $project?->client;\n            $timeEntry->client()->associate($client);\n        }\n        $task = null;\n        if ($request->has('task_id')) {\n            $task = $request->input('task_id') !== null ? Task::findOrFail((string) $request->input('task_id')) : null;\n        }\n\n        $timeEntry->fill($request->validated());\n        $timeEntry->description = $request->input('description', $timeEntry->description) ?? '';\n        $timeEntry->setComputedAttributeValue('billable_rate');\n        $timeEntry->save();\n\n        if ($oldProject !== null) {\n            RecalculateSpentTimeForProject::dispatch($oldProject);\n        }\n        if ($oldTask !== null) {\n            RecalculateSpentTimeForTask::dispatch($oldTask);\n        }\n        if ($project !== null && ($oldProject === null || $project->isNot($oldProject))) {\n            RecalculateSpentTimeForProject::dispatch($project);\n        }\n        if ($task !== null && ($oldTask === null || $task->isNot($oldTask))) {\n            RecalculateSpentTimeForTask::dispatch($task);\n        }\n\n        return new TimeEntryResource($timeEntry);\n    }\n\n    /**\n     * Update multiple time entries\n     *\n     * @operationId updateMultipleTimeEntries\n     *\n     * @throws AuthorizationException\n     */\n    public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse\n    {\n        $this->checkAnyPermission($organization, ['time-entries:update:all', 'time-entries:update:own']);\n        $canAccessAll = $this->hasPermission($organization, 'time-entries:update:all');\n\n        $ids = $request->validated('ids');\n\n        $timeEntries = TimeEntry::query()\n            ->whereBelongsTo($organization, 'organization')\n            ->with([\n                'project',\n                'task',\n            ])\n            ->whereIn('id', $ids)\n            ->get();\n\n        $changes = $request->validated('changes');\n\n        if ($request->has('changes.description')) {\n            $changes['description'] = $request->input('changes.description') ?? '';\n        }\n\n        if (isset($changes['member_id']) && ! $canAccessAll && $this->member($organization)->getKey() !== $changes['member_id']) {\n            throw new AuthorizationException;\n        }\n\n        $project = null;\n        $client = null;\n        $overwriteClient = false;\n        if ($request->has('changes.project_id')) {\n            $project = $request->input('changes.project_id') !== null ? Project::findOrFail((string) $request->input('changes.project_id')) : null;\n            $client = $project?->client;\n            $overwriteClient = true;\n        }\n\n        $task = null;\n        if ($request->has('changes.task_id')) {\n            $task = $request->input('changes.task_id') !== null ? Task::findOrFail((string) $request->input('changes.task_id')) : null;\n        }\n\n        $success = new Collection;\n        $error = new Collection;\n\n        foreach ($ids as $id) {\n            /** @var TimeEntry|null $timeEntry */\n            $timeEntry = $timeEntries->firstWhere('id', $id);\n            if ($timeEntry === null) {\n                // Note: ID wrong or time entry in different organization\n                $error->push($id);\n\n                continue;\n            }\n            if (! $canAccessAll && $timeEntry->user_id !== Auth::id()) {\n                $error->push($id);\n\n                continue;\n\n            }\n            $oldProject = $timeEntry->project;\n            $oldTask = $timeEntry->task;\n\n            $timeEntry->fill($changes);\n            // If project is changed, but task is not, we remove the old task from the time entry\n            if ($oldProject !== null && $project !== null && $oldProject->isNot($project) && $task === null) {\n                $timeEntry->task()->disassociate();\n            }\n            if ($overwriteClient) {\n                $timeEntry->client()->associate($client);\n            }\n            $timeEntry->setComputedAttributeValue('billable_rate');\n            $timeEntry->save();\n            if ($oldTask !== null) {\n                RecalculateSpentTimeForTask::dispatch($oldTask);\n            }\n            if ($oldProject !== null) {\n                RecalculateSpentTimeForProject::dispatch($oldProject);\n            }\n            if ($project !== null && ($oldProject === null || $project->isNot($oldProject))) {\n                RecalculateSpentTimeForProject::dispatch($project);\n            }\n            if ($task !== null && ($oldTask === null || $task->isNot($oldTask))) {\n                RecalculateSpentTimeForTask::dispatch($task);\n            }\n\n            $success->push($id);\n        }\n\n        return response()->json([\n            'success' => $success->toArray(),\n            'error' => $error->toArray(),\n        ]);\n    }\n\n    /**\n     * Delete time entry\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId deleteTimeEntry\n     */\n    public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse\n    {\n        if ($timeEntry->member->user_id === Auth::id()) {\n            $this->checkPermission($organization, 'time-entries:delete:own', $timeEntry);\n        } else {\n            $this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);\n        }\n\n        $project = $timeEntry->project;\n        $task = $timeEntry->task;\n\n        $timeEntry->delete();\n\n        if ($project !== null) {\n            RecalculateSpentTimeForProject::dispatch($project);\n        }\n        if ($task !== null) {\n            RecalculateSpentTimeForTask::dispatch($task);\n        }\n\n        return response()\n            ->json(null, 204);\n    }\n\n    /**\n     * Delete multiple time entries\n     *\n     * @throws AuthorizationException\n     *\n     * @operationId deleteTimeEntries\n     */\n    public function destroyMultiple(Organization $organization, TimeEntryDestroyMultipleRequest $request): JsonResponse\n    {\n        $this->checkAnyPermission($organization, ['time-entries:delete:all', 'time-entries:delete:own']);\n        $canDeleteAll = $this->hasPermission($organization, 'time-entries:delete:all');\n\n        $ids = $request->validated('ids');\n        $timeEntries = TimeEntry::query()\n            ->whereBelongsTo($organization, 'organization')\n            ->with([\n                'project',\n                'task',\n            ])\n            ->whereIn('id', $ids)\n            ->get();\n\n        $success = new Collection;\n        $error = new Collection;\n\n        foreach ($ids as $id) {\n            /** @var TimeEntry|null $timeEntry */\n            $timeEntry = $timeEntries->firstWhere('id', $id);\n            if ($timeEntry === null) {\n                // Note: ID wrong or time entry in different organization\n                $error->push($id);\n\n                continue;\n            }\n\n            if (! $canDeleteAll && $timeEntry->user_id !== Auth::id()) {\n                $error->push($id);\n\n                continue;\n\n            }\n\n            $project = $timeEntry->project;\n            $task = $timeEntry->task;\n\n            $timeEntry->delete();\n\n            if ($project !== null) {\n                RecalculateSpentTimeForProject::dispatch($project);\n            }\n            if ($task !== null) {\n                RecalculateSpentTimeForTask::dispatch($task);\n            }\n            $success->push($id);\n        }\n\n        return response()->json([\n            'success' => $success->toArray(),\n            'error' => $error->toArray(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "app/Http/Controllers/Api/V1/UserController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Controllers\\Api\\V1;\n\nuse App\\Http\\Resources\\V1\\User\\UserResource;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\n\nclass UserController extends Controller\n{\n    /**\n     * Get the current user\n     *\n     * This endpoint is independent of organization.\n     *\n     * @operationId getMe\n     *\n     * @throws AuthorizationException\n     */\n    public function me(): UserResource\n    {\n        $user = $this->user();\n\n        return new UserResource($user);\n    }\n}\n"
  },
  {
    "path": "app/Http/Controllers/Api/V1/UserMembershipController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Controllers\\Api\\V1;\n\nuse App\\Http\\Resources\\V1\\Member\\PersonalMembershipCollection;\nuse App\\Models\\Member;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Http\\Resources\\Json\\JsonResource;\n\nclass UserMembershipController extends Controller\n{\n    /**\n     * Get the memberships of the current user\n     *\n     * This endpoint is independent of organization.\n     *\n     * @operationId getMyMemberships\n     *\n     * @return PersonalMembershipCollection\n     *\n     * @throws AuthorizationException\n     */\n    public function myMemberships(): JsonResource\n    {\n        $user = $this->user();\n\n        $members = Member::query()\n            ->whereBelongsTo($user, 'user')\n            ->with(['organization'])\n            ->get();\n\n        return new PersonalMembershipCollection($members);\n    }\n}\n"
  },
  {
    "path": "app/Http/Controllers/Api/V1/UserTimeEntryController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Controllers\\Api\\V1;\n\nuse App\\Http\\Resources\\V1\\TimeEntry\\TimeEntryResource;\nuse App\\Models\\Organization;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse Illuminate\\Database\\Eloquent\\ModelNotFoundException;\nuse Illuminate\\Http\\Resources\\Json\\JsonResource;\nuse Illuminate\\Support\\Facades\\Log;\n\nclass UserTimeEntryController extends Controller\n{\n    /**\n     * Get the active time entry of the current user\n     *\n     * This endpoint is independent of organization.\n     *\n     * @operationId getMyActiveTimeEntry\n     */\n    public function myActive(): JsonResource\n    {\n        $user = $this->user();\n\n        $activeTimeEntriesOfUser = TimeEntry::query()\n            ->whereBelongsTo($user, 'user')\n            ->whereNull('end')\n            ->orderBy('start', 'desc')\n            ->get();\n\n        if ($activeTimeEntriesOfUser->count() > 1) {\n            Log::warning('User has more than one active time entry.', [\n                'user' => $user->getKey(),\n            ]);\n        }\n\n        $activeTimeEntry = $activeTimeEntriesOfUser->first();\n\n        if ($activeTimeEntry !== null) {\n            return new TimeEntryResource($activeTimeEntry);\n        } else {\n            throw new ModelNotFoundException('No active time entry');\n        }\n    }\n}\n"
  },
  {
    "path": "app/Http/Controllers/Controller.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Controllers;\n\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Illuminate\\Foundation\\Auth\\Access\\AuthorizesRequests;\nuse Illuminate\\Foundation\\Validation\\ValidatesRequests;\nuse Illuminate\\Routing\\Controller as BaseController;\nuse Illuminate\\Support\\Facades\\Auth;\nuse Illuminate\\Support\\Facades\\Log;\n\nclass Controller extends BaseController\n{\n    use AuthorizesRequests;\n    use ValidatesRequests;\n\n    /**\n     * @throws AuthorizationException\n     */\n    protected function user(): User\n    {\n        /** @var User|null $user */\n        $user = Auth::user();\n        if ($user === null) {\n            Log::error('This function should only be called in authenticated context');\n            throw new AuthorizationException;\n        }\n\n        return $user;\n    }\n\n    /**\n     * @throws AuthorizationException\n     */\n    protected function member(Organization $organization): Member\n    {\n        $user = $this->user();\n        /** @var Member|null $member */\n        $member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first();\n        if ($member === null) {\n            Log::error('This function should only be called in authenticated context after checking the user is a member of the organization', [\n                'user' => $user->getKey(),\n                'organization' => $organization->getKey(),\n            ]);\n            throw new AuthorizationException;\n        }\n\n        return $member;\n    }\n\n    /**\n     * @throws AuthorizationException\n     */\n    protected function currentOrganization(): Organization\n    {\n        $user = $this->user();\n        $organization = $user->currentTeam;\n        if ($organization === null) {\n            $organization = $user->organizations()->first();\n        }\n\n        return $organization;\n    }\n}\n"
  },
  {
    "path": "app/Http/Controllers/Web/Controller.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Controllers\\Web;\n\nabstract class Controller extends \\App\\Http\\Controllers\\Controller {}\n"
  },
  {
    "path": "app/Http/Controllers/Web/DashboardController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Controllers\\Web;\n\nuse App\\Enums\\Role;\nuse App\\Service\\DashboardService;\nuse App\\Service\\PermissionStore;\nuse Illuminate\\Auth\\Access\\AuthorizationException;\nuse Inertia\\Inertia;\nuse Inertia\\Response;\n\nclass DashboardController extends Controller\n{\n    /**\n     * @throws AuthorizationException\n     */\n    public function dashboard(DashboardService $dashboardService, PermissionStore $permissionStore): Response\n    {\n        $user = $this->user();\n        $organization = $this->currentOrganization();\n\n        $latestTeamActivity = null;\n        if ($permissionStore->has($organization, 'time-entries:view:all')) {\n            $latestTeamActivity = $dashboardService->latestTeamActivity($organization);\n        }\n\n        $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;\n\n        return Inertia::render('Dashboard');\n    }\n}\n"
  },
  {
    "path": "app/Http/Controllers/Web/HealthCheckController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Controllers\\Web;\n\nuse App\\Http\\Controllers\\Controller;\nuse App\\Models\\User;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\DB;\n\nclass HealthCheckController extends Controller\n{\n    /**\n     * Check if the application is up and running\n     * This check does not check the database or cache connectivity\n     */\n    public function up(): JsonResponse\n    {\n        return response()->json([\n            'success' => true,\n        ]);\n    }\n\n    /**\n     * Debug information for the application\n     * This check checks the database and cache connectivity\n     */\n    public function debug(Request $request): JsonResponse\n    {\n        // Check database connectivity\n        User::query()->count();\n\n        // Check cache connectivity\n        Cache::put('health-check', Carbon::now()->timestamp);\n\n        // Check ip address correct behind load balancer\n        $ipAddress = $request->ip();\n        $hostname = $request->getHost();\n        $secure = $request->secure();\n        $isTrustedProxy = $request->isFromTrustedProxy();\n\n        $dbTimezone = DB::select('show timezone;');\n\n        $response = [\n            'ip_address' => $ipAddress,\n            'url' => $request->url(),\n            'path' => $request->path(),\n            'hostname' => $hostname,\n            'timestamp' => Carbon::now()->timestamp,\n            'date_time_utc' => Carbon::now('UTC')->toDateTimeString(),\n            'date_time_app' => Carbon::now()->toDateTimeString(),\n            'timezone' => $dbTimezone[0]->TimeZone,\n            'secure' => $secure,\n            'is_trusted_proxy' => $isTrustedProxy,\n        ];\n\n        if (app()->hasDebugModeEnabled()) {\n            $response['app_debug'] = true;\n            $response['app_url'] = config('app.url');\n            $response['app_env'] = app()->environment();\n            $response['app_timezone'] = config('app.timezone');\n            $response['app_force_https'] = config('app.force_https');\n            $response['session_secure'] = config('session.secure');\n            $response['trusted_proxies'] = config('trustedproxy.proxies');\n            $headers = $request->headers->all();\n            if (isset($headers['cookie'])) {\n                $headers['cookie'] = '***';\n            }\n            $response['headers'] = $headers;\n        }\n\n        return response()\n            ->json($response);\n    }\n}\n"
  },
  {
    "path": "app/Http/Controllers/Web/HomeController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Controllers\\Web;\n\nuse Illuminate\\Http\\RedirectResponse;\nuse Illuminate\\Support\\Facades\\Auth;\n\nclass HomeController extends Controller\n{\n    public function index(): RedirectResponse\n    {\n        if (Auth::check()) {\n            return redirect()->route('dashboard');\n        } else {\n            return redirect('login');\n        }\n    }\n}\n"
  },
  {
    "path": "app/Http/Kernel.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http;\n\nuse App\\Http\\Middleware\\CheckOrganizationBlocked;\nuse App\\Http\\Middleware\\ForceJsonResponse;\nuse Illuminate\\Foundation\\Http\\Kernel as HttpKernel;\n\nclass Kernel extends HttpKernel\n{\n    /**\n     * The application's global HTTP middleware stack.\n     *\n     * These middleware are run during every request to your application.\n     *\n     * @var array<int, class-string|string>\n     */\n    protected $middleware = [\n        \\App\\Http\\Middleware\\ForceHttps::class,\n        \\App\\Http\\Middleware\\TrustProxies::class,\n        \\Illuminate\\Http\\Middleware\\HandleCors::class,\n        \\App\\Http\\Middleware\\PreventRequestsDuringMaintenance::class,\n        \\Illuminate\\Foundation\\Http\\Middleware\\ValidatePostSize::class,\n        \\App\\Http\\Middleware\\TrimStrings::class,\n        \\Illuminate\\Foundation\\Http\\Middleware\\ConvertEmptyStringsToNull::class,\n    ];\n\n    /**\n     * The application's route middleware groups.\n     *\n     * @var array<string, array<int, class-string|string>>\n     */\n    protected $middlewareGroups = [\n        'web' => [\n            \\App\\Http\\Middleware\\EncryptCookies::class,\n            \\Illuminate\\Cookie\\Middleware\\AddQueuedCookiesToResponse::class,\n            \\Illuminate\\Session\\Middleware\\StartSession::class,\n            \\Illuminate\\View\\Middleware\\ShareErrorsFromSession::class,\n            \\App\\Http\\Middleware\\VerifyCsrfToken::class,\n            \\Illuminate\\Routing\\Middleware\\SubstituteBindings::class,\n            \\App\\Http\\Middleware\\HandleInertiaRequests::class,\n            \\App\\Http\\Middleware\\ShareInertiaData::class,\n            \\Illuminate\\Http\\Middleware\\AddLinkHeadersForPreloadedAssets::class,\n            \\Laravel\\Passport\\Http\\Middleware\\CreateFreshApiToken::class,\n        ],\n\n        'api' => [\n            \\Illuminate\\Routing\\Middleware\\ThrottleRequests::class.':api',\n            \\Illuminate\\Routing\\Middleware\\SubstituteBindings::class,\n            ForceJsonResponse::class,\n        ],\n\n        'health-check' => [\n        ],\n    ];\n\n    /**\n     * The application's middleware aliases.\n     *\n     * Aliases may be used instead of class names to conveniently assign middleware to routes and groups.\n     *\n     * @var array<string, class-string|string>\n     */\n    protected $middlewareAliases = [\n        'auth' => \\App\\Http\\Middleware\\Authenticate::class,\n        'auth.basic' => \\Illuminate\\Auth\\Middleware\\AuthenticateWithBasicAuth::class,\n        'auth.session' => \\Illuminate\\Session\\Middleware\\AuthenticateSession::class,\n        'cache.headers' => \\Illuminate\\Http\\Middleware\\SetCacheHeaders::class,\n        'can' => \\Illuminate\\Auth\\Middleware\\Authorize::class,\n        'guest' => \\App\\Http\\Middleware\\RedirectIfAuthenticated::class,\n        'password.confirm' => \\Illuminate\\Auth\\Middleware\\RequirePassword::class,\n        'precognitive' => \\Illuminate\\Foundation\\Http\\Middleware\\HandlePrecognitiveRequests::class,\n        'signed' => \\App\\Http\\Middleware\\ValidateSignature::class,\n        'throttle' => \\Illuminate\\Routing\\Middleware\\ThrottleRequests::class,\n        'verified' => \\App\\Http\\Middleware\\EnsureEmailIsVerified::class,\n        'check-organization-blocked' => CheckOrganizationBlocked::class,\n    ];\n}\n"
  },
  {
    "path": "app/Http/Middleware/Authenticate.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Middleware;\n\nuse Illuminate\\Auth\\Middleware\\Authenticate as Middleware;\nuse Illuminate\\Http\\Request;\n\nclass Authenticate extends Middleware\n{\n    /**\n     * Get the path the user should be redirected to when they are not authenticated.\n     */\n    protected function redirectTo(Request $request): ?string\n    {\n        return $request->expectsJson() ? null : route('login');\n    }\n}\n"
  },
  {
    "path": "app/Http/Middleware/CheckOrganizationBlocked.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Middleware;\n\nuse App\\Exceptions\\Api\\OrganizationHasNoSubscriptionButMultipleMembersException;\nuse App\\Models\\Organization;\nuse App\\Service\\BillingContract;\nuse Closure;\nuse Illuminate\\Http\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass CheckOrganizationBlocked\n{\n    /**\n     * Handle an incoming request.\n     *\n     * @param  Closure(Request): (Response)  $next\n     *\n     * @throws OrganizationHasNoSubscriptionButMultipleMembersException\n     */\n    public function handle(Request $request, Closure $next): Response\n    {\n        $organization = $request->route('organization');\n\n        if (! ($organization instanceof Organization)) {\n            throw new \\LogicException('The organization must be loaded before this middleware.');\n        }\n\n        /** @var BillingContract $billing */\n        $billing = app(BillingContract::class);\n\n        if ($billing->isBlocked($organization)) {\n            throw new OrganizationHasNoSubscriptionButMultipleMembersException;\n        }\n\n        return $next($request);\n    }\n}\n"
  },
  {
    "path": "app/Http/Middleware/EncryptCookies.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Middleware;\n\nuse Illuminate\\Cookie\\Middleware\\EncryptCookies as Middleware;\n\nclass EncryptCookies extends Middleware\n{\n    /**\n     * The names of the cookies that should not be encrypted.\n     *\n     * @var array<int, string>\n     */\n    protected $except = [\n        //\n    ];\n}\n"
  },
  {
    "path": "app/Http/Middleware/EnsureEmailIsVerified.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Middleware;\n\nuse Closure;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Facades\\Redirect;\nuse Illuminate\\Support\\Facades\\URL;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass EnsureEmailIsVerified\n{\n    /**\n     * Handle an incoming request.\n     */\n    public function handle(Request $request, Closure $next, ?string $redirectToRoute = null): Response\n    {\n        if (! app()->isLocal()) {\n            if ($request->user() === null ||\n                (! $request->user()->hasVerifiedEmail())) {\n                return $request->expectsJson()\n                    ? abort(403, 'Your email address is not verified.')\n                    : Redirect::guest(URL::route($redirectToRoute ?: 'verification.notice'));\n            }\n        }\n\n        return $next($request);\n    }\n}\n"
  },
  {
    "path": "app/Http/Middleware/ForceHttps.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Middleware;\n\nuse Closure;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Facades\\URL;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass ForceHttps\n{\n    /**\n     * Handle an incoming request.\n     *\n     * @param  \\Closure(\\Illuminate\\Http\\Request): (\\Symfony\\Component\\HttpFoundation\\Response)  $next\n     */\n    public function handle(Request $request, Closure $next, string ...$guards): Response\n    {\n        if (config('app.force_https', false)) {\n            URL::forceScheme('https');\n            $request->server->set('HTTPS', 'on');\n            $request->headers->set('X-Forwarded-Proto', 'https');\n        }\n\n        return $next($request);\n    }\n}\n"
  },
  {
    "path": "app/Http/Middleware/ForceJsonResponse.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Middleware;\n\nuse Closure;\nuse Illuminate\\Http\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass ForceJsonResponse\n{\n    /**\n     * Handle an incoming request.\n     *\n     * @param  \\Closure(\\Illuminate\\Http\\Request): (\\Symfony\\Component\\HttpFoundation\\Response)  $next\n     */\n    public function handle(Request $request, Closure $next, string ...$guards): Response\n    {\n        $request->headers->set('Accept', 'application/json');\n\n        return $next($request);\n    }\n}\n"
  },
  {
    "path": "app/Http/Middleware/HandleInertiaRequests.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Middleware;\n\nuse App\\Service\\BillingContract;\nuse Illuminate\\Http\\Request;\nuse Inertia\\Middleware;\nuse Nwidart\\Modules\\Facades\\Module;\n\nclass HandleInertiaRequests extends Middleware\n{\n    /**\n     * The root template that's loaded on the first page visit.\n     *\n     * @see https://inertiajs.com/server-side-setup#root-template\n     *\n     * @var string\n     */\n    protected $rootView = 'app';\n\n    /**\n     * Determines the current asset version.\n     *\n     * @see https://inertiajs.com/asset-versioning\n     */\n    public function version(Request $request): ?string\n    {\n        return parent::version($request);\n    }\n\n    /**\n     * Defines the props that are shared by default.\n     *\n     * @see https://inertiajs.com/shared-data\n     *\n     * @return array<string, mixed>\n     */\n    public function share(Request $request): array\n    {\n        $hasBilling = Module::has('Billing') && Module::isEnabled('Billing');\n        $hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');\n        $hasServices = Module::has('Services') && Module::isEnabled('Services');\n\n        /** @var BillingContract $billing */\n        $billing = app(BillingContract::class);\n\n        $currentOrganization = $request->user()?->currentTeam;\n\n        return array_merge(parent::share($request), [\n            'has_billing_extension' => $hasBilling,\n            'has_invoicing_extension' => $hasInvoicing,\n            'has_services_extension' => $hasServices,\n            'billing' => $currentOrganization !== null ? [\n                'has_subscription' => $billing->hasSubscription($currentOrganization),\n                'has_trial' => $billing->hasTrial($currentOrganization),\n                'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),\n                'is_blocked' => $billing->isBlocked($currentOrganization),\n            ] : null,\n            'flash' => [\n                'message' => fn () => $request->session()->get('message'),\n            ],\n        ]);\n    }\n}\n"
  },
  {
    "path": "app/Http/Middleware/PreventRequestsDuringMaintenance.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Middleware;\n\nuse Illuminate\\Foundation\\Http\\Middleware\\PreventRequestsDuringMaintenance as Middleware;\n\nclass PreventRequestsDuringMaintenance extends Middleware\n{\n    /**\n     * The URIs that should be reachable while maintenance mode is enabled.\n     *\n     * @var array<int, string>\n     */\n    protected $except = [\n        //\n    ];\n}\n"
  },
  {
    "path": "app/Http/Middleware/RedirectIfAuthenticated.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Middleware;\n\nuse App\\Providers\\RouteServiceProvider;\nuse Closure;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Facades\\Auth;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass RedirectIfAuthenticated\n{\n    /**\n     * Handle an incoming request.\n     *\n     * @param  \\Closure(\\Illuminate\\Http\\Request): (\\Symfony\\Component\\HttpFoundation\\Response)  $next\n     */\n    public function handle(Request $request, Closure $next, string ...$guards): Response\n    {\n        $guards = empty($guards) ? [null] : $guards;\n\n        foreach ($guards as $guard) {\n            if (Auth::guard($guard)->check()) {\n                return redirect(RouteServiceProvider::HOME);\n            }\n        }\n\n        return $next($request);\n    }\n}\n"
  },
  {
    "path": "app/Http/Middleware/ShareInertiaData.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Middleware;\n\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse App\\Service\\PermissionStore;\nuse Closure;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Facades\\Gate;\nuse Illuminate\\Support\\Facades\\Session;\nuse Illuminate\\Support\\MessageBag;\nuse Inertia\\Inertia;\nuse Laravel\\Fortify\\Features;\nuse Laravel\\Jetstream\\Jetstream;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass ShareInertiaData\n{\n    /**\n     * Handle the incoming request.\n     */\n    public function handle(Request $request, Closure $next): Response\n    {\n        /** @var PermissionStore $permissions */\n        $permissions = app(PermissionStore::class);\n        Inertia::share([\n            'jetstream' => function () use ($request) {\n                /** @var User|null $user */\n                $user = $request->user();\n\n                return [\n                    'canCreateTeams' => $user !== null &&\n                        Jetstream::userHasTeamFeatures($user) &&\n                        Gate::forUser($user)->check('create', Jetstream::newTeamModel()),\n                    'canManageTwoFactorAuthentication' => Features::canManageTwoFactorAuthentication(),\n                    'canUpdatePassword' => Features::enabled(Features::updatePasswords()),\n                    'canUpdateProfileInformation' => Features::canUpdateProfileInformation(),\n                    'hasEmailVerification' => Features::enabled(Features::emailVerification()),\n                    'flash' => $request->session()->get('flash', []),\n                    'hasAccountDeletionFeatures' => Jetstream::hasAccountDeletionFeatures(),\n                    'hasApiFeatures' => Jetstream::hasApiFeatures(),\n                    'hasTeamFeatures' => Jetstream::hasTeamFeatures(),\n                    'hasTermsAndPrivacyPolicyFeature' => Jetstream::hasTermsAndPrivacyPolicyFeature(),\n                    'managesProfilePhotos' => Jetstream::managesProfilePhotos(),\n                ];\n            },\n            'auth' => [\n                'permissions' => $request->user() !== null && $request->user()->currentTeam !== null ? $permissions->getPermissions($request->user()->currentTeam) : [],\n                'user' => function () use ($request): array {\n                    /** @var User|null $user */\n                    $user = $request->user();\n\n                    if ($user === null) {\n                        return [];\n                    }\n\n                    return array_merge([\n                        'id' => $user->id,\n                        'name' => $user->name,\n                        'email' => $user->email,\n                        'email_verified_at' => $user->email_verified_at,\n                        'current_team_id' => $user->current_team_id,\n                        'profile_photo_path' => $user->profile_photo_path,\n                        'timezone' => $user->timezone,\n                        'week_start' => $user->week_start,\n                        'profile_photo_url' => $user->profile_photo_url,\n                        'two_factor_enabled' => Features::enabled(Features::twoFactorAuthentication())\n                            && ! is_null($user->two_factor_secret),\n                        'current_team' => $user->currentTeam !== null ? [\n                            'id' => $user->currentTeam->id,\n                            'user_id' => $user->currentTeam->user_id,\n                            'name' => $user->currentTeam->name,\n                            'personal_team' => $user->currentTeam->personal_team,\n                            'currency' => $user->currentTeam->currency,\n                        ] : null,\n                    ], array_filter([\n                        'all_teams' => $user->organizations->map(function (Organization $organization): array {\n                            return [\n                                'id' => $organization->id,\n                                'name' => $organization->name,\n                                'personal_team' => $organization->personal_team,\n                                'currency' => $organization->currency,\n                                'membership' => [\n                                    'role' => $organization->membership->role,\n                                    'id' => $organization->membership->id,\n                                ],\n                            ];\n                        })->all(),\n                    ]));\n                },\n            ],\n            'errorBags' => function () {\n                /** @var array<string, MessageBag>|null $bags */\n                $bags = Session::get('errors')?->getBags();\n                $bagsCollection = collect($bags ?: []);\n\n                return $bagsCollection->mapWithKeys(function (MessageBag $bag, string $key) {\n                    return [$key => $bag->messages()];\n                })->all();\n            },\n        ]);\n\n        return $next($request);\n    }\n}\n"
  },
  {
    "path": "app/Http/Middleware/TrimStrings.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Middleware;\n\nuse Illuminate\\Foundation\\Http\\Middleware\\TrimStrings as Middleware;\n\nclass TrimStrings extends Middleware\n{\n    /**\n     * The names of the attributes that should not be trimmed.\n     *\n     * @var array<int, string>\n     */\n    protected $except = [\n        'current_password',\n        'password',\n        'password_confirmation',\n    ];\n}\n"
  },
  {
    "path": "app/Http/Middleware/TrustProxies.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Middleware;\n\nuse Illuminate\\Http\\Middleware\\TrustProxies as Middleware;\nuse Illuminate\\Http\\Request;\n\nclass TrustProxies extends Middleware\n{\n    /**\n     * The trusted proxies for this application.\n     *\n     * @var array<int, string>|string|null\n     */\n    protected $proxies;\n\n    /**\n     * The headers that should be used to detect proxies.\n     *\n     * @var int\n     */\n    protected $headers =\n        Request::HEADER_X_FORWARDED_FOR |\n        Request::HEADER_X_FORWARDED_HOST |\n        Request::HEADER_X_FORWARDED_PORT |\n        Request::HEADER_X_FORWARDED_PROTO |\n        Request::HEADER_X_FORWARDED_AWS_ELB |\n        Request::HEADER_X_FORWARDED_TRAEFIK;\n}\n"
  },
  {
    "path": "app/Http/Middleware/ValidateSignature.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Middleware;\n\nuse Illuminate\\Routing\\Middleware\\ValidateSignature as Middleware;\n\nclass ValidateSignature extends Middleware\n{\n    /**\n     * The names of the query string parameters that should be ignored.\n     *\n     * @var array<int, string>\n     */\n    protected array $except = [\n        // 'fbclid',\n        // 'utm_campaign',\n        // 'utm_content',\n        // 'utm_medium',\n        // 'utm_source',\n        // 'utm_term',\n    ];\n}\n"
  },
  {
    "path": "app/Http/Middleware/VerifyCsrfToken.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Middleware;\n\nuse Illuminate\\Foundation\\Http\\Middleware\\VerifyCsrfToken as Middleware;\n\nclass VerifyCsrfToken extends Middleware\n{\n    /**\n     * The URIs that should be excluded from CSRF verification.\n     *\n     * @var array<int, string>\n     */\n    protected $except = [\n        //\n    ];\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/ApiToken/ApiTokenStoreRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\ApiToken;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\n\nclass ApiTokenStoreRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string>>\n     */\n    public function rules(): array\n    {\n        return [\n            'name' => [\n                'required',\n                'string',\n                'min:1',\n                'max:255',\n            ],\n        ];\n    }\n\n    public function getName(): string\n    {\n        return $this->input('name');\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/BaseFormRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1;\n\nuse Illuminate\\Foundation\\Http\\FormRequest;\n\nclass BaseFormRequest extends FormRequest\n{\n    /**\n     * @return list<string>\n     */\n    protected function moneyRules(bool $bigInt = false): array\n    {\n        $rules = [\n            'integer',\n            'min:0',\n        ];\n        if ($bigInt) {\n            $rules[] = 'max:9223372036854775807';\n        } else {\n            $rules[] = 'max:2147483647';\n        }\n\n        return $rules;\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/Client/ClientIndexRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\Client;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\n\nclass ClientIndexRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule>>\n     */\n    public function rules(): array\n    {\n        return [\n            'page' => [\n                'integer',\n                'min:1',\n                'max:2147483647',\n            ],\n            'archived' => [\n                'string',\n                'in:true,false,all',\n            ],\n        ];\n    }\n\n    public function getFilterArchived(): string\n    {\n        return $this->input('archived', 'false');\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/Client/ClientStoreRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\Client;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Client;\nuse App\\Models\\Organization;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Korridor\\LaravelModelValidationRules\\Rules\\UniqueEloquent;\n\n/**\n * @property Organization $organization Organization from model binding\n */\nclass ClientStoreRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule>>\n     */\n    public function rules(): array\n    {\n        return [\n            'name' => [\n                'required',\n                'string',\n                'min:1',\n                'max:255',\n                UniqueEloquent::make(Client::class, 'name', function (Builder $builder): Builder {\n                    /** @var Builder<Client> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization');\n                })->withCustomTranslation('validation.client_name_already_exists'),\n            ],\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/Client/ClientUpdateRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\Client;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Client;\nuse App\\Models\\Organization;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Korridor\\LaravelModelValidationRules\\Rules\\UniqueEloquent;\n\n/**\n * @property Organization $organization Organization from model binding\n * @property Client|null $client Client from model binding\n */\nclass ClientUpdateRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule>>\n     */\n    public function rules(): array\n    {\n        return [\n            // Name of the client\n            'name' => [\n                'required',\n                'string',\n                'min:1',\n                'max:255',\n                UniqueEloquent::make(Client::class, 'name', function (Builder $builder): Builder {\n                    /** @var Builder<Client> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization');\n                })->ignore($this->client?->getKey())->withCustomTranslation('validation.client_name_already_exists'),\n            ],\n            'is_archived' => [\n                'boolean',\n            ],\n        ];\n    }\n\n    public function getIsArchived(): bool\n    {\n        assert($this->has('is_archived'));\n\n        return (bool) $this->input('is_archived');\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/Import/ImportRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\Import;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\n\nclass ImportRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule>>\n     */\n    public function rules(): array\n    {\n        return [\n            'type' => [\n                'required',\n                'string',\n            ],\n            'data' => [\n                'required',\n                'string',\n            ],\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/Invitation/InvitationIndexRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\Invitation;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Organization;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\n\n/**\n * @property Organization $organization\n */\nclass InvitationIndexRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule>>\n     */\n    public function rules(): array\n    {\n        return [\n            'page' => [\n                'integer',\n                'min:1',\n                'max:2147483647',\n            ],\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/Invitation/InvitationStoreRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\Invitation;\n\nuse App\\Enums\\Role;\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Organization;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\nuse Illuminate\\Validation\\Rule;\n\n/**\n * @property Organization $organization\n */\nclass InvitationStoreRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule|\\Illuminate\\Contracts\\Validation\\Rule>>\n     */\n    public function rules(): array\n    {\n        return [\n            'email' => [\n                'required',\n                'email',\n            ],\n            'role' => [\n                'required',\n                'string',\n                Rule::enum(Role::class)\n                    ->except([Role::Owner, Role::Placeholder]),\n            ],\n        ];\n    }\n\n    public function getRole(): Role\n    {\n        return Role::from($this->input('role'));\n    }\n\n    public function getEmail(): string\n    {\n        return $this->input('email');\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/Member/MemberDestroyRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\Member;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Organization;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\n\n/**\n * @property Organization $organization\n */\nclass MemberDestroyRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule>>\n     */\n    public function rules(): array\n    {\n        return [\n            'delete_related' => [\n                'string',\n                'in:true,false',\n            ],\n        ];\n    }\n\n    public function getDeleteRelated(): bool\n    {\n        return $this->input('delete_related', 'false') === 'true';\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/Member/MemberIndexRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\Member;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Organization;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\n\n/**\n * @property Organization $organization\n */\nclass MemberIndexRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule>>\n     */\n    public function rules(): array\n    {\n        return [\n            'page' => [\n                'integer',\n                'min:1',\n                'max:2147483647',\n            ],\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/Member/MemberMergeIntoRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\Member;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Korridor\\LaravelModelValidationRules\\Rules\\ExistsEloquent;\n\n/**\n * @property Organization $organization\n */\nclass MemberMergeIntoRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule|\\Illuminate\\Contracts\\Validation\\Rule>>\n     */\n    public function rules(): array\n    {\n        return [\n            // ID of the member to which the data should be transferred (destination)\n            'member_id' => [\n                'string',\n                ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Member> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization');\n                })->uuid(),\n            ],\n        ];\n    }\n\n    public function getMemberId(): string\n    {\n        return (string) $this->input('member_id');\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/Member/MemberUpdateRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\Member;\n\nuse App\\Enums\\Role;\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Organization;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\nuse Illuminate\\Validation\\Rule;\n\n/**\n * @property Organization $organization\n */\nclass MemberUpdateRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule|\\Illuminate\\Contracts\\Validation\\Rule>>\n     */\n    public function rules(): array\n    {\n        return [\n            'role' => [\n                'string',\n                Rule::enum(Role::class),\n            ],\n            'billable_rate' => array_merge(\n                [\n                    'nullable',\n                ],\n                $this->moneyRules()\n            ),\n        ];\n    }\n\n    public function getBillableRate(): ?int\n    {\n        $input = $this->input('billable_rate');\n\n        return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;\n    }\n\n    public function getRole(): Role\n    {\n        return Role::from($this->input('role'));\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\Organization;\n\nuse App\\Enums\\CurrencyFormat;\nuse App\\Enums\\DateFormat;\nuse App\\Enums\\IntervalFormat;\nuse App\\Enums\\NumberFormat;\nuse App\\Enums\\TimeFormat;\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Organization;\nuse Illuminate\\Validation\\Rule;\n\n/**\n * @property Organization $organization Organization from model binding\n */\nclass OrganizationUpdateRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|\\Illuminate\\Contracts\\Validation\\Rule>>\n     */\n    public function rules(): array\n    {\n        return [\n            'name' => [\n                'string',\n                'max:255',\n            ],\n            'billable_rate' => array_merge(\n                [\n                    'nullable',\n                ],\n                $this->moneyRules()\n            ),\n            'employees_can_see_billable_rates' => [\n                'boolean',\n            ],\n            'employees_can_manage_tasks' => [\n                'boolean',\n            ],\n            'prevent_overlapping_time_entries' => [\n                'boolean',\n            ],\n            'number_format' => [\n                Rule::enum(NumberFormat::class),\n            ],\n            'currency_format' => [\n                Rule::enum(CurrencyFormat::class),\n            ],\n            'date_format' => [\n                Rule::enum(DateFormat::class),\n            ],\n            'interval_format' => [\n                Rule::enum(IntervalFormat::class),\n            ],\n            'time_format' => [\n                Rule::enum(TimeFormat::class),\n            ],\n        ];\n    }\n\n    public function getName(): ?string\n    {\n        return $this->has('name') ? (string) $this->input('name') : null;\n    }\n\n    public function getNumberFormat(): ?NumberFormat\n    {\n        return $this->has('number_format') ? NumberFormat::from($this->input('number_format')) : null;\n    }\n\n    public function getCurrencyFormat(): ?CurrencyFormat\n    {\n        return $this->has('currency_format') ? CurrencyFormat::from($this->input('currency_format')) : null;\n    }\n\n    public function getDateFormat(): ?DateFormat\n    {\n        return $this->has('date_format') ? DateFormat::from($this->input('date_format')) : null;\n    }\n\n    public function getIntervalFormat(): ?IntervalFormat\n    {\n        return $this->has('interval_format') ? IntervalFormat::from($this->input('interval_format')) : null;\n    }\n\n    public function getTimeFormat(): ?TimeFormat\n    {\n        return $this->has('time_format') ? TimeFormat::from($this->input('time_format')) : null;\n    }\n\n    public function getBillableRate(): ?int\n    {\n        $input = $this->input('billable_rate');\n\n        return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;\n    }\n\n    public function getEmployeesCanSeeBillableRates(): ?bool\n    {\n        return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;\n    }\n\n    public function getEmployeesCanManageTasks(): ?bool\n    {\n        return $this->has('employees_can_manage_tasks') ? $this->boolean('employees_can_manage_tasks') : null;\n    }\n\n    public function getPreventOverlappingTimeEntries(): ?bool\n    {\n        return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null;\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/Project/ProjectIndexRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\Project;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\n\nclass ProjectIndexRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule>>\n     */\n    public function rules(): array\n    {\n        return [\n            'page' => [\n                'integer',\n                'min:1',\n                'max:2147483647',\n            ],\n            'archived' => [\n                'string',\n                'in:true,false,all',\n            ],\n        ];\n    }\n\n    public function getFilterArchived(): string\n    {\n        return $this->input('archived', 'false');\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/Project/ProjectStoreRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\Project;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Client;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Rules\\ColorRule;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Support\\Str;\nuse Korridor\\LaravelModelValidationRules\\Rules\\ExistsEloquent;\nuse Korridor\\LaravelModelValidationRules\\Rules\\UniqueEloquent;\n\n/**\n * @property Organization $organization Organization from model binding\n */\nclass ProjectStoreRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule>>\n     */\n    public function rules(): array\n    {\n        return [\n            // Name of the project, the name needs to be unique per client and organization\n            'name' => [\n                'required',\n                'string',\n                'min:1',\n                'max:255',\n                UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder {\n                    /** @var Builder<Project> $builder */\n                    $clientId = $this->input('client_id');\n                    if (! is_string($clientId) || ! Str::isUuid($clientId)) {\n                        $clientId = null;\n                    }\n\n                    return $builder->whereBelongsTo($this->organization, 'organization')\n                        ->where('client_id', $clientId);\n                })->withCustomTranslation('validation.project_name_already_exists'),\n            ],\n            'color' => [\n                'required',\n                'string',\n                'max:255',\n                new ColorRule,\n            ],\n            'is_billable' => [\n                'required',\n                'boolean',\n            ],\n            'billable_rate' => array_merge(\n                [\n                    'nullable',\n                ],\n                $this->moneyRules()\n            ),\n            // ID of the client\n            'client_id' => [\n                'present',\n                'nullable',\n                ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Client> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization');\n                })->uuid(),\n            ],\n            // Estimated time in seconds\n            'estimated_time' => [\n                'nullable',\n                'integer',\n                'min:0',\n                'max:2147483647',\n            ],\n            // Whether the project is public\n            'is_public' => [\n                'boolean',\n            ],\n        ];\n    }\n\n    public function getIsPublic(): bool\n    {\n        return $this->has('is_public') && $this->boolean('is_public');\n    }\n\n    public function getBillableRate(): ?int\n    {\n        $input = $this->input('billable_rate');\n\n        return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;\n    }\n\n    public function getEstimatedTime(): ?int\n    {\n        $input = $this->input('estimated_time');\n\n        return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/Project/ProjectUpdateRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\Project;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Client;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Rules\\ColorRule;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Support\\Str;\nuse Korridor\\LaravelModelValidationRules\\Rules\\ExistsEloquent;\nuse Korridor\\LaravelModelValidationRules\\Rules\\UniqueEloquent;\n\n/**\n * @property Organization $organization Organization from model binding\n * @property Project|null $project Project from model binding\n */\nclass ProjectUpdateRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule>>\n     */\n    public function rules(): array\n    {\n        return [\n            'name' => [\n                'required',\n                'string',\n                'max:255',\n                UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder {\n                    /** @var Builder<Project> $builder */\n                    $clientId = $this->input('client_id');\n                    if (! is_string($clientId) || ! Str::isUuid($clientId)) {\n                        $clientId = null;\n                    }\n\n                    return $builder->whereBelongsTo($this->organization, 'organization')\n                        ->where('client_id', $clientId);\n                })->ignore($this->project?->getKey())->withCustomTranslation('validation.project_name_already_exists'),\n            ],\n            'color' => [\n                'required',\n                'string',\n                'max:255',\n                new ColorRule,\n            ],\n            'is_billable' => [\n                'required',\n                'boolean',\n            ],\n            'is_archived' => [\n                'boolean',\n            ],\n            'is_public' => [\n                'boolean',\n            ],\n            'client_id' => [\n                'present',\n                'nullable',\n                ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Client> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization');\n                })->uuid(),\n            ],\n            'billable_rate' => array_merge([\n                'nullable',\n            ],\n                $this->moneyRules()\n            ),\n            // Estimated time in seconds\n            'estimated_time' => [\n                'nullable',\n                'integer',\n                'min:0',\n                'max:2147483647',\n            ],\n        ];\n    }\n\n    public function getIsArchived(): bool\n    {\n        assert($this->has('is_archived'));\n\n        return (bool) $this->input('is_archived');\n    }\n\n    public function getBillableRate(): ?int\n    {\n        $input = $this->input('billable_rate');\n\n        return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;\n    }\n\n    public function getEstimatedTime(): ?int\n    {\n        $input = $this->input('estimated_time');\n\n        return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/ProjectMember/ProjectMemberIndexRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\ProjectMember;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\n\nclass ProjectMemberIndexRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule>>\n     */\n    public function rules(): array\n    {\n        return [\n            'page' => [\n                'integer',\n                'min:1',\n                'max:2147483647',\n            ],\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/ProjectMember/ProjectMemberStoreRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\ProjectMember;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Korridor\\LaravelModelValidationRules\\Rules\\ExistsEloquent;\n\n/**\n * @property Organization $organization Organization from model binding\n */\nclass ProjectMemberStoreRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule>>\n     */\n    public function rules(): array\n    {\n        return [\n            'member_id' => [\n                'required',\n                ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Member> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization');\n                })->uuid(),\n            ],\n            'billable_rate' => array_merge(\n                [\n                    'nullable',\n                ],\n                $this->moneyRules()\n            ),\n        ];\n    }\n\n    public function getBillableRate(): ?int\n    {\n        $input = $this->input('billable_rate');\n\n        return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/ProjectMember/ProjectMemberUpdateRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\ProjectMember;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Organization;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\n\n/**\n * @property Organization $organization Organization from model binding\n */\nclass ProjectMemberUpdateRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule>>\n     */\n    public function rules(): array\n    {\n        return [\n            'billable_rate' => array_merge(\n                [\n                    'nullable',\n                ],\n                $this->moneyRules()\n            ),\n        ];\n    }\n\n    public function getBillableRate(): ?int\n    {\n        $input = $this->input('billable_rate');\n\n        return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/Report/ReportIndexRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\Report;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\n\nclass ReportIndexRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule>>\n     */\n    public function rules(): array\n    {\n        return [\n            'page' => [\n                'integer',\n                'min:1',\n                'max:2147483647',\n            ],\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/Report/ReportStoreRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\Report;\n\nuse App\\Enums\\TimeEntryAggregationType;\nuse App\\Enums\\TimeEntryAggregationTypeInterval;\nuse App\\Enums\\TimeEntryRoundingType;\nuse App\\Enums\\Weekday;\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Organization;\nuse App\\Service\\TimeEntryFilter;\nuse Illuminate\\Contracts\\Validation\\Rule as LegacyValidationRule;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Str;\nuse Illuminate\\Validation\\Rule;\n\n/**\n * @property Organization $organization Organization from model binding\n */\nclass ReportStoreRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule|LegacyValidationRule|\\Closure>>\n     */\n    public function rules(): array\n    {\n        return [\n            'name' => [\n                'required',\n                'string',\n                'max:255',\n            ],\n            'description' => [\n                'nullable',\n                'string',\n            ],\n            'is_public' => [\n                'required',\n                'boolean',\n            ],\n            // After this date the report will be automatically set to private (is_public=false) (Format: \"Y-m-d\\TH:i:s\\Z\", UTC timezone, Example: \"2000-02-22T14:58:59Z\")\n            'public_until' => [\n                'nullable',\n                'date_format:Y-m-d\\TH:i:s\\Z',\n                'after:now',\n            ],\n            'properties' => [\n                'required',\n                'array',\n            ],\n            'properties.start' => [\n                'required',\n                'date_format:Y-m-d\\TH:i:s\\Z',\n            ],\n            'properties.end' => [\n                'required',\n                'date_format:Y-m-d\\TH:i:s\\Z',\n            ],\n            'properties.active' => [\n                'nullable',\n                'boolean',\n            ],\n            'properties.member_ids' => [\n                'nullable',\n                'array',\n            ],\n            'properties.member_ids.*' => [\n                'string',\n                'uuid',\n            ],\n            'properties.billable' => [\n                'nullable',\n                'boolean',\n            ],\n            'properties.client_ids' => [\n                'nullable',\n                'array',\n            ],\n            'properties.client_ids.*' => [\n                'string',\n                function (string $attribute, mixed $value, \\Closure $fail): void {\n                    if ($value === TimeEntryFilter::NONE_VALUE) {\n                        return;\n                    }\n                    if (! Str::isUuid($value)) {\n                        $fail('The '.$attribute.' must be a valid UUID.');\n                    }\n                },\n            ],\n            // Filter by project IDs, project IDs are OR combined\n            'properties.project_ids' => [\n                'nullable',\n                'array',\n            ],\n            'properties.project_ids.*' => [\n                'string',\n                function (string $attribute, mixed $value, \\Closure $fail): void {\n                    if ($value === TimeEntryFilter::NONE_VALUE) {\n                        return;\n                    }\n                    if (! Str::isUuid($value)) {\n                        $fail('The '.$attribute.' must be a valid UUID.');\n                    }\n                },\n            ],\n            // Filter by tag IDs, tag IDs are OR combined\n            'properties.tag_ids' => [\n                'nullable',\n                'array',\n            ],\n            'properties.tag_ids.*' => [\n                'string',\n                function (string $attribute, mixed $value, \\Closure $fail): void {\n                    if ($value === TimeEntryFilter::NONE_VALUE) {\n                        return;\n                    }\n                    if (! Str::isUuid($value)) {\n                        $fail('The '.$attribute.' must be a valid UUID.');\n                    }\n                },\n            ],\n            'properties.task_ids' => [\n                'nullable',\n                'array',\n            ],\n            'properties.task_ids.*' => [\n                'string',\n                function (string $attribute, mixed $value, \\Closure $fail): void {\n                    if ($value === TimeEntryFilter::NONE_VALUE) {\n                        return;\n                    }\n                    if (! Str::isUuid($value)) {\n                        $fail('The '.$attribute.' must be a valid UUID.');\n                    }\n                },\n            ],\n            'properties.group' => [\n                'required',\n                Rule::enum(TimeEntryAggregationType::class),\n            ],\n            'properties.sub_group' => [\n                'required',\n                Rule::enum(TimeEntryAggregationType::class),\n            ],\n            'properties.history_group' => [\n                'required',\n                Rule::enum(TimeEntryAggregationTypeInterval::class),\n            ],\n            'properties.week_start' => [\n                'nullable',\n                Rule::enum(Weekday::class),\n            ],\n            'properties.timezone' => [\n                'nullable',\n                'timezone:all',\n            ],\n            // Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.\n            'properties.rounding_type' => [\n                'nullable',\n                'string',\n                Rule::enum(TimeEntryRoundingType::class),\n            ],\n            // Defines the length of the interval that the time entry rounding rounds to.\n            'properties.rounding_minutes' => [\n                'nullable',\n                'numeric',\n                'integer',\n            ],\n        ];\n    }\n\n    public function getName(): string\n    {\n        return (string) $this->input('name');\n    }\n\n    public function getDescription(): ?string\n    {\n        return $this->input('description');\n    }\n\n    public function getIsPublic(): bool\n    {\n        return (bool) $this->input('is_public');\n    }\n\n    public function getPublicUntil(): ?Carbon\n    {\n        $publicUntil = $this->input('public_until');\n\n        return $publicUntil === null ? null : Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $publicUntil);\n    }\n\n    public function getPropertyStart(): Carbon\n    {\n        $start = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $this->input('properties.start'));\n        if ($start === null) {\n            throw new \\LogicException('Start date validation is not working');\n        }\n\n        return $start;\n    }\n\n    public function getPropertyEnd(): Carbon\n    {\n        $end = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $this->input('properties.end'));\n        if ($end === null) {\n            throw new \\LogicException('End date validation is not working');\n        }\n\n        return $end;\n    }\n\n    public function getPropertyActive(): ?bool\n    {\n        if ($this->has('properties.active') && $this->input('properties.active') !== null) {\n            return (bool) $this->input('properties.active');\n        }\n\n        return null;\n    }\n\n    public function getPropertyBillable(): ?bool\n    {\n        if ($this->has('properties.billable') && $this->input('properties.billable') !== null) {\n            return (bool) $this->input('properties.billable');\n        }\n\n        return null;\n    }\n\n    public function getPropertyGroup(): TimeEntryAggregationType\n    {\n        return TimeEntryAggregationType::from($this->input('properties.group'));\n    }\n\n    public function getPropertySubGroup(): TimeEntryAggregationType\n    {\n        return TimeEntryAggregationType::from($this->input('properties.sub_group'));\n    }\n\n    public function getPropertyHistoryGroup(): TimeEntryAggregationTypeInterval\n    {\n        return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));\n    }\n\n    public function getPropertyRoundingType(): ?TimeEntryRoundingType\n    {\n        if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) {\n            return null;\n        }\n\n        return TimeEntryRoundingType::from($this->input('properties.rounding_type'));\n    }\n\n    public function getPropertyRoundingMinutes(): ?int\n    {\n        if (! $this->has('properties.rounding_minutes') || $this->input('properties.rounding_minutes') === null) {\n            return null;\n        }\n\n        return (int) $this->input('properties.rounding_minutes');\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/Report/ReportUpdateRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\Report;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Organization;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\nuse Illuminate\\Support\\Carbon;\n\n/**\n * @property Organization $organization Organization from model binding\n */\nclass ReportUpdateRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule>>\n     */\n    public function rules(): array\n    {\n        return [\n            'name' => [\n                'string',\n                'max:255',\n            ],\n            'description' => [\n                'nullable',\n                'string',\n            ],\n            'is_public' => [\n                'boolean',\n            ],\n            'public_until' => [\n                'nullable',\n                'date_format:Y-m-d\\TH:i:s\\Z',\n                'after:now',\n            ],\n        ];\n    }\n\n    public function getName(): string\n    {\n        return (string) $this->input('name');\n    }\n\n    public function getDescription(): ?string\n    {\n        return $this->input('description');\n    }\n\n    public function getIsPublic(): bool\n    {\n        return (bool) $this->input('is_public');\n    }\n\n    public function getPublicUntil(): ?Carbon\n    {\n        $publicUntil = $this->input('public_until');\n\n        return $publicUntil === null ? null : Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $publicUntil);\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/Tag/TagIndexRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\Tag;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\n\nclass TagIndexRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule>>\n     */\n    public function rules(): array\n    {\n        return [\n            'page' => [\n                'integer',\n                'min:1',\n                'max:2147483647',\n            ],\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/Tag/TagStoreRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\Tag;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Organization;\nuse App\\Models\\Tag;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Korridor\\LaravelModelValidationRules\\Rules\\UniqueEloquent;\n\n/**\n * @property Organization $organization Organization from model binding\n */\nclass TagStoreRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule>>\n     */\n    public function rules(): array\n    {\n        return [\n            'name' => [\n                'required',\n                'string',\n                'min:1',\n                'max:255',\n                UniqueEloquent::make(Tag::class, 'name', function (Builder $builder): Builder {\n                    /** @var Builder<Tag> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization');\n                })->withCustomTranslation('validation.tag_name_already_exists'),\n            ],\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/Tag/TagUpdateRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\Tag;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Organization;\nuse App\\Models\\Tag;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Korridor\\LaravelModelValidationRules\\Rules\\UniqueEloquent;\n\n/**\n * @property Organization $organization Organization from model binding\n * @property Tag|null $tag Tag from model binding\n */\nclass TagUpdateRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule>>\n     */\n    public function rules(): array\n    {\n        return [\n            'name' => [\n                'required',\n                'string',\n                'min:1',\n                'max:255',\n                UniqueEloquent::make(Tag::class, 'name', function (Builder $builder): Builder {\n                    /** @var Builder<Tag> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization');\n                })->ignore($this->tag?->getKey())->withCustomTranslation('validation.tag_name_already_exists'),\n            ],\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/Task/TaskIndexRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\Task;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Service\\PermissionStore;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Support\\Facades\\Auth;\nuse Korridor\\LaravelModelValidationRules\\Rules\\ExistsEloquent;\n\n/**\n * @property Organization $organization Organization from model binding\n */\nclass TaskIndexRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule>>\n     */\n    public function rules(): array\n    {\n        return [\n            'page' => [\n                'integer',\n                'min:1',\n                'max:2147483647',\n            ],\n            'project_id' => [\n                ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Project> $builder */\n                    $builder = $builder->whereBelongsTo($this->organization, 'organization');\n\n                    if (! app(PermissionStore::class)->has($this->organization, 'tasks:view:all')) {\n                        $builder = $builder->visibleByEmployee(Auth::user());\n                    }\n\n                    return $builder;\n                })->uuid(),\n            ],\n            'done' => [\n                'string',\n                'in:true,false,all',\n            ],\n        ];\n    }\n\n    public function getFilterDone(): string\n    {\n        return $this->input('done', 'false');\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/Task/TaskStoreRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\Task;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\Task;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Korridor\\LaravelModelValidationRules\\Rules\\ExistsEloquent;\nuse Korridor\\LaravelModelValidationRules\\Rules\\UniqueEloquent;\n\n/**\n * @property Organization $organization Organization from model binding\n */\nclass TaskStoreRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule>>\n     */\n    public function rules(): array\n    {\n        return [\n            'name' => [\n                'required',\n                'string',\n                'min:1',\n                'max:255',\n                UniqueEloquent::make(Task::class, 'name', function (Builder $builder): Builder {\n                    /** @var Builder<Task> $builder */\n                    return $builder->where('project_id', '=', $this->input('project_id'));\n                })->withCustomTranslation('validation.task_name_already_exists'),\n            ],\n            'project_id' => [\n                'required',\n                ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Project> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization');\n                })->uuid(),\n            ],\n            // Estimated time in seconds\n            'estimated_time' => [\n                'nullable',\n                'integer',\n                'min:0',\n                'max:2147483647',\n            ],\n        ];\n    }\n\n    public function getEstimatedTime(): ?int\n    {\n        $input = $this->input('estimated_time');\n\n        return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/Task/TaskUpdateRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\Task;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Organization;\nuse App\\Models\\Task;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Korridor\\LaravelModelValidationRules\\Rules\\UniqueEloquent;\n\n/**\n * @property Organization $organization Organization from model binding\n * @property Task|null $task Task from model binding\n */\nclass TaskUpdateRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule>>\n     */\n    public function rules(): array\n    {\n        return [\n            'name' => [\n                'required',\n                'string',\n                'min:1',\n                'max:255',\n                UniqueEloquent::make(Task::class, 'name', function (Builder $builder): Builder {\n                    /** @var Builder<Task> $builder */\n                    return $builder->where('project_id', '=', $this->task->project_id);\n                })->ignore($this->task?->getKey())->withCustomTranslation('validation.task_name_already_exists'),\n            ],\n            'is_done' => [\n                'boolean',\n            ],\n            // Estimated time in seconds\n            'estimated_time' => [\n                'nullable',\n                'integer',\n                'min:0',\n                'max:2147483647',\n            ],\n        ];\n    }\n\n    public function getIsDone(): bool\n    {\n        assert($this->has('is_done'));\n\n        return $this->boolean('is_done');\n    }\n\n    public function getEstimatedTime(): ?int\n    {\n        $input = $this->input('estimated_time');\n\n        return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\TimeEntry;\n\nuse App\\Enums\\ExportFormat;\nuse App\\Enums\\TimeEntryAggregationType;\nuse App\\Enums\\TimeEntryAggregationTypeInterval;\nuse App\\Enums\\TimeEntryRoundingType;\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Client;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\Tag;\nuse App\\Models\\Task;\nuse App\\Models\\User;\nuse App\\Service\\TimeEntryFilter;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Validation\\Rule;\nuse Korridor\\LaravelModelValidationRules\\Rules\\ExistsEloquent;\n\n/**\n * @property Organization $organization\n */\nclass TimeEntryAggregateExportRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule|\\Illuminate\\Contracts\\Validation\\Rule|\\Closure>>\n     */\n    public function rules(): array\n    {\n        return [\n            // Data format of the export\n            'format' => [\n                'required',\n                'string',\n                Rule::enum(ExportFormat::class),\n            ],\n            // Type of first grouping\n            'group' => [\n                'required',\n                Rule::enum(TimeEntryAggregationType::class),\n            ],\n            // Type of second grouping\n            'sub_group' => [\n                'required',\n                Rule::enum(TimeEntryAggregationType::class),\n            ],\n            // Type of grouping of the historic aggregation (time chart)\n            'history_group' => [\n                'required',\n                'nullable',\n                Rule::enum(TimeEntryAggregationTypeInterval::class),\n            ],\n\n            // Filter by member ID\n            'member_id' => [\n                'string',\n                ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Member> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization');\n                })->uuid(),\n            ],\n            // Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter\n            'member_ids' => [\n                'array',\n                'min:1',\n            ],\n            'member_ids.*' => [\n                'string',\n                ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Member> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization');\n                })->uuid(),\n            ],\n\n            // Filter by user ID\n            'user_id' => [\n                'string',\n                ExistsEloquent::make(User::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<User> $builder */\n                    return $builder->belongsToOrganization($this->organization);\n                })->uuid(),\n            ],\n            // Filter by project IDs, project IDs are OR combined\n            'project_ids' => [\n                'array',\n                'min:1',\n            ],\n            'project_ids.*' => [\n                'string',\n                function (string $attribute, mixed $value, \\Closure $fail): void {\n                    if ($value === TimeEntryFilter::NONE_VALUE) {\n                        return;\n                    }\n                    ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {\n                        /** @var Builder<Project> $builder */\n                        return $builder->whereBelongsTo($this->organization, 'organization');\n                    })->uuid()->validate($attribute, $value, $fail);\n                },\n            ],\n            // Filter by client IDs, client IDs are OR combined\n            'client_ids' => [\n                'array',\n                'min:1',\n            ],\n            'client_ids.*' => [\n                'string',\n                function (string $attribute, mixed $value, \\Closure $fail): void {\n                    if ($value === TimeEntryFilter::NONE_VALUE) {\n                        return;\n                    }\n                    ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {\n                        /** @var Builder<Client> $builder */\n                        return $builder->whereBelongsTo($this->organization, 'organization');\n                    })->uuid()->validate($attribute, $value, $fail);\n                },\n            ],\n            // Filter by tag IDs, tag IDs are OR combined\n            'tag_ids' => [\n                'array',\n                'min:1',\n            ],\n            'tag_ids.*' => [\n                'string',\n                function (string $attribute, mixed $value, \\Closure $fail): void {\n                    if ($value === TimeEntryFilter::NONE_VALUE) {\n                        return;\n                    }\n                    ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {\n                        /** @var Builder<Tag> $builder */\n                        return $builder->whereBelongsTo($this->organization, 'organization');\n                    })->uuid()->validate($attribute, $value, $fail);\n                },\n            ],\n            // Filter by task IDs, task IDs are OR combined\n            'task_ids' => [\n                'array',\n                'min:1',\n            ],\n            'task_ids.*' => [\n                'string',\n                function (string $attribute, mixed $value, \\Closure $fail): void {\n                    if ($value === TimeEntryFilter::NONE_VALUE) {\n                        return;\n                    }\n                    ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {\n                        return $builder->whereBelongsTo($this->organization, 'organization');\n                    })->uuid()->validate($attribute, $value, $fail);\n                },\n            ],\n            // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)\n            'start' => [\n                'required',\n                'string',\n                'date_format:Y-m-d\\TH:i:s\\Z',\n                'before:end',\n            ],\n            // Filter only time entries that have a start date before the given timestamp in UTC (example: 2021-01-01T00:00:00Z)\n            'end' => [\n                'required',\n                'string',\n                'date_format:Y-m-d\\TH:i:s\\Z',\n            ],\n            // Filter by active status (active means has no end date, is still running)\n            'active' => [\n                'string',\n                'in:true,false',\n            ],\n            // Filter by billable status\n            'billable' => [\n                'string',\n                'in:true,false',\n            ],\n            'fill_gaps_in_time_groups' => [\n                'string',\n                'in:true,false',\n            ],\n            'debug' => [\n                'string',\n                'in:true,false',\n            ],\n            // Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.\n            'rounding_type' => [\n                'nullable',\n                'string',\n                Rule::enum(TimeEntryRoundingType::class),\n            ],\n            // Defines the length of the interval that the time entry rounding rounds to.\n            'rounding_minutes' => [\n                'nullable',\n                'numeric',\n                'integer',\n            ],\n        ];\n    }\n\n    public function getDebug(): bool\n    {\n        return $this->input('debug') === 'true';\n    }\n\n    public function getGroup(): TimeEntryAggregationType\n    {\n        return TimeEntryAggregationType::from($this->input('group'));\n    }\n\n    public function getSubGroup(): TimeEntryAggregationType\n    {\n        return TimeEntryAggregationType::from($this->input('sub_group'));\n    }\n\n    public function getHistoryGroup(): TimeEntryAggregationType\n    {\n        return TimeEntryAggregationType::fromInterval(TimeEntryAggregationTypeInterval::from($this->input('history_group')));\n    }\n\n    public function getStart(): Carbon\n    {\n        $start = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $this->input('start'), 'UTC');\n        if ($start === null) {\n            throw new \\LogicException('Start date validation is not working');\n        }\n\n        return $start;\n    }\n\n    public function getEnd(): Carbon\n    {\n        $end = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $this->input('end'), 'UTC');\n        if ($end === null) {\n            throw new \\LogicException('End date validation is not working');\n        }\n\n        return $end;\n    }\n\n    public function getFormatValue(): ExportFormat\n    {\n        return ExportFormat::from($this->validated('format'));\n    }\n\n    public function getRoundingType(): ?TimeEntryRoundingType\n    {\n        if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {\n            return null;\n        }\n\n        return TimeEntryRoundingType::from($this->validated('rounding_type'));\n    }\n\n    public function getRoundingMinutes(): ?int\n    {\n        if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {\n            return null;\n        }\n\n        return (int) $this->validated('rounding_minutes');\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\TimeEntry;\n\nuse App\\Enums\\TimeEntryAggregationType;\nuse App\\Enums\\TimeEntryRoundingType;\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Client;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\Tag;\nuse App\\Models\\Task;\nuse App\\Models\\User;\nuse App\\Service\\TimeEntryFilter;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Validation\\Rule;\nuse Korridor\\LaravelModelValidationRules\\Rules\\ExistsEloquent;\n\n/**\n * @property Organization $organization\n */\nclass TimeEntryAggregateRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule|\\Illuminate\\Contracts\\Validation\\Rule|\\Closure>>\n     */\n    public function rules(): array\n    {\n        return [\n            // Type of first grouping\n            'group' => [\n                'nullable',\n                'required_with:sub_group',\n                Rule::enum(TimeEntryAggregationType::class),\n            ],\n            // Type of second grouping\n            'sub_group' => [\n                'nullable',\n                Rule::enum(TimeEntryAggregationType::class),\n            ],\n            // Filter by member ID\n            'member_id' => [\n                'string',\n                ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Member> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization');\n                })->uuid(),\n            ],\n            // Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter\n            'member_ids' => [\n                'array',\n                'min:1',\n            ],\n            'member_ids.*' => [\n                'string',\n                ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Member> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization');\n                })->uuid(),\n            ],\n\n            // Filter by user ID\n            'user_id' => [\n                'string',\n                ExistsEloquent::make(User::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<User> $builder */\n                    return $builder->belongsToOrganization($this->organization);\n                })->uuid(),\n            ],\n            // Filter by project IDs, project IDs are OR combined\n            'project_ids' => [\n                'array',\n                'min:1',\n            ],\n            'project_ids.*' => [\n                'string',\n                function (string $attribute, mixed $value, \\Closure $fail): void {\n                    if ($value === TimeEntryFilter::NONE_VALUE) {\n                        return;\n                    }\n                    ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {\n                        /** @var Builder<Project> $builder */\n                        return $builder->whereBelongsTo($this->organization, 'organization');\n                    })->uuid()->validate($attribute, $value, $fail);\n                },\n            ],\n            // Filter by client IDs, client IDs are OR combined\n            'client_ids' => [\n                'array',\n                'min:1',\n            ],\n            'client_ids.*' => [\n                'string',\n                function (string $attribute, mixed $value, \\Closure $fail): void {\n                    if ($value === TimeEntryFilter::NONE_VALUE) {\n                        return;\n                    }\n                    ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {\n                        /** @var Builder<Client> $builder */\n                        return $builder->whereBelongsTo($this->organization, 'organization');\n                    })->uuid()->validate($attribute, $value, $fail);\n                },\n            ],\n            // Filter by tag IDs, tag IDs are OR combined\n            'tag_ids' => [\n                'array',\n                'min:1',\n            ],\n            'tag_ids.*' => [\n                'string',\n                function (string $attribute, mixed $value, \\Closure $fail): void {\n                    if ($value === TimeEntryFilter::NONE_VALUE) {\n                        return;\n                    }\n                    ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {\n                        /** @var Builder<Tag> $builder */\n                        return $builder->whereBelongsTo($this->organization, 'organization');\n                    })->uuid()->validate($attribute, $value, $fail);\n                },\n            ],\n            // Filter by task IDs, task IDs are OR combined\n            'task_ids' => [\n                'array',\n                'min:1',\n            ],\n            'task_ids.*' => [\n                'string',\n                function (string $attribute, mixed $value, \\Closure $fail): void {\n                    if ($value === TimeEntryFilter::NONE_VALUE) {\n                        return;\n                    }\n                    ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {\n                        return $builder->whereBelongsTo($this->organization, 'organization');\n                    })->uuid()->validate($attribute, $value, $fail);\n                },\n            ],\n            // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)\n            'start' => [\n                'nullable',\n                'string',\n                'date_format:Y-m-d\\TH:i:s\\Z',\n                'before:end',\n            ],\n            // Filter only time entries that have a start date before the given timestamp in UTC (example: 2021-01-01T00:00:00Z)\n            'end' => [\n                'nullable',\n                'string',\n                'date_format:Y-m-d\\TH:i:s\\Z',\n            ],\n            // Filter by active status (active means has no end date, is still running)\n            'active' => [\n                'string',\n                'in:true,false',\n            ],\n            // Filter by billable status\n            'billable' => [\n                'string',\n                'in:true,false',\n            ],\n            'fill_gaps_in_time_groups' => [\n                'string',\n                'in:true,false',\n            ],\n            // Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.\n            'rounding_type' => [\n                'nullable',\n                'string',\n                Rule::enum(TimeEntryRoundingType::class),\n            ],\n            // Defines the length of the interval that the time entry rounding rounds to.\n            'rounding_minutes' => [\n                'nullable',\n                'numeric',\n                'integer',\n            ],\n        ];\n    }\n\n    public function getGroup(): ?TimeEntryAggregationType\n    {\n        return $this->input('group') !== null ? TimeEntryAggregationType::from($this->input('group')) : null;\n    }\n\n    public function getSubGroup(): ?TimeEntryAggregationType\n    {\n        return $this->input('sub_group') !== null ? TimeEntryAggregationType::from($this->input('sub_group')) : null;\n    }\n\n    public function getFillGapsInTimeGroups(): bool\n    {\n        return $this->has('fill_gaps_in_time_groups') && $this->input('fill_gaps_in_time_groups') === 'true';\n    }\n\n    public function getStart(): ?Carbon\n    {\n        return $this->input('start') !== null ? Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $this->input('start'), 'UTC') : null;\n    }\n\n    public function getEnd(): ?Carbon\n    {\n        return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $this->input('end'), 'UTC') : null;\n    }\n\n    public function getRoundingType(): ?TimeEntryRoundingType\n    {\n        if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {\n            return null;\n        }\n\n        return TimeEntryRoundingType::from($this->validated('rounding_type'));\n    }\n\n    public function getRoundingMinutes(): ?int\n    {\n        if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {\n            return null;\n        }\n\n        return (int) $this->validated('rounding_minutes');\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/TimeEntry/TimeEntryDestroyMultipleRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\TimeEntry;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Organization;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\n\n/**\n * @property Organization $organization Organization from model binding\n */\nclass TimeEntryDestroyMultipleRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule>>\n     */\n    public function rules(): array\n    {\n        return [\n            'ids' => [\n                'required',\n                'array',\n            ],\n            'ids.*' => [\n                'string',\n                'uuid',\n            ],\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\TimeEntry;\n\nuse App\\Enums\\ExportFormat;\nuse App\\Enums\\TimeEntryRoundingType;\nuse App\\Models\\Client;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\Tag;\nuse App\\Models\\Task;\nuse App\\Service\\TimeEntryFilter;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Validation\\Rule;\nuse Korridor\\LaravelModelValidationRules\\Rules\\ExistsEloquent;\n\n/**\n * @property Organization $organization\n */\nclass TimeEntryIndexExportRequest extends TimeEntryIndexRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule|\\Illuminate\\Contracts\\Validation\\Rule|\\Closure>>\n     */\n    public function rules(): array\n    {\n        return [\n            'format' => [\n                'required',\n                'string',\n                Rule::enum(ExportFormat::class),\n            ],\n            // Filter by member ID\n            'member_id' => [\n                'string',\n                'uuid',\n                new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Member> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization');\n                }),\n            ],\n            // Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter\n            'member_ids' => [\n                'array',\n                'min:1',\n            ],\n            'member_ids.*' => [\n                'string',\n                'uuid',\n                new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Member> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization');\n                }),\n            ],\n            // Filter by client IDs, client IDs are OR combined\n            'client_ids' => [\n                'array',\n                'min:1',\n            ],\n            'client_ids.*' => [\n                'string',\n                function (string $attribute, mixed $value, \\Closure $fail): void {\n                    if ($value === TimeEntryFilter::NONE_VALUE) {\n                        return;\n                    }\n                    ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {\n                        /** @var Builder<Client> $builder */\n                        return $builder->whereBelongsTo($this->organization, 'organization');\n                    })->uuid()->validate($attribute, $value, $fail);\n                },\n            ],\n            // Filter by project IDs, project IDs are OR combined\n            'project_ids' => [\n                'array',\n                'min:1',\n            ],\n            'project_ids.*' => [\n                'string',\n                function (string $attribute, mixed $value, \\Closure $fail): void {\n                    if ($value === TimeEntryFilter::NONE_VALUE) {\n                        return;\n                    }\n                    ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {\n                        /** @var Builder<Project> $builder */\n                        return $builder->whereBelongsTo($this->organization, 'organization');\n                    })->uuid()->validate($attribute, $value, $fail);\n                },\n            ],\n            // Filter by tag IDs, tag IDs are OR combined\n            'tag_ids' => [\n                'array',\n                'min:1',\n            ],\n            'tag_ids.*' => [\n                'string',\n                function (string $attribute, mixed $value, \\Closure $fail): void {\n                    if ($value === TimeEntryFilter::NONE_VALUE) {\n                        return;\n                    }\n                    ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {\n                        /** @var Builder<Tag> $builder */\n                        return $builder->whereBelongsTo($this->organization, 'organization');\n                    })->uuid()->validate($attribute, $value, $fail);\n                },\n            ],\n            // Filter by task IDs, task IDs are OR combined\n            'task_ids' => [\n                'array',\n                'min:1',\n            ],\n            'task_ids.*' => [\n                'string',\n                function (string $attribute, mixed $value, \\Closure $fail): void {\n                    if ($value === TimeEntryFilter::NONE_VALUE) {\n                        return;\n                    }\n                    ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {\n                        /** @var Builder<Task> $builder */\n                        return $builder->whereBelongsTo($this->organization, 'organization');\n                    })->uuid()->validate($attribute, $value, $fail);\n                },\n            ],\n            // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)\n            'start' => [\n                'required',\n                'string',\n                'date_format:Y-m-d\\TH:i:s\\Z',\n                'before:end',\n            ],\n            // Filter only time entries that have a start date before the given timestamp in UTC (example: 2021-01-01T00:00:00Z)\n            'end' => [\n                'required',\n                'string',\n                'date_format:Y-m-d\\TH:i:s\\Z',\n            ],\n            // Filter by active status (active means has no end date, is still running)\n            'active' => [\n                'string',\n                'in:true,false',\n            ],\n            // Filter by billable status\n            'billable' => [\n                'string',\n                'in:true,false',\n            ],\n            // Limit the number of returned time entries (default: 150)\n            'limit' => [\n                'integer',\n                'min:1',\n                'max:500',\n            ],\n            // Filter makes sure that only time entries of a whole date are returned\n            'only_full_dates' => [\n                'string',\n                'in:true,false',\n            ],\n            'debug' => [\n                'string',\n                'in:true,false',\n            ],\n            // Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.\n            'rounding_type' => [\n                'nullable',\n                'string',\n                Rule::enum(TimeEntryRoundingType::class),\n            ],\n            // Defines the length of the interval that the time entry rounding rounds to.\n            'rounding_minutes' => [\n                'nullable',\n                'numeric',\n                'integer',\n            ],\n        ];\n    }\n\n    public function getDebug(): bool\n    {\n        return $this->input('debug', 'false') === 'true';\n    }\n\n    public function getStart(): Carbon\n    {\n        $start = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $this->input('start'), 'UTC');\n        if ($start === null) {\n            throw new \\LogicException('Start date validation is not working');\n        }\n\n        return $start;\n    }\n\n    public function getEnd(): Carbon\n    {\n        $end = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $this->input('end'), 'UTC');\n        if ($end === null) {\n            throw new \\LogicException('End date validation is not working');\n        }\n\n        return $end;\n    }\n\n    public function getOnlyFullDates(): bool\n    {\n        return $this->input('only_full_dates', 'false') === 'true';\n    }\n\n    public function getFormatValue(): ExportFormat\n    {\n        return ExportFormat::from($this->validated('format'));\n    }\n\n    public function getRoundingType(): ?TimeEntryRoundingType\n    {\n        if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {\n            return null;\n        }\n\n        return TimeEntryRoundingType::from($this->validated('rounding_type'));\n    }\n\n    public function getRoundingMinutes(): ?int\n    {\n        if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {\n            return null;\n        }\n\n        return (int) $this->validated('rounding_minutes');\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\TimeEntry;\n\nuse App\\Enums\\TimeEntryRoundingType;\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Client;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\Tag;\nuse App\\Models\\Task;\nuse App\\Service\\TimeEntryFilter;\nuse Illuminate\\Contracts\\Validation\\Rule as RuleContract;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Validation\\Rule;\nuse Korridor\\LaravelModelValidationRules\\Rules\\ExistsEloquent;\n\n/**\n * @property Organization $organization\n */\nclass TimeEntryIndexRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule|RuleContract|\\Closure>>\n     */\n    public function rules(): array\n    {\n        return [\n            // Filter by member ID\n            'member_id' => [\n                'string',\n                ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Member> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization');\n                })->uuid(),\n            ],\n            // Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter\n            'member_ids' => [\n                'array',\n                'min:1',\n            ],\n            'member_ids.*' => [\n                'string',\n                ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Member> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization');\n                })->uuid(),\n            ],\n            // Filter by client IDs, client IDs are OR combined\n            'client_ids' => [\n                'array',\n                'min:1',\n            ],\n            'client_ids.*' => [\n                'string',\n                function (string $attribute, mixed $value, \\Closure $fail): void {\n                    if ($value === TimeEntryFilter::NONE_VALUE) {\n                        return;\n                    }\n                    ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {\n                        /** @var Builder<Client> $builder */\n                        return $builder->whereBelongsTo($this->organization, 'organization');\n                    })->uuid()->validate($attribute, $value, $fail);\n                },\n            ],\n            // Filter by project IDs, project IDs are OR combined\n            'project_ids' => [\n                'array',\n                'min:1',\n            ],\n            'project_ids.*' => [\n                'string',\n                function (string $attribute, mixed $value, \\Closure $fail): void {\n                    if ($value === TimeEntryFilter::NONE_VALUE) {\n                        return;\n                    }\n                    ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {\n                        /** @var Builder<Project> $builder */\n                        return $builder->whereBelongsTo($this->organization, 'organization');\n                    })->uuid()->validate($attribute, $value, $fail);\n                },\n            ],\n            // Filter by tag IDs, tag IDs are OR combined\n            'tag_ids' => [\n                'array',\n                'min:1',\n            ],\n            'tag_ids.*' => [\n                'string',\n                function (string $attribute, mixed $value, \\Closure $fail): void {\n                    if ($value === TimeEntryFilter::NONE_VALUE) {\n                        return;\n                    }\n                    ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {\n                        /** @var Builder<Tag> $builder */\n                        return $builder->whereBelongsTo($this->organization, 'organization');\n                    })->uuid()->validate($attribute, $value, $fail);\n                },\n            ],\n            // Filter by task IDs, task IDs are OR combined\n            'task_ids' => [\n                'array',\n                'min:1',\n            ],\n            'task_ids.*' => [\n                'string',\n                function (string $attribute, mixed $value, \\Closure $fail): void {\n                    if ($value === TimeEntryFilter::NONE_VALUE) {\n                        return;\n                    }\n                    ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {\n                        /** @var Builder<Task> $builder */\n                        return $builder->whereBelongsTo($this->organization, 'organization');\n                    })->uuid()->validate($attribute, $value, $fail);\n                },\n            ],\n            // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)\n            'start' => [\n                'nullable',\n                'string',\n                'date_format:Y-m-d\\TH:i:s\\Z',\n                'before:end',\n            ],\n            // Filter only time entries that have a start date before the given timestamp in UTC (example: 2021-01-01T00:00:00Z)\n            'end' => [\n                'nullable',\n                'string',\n                'date_format:Y-m-d\\TH:i:s\\Z',\n            ],\n            // Filter by active status (active means has no end date, is still running)\n            'active' => [\n                'string',\n                'in:true,false',\n            ],\n            // Filter by billable status\n            'billable' => [\n                'string',\n                'in:true,false',\n            ],\n            // Limit the number of returned time entries (default: 150)\n            'limit' => [\n                'integer',\n                'min:1',\n                'max:500',\n            ],\n            // Skip the first n time entries (default: 0)\n            'offset' => [\n                'integer',\n                'min:0',\n                'max:2147483647',\n            ],\n            // Filter makes sure that only time entries of a whole date are returned\n            'only_full_dates' => [\n                'string',\n                'in:true,false',\n            ],\n            // Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.\n            'rounding_type' => [\n                'nullable',\n                'string',\n                Rule::enum(TimeEntryRoundingType::class),\n            ],\n            // Defines the length of the interval that the time entry rounding rounds to.\n            'rounding_minutes' => [\n                'nullable',\n                'numeric',\n                'integer',\n            ],\n        ];\n    }\n\n    public function getOnlyFullDates(): bool\n    {\n        return $this->input('only_full_dates', 'false') === 'true';\n    }\n\n    public function getLimit(): int\n    {\n        return $this->has('limit') ? (int) $this->validated('limit', 100) : 100;\n    }\n\n    public function getOffset(): int\n    {\n        return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;\n    }\n\n    public function getRoundingType(): ?TimeEntryRoundingType\n    {\n        if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {\n            return null;\n        }\n\n        return TimeEntryRoundingType::from($this->validated('rounding_type'));\n    }\n\n    public function getRoundingMinutes(): ?int\n    {\n        if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {\n            return null;\n        }\n\n        return (int) $this->validated('rounding_minutes');\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\TimeEntry;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\Tag;\nuse App\\Models\\Task;\nuse App\\Service\\PermissionStore;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Support\\Facades\\Auth;\nuse Korridor\\LaravelModelValidationRules\\Rules\\ExistsEloquent;\n\n/**\n * @property Organization $organization Organization from model binding\n */\nclass TimeEntryStoreRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule>>\n     */\n    public function rules(): array\n    {\n        return [\n            // ID of the organization member that the time entry should belong to\n            'member_id' => [\n                'required',\n                'string',\n                ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Member> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization');\n                })->uuid(),\n            ],\n            'project_id' => [\n                'nullable',\n                'string',\n                'required_with:task_id',\n                ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Project> $builder */\n                    $builder = $builder->whereBelongsTo($this->organization, 'organization');\n\n                    // If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of\n                    $permissionStore = app(PermissionStore::class);\n                    if (! $permissionStore->has($this->organization, 'time-entries:create:all')\n                        && ! $permissionStore->has($this->organization, 'projects:view:all')) {\n                        $builder = $builder->visibleByEmployee(Auth::user());\n                    }\n\n                    return $builder;\n                })->uuid(),\n            ],\n            // ID of the task that the time entry should belong to\n            'task_id' => [\n                'nullable',\n                'string',\n                ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Task> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization');\n                })->uuid(),\n                ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Task> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization')\n                        ->where('project_id', $this->input('project_id'));\n                })->uuid()->withMessage(__('validation.task_belongs_to_project')),\n            ],\n            // Start of time entry (Format: \"Y-m-d\\TH:i:s\\Z\", UTC timezone, Example: \"2000-02-22T14:58:59Z\")\n            'start' => [\n                'required',\n                'date_format:Y-m-d\\TH:i:s\\Z',\n            ],\n            // End of time entry (Format: \"Y-m-d\\TH:i:s\\Z\", UTC timezone, Example: \"2000-02-22T14:58:59Z\")\n            'end' => [\n                'nullable',\n                'date_format:Y-m-d\\TH:i:s\\Z',\n                'after_or_equal:start',\n            ],\n            // Whether time entry is billable\n            'billable' => [\n                'required',\n                'boolean',\n            ],\n            // Description of time entry\n            'description' => [\n                'nullable',\n                'string',\n                'max:5000',\n            ],\n            // List of tag IDs\n            'tags' => [\n                'nullable',\n                'array',\n            ],\n            'tags.*' => [\n                ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Tag> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization');\n                })->uuid(),\n            ],\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/TimeEntry/TimeEntryUpdateMultipleRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\TimeEntry;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\Tag;\nuse App\\Models\\Task;\nuse App\\Service\\PermissionStore;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Support\\Facades\\Auth;\nuse Korridor\\LaravelModelValidationRules\\Rules\\ExistsEloquent;\n\n/**\n * @property Organization $organization Organization from model binding\n */\nclass TimeEntryUpdateMultipleRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule>>\n     */\n    public function rules(): array\n    {\n        return [\n            'ids' => [\n                'required',\n                'array',\n            ],\n            'ids.*' => [\n                'string',\n                'uuid',\n            ],\n            'changes' => [\n                'required',\n                'array',\n            ],\n            // ID of the organization member that the time entry should belong to\n            'changes.member_id' => [\n                'string',\n                ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Member> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization');\n                })->uuid(),\n            ],\n            // ID of the project that the time entry should belong to\n            'changes.project_id' => [\n                'nullable',\n                'string',\n                'required_with:task_id',\n                ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Project> $builder */\n                    $builder = $builder->whereBelongsTo($this->organization, 'organization');\n\n                    // If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of\n                    $permissionStore = app(PermissionStore::class);\n                    if (! $permissionStore->has($this->organization, 'time-entries:update:all')\n                        && ! $permissionStore->has($this->organization, 'projects:view:all')) {\n                        $builder = $builder->visibleByEmployee(Auth::user());\n                    }\n\n                    return $builder;\n                })->uuid(),\n            ],\n            // ID of the task that the time entry should belong to\n            'changes.task_id' => [\n                'nullable',\n                'string',\n                ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Task> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization');\n                })->uuid(),\n                ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Task> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization')\n                        ->where('project_id', $this->input('changes.project_id'));\n                })->uuid()->withMessage(__('validation.task_belongs_to_project')),\n            ],\n            // Whether time entry is billable\n            'changes.billable' => [\n                'boolean',\n            ],\n            // Description of time entry\n            'changes.description' => [\n                'nullable',\n                'string',\n                'max:5000',\n            ],\n            // List of tag IDs\n            'changes.tags' => [\n                'nullable',\n                'array',\n            ],\n            'changes.tags.*' => [\n                'string',\n                ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Tag> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization');\n                })->uuid(),\n            ],\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Requests/V1/TimeEntry/TimeEntryUpdateRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Requests\\V1\\TimeEntry;\n\nuse App\\Http\\Requests\\V1\\BaseFormRequest;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\Tag;\nuse App\\Models\\Task;\nuse App\\Service\\PermissionStore;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Support\\Facades\\Auth;\nuse Korridor\\LaravelModelValidationRules\\Rules\\ExistsEloquent;\n\n/**\n * @property Organization $organization Organization from model binding\n */\nclass TimeEntryUpdateRequest extends BaseFormRequest\n{\n    /**\n     * Get the validation rules that apply to the request.\n     *\n     * @return array<string, array<string|ValidationRule>>\n     */\n    public function rules(): array\n    {\n        return [\n            // ID of the organization member that the time entry should belong to\n            'member_id' => [\n                'string',\n                ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Member> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization');\n                })->uuid(),\n            ],\n            // ID of the project that the time entry should belong to\n            'project_id' => [\n                'nullable',\n                'string',\n                'required_with:task_id',\n                ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Project> $builder */\n                    $builder = $builder->whereBelongsTo($this->organization, 'organization');\n\n                    // If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of\n                    $permissionStore = app(PermissionStore::class);\n                    if (! $permissionStore->has($this->organization, 'time-entries:update:all')\n                        && ! $permissionStore->has($this->organization, 'projects:view:all')) {\n                        $builder = $builder->visibleByEmployee(Auth::user());\n                    }\n\n                    return $builder;\n                })->uuid(),\n            ],\n            // ID of the task that the time entry should belong to\n            'task_id' => [\n                'nullable',\n                'string',\n                ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Task> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization');\n                })->uuid(),\n                ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Task> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization')\n                        ->where('project_id', $this->input('project_id'));\n                })->uuid()->withMessage(__('validation.task_belongs_to_project')),\n            ],\n            // Start of time entry (Format: \"Y-m-d\\TH:i:s\\Z\", UTC timezone, Example: \"2000-02-22T14:58:59Z\")\n            'start' => [\n                'date_format:Y-m-d\\TH:i:s\\Z',\n            ],\n            // End of time entry (Format: \"Y-m-d\\TH:i:s\\Z\", UTC timezone, Example: \"2000-02-22T14:58:59Z\")\n            'end' => [\n                'nullable',\n                'date_format:Y-m-d\\TH:i:s\\Z',\n                'after_or_equal:start',\n            ],\n            // Whether time entry is billable\n            'billable' => [\n                'boolean',\n            ],\n            // Description of time entry\n            'description' => [\n                'nullable',\n                'string',\n                'max:5000',\n            ],\n            // List of tag IDs\n            'tags' => [\n                'nullable',\n                'array',\n            ],\n            'tags.*' => [\n                'string',\n                ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {\n                    /** @var Builder<Tag> $builder */\n                    return $builder->whereBelongsTo($this->organization, 'organization');\n                })->uuid(),\n            ],\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Resources/PaginatedResourceCollection.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources;\n\ninterface PaginatedResourceCollection {}\n"
  },
  {
    "path": "app/Http/Resources/V1/ApiToken/ApiTokenCollection.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\ApiToken;\n\nuse Illuminate\\Http\\Resources\\Json\\ResourceCollection;\n\nclass ApiTokenCollection extends ResourceCollection\n{\n    /**\n     * The resource that this resource collects.\n     *\n     * @var string\n     */\n    public $collects = ApiTokenResource::class;\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/ApiToken/ApiTokenResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\ApiToken;\n\nuse App\\Http\\Resources\\V1\\BaseResource;\nuse App\\Models\\Passport\\Token;\nuse Illuminate\\Http\\Request;\n\n/**\n * @property-read Token $resource\n */\nclass ApiTokenResource extends BaseResource\n{\n    /**\n     * Transform the resource into an array.\n     *\n     * @return array<string, string|bool|int|null|array<string>>\n     */\n    public function toArray(Request $request): array\n    {\n        return [\n            /** @var string $id ID of the API token, this ID is NOT a UUID */\n            'id' => $this->resource->id,\n            /** @var string $name Name of the API token */\n            'name' => $this->resource->name,\n            /** @var bool $revoked Whether the API token is revoked */\n            'revoked' => $this->resource->revoked,\n            /** @var array<string> $scopes List of scopes that the API token has */\n            'scopes' => $this->resource->scopes,\n            /** @var string $created_at When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */\n            'created_at' => $this->formatDateTime($this->resource->created_at),\n            /** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */\n            'expires_at' => $this->formatDateTime($this->resource->expires_at),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/ApiToken/ApiTokenWithAccessTokenResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\ApiToken;\n\nuse App\\Http\\Resources\\V1\\BaseResource;\nuse App\\Models\\Passport\\Token;\nuse Illuminate\\Http\\Request;\n\n/**\n * @property-read Token $resource\n */\nclass ApiTokenWithAccessTokenResource extends BaseResource\n{\n    private string $accessToken;\n\n    public function __construct(Token $resource, string $accessToken)\n    {\n        $this->accessToken = $accessToken;\n        parent::__construct($resource);\n    }\n\n    /**\n     * Transform the resource into an array.\n     *\n     * @return array<string, string|bool|int|null|array<string>>\n     */\n    public function toArray(Request $request): array\n    {\n        return [\n            /** @var string $id ID of the API token, this ID is NOT a UUID */\n            'id' => $this->resource->id,\n            /** @var string $name Name of the API token */\n            'name' => $this->resource->name,\n            /** @var bool $revoked Whether the API token is revoked */\n            'revoked' => $this->resource->revoked,\n            /** @var array<string> $scopes List of scopes that the API token has */\n            'scopes' => $this->resource->scopes,\n            /** @var string $created_at When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */\n            'created_at' => $this->formatDateTime($this->resource->created_at),\n            /** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */\n            'expires_at' => $this->formatDateTime($this->resource->expires_at),\n            // Additional fields\n            /** @var string $access_token Access token that can be used to authenticate requests */\n            'access_token' => $this->accessToken,\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/BaseResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1;\n\nuse Illuminate\\Http\\Resources\\Json\\JsonResource;\nuse Illuminate\\Support\\Carbon;\n\nabstract class BaseResource extends JsonResource\n{\n    protected function formatDateTime(?Carbon $carbon): ?string\n    {\n        return $carbon?->toIso8601ZuluString();\n    }\n\n    protected function formatDate(?Carbon $carbon): ?string\n    {\n        return $carbon?->format('Y-m-d');\n    }\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/Client/ClientCollection.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\Client;\n\nuse App\\Http\\Resources\\PaginatedResourceCollection;\nuse Illuminate\\Http\\Resources\\Json\\ResourceCollection;\n\nclass ClientCollection extends ResourceCollection implements PaginatedResourceCollection\n{\n    /**\n     * The resource that this resource collects.\n     *\n     * @var string\n     */\n    public $collects = ClientResource::class;\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/Client/ClientResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\Client;\n\nuse App\\Http\\Resources\\V1\\BaseResource;\nuse App\\Models\\Client;\nuse Illuminate\\Http\\Request;\n\n/**\n * @property Client $resource\n */\nclass ClientResource extends BaseResource\n{\n    /**\n     * Transform the resource into an array.\n     *\n     * @return array<string, string|bool|int|null>\n     */\n    public function toArray(Request $request): array\n    {\n        return [\n            /** @var string $id ID */\n            'id' => $this->resource->id,\n            /** @var string $name Name */\n            'name' => $this->resource->name,\n            /** @var bool $is_archived Whether the client is archived */\n            'is_archived' => $this->resource->is_archived,\n            /** @var string $created_at When the tag was created */\n            'created_at' => $this->formatDateTime($this->resource->created_at),\n            /** @var string $updated_at When the tag was last updated */\n            'updated_at' => $this->formatDateTime($this->resource->updated_at),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/Invitation/InvitationCollection.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\Invitation;\n\nuse App\\Http\\Resources\\PaginatedResourceCollection;\nuse Illuminate\\Http\\Resources\\Json\\ResourceCollection;\n\nclass InvitationCollection extends ResourceCollection implements PaginatedResourceCollection\n{\n    /**\n     * The resource that this resource collects.\n     *\n     * @var string\n     */\n    public $collects = InvitationResource::class;\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/Invitation/InvitationResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\Invitation;\n\nuse App\\Http\\Resources\\V1\\BaseResource;\nuse App\\Models\\OrganizationInvitation;\nuse Illuminate\\Http\\Request;\n\n/**\n * @property OrganizationInvitation $resource\n */\nclass InvitationResource extends BaseResource\n{\n    /**\n     * Transform the resource into an array.\n     *\n     * @return array<string, string|bool|int|null|array<string>>\n     */\n    public function toArray(Request $request): array\n    {\n        return [\n            /** @var string $id ID of the invitation */\n            'id' => $this->resource->id,\n            /** @var string $email Email */\n            'email' => $this->resource->email,\n            /** @var string $role Role */\n            'role' => $this->resource->role,\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/Member/MemberCollection.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\Member;\n\nuse App\\Http\\Resources\\PaginatedResourceCollection;\nuse Illuminate\\Http\\Resources\\Json\\ResourceCollection;\n\nclass MemberCollection extends ResourceCollection implements PaginatedResourceCollection\n{\n    /**\n     * The resource that this resource collects.\n     *\n     * @var string\n     */\n    public $collects = MemberResource::class;\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/Member/MemberResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\Member;\n\nuse App\\Http\\Resources\\V1\\BaseResource;\nuse App\\Models\\Member;\nuse App\\Models\\User;\nuse Illuminate\\Http\\Request;\n\n/**\n * @property Member $resource\n */\nclass MemberResource extends BaseResource\n{\n    /**\n     * Transform the resource into an array.\n     *\n     * @return array<string, string|bool|int|null|array<string>>\n     */\n    public function toArray(Request $request): array\n    {\n        return [\n            /** @var string $id ID of membership */\n            'id' => $this->resource->id,\n            /** @var string $id ID of user */\n            'user_id' => $this->resource->user->id,\n            /** @var string $name Name */\n            'name' => $this->resource->user->name,\n            /** @var string $email Email */\n            'email' => $this->resource->user->email,\n            /** @var string $role Role */\n            'role' => $this->resource->role,\n            /** @var bool $is_placeholder Placeholder user for imports, user might not really exist and does not know about this placeholder membership */\n            'is_placeholder' => $this->resource->user->is_placeholder,\n            /** @var int|null $billable_rate Billable rate in cents per hour */\n            'billable_rate' => $this->resource->billable_rate,\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/Member/PersonalMembershipCollection.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\Member;\n\nuse App\\Http\\Resources\\PaginatedResourceCollection;\nuse Illuminate\\Http\\Resources\\Json\\ResourceCollection;\n\nclass PersonalMembershipCollection extends ResourceCollection implements PaginatedResourceCollection\n{\n    /**\n     * The resource that this resource collects.\n     *\n     * @var string\n     */\n    public $collects = PersonalMembershipResource::class;\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/Member/PersonalMembershipResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\Member;\n\nuse App\\Http\\Resources\\V1\\BaseResource;\nuse App\\Models\\Member;\nuse Illuminate\\Http\\Request;\n\n/**\n * @property Member $resource\n */\nclass PersonalMembershipResource extends BaseResource\n{\n    /**\n     * Transform the resource into an array.\n     *\n     * @return array<string, string|bool|int|null|array<string>>\n     */\n    public function toArray(Request $request): array\n    {\n        return [\n            /** @var string $id ID of membership */\n            'id' => $this->resource->id,\n            'organization' => [\n                /** @var string $id ID of organization */\n                'id' => $this->resource->organization->id,\n                /** @var string $name Name of organization */\n                'name' => $this->resource->organization->name,\n                /** @var string $currency Currency code (ISO 4217) of organization */\n                'currency' => $this->resource->organization->currency,\n            ],\n            /** @var string $role Role */\n            'role' => $this->resource->role,\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/Organization/OrganizationResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\Organization;\n\nuse App\\Enums\\CurrencyFormat;\nuse App\\Enums\\DateFormat;\nuse App\\Enums\\IntervalFormat;\nuse App\\Enums\\NumberFormat;\nuse App\\Enums\\TimeFormat;\nuse App\\Http\\Resources\\V1\\BaseResource;\nuse App\\Models\\Organization;\nuse App\\Service\\CurrencyService;\nuse Illuminate\\Http\\Request;\n\n/**\n * @property Organization $resource\n */\nclass OrganizationResource extends BaseResource\n{\n    private bool $showBillableRate;\n\n    /**\n     * Create a new resource instance.\n     *\n     * @return void\n     */\n    public function __construct(Organization $resource, bool $showBillableRate)\n    {\n        parent::__construct($resource);\n\n        $this->showBillableRate = $showBillableRate;\n    }\n\n    /**\n     * Transform the resource into an array.\n     *\n     * @return array<string, string|bool|int|null>\n     */\n    public function toArray(Request $request): array\n    {\n        $currencyService = app(CurrencyService::class);\n\n        return [\n            /** @var string $id ID */\n            'id' => $this->resource->id,\n            /** @var string $name Name */\n            'name' => $this->resource->name,\n            /** @var bool $color Personal organizations automatically created after registration */\n            'is_personal' => $this->resource->personal_team,\n            /** @var int|null $billable_rate Billable rate in cents per hour */\n            'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,\n            /** @var bool $employees_can_see_billable_rates Can members of the organization with role \"employee\" see the billable rates */\n            'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,\n            /** @var bool $employees_can_manage_tasks Can members of the organization with role \"employee\" manage tasks in public projects and projects they are assigned to */\n            'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks,\n            /** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */\n            'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,\n            /** @var string $currency Currency code (ISO 4217) */\n            'currency' => $this->resource->currency,\n            /** @var string $currency_symbol Currency symbol */\n            'currency_symbol' => $currencyService->getCurrencySymbol($this->resource->currency),\n            /** @var NumberFormat $number_format Number format */\n            'number_format' => $this->resource->number_format->value,\n            /** @var CurrencyFormat $currency_format Currency format */\n            'currency_format' => $this->resource->currency_format->value,\n            /** @var DateFormat $date_format Date format */\n            'date_format' => $this->resource->date_format->value,\n            /** @var IntervalFormat $interval_format Interval format */\n            'interval_format' => $this->resource->interval_format->value,\n            /** @var TimeFormat $time_format Time format */\n            'time_format' => $this->resource->time_format->value,\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/Project/ProjectCollection.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\Project;\n\nuse App\\Http\\Resources\\PaginatedResourceCollection;\nuse App\\Models\\Project;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Http\\Resources\\Json\\ResourceCollection;\n\nclass ProjectCollection extends ResourceCollection implements PaginatedResourceCollection\n{\n    private bool $showBillableRates;\n\n    public function __construct($resource, bool $showBillableRates)\n    {\n        parent::__construct($resource);\n        $this->showBillableRates = $showBillableRates;\n    }\n\n    protected function collects(): ?string\n    {\n        return null;\n    }\n\n    /**\n     * Transform the resource collection into an array.\n     *\n     * @return array<array<string, string|bool|int|null>>\n     */\n    public function toArray(Request $request): array\n    {\n        return $this->collection->map(function (Project $project) use ($request): array {\n            return (new ProjectResource($project, $this->showBillableRates))\n                ->toArray($request);\n        })->all();\n    }\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/Project/ProjectResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\Project;\n\nuse App\\Http\\Resources\\V1\\BaseResource;\nuse App\\Models\\Project;\nuse Illuminate\\Http\\Request;\n\n/**\n * @property Project $resource\n */\nclass ProjectResource extends BaseResource\n{\n    private bool $showBillableRate;\n\n    public function __construct(Project $resource, bool $showBillableRate)\n    {\n        parent::__construct($resource);\n\n        $this->showBillableRate = $showBillableRate;\n    }\n\n    /**\n     * Transform the resource into an array.\n     *\n     * @return array<string, string|bool|int|null>\n     */\n    public function toArray(Request $request): array\n    {\n        return [\n            /** @var string $id ID of project */\n            'id' => $this->resource->id,\n            /** @var string $name Name of project */\n            'name' => $this->resource->name,\n            /** @var string $color Color of project */\n            'color' => $this->resource->color,\n            /** @var string|null $client_id ID of client */\n            'client_id' => $this->resource->client_id,\n            /** @var bool $is_archived Whether the client is archived */\n            'is_archived' => $this->resource->is_archived,\n            /** @var int|null $billable_rate Billable rate in cents per hour */\n            'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,\n            /** @var bool $is_billable Project time entries billable default */\n            'is_billable' => $this->resource->is_billable,\n            /** @var int|null $estimated_time Estimated time in seconds */\n            'estimated_time' => $this->resource->estimated_time,\n            /** @var int $spent_time Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */\n            'spent_time' => $this->resource->spent_time,\n            /** @var bool $is_public Whether the project is public */\n            'is_public' => $this->resource->is_public,\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/ProjectMember/ProjectMemberCollection.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\ProjectMember;\n\nuse App\\Http\\Resources\\PaginatedResourceCollection;\nuse Illuminate\\Http\\Resources\\Json\\ResourceCollection;\n\nclass ProjectMemberCollection extends ResourceCollection implements PaginatedResourceCollection\n{\n    /**\n     * The resource that this resource collects.\n     *\n     * @var string\n     */\n    public $collects = ProjectMemberResource::class;\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/ProjectMember/ProjectMemberResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\ProjectMember;\n\nuse App\\Http\\Resources\\V1\\BaseResource;\nuse App\\Models\\ProjectMember;\nuse Illuminate\\Http\\Request;\n\n/**\n * @property ProjectMember $resource\n */\nclass ProjectMemberResource extends BaseResource\n{\n    /**\n     * Transform the resource into an array.\n     *\n     * @return array<string, string|bool|int|null>\n     */\n    public function toArray(Request $request): array\n    {\n        return [\n            /** @var string $id ID of project member */\n            'id' => $this->resource->id,\n            /** @var int|null $billable_rate Billable rate in cents per hour */\n            'billable_rate' => $this->resource->billable_rate,\n            /** @var string $member_id ID of the organization member */\n            'member_id' => $this->resource->member_id,\n            /** @var string $project_id ID of the project */\n            'project_id' => $this->resource->project_id,\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/Report/DetailedReportResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\Report;\n\nuse App\\Http\\Resources\\V1\\BaseResource;\nuse App\\Models\\Report;\nuse Illuminate\\Http\\Request;\n\n/**\n * @property Report $resource\n */\nclass DetailedReportResource extends BaseResource\n{\n    /**\n     * Transform the resource into an array.\n     *\n     * @return array<string, string|bool|int|null|array<string, string|bool|int|null|array<int, string>>>\n     */\n    public function toArray(Request $request): array\n    {\n        return [\n            /** @var string $id ID of the report */\n            'id' => $this->resource->id,\n            /** @var string $name Name */\n            'name' => $this->resource->name,\n            /** @var string|null $email Description */\n            'description' => $this->resource->description,\n            /** @var bool $is_public Whether the report can be accessed via an external link */\n            'is_public' => $this->resource->is_public,\n            /** @var string|null $public_until Date until the report is public */\n            'public_until' => $this->formatDateTime($this->resource->public_until),\n            /** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */\n            'shareable_link' => $this->resource->getShareableLink(),\n            'properties' => [\n                /** @var string $group Type of first grouping */\n                'group' => $this->resource->properties->group->value,\n                /** @var string $sub_group Type of second grouping */\n                'sub_group' => $this->resource->properties->subGroup->value,\n                /** @var string $history_group Type of grouping of the historic aggregation (time chart) */\n                'history_group' => $this->resource->properties->historyGroup->value,\n                /** @var string $start Start date of the report */\n                'start' => $this->formatDateTime($this->resource->properties->start),\n                /** @var string $end End date of the report */\n                'end' => $this->formatDateTime($this->resource->properties->end),\n                /** @var bool|null $active Whether the report is active */\n                'active' => $this->resource->properties->active,\n                /** @var array<string>|null $member_ids Filter by multiple member IDs, member IDs are OR combined */\n                'member_ids' => $this->resource->properties->memberIds?->toArray(),\n                /** @var bool|null $billable Filter by billable status */\n                'billable' => $this->resource->properties->billable,\n                /** @var array<string>|null $client_ids Filter by client IDs, client IDs are OR combined */\n                'client_ids' => $this->resource->properties->clientIds?->toArray(),\n                /** @var array<string>|null $project_ids Filter by project IDs, project IDs are OR combined */\n                'project_ids' => $this->resource->properties->projectIds?->toArray(),\n                /** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */\n                'tag_ids' => $this->resource->properties->tagIds?->toArray(),\n                /** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */\n                'task_ids' => $this->resource->properties->taskIds?->toArray(),\n                /** @var string|null $rounding_type Rounding type for time entries */\n                'rounding_type' => $this->resource->properties->roundingType?->value,\n                /** @var int|null $rounding_minutes Rounding minutes for time entries */\n                'rounding_minutes' => $this->resource->properties->roundingMinutes,\n            ],\n            /** @var string $created_at Date when the report was created */\n            'created_at' => $this->formatDateTime($this->resource->created_at),\n            /** @var string $updated_at Date when the report was last updated */\n            'updated_at' => $this->formatDateTime($this->resource->updated_at),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/Report/DetailedWithDataReportResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\Report;\n\nuse App\\Enums\\CurrencyFormat;\nuse App\\Enums\\DateFormat;\nuse App\\Enums\\IntervalFormat;\nuse App\\Enums\\NumberFormat;\nuse App\\Enums\\TimeFormat;\nuse App\\Http\\Resources\\V1\\BaseResource;\nuse App\\Models\\Report;\nuse App\\Service\\CurrencyService;\nuse Illuminate\\Http\\Request;\n\n/**\n * @property Report $resource\n *\n * @phpstan-type Data array{\n *          grouped_type: string|null,\n *          grouped_data: null|array<array{\n *              key: string|null,\n *              description: string|null,\n *              color: string|null,\n *              seconds: int,\n *              cost: int|null,\n *              grouped_type: string|null,\n *              grouped_data: null|array<array{\n *                  key: string|null,\n *                  description: string|null,\n *                  color: string|null,\n *                  seconds: int,\n *                  cost: int|null,\n *                  grouped_type: null,\n *                  grouped_data: null\n *              }>\n *          }>,\n *          seconds: int,\n *          cost: int|null\n *    }\n */\nclass DetailedWithDataReportResource extends BaseResource\n{\n    /**\n     * @var Data\n     */\n    private array $data;\n\n    /**\n     * @var Data\n     */\n    private array $historyData;\n\n    /**\n     * @param  Data  $data\n     * @param  Data  $historyData\n     */\n    public function __construct(Report $resource, array $data, array $historyData)\n    {\n        parent::__construct($resource);\n        $this->data = $data;\n        $this->historyData = $historyData;\n    }\n\n    /**\n     * Transform the resource into an array.\n     *\n     * @return array<string, string|bool|int|null|Data|array<string, string|bool|int|null|array<int, string>>>\n     */\n    public function toArray(Request $request): array\n    {\n        $currencyService = app(CurrencyService::class);\n\n        return [\n            /** @var string $name Name */\n            'name' => $this->resource->name,\n            /** @var string|null $email Description */\n            'description' => $this->resource->description,\n            /** @var string|null $public_until Date until the report is public */\n            'public_until' => $this->formatDateTime($this->resource->public_until),\n            /** @var string $currency Currency code (ISO 4217) */\n            'currency' => $this->resource->organization->currency,\n            /** @var NumberFormat $number_format Number format */\n            'number_format' => $this->resource->organization->number_format->value,\n            /** @var CurrencyFormat $currency_format Currency format */\n            'currency_format' => $this->resource->organization->currency_format->value,\n            /** @var string $currency_symbol Currency symbol */\n            'currency_symbol' => $currencyService->getCurrencySymbol($this->resource->organization->currency),\n            /** @var DateFormat $date_format Date format */\n            'date_format' => $this->resource->organization->date_format->value,\n            /** @var IntervalFormat $interval_format Interval format */\n            'interval_format' => $this->resource->organization->interval_format->value,\n            /** @var TimeFormat $time_format Time format */\n            'time_format' => $this->resource->organization->time_format->value,\n            'properties' => [\n                /** @var string $group Type of first grouping */\n                'group' => $this->resource->properties->group->value,\n                /** @var string $sub_group Type of second grouping */\n                'sub_group' => $this->resource->properties->subGroup->value,\n                /** @var string $history_group Type of grouping of the historic aggregation (time chart) */\n                'history_group' => $this->resource->properties->historyGroup->value,\n                /** @var string $start Start date of the report */\n                'start' => $this->formatDateTime($this->resource->properties->start),\n                /** @var string $end End date of the report */\n                'end' => $this->formatDateTime($this->resource->properties->end),\n            ],\n            /** @var array{\n             *        grouped_type: string|null,\n             *        grouped_data: null|array<array{\n             *            key: string|null,\n             *            description: string|null,\n             *            color: string|null,\n             *            seconds: int,\n             *            cost: int,\n             *            grouped_type: string|null,\n             *            grouped_data: null|array<array{\n             *                key: string|null,\n             *                description: string|null,\n             *                color: string|null,\n             *                seconds: int,\n             *                cost: int,\n             *                grouped_type: null,\n             *                grouped_data: null\n             *            }>\n             *        }>,\n             *        seconds: int,\n             *        cost: int\n             *  } $data Aggregated data\n             */\n            'data' => $this->data,\n            /** @var array{\n             *        grouped_type: string|null,\n             *        grouped_data: null|array<array{\n             *            key: string|null,\n             *            description: string|null,\n             *            seconds: int,\n             *            cost: int,\n             *            grouped_type: string|null,\n             *            grouped_data: null|array<array{\n             *                key: string|null,\n             *                description: string|null,\n             *                seconds: int,\n             *                cost: int,\n             *                grouped_type: null,\n             *                grouped_data: null\n             *            }>\n             *        }>,\n             *        seconds: int,\n             *        cost: int\n             *  } $history_data Historic aggregated data\n             */\n            'history_data' => $this->historyData,\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/Report/ReportCollection.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\Report;\n\nuse App\\Http\\Resources\\PaginatedResourceCollection;\nuse Illuminate\\Http\\Resources\\Json\\ResourceCollection;\n\nclass ReportCollection extends ResourceCollection implements PaginatedResourceCollection\n{\n    /**\n     * The resource that this resource collects.\n     *\n     * @var string\n     */\n    public $collects = ReportResource::class;\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/Report/ReportResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\Report;\n\nuse App\\Http\\Resources\\V1\\BaseResource;\nuse App\\Models\\Report;\nuse Illuminate\\Http\\Request;\n\n/**\n * @property Report $resource\n */\nclass ReportResource extends BaseResource\n{\n    /**\n     * Transform the resource into an array.\n     *\n     * @return array<string, string|bool|int|null|array<string>>\n     */\n    public function toArray(Request $request): array\n    {\n        return [\n            /** @var string $id ID of the report */\n            'id' => $this->resource->id,\n            /** @var string $name Name */\n            'name' => $this->resource->name,\n            /** @var string|null $email Description */\n            'description' => $this->resource->description,\n            /** @var bool $is_public Whether the report can be accessed via an external link */\n            'is_public' => $this->resource->is_public,\n            /** @var string|null $public_until Date until the report is public */\n            'public_until' => $this->formatDateTime($this->resource->public_until),\n            /** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */\n            'shareable_link' => $this->resource->getShareableLink(),\n            /** @var string $created_at Date when the report was created */\n            'created_at' => $this->formatDateTime($this->resource->created_at),\n            /** @var string $updated_at Date when the report was last updated */\n            'updated_at' => $this->formatDateTime($this->resource->updated_at),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/Tag/TagCollection.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\Tag;\n\nuse App\\Http\\Resources\\PaginatedResourceCollection;\nuse Illuminate\\Http\\Resources\\Json\\ResourceCollection;\n\nclass TagCollection extends ResourceCollection implements PaginatedResourceCollection\n{\n    /**\n     * The resource that this resource collects.\n     *\n     * @var string\n     */\n    public $collects = TagResource::class;\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/Tag/TagResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\Tag;\n\nuse App\\Http\\Resources\\V1\\BaseResource;\nuse App\\Models\\Tag;\nuse Illuminate\\Http\\Request;\n\n/**\n * @property Tag $resource\n */\nclass TagResource extends BaseResource\n{\n    /**\n     * Transform the resource into an array.\n     *\n     * @return array<string, string|bool|int|null>\n     */\n    public function toArray(Request $request): array\n    {\n        return [\n            /** @var string $id ID */\n            'id' => $this->resource->id,\n            /** @var string $name Name */\n            'name' => $this->resource->name,\n            /** @var string $created_at When the tag was created */\n            'created_at' => $this->formatDateTime($this->resource->created_at),\n            /** @var string $updated_at When the tag was last updated */\n            'updated_at' => $this->formatDateTime($this->resource->updated_at),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/Task/TaskCollection.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\Task;\n\nuse App\\Http\\Resources\\PaginatedResourceCollection;\nuse Illuminate\\Http\\Resources\\Json\\ResourceCollection;\n\nclass TaskCollection extends ResourceCollection implements PaginatedResourceCollection\n{\n    /**\n     * The resource that this resource collects.\n     *\n     * @var string\n     */\n    public $collects = TaskResource::class;\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/Task/TaskResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\Task;\n\nuse App\\Http\\Resources\\V1\\BaseResource;\nuse App\\Models\\Tag;\nuse App\\Models\\Task;\nuse Illuminate\\Http\\Request;\n\n/**\n * @property Task $resource\n */\nclass TaskResource extends BaseResource\n{\n    /**\n     * Transform the resource into an array.\n     *\n     * @return array<string, string|bool|int|null>\n     */\n    public function toArray(Request $request): array\n    {\n        return [\n            /** @var string $id ID */\n            'id' => $this->resource->id,\n            /** @var string $name Name */\n            'name' => $this->resource->name,\n            /** @var bool $is_done Whether the task is done */\n            'is_done' => $this->resource->is_done,\n            /** @var string $project_id ID of the project */\n            'project_id' => $this->resource->project_id,\n            /** @var int|null $estimated_time Estimated time in seconds */\n            'estimated_time' => $this->resource->estimated_time,\n            /** @var int $spent_time Spent time on this task in seconds (sum of the duration of all associated time entries, excl. still running time entries) */\n            'spent_time' => $this->resource->spent_time,\n            /** @var string $created_at When the tag was created */\n            'created_at' => $this->formatDateTime($this->resource->created_at),\n            /** @var string $updated_at When the tag was last updated */\n            'updated_at' => $this->formatDateTime($this->resource->updated_at),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/TimeEntry/TimeEntryCollection.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\TimeEntry;\n\nuse App\\Http\\Resources\\PaginatedResourceCollection;\nuse Illuminate\\Http\\Resources\\Json\\ResourceCollection;\n\nclass TimeEntryCollection extends ResourceCollection implements PaginatedResourceCollection\n{\n    /**\n     * The resource that this resource collects.\n     *\n     * @var string\n     */\n    public $collects = TimeEntryResource::class;\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/TimeEntry/TimeEntryResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\TimeEntry;\n\nuse App\\Http\\Resources\\V1\\BaseResource;\nuse App\\Models\\TimeEntry;\nuse Illuminate\\Http\\Request;\n\n/**\n * @property TimeEntry $resource\n */\nclass TimeEntryResource extends BaseResource\n{\n    /**\n     * Transform the resource into an array.\n     *\n     * @return array<string, string|bool|int|null|array<string>>\n     */\n    public function toArray(Request $request): array\n    {\n        return [\n            /** @var string $id ID of time entry */\n            'id' => $this->resource->id,\n            /**\n             * @var string $start Start of time entry (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z)\n             */\n            'start' => $this->formatDateTime($this->resource->start),\n            /**\n             * @var string|null $end End of time entry (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z)\n             */\n            'end' => $this->formatDateTime($this->resource->end),\n            /** @var int|null $duration Duration of time entry in seconds */\n            'duration' => (int) $this->resource->getDuration()?->totalSeconds,\n            /** @var string|null $description Description of time entry */\n            'description' => $this->resource->description,\n            /** @var string|null $task_id ID of task */\n            'task_id' => $this->resource->task_id,\n            /** @var string|null $project_id ID of project */\n            'project_id' => $this->resource->project_id,\n            /** @var string $organization_id ID of organization */\n            'organization_id' => $this->resource->organization_id,\n            /** @var string $user_id ID of user */\n            'user_id' => $this->resource->user_id,\n            /** @var array<string> $tags List of tag IDs */\n            'tags' => $this->resource->tags ?? [],\n            /** @var bool $billable Whether time entry is billable */\n            'billable' => $this->resource->billable,\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Http/Resources/V1/User/UserResource.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Http\\Resources\\V1\\User;\n\nuse App\\Enums\\Weekday;\nuse App\\Http\\Resources\\V1\\BaseResource;\nuse App\\Models\\User;\nuse Illuminate\\Http\\Request;\n\n/**\n * @property User $resource\n */\nclass UserResource extends BaseResource\n{\n    /**\n     * Transform the resource into an array.\n     *\n     * @return array<string, string|bool|int|null|array<string>>\n     */\n    public function toArray(Request $request): array\n    {\n        return [\n            /** @var string $id ID of user */\n            'id' => $this->resource->id,\n            /** @var string $name Name of user */\n            'name' => $this->resource->name,\n            /** @var string $email Email of user */\n            'email' => $this->resource->email,\n            /** @var string $profile_photo_url Profile photo URL */\n            'profile_photo_url' => $this->resource->profile_photo_url,\n            /** @var string $timezone Timezone (f.e. Europe/Berlin or America/New_York) */\n            'timezone' => $this->resource->timezone,\n            /** @var Weekday $week_start Starting day of the week */\n            'week_start' => $this->resource->week_start->value,\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Jobs/RecalculateSpentTimeForProject.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Jobs;\n\nuse App\\Models\\Project;\nuse Exception;\nuse Illuminate\\Bus\\Queueable;\nuse Illuminate\\Contracts\\Events\\ShouldDispatchAfterCommit;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Illuminate\\Foundation\\Bus\\Dispatchable;\nuse Illuminate\\Queue\\InteractsWithQueue;\nuse Illuminate\\Queue\\SerializesModels;\n\nclass RecalculateSpentTimeForProject implements ShouldDispatchAfterCommit, ShouldQueue\n{\n    use Dispatchable;\n    use InteractsWithQueue;\n    use Queueable;\n    use SerializesModels;\n\n    public Project $project;\n\n    /**\n     * Create a new job instance.\n     */\n    public function __construct(Project $project)\n    {\n        $this->project = $project;\n    }\n\n    /**\n     * Execute the job.\n     *\n     * @throws Exception\n     */\n    public function handle(): void\n    {\n        $this->project->setComputedAttributeValue('spent_time');\n        if ($this->project->isDirty()) {\n            $this->project->save();\n        }\n    }\n}\n"
  },
  {
    "path": "app/Jobs/RecalculateSpentTimeForTask.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Jobs;\n\nuse App\\Models\\Task;\nuse Exception;\nuse Illuminate\\Bus\\Queueable;\nuse Illuminate\\Contracts\\Events\\ShouldDispatchAfterCommit;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Illuminate\\Foundation\\Bus\\Dispatchable;\nuse Illuminate\\Queue\\InteractsWithQueue;\nuse Illuminate\\Queue\\SerializesModels;\n\nclass RecalculateSpentTimeForTask implements ShouldDispatchAfterCommit, ShouldQueue\n{\n    use Dispatchable;\n    use InteractsWithQueue;\n    use Queueable;\n    use SerializesModels;\n\n    public Task $task;\n\n    /**\n     * Create a new job instance.\n     */\n    public function __construct(Task $task)\n    {\n        $this->task = $task;\n    }\n\n    /**\n     * Execute the job.\n     *\n     * @throws Exception\n     */\n    public function handle(): void\n    {\n        $this->task->setComputedAttributeValue('spent_time');\n        if ($this->task->isDirty()) {\n            $this->task->save();\n        }\n    }\n}\n"
  },
  {
    "path": "app/Jobs/Test/TestJob.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Jobs\\Test;\n\nuse App\\Models\\User;\nuse Exception;\nuse Illuminate\\Bus\\Queueable;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Illuminate\\Foundation\\Bus\\Dispatchable;\nuse Illuminate\\Queue\\InteractsWithQueue;\nuse Illuminate\\Queue\\SerializesModels;\nuse Illuminate\\Support\\Facades\\Log;\n\nclass TestJob implements ShouldQueue\n{\n    use Dispatchable;\n    use InteractsWithQueue;\n    use Queueable;\n    use SerializesModels;\n\n    private User $user;\n\n    private string $message;\n\n    private bool $fail;\n\n    /**\n     * Create a new job instance.\n     */\n    public function __construct(User $user, string $message, bool $fail = false)\n    {\n        $this->user = $user;\n        $this->message = $message;\n        $this->fail = $fail;\n    }\n\n    /**\n     * Execute the job.\n     *\n     * @throws Exception\n     */\n    public function handle(): void\n    {\n        Log::debug('TestJob: '.$this->message, [\n            'user' => $this->user->getKey(),\n        ]);\n        if ($this->fail) {\n            throw new Exception('TestJob failed.');\n        }\n    }\n}\n"
  },
  {
    "path": "app/Listeners/RemovePlaceholder.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Listeners;\n\nuse App\\Models\\Member;\nuse App\\Models\\User;\nuse App\\Service\\MemberService;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Laravel\\Jetstream\\Events\\TeamMemberAdded;\n\nclass RemovePlaceholder\n{\n    /**\n     * Handle the event.\n     */\n    public function handle(TeamMemberAdded $event): void\n    {\n        $memberService = app(MemberService::class);\n        $member = Member::query()\n            ->whereBelongsTo($event->team, 'organization')\n            ->whereBelongsTo($event->user, 'user')\n            ->firstOrFail();\n        $placeholders = Member::query()\n            ->whereHas('user', function (Builder $query) use ($event): void {\n                /** @var Builder<User> $query */\n                $query->where('is_placeholder', '=', true)\n                    ->where('email', '=', $event->user->email);\n            })\n            ->whereBelongsTo($event->team, 'organization')\n            ->with(['user'])\n            ->get();\n\n        foreach ($placeholders as $placeholder) {\n            /** @var Member $placeholder */\n            $placeholderUser = $placeholder->user;\n            $memberService->assignOrganizationEntitiesToDifferentMember($event->team, $placeholder, $member);\n            $placeholder->delete();\n            $placeholderUser->delete();\n        }\n    }\n}\n"
  },
  {
    "path": "app/Mail/AuthApiTokenExpirationReminderMail.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Mail;\n\nuse App\\Models\\Passport\\Token;\nuse App\\Models\\User;\nuse Illuminate\\Bus\\Queueable;\nuse Illuminate\\Mail\\Mailable;\nuse Illuminate\\Queue\\SerializesModels;\nuse Illuminate\\Support\\Facades\\URL;\n\nclass AuthApiTokenExpirationReminderMail extends Mailable\n{\n    use Queueable, SerializesModels;\n\n    public Token $token;\n\n    public User $user;\n\n    /**\n     * Create a new message instance.\n     *\n     * @return void\n     */\n    public function __construct(Token $token, User $user)\n    {\n        $this->token = $token;\n        $this->user = $user;\n    }\n\n    /**\n     * Build the message.\n     */\n    public function build(): self\n    {\n        return $this->markdown('emails.auth-api-expiration-reminder', [\n            'profileUrl' => URL::to('user/profile'),\n            'tokenName' => $this->token->name,\n        ])\n            ->subject(__('Your API token will expire in 7 days!'));\n    }\n}\n"
  },
  {
    "path": "app/Mail/AuthApiTokenExpiredMail.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Mail;\n\nuse App\\Models\\Passport\\Token;\nuse App\\Models\\User;\nuse Illuminate\\Bus\\Queueable;\nuse Illuminate\\Mail\\Mailable;\nuse Illuminate\\Queue\\SerializesModels;\nuse Illuminate\\Support\\Facades\\URL;\n\nclass AuthApiTokenExpiredMail extends Mailable\n{\n    use Queueable, SerializesModels;\n\n    public Token $token;\n\n    public User $user;\n\n    /**\n     * Create a new message instance.\n     *\n     * @return void\n     */\n    public function __construct(Token $token, User $user)\n    {\n        $this->token = $token;\n        $this->user = $user;\n    }\n\n    /**\n     * Build the message.\n     */\n    public function build(): self\n    {\n        return $this->markdown('emails.auth-api-token-expired', [\n            'profileUrl' => URL::to('user/profile'),\n            'tokenName' => $this->token->name,\n        ])\n            ->subject(__('Your API token has expired!'));\n    }\n}\n"
  },
  {
    "path": "app/Mail/OrganizationInvitationMail.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Mail;\n\nuse App\\Models\\OrganizationInvitation;\nuse Illuminate\\Bus\\Queueable;\nuse Illuminate\\Mail\\Mailable;\nuse Illuminate\\Queue\\SerializesModels;\nuse Illuminate\\Support\\Facades\\URL;\n\nclass OrganizationInvitationMail extends Mailable\n{\n    use Queueable, SerializesModels;\n\n    public OrganizationInvitation $invitation;\n\n    /**\n     * Create a new message instance.\n     *\n     * @return void\n     */\n    public function __construct(OrganizationInvitation $invitation)\n    {\n        $this->invitation = $invitation;\n    }\n\n    /**\n     * Build the message.\n     */\n    public function build(): self\n    {\n        return $this->markdown('emails.organization-invitation', [\n            'acceptUrl' => URL::signedRoute('team-invitations.accept', [\n                'invitation' => $this->invitation,\n            ]),\n        ])->subject(__('Organization Invitation'));\n    }\n}\n"
  },
  {
    "path": "app/Mail/TimeEntryStillRunningMail.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Mail;\n\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse Illuminate\\Bus\\Queueable;\nuse Illuminate\\Mail\\Mailable;\nuse Illuminate\\Queue\\SerializesModels;\nuse Illuminate\\Support\\Facades\\URL;\n\nclass TimeEntryStillRunningMail extends Mailable\n{\n    use Queueable, SerializesModels;\n\n    public TimeEntry $timeEntry;\n\n    public User $user;\n\n    /**\n     * Create a new message instance.\n     *\n     * @return void\n     */\n    public function __construct(TimeEntry $timeEntry, User $user)\n    {\n        $this->timeEntry = $timeEntry;\n        $this->user = $user;\n    }\n\n    /**\n     * Build the message.\n     */\n    public function build(): self\n    {\n        return $this->markdown('emails.time-entry-still-running', [\n            'dashboardUrl' => URL::route('dashboard'),\n        ])\n            ->subject(__('Your Time Tracker is still running!'));\n    }\n}\n"
  },
  {
    "path": "app/Models/Audit.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Models;\n\nuse Database\\Factories\\AuditFactory;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Support\\Carbon;\nuse OwenIt\\Auditing\\Models\\Audit as PackageAuditModel;\n\n/**\n * @property int $id\n * @property string|null $user_type\n * @property string|null $user_id\n * @property string $event\n * @property string $auditable_type\n * @property string $auditable_id\n * @property array<string, mixed>|null $old_values\n * @property array<string, mixed>|null $new_values\n * @property string|null $url\n * @property string|null $ip_address\n * @property string|null $user_agent\n * @property string|null $tags\n * @property Carbon|null $created_at\n * @property Carbon|null $updated_at\n *\n * @method static AuditFactory factory()\n */\nclass Audit extends PackageAuditModel\n{\n    /** @use HasFactory<AuditFactory> */\n    use HasFactory;\n}\n"
  },
  {
    "path": "app/Models/Client.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Models;\n\nuse App\\Models\\Concerns\\CustomAuditable;\nuse App\\Models\\Concerns\\HasUuids;\nuse Database\\Factories\\ClientFactory;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Database\\Eloquent\\Casts\\Attribute;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Support\\Carbon;\nuse OwenIt\\Auditing\\Contracts\\Auditable as AuditableContract;\n\n/**\n * @property string $id\n * @property string $name\n * @property string $organization_id\n * @property-read bool $is_archived\n * @property Carbon|null $archived_at\n * @property Carbon|null $created_at\n * @property Carbon|null $updated_at\n * @property-read Organization $organization\n *\n * @method static ClientFactory factory()\n */\nclass Client extends Model implements AuditableContract\n{\n    use CustomAuditable;\n\n    /** @use HasFactory<ClientFactory> */\n    use HasFactory;\n\n    use HasUuids;\n\n    /**\n     * The attributes that should be cast.\n     *\n     * @var array<string, string>\n     */\n    protected $casts = [\n        'name' => 'string',\n        'archived_at' => 'datetime',\n    ];\n\n    /**\n     * @return BelongsTo<Organization, $this>\n     */\n    public function organization(): BelongsTo\n    {\n        return $this->belongsTo(Organization::class, 'organization_id');\n    }\n\n    /**\n     * @return HasMany<Project, $this>\n     */\n    public function projects(): HasMany\n    {\n        return $this->hasMany(Project::class, 'client_id');\n    }\n\n    /**\n     * @param  Builder<Client>  $builder\n     * @return Builder<Client>\n     */\n    public function scopeVisibleByEmployee(Builder $builder, User $user): Builder\n    {\n        return $builder->whereHas('projects', function (Builder $builder) use ($user): Builder {\n            /** @var Builder<Project> $builder */\n            return $builder->visibleByEmployee($user);\n        });\n    }\n\n    /**\n     * @return Attribute<bool, never>\n     */\n    protected function isArchived(): Attribute\n    {\n        return Attribute::make(\n            get: fn (mixed $value, array $attributes) => isset($attributes['archived_at']),\n        );\n    }\n}\n"
  },
  {
    "path": "app/Models/Concerns/CustomAuditable.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Models\\Concerns;\n\nuse OwenIt\\Auditing\\Auditable;\n\ntrait CustomAuditable\n{\n    use Auditable;\n\n    /**\n     * @var array<string>|null\n     */\n    protected ?array $auditEvents = null;\n\n    public function disableAuditing(): void\n    {\n        $this->auditEvents = [];\n    }\n}\n"
  },
  {
    "path": "app/Models/Concerns/HasUuids.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Models\\Concerns;\n\nuse Ramsey\\Uuid\\Uuid;\n\ntrait HasUuids\n{\n    use \\Illuminate\\Database\\Eloquent\\Concerns\\HasUuids;\n\n    /**\n     * Generate a new UUID for the model.\n     */\n    public function newUniqueId(): string\n    {\n        return (string) Uuid::uuid4();\n    }\n}\n"
  },
  {
    "path": "app/Models/FailedJob.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Models;\n\nuse Database\\Factories\\FailedJobFactory;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Support\\Carbon;\n\n/**\n * @property string $uuid\n * @property string $connection\n * @property string $queue\n * @property Carbon $failed_at\n */\nclass FailedJob extends Model\n{\n    /** @use HasFactory<FailedJobFactory> */\n    use HasFactory;\n\n    /**\n     * Indicates if the model should be timestamped.\n     *\n     * @var bool\n     */\n    public $timestamps = false;\n\n    /**\n     * The attributes that should be cast to native types.\n     *\n     * @var array<string, string>\n     */\n    protected $casts = [\n        'failed_at' => 'datetime',\n        'payload' => 'json',\n    ];\n}\n"
  },
  {
    "path": "app/Models/Member.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Models;\n\nuse App\\Models\\Concerns\\CustomAuditable;\nuse App\\Models\\Concerns\\HasUuids;\nuse Database\\Factories\\MemberFactory;\nuse Illuminate\\Database\\Eloquent\\Collection;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Support\\Carbon;\nuse Laravel\\Jetstream\\Membership as JetstreamMembership;\nuse OwenIt\\Auditing\\Contracts\\Auditable as AuditableContract;\n\n/**\n * @property string $id\n * @property string $role\n * @property int|null $billable_rate\n * @property string $organization_id\n * @property string $user_id\n * @property Carbon|null $created_at\n * @property Carbon|null $updated_at\n * @property-read Organization $organization\n * @property-read User $user\n * @property-read Collection<int, ProjectMember> $projectMembers\n * @property-read Collection<int, TimeEntry> $timeEntries\n *\n * @method static MemberFactory factory()\n */\nclass Member extends JetstreamMembership implements AuditableContract\n{\n    use CustomAuditable;\n\n    /** @use HasFactory<MemberFactory> */\n    use HasFactory;\n\n    use HasUuids;\n\n    /**\n     * The table associated with the pivot model.\n     *\n     * @var string\n     */\n    protected $table = 'members';\n\n    /**\n     * @return BelongsTo<User, $this>\n     */\n    public function user(): BelongsTo\n    {\n        return $this->belongsTo(User::class, 'user_id');\n    }\n\n    /**\n     * @return BelongsTo<Organization, $this>\n     */\n    public function organization(): BelongsTo\n    {\n        return $this->belongsTo(Organization::class, 'organization_id');\n    }\n\n    /**\n     * @return HasMany<TimeEntry, $this>\n     */\n    public function timeEntries(): HasMany\n    {\n        return $this->hasMany(TimeEntry::class, 'member_id');\n    }\n\n    /**\n     * @return HasMany<ProjectMember, $this>\n     */\n    public function projectMembers(): HasMany\n    {\n        return $this->hasMany(ProjectMember::class, 'member_id');\n    }\n}\n"
  },
  {
    "path": "app/Models/Organization.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Models;\n\nuse App\\Enums\\CurrencyFormat;\nuse App\\Enums\\DateFormat;\nuse App\\Enums\\IntervalFormat;\nuse App\\Enums\\NumberFormat;\nuse App\\Enums\\TimeFormat;\nuse App\\Models\\Concerns\\CustomAuditable;\nuse App\\Models\\Concerns\\HasUuids;\nuse Database\\Factories\\OrganizationFactory;\nuse Illuminate\\Database\\Eloquent\\Collection;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\ModelNotFoundException;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Database\\Eloquent\\Relations\\Pivot;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Str;\nuse Laravel\\Jetstream\\Events\\TeamCreated;\nuse Laravel\\Jetstream\\Events\\TeamDeleted;\nuse Laravel\\Jetstream\\Events\\TeamUpdated;\nuse Laravel\\Jetstream\\Team as JetstreamTeam;\nuse OwenIt\\Auditing\\Contracts\\Auditable as AuditableContract;\n\n/**\n * @property string $id\n * @property string $name\n * @property bool $personal_team\n * @property string $currency\n * @property int|null $billable_rate\n * @property string $user_id\n * @property bool $employees_can_see_billable_rates\n * @property bool $employees_can_manage_tasks\n * @property User $owner\n * @property Carbon|null $created_at\n * @property Carbon|null $updated_at\n * @property Collection<int, User> $users\n * @property Collection<int, User> $realUsers\n * @property-read Collection<int, OrganizationInvitation> $teamInvitations\n * @property Member $membership\n * @property NumberFormat $number_format\n * @property CurrencyFormat $currency_format\n * @property DateFormat $date_format\n * @property IntervalFormat $interval_format\n * @property TimeFormat $time_format\n *\n * @method HasMany<OrganizationInvitation, $this> teamInvitations()\n * @method static OrganizationFactory factory()\n */\nclass Organization extends JetstreamTeam implements AuditableContract\n{\n    use CustomAuditable;\n\n    /** @use HasFactory<OrganizationFactory> */\n    use HasFactory;\n\n    use HasUuids;\n\n    /**\n     * The attributes that should be cast.\n     *\n     * @var array<string, string>\n     */\n    protected $casts = [\n        'name' => 'string',\n        'personal_team' => 'boolean',\n        'currency' => 'string',\n        'employees_can_see_billable_rates' => 'boolean',\n        'employees_can_manage_tasks' => 'boolean',\n        'prevent_overlapping_time_entries' => 'boolean',\n        'number_format' => NumberFormat::class,\n        'currency_format' => CurrencyFormat::class,\n        'date_format' => DateFormat::class,\n        'interval_format' => IntervalFormat::class,\n        'time_format' => TimeFormat::class,\n    ];\n\n    /**\n     * The attributes that are mass assignable.\n     *\n     * @var list<string>\n     */\n    protected $fillable = [\n        'name',\n        'personal_team',\n    ];\n\n    /**\n     * The event map for the model.\n     *\n     * @var array<string, class-string>\n     */\n    protected $dispatchesEvents = [\n        'created' => TeamCreated::class,\n        'updated' => TeamUpdated::class,\n        'deleted' => TeamDeleted::class,\n    ];\n\n    /**\n     * The model's default values for attributes.\n     *\n     * @var array<string, mixed>\n     */\n    protected $attributes = [\n    ];\n\n    /**\n     * Get all the non-placeholder users of the organization including its owner.\n     *\n     * @return Collection<int, User>\n     */\n    public function allRealUsers(): Collection\n    {\n        return $this->realUsers->merge([$this->owner]);\n    }\n\n    public function hasRealUserWithEmail(string $email): bool\n    {\n        return $this->allRealUsers()->contains(function (User $user) use ($email): bool {\n            return $user->email === $email;\n        });\n    }\n\n    /**\n     * Get all the users that belong to the team.\n     *\n     * @return BelongsToMany<User, $this, Pivot, 'membership'>\n     */\n    public function users(): BelongsToMany\n    {\n        return $this->belongsToMany(User::class, Member::class)\n            ->withPivot([\n                'id',\n                'role',\n                'billable_rate',\n            ])\n            ->withTimestamps()\n            ->as('membership');\n    }\n\n    /**\n     * Get the owner of the team.\n     *\n     * @return BelongsTo<User, $this>\n     */\n    public function owner(): BelongsTo\n    {\n        return $this->belongsTo(User::class, 'user_id');\n    }\n\n    /**\n     * @return HasMany<Member, $this>\n     */\n    public function members(): HasMany\n    {\n        return $this->hasMany(Member::class);\n    }\n\n    /**\n     * @return BelongsToMany<User, $this, Pivot, 'membership'>\n     */\n    public function realUsers(): BelongsToMany\n    {\n        return $this->users()\n            ->where('is_placeholder', false);\n    }\n\n    /**\n     * This method prevents an unhandled exception when the ID is not a UUID.\n     * Normally this can be fixed with a route pattern, but Jetstream does not use route model binding.\n     *\n     * @param  array<string>  $columns\n     */\n    public function findOrFail(string $id, array $columns = ['*']): \\Laravel\\Jetstream\\Team\n    {\n        if (! Str::isUuid($id)) {\n            throw (new ModelNotFoundException)->setModel(\n                self::class, $id\n            );\n        }\n\n        return parent::findOrFail($id, $columns);\n    }\n}\n"
  },
  {
    "path": "app/Models/OrganizationInvitation.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Models;\n\nuse App\\Models\\Concerns\\CustomAuditable;\nuse App\\Models\\Concerns\\HasUuids;\nuse Database\\Factories\\OrganizationInvitationFactory;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\nuse Illuminate\\Support\\Carbon;\nuse Laravel\\Jetstream\\TeamInvitation as JetstreamTeamInvitation;\nuse OwenIt\\Auditing\\Contracts\\Auditable as AuditableContract;\n\n/**\n * @property string $id\n * @property string $email\n * @property string $role\n * @property string $organization_id\n * @property Carbon|null $updated_at\n * @property Carbon|null $created_at\n * @property-read Organization $organization\n *\n * @method static OrganizationInvitationFactory factory()\n */\nclass OrganizationInvitation extends JetstreamTeamInvitation implements AuditableContract\n{\n    use CustomAuditable;\n\n    /** @use HasFactory<OrganizationInvitationFactory> */\n    use HasFactory;\n\n    use HasUuids;\n\n    /**\n     * The table associated with the model.\n     *\n     * @var string\n     */\n    protected $table = 'organization_invitations';\n\n    /**\n     * The attributes that are mass assignable.\n     *\n     * @var array<int, string>\n     */\n    protected $fillable = [\n        'email',\n        'role',\n    ];\n\n    /**\n     * Get the organization that the invitation belongs to.\n     *\n     * @return BelongsTo<Organization, $this>\n     */\n    public function organization(): BelongsTo\n    {\n        return $this->belongsTo(Organization::class, 'organization_id');\n    }\n\n    /**\n     * Get the organization that the invitation belongs to.\n     *\n     * @return BelongsTo<Organization, $this>\n     */\n    public function team(): BelongsTo\n    {\n        return $this->belongsTo(Organization::class, 'organization_id');\n    }\n}\n"
  },
  {
    "path": "app/Models/Passport/AuthCode.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Models\\Passport;\n\nuse App\\Models\\User;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\nuse Illuminate\\Support\\Carbon;\nuse Laravel\\Passport\\AuthCode as PassportAuthCode;\n\n/**\n * @property string $id\n * @property string $user_id\n * @property string $client_id\n * @property string|null $scopes\n * @property bool $revoked\n * @property Carbon $expires_at\n */\nclass AuthCode extends PassportAuthCode\n{\n    /**\n     * @return BelongsTo<User, $this>\n     */\n    public function user(): BelongsTo\n    {\n        return $this->belongsTo(User::class, 'user_id');\n    }\n}\n"
  },
  {
    "path": "app/Models/Passport/Client.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Models\\Passport;\n\nuse Database\\Factories\\Passport\\ClientFactory;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Support\\Carbon;\nuse Laravel\\Passport\\Client as PassportClient;\n\n/**\n * @property string $id\n * @property string|null $owner_id\n * @property string|null $owner_type\n * @property string $name\n * @property string|null $secret\n * @property string|null $provider\n * @property array<string> $grant_types\n * @property array<string> $redirect_uris\n * @property Carbon|null $created_at\n * @property Carbon|null $updated_at\n * @property bool $revoked\n */\nclass Client extends PassportClient\n{\n    /** @use HasFactory<ClientFactory> */\n    use HasFactory;\n\n    /**\n     * Create a new factory instance for the model.\n     *\n     * @return ClientFactory\n     */\n    protected static function newFactory(): Factory\n    {\n        return ClientFactory::new();\n    }\n}\n"
  },
  {
    "path": "app/Models/Passport/RefreshToken.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Models\\Passport;\n\nuse Laravel\\Passport\\RefreshToken as PassportRefreshToken;\n\nclass RefreshToken extends PassportRefreshToken {}\n"
  },
  {
    "path": "app/Models/Passport/Token.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Models\\Passport;\n\nuse App\\Models\\User;\nuse Database\\Factories\\Passport\\TokenFactory;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\nuse Illuminate\\Support\\Carbon;\nuse Laravel\\Passport\\Token as PassportToken;\n\n/**\n * @property string $id\n * @property null|string $user_id\n * @property string $client_id\n * @property null|string $name\n * @property array<string> $scopes\n * @property bool $revoked\n * @property Carbon|null $reminder_sent_at\n * @property Carbon|null $expired_info_sent_at\n * @property Carbon|null $created_at\n * @property Carbon|null $updated_at\n * @property Carbon|null $expires_at\n * @property-read Client|null $client\n * @property-read User|null $user\n *\n * @method Builder<Token> isApiToken(bool $isApiToken = true)\n */\nclass Token extends PassportToken\n{\n    /** @use HasFactory<TokenFactory> */\n    use HasFactory;\n\n    /**\n     * Get the client that the token belongs to.\n     *\n     * @return BelongsTo<Client, $this>\n     */\n    // @phpstan-ignore method.childReturnType\n    public function client(): BelongsTo\n    {\n        return $this->belongsTo(Client::class, 'client_id', 'id');\n    }\n\n    /**\n     * Get the user that the token belongs to.\n     *\n     * @deprecated Will be removed in a future Laravel version.\n     *\n     * @return BelongsTo<User, $this>\n     */\n    // @phpstan-ignore method.childReturnType\n    public function user(): BelongsTo\n    {\n        return $this->belongsTo(User::class, 'user_id');\n    }\n\n    /**\n     * Get the attributes that should be cast.\n     *\n     * @return array<string, string>\n     */\n    protected function casts(): array\n    {\n        return [\n            'scopes' => 'array',\n            'revoked' => 'bool',\n            'expires_at' => 'datetime',\n            'reminder_sent_at' => 'datetime',\n            'expired_info_sent_at' => 'datetime',\n        ];\n    }\n\n    /**\n     * @param  Builder<static>  $query\n     * @return Builder<static>\n     */\n    public function scopeIsApiToken(Builder $query, bool $isApiToken = true): Builder\n    {\n        if ($isApiToken) {\n            return $query->whereHas('client', function (Builder $query): void {\n                /** @var Builder<Client> $query */\n                $query->whereJsonContains('grant_types', 'personal_access');\n            });\n        } else {\n            return $query->whereHas('client', function (Builder $query): void {\n                /** @var Builder<Client> $query */\n                $query->whereJsonDoesntContain('grant_types', 'personal_access');\n            });\n        }\n\n    }\n}\n"
  },
  {
    "path": "app/Models/Project.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Models;\n\nuse App\\Models\\Concerns\\CustomAuditable;\nuse App\\Models\\Concerns\\HasUuids;\nuse Database\\Factories\\ProjectFactory;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Database\\Eloquent\\Casts\\Attribute;\nuse Illuminate\\Database\\Eloquent\\Collection;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Facades\\DB;\nuse Korridor\\LaravelComputedAttributes\\ComputedAttributes;\nuse OwenIt\\Auditing\\Contracts\\Auditable as AuditableContract;\n\n/**\n * @property string $id\n * @property string $name\n * @property string $color\n * @property string $organization_id\n * @property string $client_id\n * @property int|null $billable_rate\n * @property bool $is_public\n * @property bool $is_billable\n * @property-read bool $is_archived\n * @property int|null $estimated_time\n * @property int $spent_time\n * @property Carbon|null $archived_at\n * @property Carbon|null $created_at\n * @property Carbon|null $updated_at\n * @property-read Organization $organization\n * @property-read Client|null $client\n * @property-read Collection<int, Task> $tasks\n * @property-read Collection<int, ProjectMember> $members\n *\n * @method Builder<Project> visibleByEmployee(User $user)\n * @method static ProjectFactory factory()\n */\nclass Project extends Model implements AuditableContract\n{\n    use ComputedAttributes;\n    use CustomAuditable;\n\n    /** @use HasFactory<ProjectFactory> */\n    use HasFactory;\n\n    use HasUuids;\n\n    /**\n     * The attributes that should be cast.\n     *\n     * @var array<string, string>\n     */\n    protected $casts = [\n        'name' => 'string',\n        'color' => 'string',\n        'archived_at' => 'datetime',\n        'estimated_time' => 'integer',\n        'spent_time' => 'integer',\n    ];\n\n    /**\n     * Set default values for attributes.\n     *\n     * @var array<string, mixed>\n     */\n    protected $attributes = [\n        'is_billable' => false,\n    ];\n\n    /**\n     * The attributes that are computed. (f.e. for performance reasons)\n     * These attributes can be regenerated at any time.\n     *\n     * @var string[]\n     */\n    protected array $computed = [\n        'spent_time',\n    ];\n\n    /**\n     * Attributes to exclude from the Audit.\n     *\n     * @var array<string>\n     */\n    protected array $auditExclude = [\n        'spent_time',\n    ];\n\n    public function getSpentTimeComputed(): ?int\n    {\n        if ($this->hasAttribute('spent_time_computed')) {\n            return $this->attributes['spent_time_computed'] === null ? 0 : (int) $this->attributes['spent_time_computed'];\n        } else {\n            /** @var object{ spent_time: string } $result */\n            $result = $this->timeEntries()\n                ->whereNotNull('end')\n                ->selectRaw('sum(extract(epoch from (\"end\" - start))) as spent_time')\n                ->first();\n\n            return (int) $result->spent_time;\n        }\n    }\n\n    /**\n     * This scope will be applied during the computed property generation with artisan computed-attributes:generate.\n     *\n     * @param  Builder<Project>  $builder\n     * @param  array<string>  $attributes  Attributes that will be generated.\n     * @return Builder<Project>\n     */\n    public function scopeComputedAttributesGenerate(Builder $builder, array $attributes): Builder\n    {\n        if (in_array('spent_time', $attributes, true)) {\n            $builder->withAggregate('timeEntries as spent_time_computed', DB::raw('extract(epoch from (\"end\" - start))'), 'sum');\n        }\n\n        return $builder;\n    }\n\n    /**\n     * This scope will be applied during the computed property validation with artisan computed-attributes:validate.\n     *\n     * @param  Builder<Project>  $builder\n     * @param  array<string>  $attributes  Attributes that will be validated.\n     * @return Builder<Project>\n     */\n    public function scopeComputedAttributesValidate(Builder $builder, array $attributes): Builder\n    {\n        return $this->scopeComputedAttributesGenerate($builder, $attributes);\n    }\n\n    /**\n     * @return BelongsTo<Organization, $this>\n     */\n    public function organization(): BelongsTo\n    {\n        return $this->belongsTo(Organization::class, 'organization_id');\n    }\n\n    /**\n     * @return BelongsTo<Client, $this>\n     */\n    public function client(): BelongsTo\n    {\n        return $this->belongsTo(Client::class, 'client_id');\n    }\n\n    /**\n     * @return HasMany<ProjectMember, $this>\n     */\n    public function members(): HasMany\n    {\n        return $this->hasMany(ProjectMember::class, 'project_id');\n    }\n\n    /**\n     * @return HasMany<Task, $this>\n     */\n    public function tasks(): HasMany\n    {\n        return $this->hasMany(Task::class);\n    }\n\n    /**\n     * @return HasMany<TimeEntry, $this>\n     */\n    public function timeEntries(): HasMany\n    {\n        return $this->hasMany(TimeEntry::class, 'project_id');\n    }\n\n    /**\n     * @param  Builder<Project>  $builder\n     */\n    public function scopeVisibleByEmployee(Builder $builder, User $user): void\n    {\n        $builder->where(function (Builder $builder) use ($user): Builder {\n            return $builder->where('is_public', '=', true)\n                ->orWhereHas('members', function (Builder $builder) use ($user): Builder {\n                    return $builder->whereBelongsTo($user, 'user');\n                });\n        });\n    }\n\n    /**\n     * @return Attribute<bool, never>\n     */\n    protected function isArchived(): Attribute\n    {\n        return Attribute::make(\n            get: fn (mixed $value, array $attributes) => isset($attributes['archived_at']),\n        );\n    }\n}\n"
  },
  {
    "path": "app/Models/ProjectMember.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Models;\n\nuse App\\Models\\Concerns\\CustomAuditable;\nuse App\\Models\\Concerns\\HasUuids;\nuse Database\\Factories\\ProjectMemberFactory;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\nuse Illuminate\\Support\\Carbon;\nuse OwenIt\\Auditing\\Contracts\\Auditable as AuditableContract;\n\n/**\n * @property string $id\n * @property int|null $billable_rate\n * @property string $project_id Project ID\n * @property string $member_id Member ID\n * @property string $user_id User ID (legacy)\n * @property Carbon|null $created_at\n * @property Carbon|null $updated_at\n * @property-read Project $project\n * @property-read Member $member\n * @property-read User $user\n *\n * @method static Builder<ProjectMember> whereBelongsToOrganization(Organization $organization)\n * @method static ProjectMemberFactory factory()\n */\nclass ProjectMember extends Model implements AuditableContract\n{\n    use CustomAuditable;\n\n    /** @use HasFactory<ProjectMemberFactory> */\n    use HasFactory;\n\n    use HasUuids;\n\n    /**\n     * The attributes that should be cast.\n     *\n     * @var array<string, string>\n     */\n    protected $casts = [\n        'billable_rate' => 'int',\n    ];\n\n    /**\n     * @return BelongsTo<Project, $this>\n     */\n    public function project(): BelongsTo\n    {\n        return $this->belongsTo(Project::class, 'project_id');\n    }\n\n    /**\n     * @deprecated Use member relationship instead\n     *\n     * @return BelongsTo<User, $this>\n     */\n    public function user(): BelongsTo\n    {\n        return $this->belongsTo(User::class, 'user_id');\n    }\n\n    /**\n     * @return BelongsTo<Member, $this>\n     */\n    public function member(): BelongsTo\n    {\n        return $this->belongsTo(Member::class, 'member_id');\n    }\n\n    /**\n     * @param  Builder<ProjectMember>  $builder\n     */\n    public function scopeWhereBelongsToOrganization(Builder $builder, Organization $organization): void\n    {\n        $builder->whereHas('project', static function (Builder $query) use ($organization): void {\n            $query->whereBelongsTo($organization, 'organization');\n        });\n    }\n}\n"
  },
  {
    "path": "app/Models/Report.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Models;\n\nuse App\\Models\\Concerns\\HasUuids;\nuse App\\Service\\Dto\\ReportPropertiesDto;\nuse Database\\Factories\\ReportFactory;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\nuse Illuminate\\Support\\Carbon;\n\n/**\n * @property string $id\n * @property string $name\n * @property string|null $description\n * @property string $organization_id\n * @property bool $is_public\n * @property Carbon|null $public_until\n * @property string|null $share_secret\n * @property ReportPropertiesDto $properties\n * @property-read Organization $organization\n * @property Carbon|null $created_at\n * @property Carbon|null $updated_at\n *\n * @method static ReportFactory factory()\n */\nclass Report extends Model\n{\n    /** @use HasFactory<ReportFactory> */\n    use HasFactory;\n\n    use HasUuids;\n\n    /**\n     * The attributes that should be cast.\n     *\n     * @var array<string, string>\n     */\n    protected $casts = [\n        'is_public' => 'bool',\n        'public_until' => 'datetime',\n        'properties' => ReportPropertiesDto::class,\n    ];\n\n    public function getShareableLink(): ?string\n    {\n        if ($this->is_public && $this->share_secret !== null) {\n            return route('shared-report').'#'.$this->share_secret;\n        }\n\n        return null;\n    }\n\n    /**\n     * @return BelongsTo<Organization, $this>\n     */\n    public function organization(): BelongsTo\n    {\n        return $this->belongsTo(Organization::class, 'organization_id');\n    }\n}\n"
  },
  {
    "path": "app/Models/Tag.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Models;\n\nuse App\\Models\\Concerns\\CustomAuditable;\nuse App\\Models\\Concerns\\HasUuids;\nuse Database\\Factories\\TagFactory;\nuse Illuminate\\Database\\Eloquent\\Collection;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\nuse Illuminate\\Support\\Carbon;\nuse OwenIt\\Auditing\\Contracts\\Auditable as AuditableContract;\nuse Staudenmeir\\EloquentJsonRelations\\HasJsonRelationships;\nuse Staudenmeir\\EloquentJsonRelations\\Relations\\HasManyJson;\n\n/**\n * @property string $id\n * @property string $name\n * @property string $organization_id\n * @property Carbon|null $created_at\n * @property Carbon|null $updated_at\n * @property-read Collection<int, TimeEntry> $timeEntries\n * @property-read Organization $organization\n *\n * @method static TagFactory factory()\n */\nclass Tag extends Model implements AuditableContract\n{\n    use CustomAuditable;\n\n    /** @use HasFactory<TagFactory> */\n    use HasFactory;\n\n    use HasJsonRelationships;\n    use HasUuids;\n\n    /**\n     * The attributes that should be cast.\n     *\n     * @var array<string, string>\n     */\n    protected $casts = [\n        'name' => 'string',\n    ];\n\n    /**\n     * @return BelongsTo<Organization, $this>\n     */\n    public function organization(): BelongsTo\n    {\n        return $this->belongsTo(Organization::class, 'organization_id');\n    }\n\n    /**\n     * Warning: This relation based on a JSON column. Please make sure that there are no performance issues, before using it.\n     *\n     * @return HasManyJson<TimeEntry, $this>\n     */\n    public function timeEntries(): HasManyJson\n    {\n        return $this->hasManyJson(TimeEntry::class, 'tags');\n    }\n}\n"
  },
  {
    "path": "app/Models/Task.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Models;\n\nuse App\\Models\\Concerns\\CustomAuditable;\nuse App\\Models\\Concerns\\HasUuids;\nuse Database\\Factories\\TaskFactory;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Database\\Eloquent\\Casts\\Attribute;\nuse Illuminate\\Database\\Eloquent\\Collection;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Facades\\DB;\nuse Korridor\\LaravelComputedAttributes\\ComputedAttributes;\nuse OwenIt\\Auditing\\Contracts\\Auditable as AuditableContract;\n\n/**\n * @property string $id\n * @property string $name\n * @property string $project_id\n * @property string $organization_id\n * @property Carbon|null $done_at\n * @property int|null $estimated_time\n * @property int $spent_time\n * @property Carbon|null $created_at\n * @property Carbon|null $updated_at\n * @property-read Project $project\n * @property-read Organization $organization\n * @property-read Collection<int, TimeEntry> $timeEntries\n * @property-read bool $is_done\n *\n * @method static TaskFactory factory()\n */\nclass Task extends Model implements AuditableContract\n{\n    use ComputedAttributes;\n    use CustomAuditable;\n\n    /** @use HasFactory<TaskFactory> */\n    use HasFactory;\n\n    use HasUuids;\n\n    /**\n     * The attributes that should be cast.\n     *\n     * @var array<string, string>\n     */\n    protected $casts = [\n        'name' => 'string',\n        'estimated_time' => 'integer',\n        'done_at' => 'datetime',\n    ];\n\n    /**\n     * The attributes that are computed. (f.e. for performance reasons)\n     * These attributes can be regenerated at any time.\n     *\n     * @var string[]\n     */\n    protected array $computed = [\n        'spent_time',\n    ];\n\n    /**\n     * Attributes to exclude from the Audit.\n     *\n     * @var array<string>\n     */\n    protected array $auditExclude = [\n        'spent_time',\n    ];\n\n    public function getSpentTimeComputed(): ?int\n    {\n        if ($this->hasAttribute('spent_time_computed')) {\n            return $this->attributes['spent_time_computed'] === null ? 0 : (int) $this->attributes['spent_time_computed'];\n        } else {\n            /** @var object{ spent_time: string } $result */\n            $result = $this->timeEntries()\n                ->whereNotNull('end')\n                ->selectRaw('sum(extract(epoch from (\"end\" - start))) as spent_time')\n                ->first();\n\n            return (int) $result->spent_time;\n        }\n    }\n\n    /**\n     * This scope will be applied during the computed property generation with artisan computed-attributes:generate.\n     *\n     * @param  Builder<Task>  $builder\n     * @param  array<string>  $attributes  Attributes that will be generated.\n     * @return Builder<Task>\n     */\n    public function scopeComputedAttributesGenerate(Builder $builder, array $attributes): Builder\n    {\n        if (in_array('spent_time', $attributes, true)) {\n            $builder->withAggregate('timeEntries as spent_time_computed', DB::raw('extract(epoch from (\"end\" - start))'), 'sum');\n        }\n\n        return $builder;\n    }\n\n    /**\n     * This scope will be applied during the computed property validation with artisan computed-attributes:validate.\n     *\n     * @param  Builder<Task>  $builder\n     * @param  array<string>  $attributes  Attributes that will be validated.\n     * @return Builder<Task>\n     */\n    public function scopeComputedAttributesValidate(Builder $builder, array $attributes): Builder\n    {\n        return $this->scopeComputedAttributesGenerate($builder, $attributes);\n    }\n\n    /**\n     * @return BelongsTo<Project, $this>\n     */\n    public function project(): BelongsTo\n    {\n        return $this->belongsTo(Project::class);\n    }\n\n    /**\n     * @return BelongsTo<Organization, $this>\n     */\n    public function organization(): BelongsTo\n    {\n        return $this->belongsTo(Organization::class, 'organization_id');\n    }\n\n    /**\n     * @return HasMany<TimeEntry, $this>\n     */\n    public function timeEntries(): HasMany\n    {\n        return $this->hasMany(TimeEntry::class, 'task_id');\n    }\n\n    /**\n     * @param  Builder<Task>  $builder\n     * @return Builder<Task>\n     */\n    public function scopeVisibleByEmployee(Builder $builder, User $user): Builder\n    {\n        return $builder->whereHas('project', function (Builder $builder) use ($user): Builder {\n            /** @var Builder<Project> $builder */\n            return $builder->visibleByEmployee($user);\n        });\n    }\n\n    /**\n     * @return Attribute<bool, never>\n     */\n    public function isDone(): Attribute\n    {\n        return Attribute::make(\n            get: fn (mixed $value, array $attributes) => isset($attributes['done_at']),\n        );\n    }\n}\n"
  },
  {
    "path": "app/Models/TimeEntry.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Models;\n\nuse App\\Models\\Concerns\\CustomAuditable;\nuse App\\Models\\Concerns\\HasUuids;\nuse App\\Service\\BillableRateService;\nuse Carbon\\CarbonInterval;\nuse Database\\Factories\\TimeEntryFactory;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Database\\Eloquent\\Collection;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\nuse Illuminate\\Database\\Eloquent\\Relations\\Relation;\nuse Illuminate\\Support\\Carbon;\nuse Korridor\\LaravelComputedAttributes\\ComputedAttributes;\nuse OwenIt\\Auditing\\Contracts\\Auditable as AuditableContract;\nuse Staudenmeir\\EloquentJsonRelations\\HasJsonRelationships;\nuse Staudenmeir\\EloquentJsonRelations\\Relations\\BelongsToJson;\n\n/**\n * @property string $id\n * @property string $description\n * @property Carbon $start\n * @property Carbon|null $end\n * @property int|null $billable_rate Billable rate per hour in cents\n * @property bool $billable\n * @property array<string> $tags\n * @property string $user_id\n * @property string $member_id\n * @property bool $is_imported\n * @property Carbon|null $still_active_email_sent_at\n * @property Carbon|null $created_at\n * @property Carbon|null $updated_at\n * @property-read User $user\n * @property-read Member $member\n * @property string $organization_id\n * @property-read Organization $organization\n * @property string|null $project_id\n * @property-read Project|null $project\n * @property string|null $client_id\n * @property-read Client|null $client\n * @property string|null $task_id\n * @property-read Task|null $task\n * @property-read Collection<int, Tag> $tagsRelation\n *\n * @method Builder<TimeEntry> hasTag(Tag $tag)\n * @method static TimeEntryFactory factory()\n */\nclass TimeEntry extends Model implements AuditableContract\n{\n    use ComputedAttributes;\n    use CustomAuditable;\n\n    /** @use HasFactory<TimeEntryFactory> */\n    use HasFactory;\n\n    use HasJsonRelationships;\n    use HasUuids;\n\n    /**\n     * The attributes that should be cast.\n     *\n     * @var array<string, string>\n     */\n    protected $casts = [\n        'description' => 'string',\n        'start' => 'datetime',\n        'end' => 'datetime',\n        'billable' => 'bool',\n        'tags' => 'array',\n        'billable_rate' => 'int',\n        'is_imported' => 'bool',\n        'still_active_email_sent_at' => 'datetime',\n    ];\n\n    public const array SELECT_COLUMNS = [\n        'id',\n        'description',\n        'start',\n        'end',\n        'billable_rate',\n        'billable',\n        'user_id',\n        'organization_id',\n        'project_id',\n        'task_id',\n        'tags',\n        'created_at',\n        'updated_at',\n        'member_id',\n        'client_id',\n        'is_imported',\n        'still_active_email_sent_at',\n    ];\n\n    /**\n     * The attributes that are computed. (f.e. for performance reasons)\n     * These attributes can be regenerated at any time.\n     *\n     * @var string[]\n     */\n    protected array $computed = [\n        'billable_rate',\n        'client_id',\n    ];\n\n    /**\n     * Attributes to exclude from the Audit.\n     *\n     * @var array<string>\n     */\n    protected array $auditExclude = [\n        'billable_rate',\n    ];\n\n    public function getBillableRateComputed(): ?int\n    {\n        return app(BillableRateService::class)->getBillableRateForTimeEntry($this);\n    }\n\n    public function getClientIdComputed(): ?string\n    {\n        return $this->project_id === null || $this->project === null ? null : $this->project->client_id;\n    }\n\n    /**\n     * This scope will be applied during the computed property generation with artisan computed-attributes:generate.\n     *\n     * @param  Builder<TimeEntry>  $builder\n     * @param  array<string>  $attributes  Attributes that will be generated.\n     * @return Builder<TimeEntry>\n     */\n    public function scopeComputedAttributesGenerate(Builder $builder, array $attributes): Builder\n    {\n        if (in_array('client_id', $attributes, true)) {\n            $builder->with([\n                'project' => function (Relation $builder): void {\n                    /** @var Builder<Project> $builder */\n                    $builder->select('id', 'client_id');\n                },\n            ]);\n        }\n\n        return $builder;\n    }\n\n    /**\n     * This scope will be applied during the computed property validation with artisan computed-attributes:validate.\n     *\n     * @param  Builder<TimeEntry>  $builder\n     * @param  array<string>  $attributes  Attributes that will be validated.\n     * @return Builder<TimeEntry>\n     */\n    public function scopeComputedAttributesValidate(Builder $builder, array $attributes): Builder\n    {\n        return $this->scopeComputedAttributesGenerate($builder, $attributes);\n    }\n\n    public function getDuration(): ?CarbonInterval\n    {\n        return $this->end === null ? null : $this->start->diffAsCarbonInterval($this->end);\n    }\n\n    /**\n     * @param  Builder<TimeEntry>  $builder\n     */\n    public function scopeHasTag(Builder $builder, Tag $tag): void\n    {\n        $builder->whereJsonContains('tags', $tag->getKey());\n    }\n\n    /**\n     * @return BelongsTo<User, $this>\n     */\n    public function user(): BelongsTo\n    {\n        return $this->belongsTo(User::class, 'user_id');\n    }\n\n    /**\n     * @return BelongsTo<Member, $this>\n     */\n    public function member(): BelongsTo\n    {\n        return $this->belongsTo(Member::class, 'member_id');\n    }\n\n    /**\n     * @return BelongsTo<Organization, $this>\n     */\n    public function organization(): BelongsTo\n    {\n        return $this->belongsTo(Organization::class, 'organization_id');\n    }\n\n    /**\n     * @return BelongsTo<Project, $this>\n     */\n    public function project(): BelongsTo\n    {\n        return $this->belongsTo(Project::class, 'project_id');\n    }\n\n    /**\n     * @return BelongsTo<Task, $this>\n     */\n    public function task(): BelongsTo\n    {\n        return $this->belongsTo(Task::class, 'task_id');\n    }\n\n    /**\n     * This relation can be reconstructed via the task relation. It is only here for performance reasons.\n     *\n     * @return BelongsTo<Client, $this>\n     */\n    public function client(): BelongsTo\n    {\n        return $this->belongsTo(Client::class, 'client_id');\n    }\n\n    /**\n     * Warning: This relation based on a JSON column. Please make sure that there are no performance issues, before using it.\n     *\n     * @return BelongsToJson<Tag, $this>\n     */\n    public function tagsRelation(): BelongsToJson\n    {\n        return $this->belongsToJson(Tag::class, 'tags');\n    }\n}\n"
  },
  {
    "path": "app/Models/User.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Models;\n\nuse App\\Enums\\Weekday;\nuse App\\Models\\Concerns\\CustomAuditable;\nuse App\\Models\\Concerns\\HasUuids;\nuse App\\Models\\Passport\\Token;\nuse Database\\Factories\\UserFactory;\nuse Filament\\Models\\Contracts\\FilamentUser;\nuse Filament\\Panel;\nuse Illuminate\\Contracts\\Auth\\MustVerifyEmail;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Database\\Eloquent\\Casts\\Attribute;\nuse Illuminate\\Database\\Eloquent\\Collection;\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Database\\Eloquent\\Relations\\Pivot;\nuse Illuminate\\Foundation\\Auth\\User as Authenticatable;\nuse Illuminate\\Notifications\\Notifiable;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Facades\\Storage;\nuse Laravel\\Fortify\\TwoFactorAuthenticatable;\nuse Laravel\\Jetstream\\HasProfilePhoto;\nuse Laravel\\Jetstream\\HasTeams;\nuse Laravel\\Passport\\AuthCode;\nuse Laravel\\Passport\\Contracts\\OAuthenticatable;\nuse Laravel\\Passport\\HasApiTokens;\nuse OwenIt\\Auditing\\Contracts\\Auditable as AuditableContract;\n\n/**\n * @property string $id\n * @property string $name\n * @property string $email\n * @property Carbon|null $email_verified_at\n * @property string|null $password\n * @property string|null $two_factor_secret\n * @property string $timezone\n * @property bool $is_placeholder\n * @property Weekday $week_start\n * @property string|null $profile_photo_path\n * @property-read Organization|null $currentOrganization\n * @property-read Organization|null $currentTeam\n * @property-read string $profile_photo_url\n * @property-read Collection<int, Token> $tokens\n * @property Carbon|null $created_at\n * @property Carbon|null $updated_at\n * @property string|null $current_team_id\n * @property Collection<int, Organization> $organizations\n * @property Collection<int, TimeEntry> $timeEntries\n * @property Member $membership\n *\n * @method HasMany<Organization, $this> ownedTeams()\n * @method static UserFactory factory()\n * @method static Builder<User> query()\n * @method Builder<User> belongsToOrganization(Organization $organization)\n * @method Builder<User> active()\n */\nclass User extends Authenticatable implements AuditableContract, FilamentUser, MustVerifyEmail, OAuthenticatable\n{\n    use CustomAuditable;\n    use HasApiTokens;\n\n    /** @use HasFactory<UserFactory> */\n    use HasFactory;\n\n    use HasProfilePhoto;\n    use HasTeams;\n    use HasUuids;\n    use Notifiable;\n    use TwoFactorAuthenticatable;\n\n    /**\n     * The attributes that are mass assignable.\n     *\n     * @var list<string>\n     */\n    protected $fillable = [\n        'name',\n        'email',\n        'password',\n    ];\n\n    /**\n     * The attributes that should be hidden for serialization.\n     *\n     * @var list<string>\n     */\n    protected $hidden = [\n        'password',\n        'remember_token',\n        'two_factor_recovery_codes',\n        'two_factor_secret',\n    ];\n\n    /**\n     * The attributes that should be cast.\n     *\n     * @var array<string, string>\n     */\n    protected $casts = [\n        'name' => 'string',\n        'email' => 'string',\n        'email_verified_at' => 'datetime',\n        'is_admin' => 'boolean',\n        'is_placeholder' => 'boolean',\n        'week_start' => Weekday::class,\n    ];\n\n    /**\n     * The model's default values for attributes.\n     *\n     * @var array<string, mixed>\n     */\n    protected $attributes = [\n        'week_start' => Weekday::Monday,\n    ];\n\n    /**\n     * Get the URL to the user's profile photo.\n     *\n     * @return Attribute<string, never>\n     */\n    protected function profilePhotoUrl(): Attribute\n    {\n        return Attribute::get(function (): string {\n            return $this->profile_photo_path\n                ? Storage::disk($this->profilePhotoDisk())->url($this->profile_photo_path)\n                : $this->defaultProfilePhotoUrl();\n        });\n    }\n\n    public function canAccessPanel(Panel $panel): bool\n    {\n        return in_array($this->email, config('auth.super_admins', []), true) && $this->hasVerifiedEmail();\n    }\n\n    public function canBeImpersonated(): bool\n    {\n        return $this->is_placeholder === false;\n    }\n\n    /**\n     * @return BelongsToMany<Organization, $this, Pivot, 'membership'>\n     */\n    public function organizations(): BelongsToMany\n    {\n        return $this->belongsToMany(Organization::class, Member::class)\n            ->withPivot([\n                'id',\n                'role',\n                'billable_rate',\n            ])\n            ->withTimestamps()\n            ->as('membership');\n    }\n\n    /**\n     * @return HasMany<TimeEntry, $this>\n     */\n    public function timeEntries(): HasMany\n    {\n        return $this->hasMany(TimeEntry::class);\n    }\n\n    /**\n     * @return BelongsTo<Organization, $this>\n     */\n    public function currentOrganization(): BelongsTo\n    {\n        return $this->belongsTo(Organization::class, 'current_team_id');\n    }\n\n    /**\n     * @return HasMany<ProjectMember, $this>\n     */\n    public function projectMembers(): HasMany\n    {\n        return $this->hasMany(ProjectMember::class, 'user_id');\n    }\n\n    /**\n     * @return HasMany<Token, $this>\n     */\n    public function accessTokens(): HasMany\n    {\n        return $this->hasMany(Token::class);\n    }\n\n    /**\n     * @return HasMany<AuthCode, $this>\n     */\n    public function authCodes(): HasMany\n    {\n        return $this->hasMany(AuthCode::class);\n    }\n\n    /**\n     * @param  Builder<User>  $builder\n     */\n    public function scopeActive(Builder $builder): void\n    {\n        $builder->where('is_placeholder', '=', false);\n    }\n\n    /**\n     * @param  Builder<User>  $builder\n     * @return Builder<User>\n     */\n    public function scopeBelongsToOrganization(Builder $builder, Organization $organization): Builder\n    {\n        return $builder->where(function (Builder $builder) use ($organization): Builder {\n            return $builder->whereHas('organizations', function (Builder $query) use ($organization): void {\n                $query->whereKey($organization->getKey());\n            })->orWhereHas('ownedTeams', function (Builder $query) use ($organization): void {\n                $query->whereKey($organization->getKey());\n            });\n        });\n    }\n}\n"
  },
  {
    "path": "app/Policies/OrganizationPolicy.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Policies;\n\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse App\\Service\\PermissionStore;\nuse Filament\\Facades\\Filament;\nuse Illuminate\\Auth\\Access\\HandlesAuthorization;\n\nclass OrganizationPolicy\n{\n    use HandlesAuthorization;\n\n    /**\n     * Determine whether the user can view any models.\n     */\n    public function viewAny(User $user): bool\n    {\n        if (Filament::isServing()) {\n            return true;\n        }\n\n        return false;\n    }\n\n    /**\n     * Determine whether the user can view the model.\n     */\n    public function view(User $user, Organization $organization): bool\n    {\n        if (Filament::isServing()) {\n            return true;\n        }\n\n        return $user->belongsToTeam($organization);\n    }\n\n    /**\n     * Determine whether the user can create models.\n     */\n    public function create(User $user): bool\n    {\n        if (Filament::isServing()) {\n            return true;\n        }\n\n        return true;\n    }\n\n    /**\n     * Determine whether the user can update the model.\n     */\n    public function update(User $user, Organization $organization): bool\n    {\n        if (Filament::isServing()) {\n            return true;\n        }\n\n        return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update');\n    }\n\n    /**\n     * Determine whether the user can add team members.\n     */\n    public function addTeamMember(User $user, Organization $organization): bool\n    {\n        if (Filament::isServing()) {\n            return true;\n        }\n\n        return true;\n    }\n\n    /**\n     * Determine whether the user can update team member permissions.\n     */\n    public function updateTeamMember(User $user, Organization $organization): bool\n    {\n        if (Filament::isServing()) {\n            return true;\n        }\n\n        // Note: since this policy is only used for jetstream endpoints, we can return false here\n        return false;\n    }\n\n    /**\n     * Determine whether the user can remove team members.\n     */\n    public function removeTeamMember(User $user, Organization $organization): bool\n    {\n        if (Filament::isServing()) {\n            return true;\n        }\n\n        // Note: since this policy is only used for jetstream endpoints that are no longer in use, we can return false here\n        return false;\n    }\n\n    /**\n     * Determine whether the user can delete the model.\n     */\n    public function delete(User $user, Organization $organization): bool\n    {\n        if (Filament::isServing()) {\n            return true;\n        }\n\n        return $user->ownsTeam($organization);\n    }\n}\n"
  },
  {
    "path": "app/Providers/AppServiceProvider.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Providers;\n\nuse App\\Models\\Client;\nuse App\\Models\\FailedJob;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\OrganizationInvitation;\nuse App\\Models\\Passport\\Token;\nuse App\\Models\\Project;\nuse App\\Models\\ProjectMember;\nuse App\\Models\\Tag;\nuse App\\Models\\Task;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse App\\Service\\BillingContract;\nuse App\\Service\\IpLookup\\IpLookupServiceContract;\nuse App\\Service\\IpLookup\\NoIpLookupService;\nuse App\\Service\\PermissionStore;\nuse Dedoc\\Scramble\\Scramble;\nuse Dedoc\\Scramble\\Support\\Generator\\OpenApi;\nuse Dedoc\\Scramble\\Support\\Generator\\SecurityScheme;\nuse Dedoc\\Scramble\\Support\\Generator\\SecuritySchemes\\OAuthFlow;\nuse Filament\\Forms\\Components\\Section;\nuse Filament\\Tables\\Table;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Database\\Eloquent\\Relations\\Relation;\nuse Illuminate\\Foundation\\Application;\nuse Illuminate\\Support\\Facades\\Route;\nuse Illuminate\\Support\\ServiceProvider;\n\nclass AppServiceProvider extends ServiceProvider\n{\n    /**\n     * Register any application services.\n     */\n    public function register(): void\n    {\n        //\n    }\n\n    /**\n     * Bootstrap any application services.\n     */\n    public function boot(): void\n    {\n        if ($this->app->environment('local')) {\n            $this->app->register(\\Laravel\\Telescope\\TelescopeServiceProvider::class);\n            $this->app->register(TelescopeServiceProvider::class);\n        }\n\n        // Eloquent\n        Model::preventLazyLoading(! $this->app->isProduction());\n        Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction());\n        Model::preventAccessingMissingAttributes(! $this->app->isProduction());\n        Relation::enforceMorphMap([\n            'client' => Client::class,\n            'failed-job' => FailedJob::class,\n            'membership' => Member::class,\n            'organization' => Organization::class,\n            'organization-invitation' => OrganizationInvitation::class,\n            'project' => Project::class,\n            'project-member' => ProjectMember::class,\n            'tag' => Tag::class,\n            'task' => Task::class,\n            'time-entry' => TimeEntry::class,\n            'user' => User::class,\n        ]);\n        Model::unguard();\n\n        // Filament\n        Section::configureUsing(function (Section $section): void {\n            $section->columns(1);\n        }, null, true);\n        Table::configureUsing(function (Table $table): void {\n            $table->paginated([10, 25, 50, 100]);\n        });\n\n        // Scramble\n        Scramble::extendOpenApi(function (OpenApi $openApi): void {\n            $openApi->secure(\n                SecurityScheme::oauth2()\n                    ->flow('authorizationCode', function (OAuthFlow $flow): void {\n                        $flow\n                            ->authorizationUrl('https://solidtime.test/oauth/authorize');\n                    })\n            );\n        });\n\n        $this->app->scoped(PermissionStore::class, function (Application $app): PermissionStore {\n            return new PermissionStore;\n        });\n\n        // Extensions\n        $this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class);\n        $this->app->bind(BillingContract::class);\n\n        // Routing\n        Route::model('member', Member::class);\n        Route::model('invitation', OrganizationInvitation::class);\n        Route::model('apiToken', Token::class);\n    }\n}\n"
  },
  {
    "path": "app/Providers/AuthServiceProvider.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Providers;\n\nuse App\\Models\\Organization;\nuse App\\Models\\Passport\\AuthCode;\nuse App\\Models\\Passport\\Client;\nuse App\\Models\\Passport\\RefreshToken;\nuse App\\Models\\Passport\\Token;\nuse App\\Policies\\OrganizationPolicy;\nuse Illuminate\\Foundation\\Support\\Providers\\AuthServiceProvider as ServiceProvider;\nuse Laravel\\Jetstream\\Jetstream;\nuse Laravel\\Passport\\Passport;\n\nclass AuthServiceProvider extends ServiceProvider\n{\n    /**\n     * The model to policy mappings for the application.\n     *\n     * @var array<class-string, class-string>\n     */\n    protected $policies = [\n        Organization::class => OrganizationPolicy::class,\n    ];\n\n    /**\n     * Register any authentication / authorization services.\n     */\n    public function boot(): void\n    {\n        // define scopes for passport tokens\n        Passport::tokensCan([\n            'create' => 'Create resources',\n            'read' => 'Read Resources',\n            'update' => 'Update Resources',\n            'delete' => 'Delete Resources',\n        ]);\n\n        // default scope for passport tokens\n        Passport::setDefaultScope([\n            // 'create',\n            'read',\n            // 'update',\n            // 'delete',\n        ]);\n\n        Passport::useTokenModel(Token::class);\n        Passport::useRefreshTokenModel(RefreshToken::class);\n        Passport::useAuthCodeModel(AuthCode::class);\n        Passport::useClientModel(Client::class);\n\n        Passport::authorizationView('auth.oauth.authorize');\n\n        // Passport::tokensExpireIn(now()->addDays(15));\n        // Passport::refreshTokensExpireIn(now()->addDays(30));\n        Passport::personalAccessTokensExpireIn(now()->addMonths(12));\n\n        // same as passport default above\n        Jetstream::defaultApiTokenPermissions(['read']);\n\n        // use passport scopes for jetstream token permissions\n        Jetstream::permissions(Passport::scopeIds());\n    }\n}\n"
  },
  {
    "path": "app/Providers/EventServiceProvider.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Providers;\n\nuse App\\Listeners\\RemovePlaceholder;\nuse Illuminate\\Auth\\Events\\Registered;\nuse Illuminate\\Auth\\Listeners\\SendEmailVerificationNotification;\nuse Illuminate\\Foundation\\Support\\Providers\\EventServiceProvider as ServiceProvider;\nuse Laravel\\Jetstream\\Events\\TeamMemberAdded;\n\nclass EventServiceProvider extends ServiceProvider\n{\n    /**\n     * The event to listener mappings for the application.\n     *\n     * @var array<class-string, array<int, class-string>>\n     */\n    protected $listen = [\n        Registered::class => [\n            SendEmailVerificationNotification::class,\n        ],\n        TeamMemberAdded::class => [\n            RemovePlaceholder::class,\n        ],\n    ];\n\n    /**\n     * Register any events for your application.\n     */\n    public function boot(): void\n    {\n        //\n    }\n\n    /**\n     * Determine if events and listeners should be automatically discovered.\n     */\n    public function shouldDiscoverEvents(): bool\n    {\n        return false;\n    }\n}\n"
  },
  {
    "path": "app/Providers/Filament/AdminPanelProvider.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Providers\\Filament;\n\nuse App\\Filament\\Widgets\\ActiveUserOverview;\nuse App\\Filament\\Widgets\\ServerOverview;\nuse App\\Filament\\Widgets\\TimeEntriesCreated;\nuse App\\Filament\\Widgets\\TimeEntriesImported;\nuse App\\Filament\\Widgets\\UserRegistrations;\nuse Filament\\Http\\Middleware\\Authenticate;\nuse Filament\\Http\\Middleware\\DisableBladeIconComponents;\nuse Filament\\Http\\Middleware\\DispatchServingFilamentEvent;\nuse Filament\\Navigation\\NavigationGroup;\nuse Filament\\Pages;\nuse Filament\\Panel;\nuse Filament\\PanelProvider;\nuse Filament\\Support\\Colors\\Color;\nuse Illuminate\\Cookie\\Middleware\\AddQueuedCookiesToResponse;\nuse Illuminate\\Cookie\\Middleware\\EncryptCookies;\nuse Illuminate\\Foundation\\Http\\Middleware\\VerifyCsrfToken;\nuse Illuminate\\Routing\\Middleware\\SubstituteBindings;\nuse Illuminate\\Session\\Middleware\\AuthenticateSession;\nuse Illuminate\\Session\\Middleware\\StartSession;\nuse Illuminate\\Support\\Facades\\App;\nuse Illuminate\\View\\Middleware\\ShareErrorsFromSession;\nuse Nwidart\\Modules\\Facades\\Module;\nuse pxlrbt\\FilamentEnvironmentIndicator\\EnvironmentIndicatorPlugin;\n\nclass AdminPanelProvider extends PanelProvider\n{\n    public function panel(Panel $panel): Panel\n    {\n        $panel->default()\n            ->id('admin')\n            ->path('admin')\n            ->colors([\n                'primary' => Color::Amber,\n            ])\n            ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\\\Filament\\\\Resources')\n            ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\\\Filament\\\\Pages')\n            ->pages([\n                Pages\\Dashboard::class,\n            ])\n            ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\\\Filament\\\\Widgets')\n            ->widgets([\n                ServerOverview::class,\n                ActiveUserOverview::class,\n                UserRegistrations::class,\n                TimeEntriesCreated::class,\n                TimeEntriesImported::class,\n            ])\n            ->viteTheme('resources/css/filament/admin/theme.css')\n            ->plugins([\n                EnvironmentIndicatorPlugin::make()\n                    ->color(fn () => match (App::environment()) {\n                        'production' => null,\n                        'staging' => Color::Orange,\n                        default => Color::Blue,\n                    }),\n            ])\n            ->navigationGroups([\n                NavigationGroup::make()\n                    ->label('Timetracking'),\n                NavigationGroup::make()\n                    ->label('Users')\n                    ->collapsed(),\n                NavigationGroup::make()\n                    ->label('System')\n                    ->collapsed(),\n                NavigationGroup::make()\n                    ->label('Auth')\n                    ->collapsed(),\n            ])\n            ->middleware([\n                EncryptCookies::class,\n                AddQueuedCookiesToResponse::class,\n                StartSession::class,\n                AuthenticateSession::class,\n                ShareErrorsFromSession::class,\n                VerifyCsrfToken::class,\n                SubstituteBindings::class,\n                DisableBladeIconComponents::class,\n                DispatchServingFilamentEvent::class,\n            ])\n            ->authMiddleware([\n                Authenticate::class,\n            ]);\n\n        $modules = Module::allEnabled();\n\n        foreach ($modules as $module) {\n            $panel->discoverResources(\n                in: module_path($module->getName(), 'app/Filament/Resources'),\n                for: 'Extensions\\\\'.$module->getName().'\\\\App\\\\Filament\\\\Resources'\n            );\n\n            $panel->discoverPages(\n                in: module_path($module->getName(), 'app/Filament/Pages'),\n                for: 'Extensions\\\\'.$module->getName().'\\\\App\\\\Filament\\\\Pages'\n            );\n\n            $panel->discoverWidgets(\n                in: module_path($module->getName(), 'app/Filament/Widgets'),\n                for: 'Extensions\\\\'.$module->getName().'\\\\App\\\\Filament\\\\Widgets'\n            );\n        }\n\n        return $panel;\n    }\n}\n"
  },
  {
    "path": "app/Providers/FortifyServiceProvider.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Providers;\n\nuse App\\Actions\\Fortify\\CreateNewUser;\nuse App\\Actions\\Fortify\\ResetUserPassword;\nuse App\\Actions\\Fortify\\UpdateUserPassword;\nuse App\\Actions\\Fortify\\UpdateUserProfileInformation;\nuse App\\Extensions\\Fortify\\CustomLoginResponse;\nuse App\\Extensions\\Fortify\\CustomTwoFactorLoginResponse;\nuse App\\Models\\User;\nuse Illuminate\\Cache\\RateLimiting\\Limit;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Facades\\Hash;\nuse Illuminate\\Support\\Facades\\RateLimiter;\nuse Illuminate\\Support\\ServiceProvider;\nuse Illuminate\\Support\\Str;\nuse Laravel\\Fortify\\Contracts\\TwoFactorLoginResponse;\nuse Laravel\\Fortify\\Fortify;\nuse Laravel\\Fortify\\Http\\Responses\\LoginResponse;\n\nclass FortifyServiceProvider extends ServiceProvider\n{\n    /**\n     * Register any application services.\n     */\n    public function register(): void\n    {\n        //\n    }\n\n    /**\n     * Bootstrap any application services.\n     */\n    public function boot(): void\n    {\n        Fortify::createUsersUsing(CreateNewUser::class);\n        Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);\n        Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);\n        Fortify::resetUserPasswordsUsing(ResetUserPassword::class);\n\n        Fortify::authenticateUsing(function (Request $request): ?User {\n            /** @var User|null $user */\n            $user = User::query()\n                ->where('email', $request->email)\n                ->where('is_placeholder', '=', false)\n                ->first();\n\n            if ($user !== null && Hash::check($request->password, $user->password)) {\n                return $user;\n            }\n\n            return null;\n        });\n\n        RateLimiter::for('login', function (Request $request) {\n            $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());\n\n            return Limit::perMinute(5)->by($throttleKey);\n        });\n\n        RateLimiter::for('two-factor', function (Request $request) {\n            return Limit::perMinute(5)->by($request->session()->get('login.id'));\n        });\n\n        $this->app->instance(LoginResponse::class, new CustomLoginResponse);\n        $this->app->instance(TwoFactorLoginResponse::class, new CustomTwoFactorLoginResponse);\n    }\n}\n"
  },
  {
    "path": "app/Providers/JetstreamServiceProvider.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Providers;\n\nuse App\\Actions\\Jetstream\\AddOrganizationMember;\nuse App\\Actions\\Jetstream\\CreateOrganization;\nuse App\\Actions\\Jetstream\\DeleteOrganization;\nuse App\\Actions\\Jetstream\\DeleteUser;\nuse App\\Actions\\Jetstream\\InviteOrganizationMember;\nuse App\\Actions\\Jetstream\\RemoveOrganizationMember;\nuse App\\Actions\\Jetstream\\UpdateMemberRole;\nuse App\\Actions\\Jetstream\\UpdateOrganization;\nuse App\\Actions\\Jetstream\\ValidateOrganizationDeletion;\nuse App\\Enums\\Role;\nuse App\\Enums\\Weekday;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\OrganizationInvitation;\nuse App\\Models\\User;\nuse App\\Service\\TimezoneService;\nuse Brick\\Money\\Currency;\nuse Brick\\Money\\ISOCurrencyProvider;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Facades\\Gate;\nuse Illuminate\\Support\\ServiceProvider;\nuse Inertia\\Inertia;\nuse Laravel\\Fortify\\Fortify;\nuse Laravel\\Jetstream\\Actions\\UpdateTeamMemberRole;\nuse Laravel\\Jetstream\\Actions\\ValidateTeamDeletion;\nuse Laravel\\Jetstream\\Jetstream;\n\nclass JetstreamServiceProvider extends ServiceProvider\n{\n    /**\n     * Register any application services.\n     */\n    public function register(): void\n    {\n        //\n    }\n\n    /**\n     * Bootstrap any application services.\n     */\n    public function boot(): void\n    {\n        $this->configurePermissions();\n\n        Jetstream::createTeamsUsing(CreateOrganization::class);\n        Jetstream::updateTeamNamesUsing(UpdateOrganization::class);\n        Jetstream::addTeamMembersUsing(AddOrganizationMember::class);\n        Jetstream::inviteTeamMembersUsing(InviteOrganizationMember::class);\n        Jetstream::removeTeamMembersUsing(RemoveOrganizationMember::class);\n        Jetstream::deleteTeamsUsing(DeleteOrganization::class);\n        Jetstream::deleteUsersUsing(DeleteUser::class);\n        Jetstream::useTeamModel(Organization::class);\n        Jetstream::useMembershipModel(Member::class);\n        Jetstream::useTeamInvitationModel(OrganizationInvitation::class);\n        app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class);\n        app()->singleton(ValidateTeamDeletion::class, ValidateOrganizationDeletion::class);\n        Fortify::registerView(function () {\n            return Inertia::render('Auth/Register', [\n                'terms_url' => config('auth.terms_url'),\n                'privacy_policy_url' => config('auth.privacy_policy_url'),\n                'newsletter_consent' => config('auth.newsletter_consent'),\n            ]);\n        });\n        Gate::define('removeTeamMember', function (User $user, Organization $team) {\n            return false;\n        });\n    }\n\n    /**\n     * Configure the roles and permissions that are available within the application.\n     */\n    protected function configurePermissions(): void\n    {\n        Jetstream::defaultApiTokenPermissions([]);\n\n        Jetstream::role(Role::Owner->value, 'Owner', [\n            'charts:view:own',\n            'charts:view:all',\n            'projects:view',\n            'projects:view:all',\n            'projects:create',\n            'projects:update',\n            'projects:delete',\n            'project-members:view',\n            'project-members:create',\n            'project-members:update',\n            'project-members:delete',\n            'tasks:view',\n            'tasks:view:all',\n            'tasks:create',\n            'tasks:create:all',\n            'tasks:update',\n            'tasks:update:all',\n            'tasks:delete',\n            'tasks:delete:all',\n            'time-entries:view:all',\n            'time-entries:create:all',\n            'time-entries:update:all',\n            'time-entries:delete:all',\n            'time-entries:view:own',\n            'time-entries:create:own',\n            'time-entries:update:own',\n            'time-entries:delete:own',\n            'tags:view',\n            'tags:create',\n            'tags:update',\n            'tags:delete',\n            'clients:view',\n            'clients:view:all',\n            'clients:create',\n            'clients:update',\n            'clients:delete',\n            'organizations:view',\n            'organizations:update',\n            'organizations:delete',\n            'import',\n            'export',\n            'invitations:view',\n            'invitations:create',\n            'invitations:resend',\n            'invitations:remove',\n            'members:view',\n            'members:invite-placeholder',\n            'members:change-ownership',\n            'members:make-placeholder',\n            'members:merge-into',\n            'members:update',\n            'members:delete',\n            'billing',\n            'reports:view',\n            'reports:create',\n            'reports:update',\n            'reports:delete',\n            'invoices:view',\n            'invoices:create',\n            'invoices:update',\n            'invoices:download',\n            'invoices:delete',\n            'invoice-settings:view',\n            'invoice-settings:update',\n        ])->description('Owner users can perform any action. There is only one owner per organization.');\n\n        Jetstream::role(Role::Admin->value, 'Administrator', [\n            'charts:view:own',\n            'charts:view:all',\n            'projects:view',\n            'projects:view:all',\n            'projects:create',\n            'projects:update',\n            'projects:delete',\n            'project-members:view',\n            'project-members:create',\n            'project-members:update',\n            'project-members:delete',\n            'tasks:view',\n            'tasks:view:all',\n            'tasks:create',\n            'tasks:create:all',\n            'tasks:update',\n            'tasks:update:all',\n            'tasks:delete',\n            'tasks:delete:all',\n            'time-entries:view:all',\n            'time-entries:create:all',\n            'time-entries:update:all',\n            'time-entries:delete:all',\n            'time-entries:view:own',\n            'time-entries:create:own',\n            'time-entries:update:own',\n            'time-entries:delete:own',\n            'tags:view',\n            'tags:create',\n            'tags:update',\n            'tags:delete',\n            'clients:view',\n            'clients:view:all',\n            'clients:create',\n            'clients:update',\n            'clients:delete',\n            'organizations:view',\n            'organizations:update',\n            'import',\n            'export',\n            'invitations:view',\n            'invitations:create',\n            'invitations:resend',\n            'invitations:remove',\n            'members:view',\n            'members:invite-placeholder',\n            'members:make-placeholder',\n            'members:merge-into',\n            'members:delete',\n            'members:update',\n            'reports:view',\n            'reports:create',\n            'reports:update',\n            'reports:delete',\n            'invoices:view',\n            'invoices:create',\n            'invoices:update',\n            'invoices:download',\n            'invoices:delete',\n            'invoice-settings:view',\n            'invoice-settings:update',\n        ])->description('Administrator users can perform any action, except accessing the billing dashboard.');\n\n        Jetstream::role(Role::Manager->value, 'Manager', [\n            'charts:view:own',\n            'charts:view:all',\n            'projects:view',\n            'projects:view:all',\n            'projects:create',\n            'projects:update',\n            'projects:delete',\n            'project-members:view',\n            'project-members:create',\n            'project-members:update',\n            'project-members:delete',\n            'tasks:view',\n            'tasks:view:all',\n            'tasks:create',\n            'tasks:create:all',\n            'tasks:update',\n            'tasks:update:all',\n            'tasks:delete',\n            'tasks:delete:all',\n            'time-entries:view:all',\n            'time-entries:create:all',\n            'time-entries:update:all',\n            'time-entries:delete:all',\n            'time-entries:view:own',\n            'time-entries:create:own',\n            'time-entries:update:own',\n            'time-entries:delete:own',\n            'tags:view',\n            'tags:create',\n            'tags:update',\n            'tags:delete',\n            'clients:view',\n            'clients:view:all',\n            'clients:create',\n            'clients:update',\n            'clients:delete',\n            'organizations:view',\n            'invitations:view',\n            'members:view',\n            'reports:view',\n            'reports:create',\n            'reports:update',\n            'reports:delete',\n            'invoices:view',\n            'invoices:create',\n            'invoices:update',\n            'invoices:download',\n            'invoices:delete',\n            'invoice-settings:view',\n            'invoice-settings:update',\n        ])->description('Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).');\n\n        Jetstream::role(Role::Employee->value, 'Employee', [\n            'charts:view:own',\n            'projects:view',\n            'tags:view',\n            'tasks:view',\n            'clients:view',\n            'time-entries:view:own',\n            'time-entries:create:own',\n            'time-entries:update:own',\n            'time-entries:delete:own',\n            'organizations:view',\n        ])->description('Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.');\n\n        Jetstream::role(Role::Placeholder->value, 'Placeholder', [\n        ])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');\n\n        Jetstream::inertia()\n            ->whenRendering(\n                'Profile/Show',\n                function (Request $request, array $data): array {\n                    return array_merge($data, [\n                        'timezones' => $this->app->get(TimezoneService::class)->getSelectOptions(),\n                        'weekdays' => Weekday::toSelectArray(),\n                    ]);\n                }\n            )\n            ->whenRendering(\n                'Teams/Show',\n                function (Request $request, array $data): array {\n                    /** @var Organization $teamModel */\n                    $teamModel = $data['team'];\n                    $owner = $teamModel->owner;\n\n                    return array_merge($data, [\n                        'team' => [\n                            'id' => $teamModel->getKey(),\n                            'name' => $teamModel->name,\n                            'currency' => $teamModel->currency,\n                            'owner' => [\n                                'id' => $owner->getKey(),\n                                'name' => $owner->name,\n                                'email' => $owner->email,\n                                'profile_photo_url' => $owner->profile_photo_url,\n                            ],\n                            'users' => $teamModel->users->map(function (User $user): array {\n                                return [\n                                    'id' => $user->getKey(),\n                                    'name' => $user->name,\n                                    'email' => $user->email,\n                                    'profile_photo_url' => $user->profile_photo_url,\n                                    'membership' => [\n                                        'id' => $user->membership->id,\n                                        'role' => $user->membership->role,\n                                    ],\n                                ];\n                            }),\n                            'team_invitations' => $teamModel->teamInvitations->map(function (OrganizationInvitation $invitation): array {\n                                return [\n                                    'id' => $invitation->getKey(),\n                                    'email' => $invitation->email,\n                                    'role' => $invitation->role,\n                                ];\n                            }),\n                        ],\n                        'currencies' => array_map(function (Currency $currency): string {\n                            return $currency->getName();\n                        }, ISOCurrencyProvider::getInstance()->getAvailableCurrencies()),\n                    ]);\n                }\n            );\n    }\n}\n"
  },
  {
    "path": "app/Providers/RouteServiceProvider.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Providers;\n\nuse App\\Http\\Controllers\\Web\\HealthCheckController;\nuse Illuminate\\Cache\\RateLimiting\\Limit;\nuse Illuminate\\Foundation\\Support\\Providers\\RouteServiceProvider as ServiceProvider;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Facades\\RateLimiter;\nuse Illuminate\\Support\\Facades\\Route;\n\nclass RouteServiceProvider extends ServiceProvider\n{\n    /**\n     * The path to your application's \"home\" route.\n     *\n     * Typically, users are redirected here after authentication.\n     *\n     * @var string\n     */\n    public const HOME = '/dashboard';\n\n    /**\n     * Define your route model bindings, pattern filters, and other route configuration.\n     */\n    public function boot(): void\n    {\n        RateLimiter::for('api', function (Request $request) {\n            if (! $this->app->isProduction()) {\n                return Limit::none();\n            }\n\n            return $request->user()\n                ? Limit::perMinute(200)->by($request->user()->id)\n                : Limit::perMinute(60)->by($request->ip());\n        });\n\n        $this->routes(function (): void {\n            Route::middleware('health-check')\n                ->group(function (): void {\n                    Route::get('health-check/up', [HealthCheckController::class, 'up']);\n                    Route::get('health-check/debug', [HealthCheckController::class, 'debug']);\n                });\n\n            Route::middleware('api')\n                ->prefix('api')\n                ->name('api.')\n                ->group(base_path('routes/api.php'));\n\n            Route::middleware('web')\n                ->group(base_path('routes/web.php'));\n        });\n    }\n}\n"
  },
  {
    "path": "app/Providers/TelescopeServiceProvider.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Providers;\n\nuse App\\Models\\User;\nuse Illuminate\\Support\\Facades\\Gate;\nuse Laravel\\Telescope\\IncomingEntry;\nuse Laravel\\Telescope\\Telescope;\nuse Laravel\\Telescope\\TelescopeApplicationServiceProvider;\n\nclass TelescopeServiceProvider extends TelescopeApplicationServiceProvider\n{\n    /**\n     * Register any application services.\n     */\n    public function register(): void\n    {\n        Telescope::night();\n\n        $this->hideSensitiveRequestDetails();\n\n        Telescope::filter(function (IncomingEntry $entry): bool {\n            if ($this->app->environment('local')) {\n                return true;\n            }\n\n            return $entry->isReportableException() ||\n                   $entry->isFailedRequest() ||\n                   $entry->isFailedJob() ||\n                   $entry->isScheduledTask() ||\n                   $entry->hasMonitoredTag();\n        });\n    }\n\n    /**\n     * Prevent sensitive request details from being logged by Telescope.\n     */\n    protected function hideSensitiveRequestDetails(): void\n    {\n        if ($this->app->environment('local')) {\n            return;\n        }\n\n        Telescope::hideRequestParameters(['_token']);\n\n        Telescope::hideRequestHeaders([\n            'cookie',\n            'x-csrf-token',\n            'x-xsrf-token',\n        ]);\n    }\n\n    /**\n     * Register the Telescope gate.\n     *\n     * This gate determines who can access Telescope in non-local environments.\n     */\n    protected function gate(): void\n    {\n        Gate::define('viewTelescope', function (User $user): bool {\n            // Note: Telescope is only available in local environments, so this should not be relevant.\n            return false;\n        });\n    }\n}\n"
  },
  {
    "path": "app/Rules/ColorRule.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Rules;\n\nuse App\\Service\\ColorService;\nuse Closure;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\nuse Illuminate\\Translation\\PotentiallyTranslatedString;\n\nclass ColorRule implements ValidationRule\n{\n    /**\n     * Run the validation rule.\n     *\n     * @param  Closure(string): PotentiallyTranslatedString  $fail\n     */\n    public function validate(string $attribute, mixed $value, Closure $fail): void\n    {\n        if (! is_string($value)) {\n            $fail(__('validation.string'));\n\n            return;\n        }\n        if (! app(ColorService::class)->isValid($value)) {\n            $fail(__('validation.color'));\n\n            return;\n        }\n    }\n}\n"
  },
  {
    "path": "app/Rules/CurrencyRule.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Rules;\n\nuse Brick\\Money\\ISOCurrencyProvider;\nuse Closure;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\nuse Illuminate\\Translation\\PotentiallyTranslatedString;\n\nclass CurrencyRule implements ValidationRule\n{\n    /**\n     * Run the validation rule.\n     *\n     * @param  Closure(string): PotentiallyTranslatedString  $fail\n     */\n    public function validate(string $attribute, mixed $value, Closure $fail): void\n    {\n        if (! is_string($value)) {\n            $fail(__('validation.string'));\n\n            return;\n        }\n\n        $currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();\n        if (array_key_exists($value, $currencies)) {\n            return;\n        }\n\n        $fail(__('validation.currency'));\n    }\n}\n"
  },
  {
    "path": "app/Service/ApiService.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Models\\Audit;\nuse App\\Models\\Client;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\ProjectMember;\nuse App\\Models\\Task;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Http;\nuse Log;\n\nclass ApiService\n{\n    private const string API_URL = 'https://app.solidtime.io/api/v1';\n\n    public function checkForUpdate(): ?string\n    {\n        try {\n            $response = Http::asJson()\n                ->timeout(3)\n                ->connectTimeout(2)\n                ->post(self::API_URL.'/ping/version', [\n                    'version' => config('app.version'),\n                    'build' => config('app.build'),\n                    'url' => config('app.url'),\n                ]);\n\n            if ($response->status() === 200 && isset($response->json()['version']) && is_string($response->json()['version'])) {\n                return $response->json()['version'];\n            } else {\n                Log::warning('Failed to check for update', [\n                    'status' => $response->status(),\n                    'body' => $response->body(),\n                ]);\n\n                return null;\n            }\n        } catch (\\Throwable $e) {\n            Log::warning('Failed to check for update', [\n                'message' => $e->getMessage(),\n            ]);\n\n            return null;\n        }\n    }\n\n    public function telemetry(): bool\n    {\n        try {\n            $response = Http::asJson()\n                ->timeout(3)\n                ->connectTimeout(2)\n                ->post(self::API_URL.'/ping/telemetry', [\n                    'version' => config('app.version'),\n                    'build' => config('app.build'),\n                    'url' => config('app.url'),\n                    // telemetry data\n                    'user_count' => User::count(),\n                    'organization_count' => Organization::count(),\n                    'audit_count' => Audit::count(),\n                    'project_count' => Project::count(),\n                    'project_member_count' => ProjectMember::count(),\n                    'client_count' => Client::count(),\n                    'task_count' => Task::count(),\n                    'time_entry_count' => TimeEntry::count(),\n                ]);\n\n            if ($response->status() === 200) {\n                return true;\n            } else {\n                Log::warning('Failed send telemetry data', [\n                    'status' => $response->status(),\n                    'body' => $response->body(),\n                ]);\n\n                return false;\n            }\n        } catch (Exception $e) {\n            Log::warning('Failed send telemetry data', [\n                'message' => $e->getMessage(),\n            ]);\n\n            return false;\n        }\n    }\n}\n"
  },
  {
    "path": "app/Service/BillableRateService.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\ProjectMember;\nuse App\\Models\\TimeEntry;\nuse Illuminate\\Database\\Eloquent\\Builder;\n\nclass BillableRateService\n{\n    public function updateTimeEntriesBillableRateForProjectMember(ProjectMember $projectMember): void\n    {\n        TimeEntry::query()\n            ->where('billable', '=', true)\n            ->where('member_id', '=', $projectMember->member_id)\n            ->where('project_id', '=', $projectMember->project_id)\n            ->update(['billable_rate' => $projectMember->billable_rate]);\n    }\n\n    public function updateTimeEntriesBillableRateForProject(Project $project): void\n    {\n        TimeEntry::query()\n            ->where('billable', '=', true)\n            ->where('organization_id', '=', $project->organization_id)\n            ->whereBelongsTo($project, 'project')\n            ->whereDoesntHave('member', function (Builder $query) use ($project): void {\n                /** @var Builder<Member> $query */\n                $query->whereHas('projectMembers', function (Builder $query) use ($project): void {\n                    /** @var Builder<ProjectMember> $query */\n                    $query->whereBelongsTo($project, 'project')\n                        ->whereNotNull('billable_rate');\n                });\n            })\n            ->update(['billable_rate' => $project->billable_rate]);\n    }\n\n    public function updateTimeEntriesBillableRateForMember(Member $member): void\n    {\n        TimeEntry::query()\n            ->where('billable', '=', true)\n            ->where('organization_id', '=', $member->organization_id)\n            ->where('member_id', '=', $member->getKey())\n            ->whereDoesntHave('project', function (Builder $builder) use ($member): void {\n                /** @var Builder<Project> $builder */\n                $builder->whereNotNull('billable_rate')\n                    ->orWhereHas('members', function (Builder $builder) use ($member): void {\n                        /** @var Builder<ProjectMember> $builder */\n                        $builder->whereNotNull('billable_rate')\n                            ->where('member_id', '=', $member->getKey());\n                    });\n            })\n            ->update(['billable_rate' => $member->billable_rate]);\n    }\n\n    public function updateTimeEntriesBillableRateForOrganization(Organization $organization): void\n    {\n        TimeEntry::query()\n            ->where('billable', '=', true)\n            ->where('organization_id', '=', $organization->getKey())\n            ->whereDoesntHave('member', function (Builder $builder): void {\n                /** @var Builder<Member> $builder */\n                $builder->whereNotNull('billable_rate');\n            })\n            ->whereDoesntHave('project', function (Builder $builder): void {\n                /** @var Builder<Project> $builder */\n                $builder->whereNotNull('billable_rate')\n                    ->orWhereHas('members', function (Builder $builder): void {\n                        /** @var Builder<ProjectMember> $builder */\n                        $builder->whereNotNull('billable_rate')\n                            ->whereRaw('member_id = time_entries.member_id');\n                    });\n            })\n            ->update(['billable_rate' => $organization->billable_rate]);\n    }\n\n    public function getBillableRateForTimeEntryWithGivenRelations(TimeEntry $timeEntry, ?ProjectMember $projectMember, ?Project $project, ?Member $member, ?Organization $organization): ?int\n    {\n        if (! $timeEntry->billable) {\n            return null;\n        }\n        if ($projectMember !== null && $projectMember->billable_rate !== null) {\n            return $projectMember->billable_rate;\n        }\n        if ($project !== null && $project->billable_rate !== null) {\n            return $project->billable_rate;\n        }\n        if ($member !== null && $member->billable_rate !== null) {\n            return $member->billable_rate;\n        }\n        if ($organization !== null && $organization->billable_rate !== null) {\n            return $organization->billable_rate;\n        }\n\n        return null;\n    }\n\n    public function getBillableRateForTimeEntry(TimeEntry $timeEntry): ?int\n    {\n        if (! $timeEntry->billable) {\n            return null;\n        }\n        if ($timeEntry->project_id !== null) {\n            // Project member rate\n            /** @var ProjectMember|null $projectMember */\n            $projectMember = ProjectMember::query()\n                ->where('user_id', '=', $timeEntry->user_id)\n                ->where('project_id', '=', $timeEntry->project_id)\n                ->first();\n            if ($projectMember !== null && $projectMember->billable_rate !== null) {\n                return $projectMember->billable_rate;\n            }\n\n            // Project rate\n            /** @var Project|null $project */\n            $project = Project::find($timeEntry->project_id);\n            if ($project !== null && $project->billable_rate !== null) {\n                return $project->billable_rate;\n            }\n        }\n        // Member rate\n        /** @var Member|null $member */\n        $member = Member::query()\n            ->where('user_id', '=', $timeEntry->user_id)\n            ->where('organization_id', '=', $timeEntry->organization_id)\n            ->first();\n        if ($member !== null && $member->billable_rate !== null) {\n            return $member->billable_rate;\n        }\n\n        // Organization rate\n        /** @var Organization|null $organization */\n        $organization = Organization::query()\n            ->where('id', '=', $timeEntry->organization_id)\n            ->first();\n        if ($organization !== null && $organization->billable_rate !== null) {\n            return $organization->billable_rate;\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "app/Service/BillingContract.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Models\\Organization;\nuse Illuminate\\Support\\Carbon;\n\n/**\n * This class is a contract for the billing system\n * The billing system is responsible for managing the subscriptions of organizations\n * The concrete implementation of this contract for the cloud version of solidtime is implemented in an extension\n */\nclass BillingContract\n{\n    /**\n     * Check if the organization has a Professional subscription\n     * A Professional subscription is a paid subscription that allows the organization to:\n     *  - Have more than 1 non-placeholder member\n     *  - Access features that are not available to free organizations\n     */\n    public function hasSubscription(Organization $organization): bool\n    {\n        return true;\n    }\n\n    /**\n     * Check if the organization has a trial subscription\n     * A trial subscription gives the organization the same benefits as a Professional subscription, but for a limited time\n     */\n    public function hasTrial(Organization $organization): bool\n    {\n        return false;\n    }\n\n    /**\n     * Get the date until which the organization's trial subscription is valid\n     * If the organization does not have a trial subscription, this method should return null\n     */\n    public function getTrialUntil(Organization $organization): ?Carbon\n    {\n        return null;\n    }\n\n    /**\n     * Check if the organization is blocked\n     * A blocked organization is an organization that has more than 1 non-placeholder member but no subscription/trial\n     * This can happen if:\n     *  - The organization's trial has expired and during the trial the organization added non-placeholder members\n     *  - The organization's subscription has expired and the organization has more than 1 non-placeholder member\n     */\n    public function isBlocked(Organization $organization): bool\n    {\n        return false;\n    }\n}\n"
  },
  {
    "path": "app/Service/ColorService.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nclass ColorService\n{\n    /**\n     * @var array<string>\n     */\n    private const array COLORS = [\n        '#ef5350',\n        '#ec407a',\n        '#ab47bc',\n        '#7e57c2',\n        '#5c6bc0',\n        '#42a5f5',\n        '#29b6f6',\n        '#26c6da',\n        '#26a69a',\n        '#66bb6a',\n        '#9ccc65',\n        '#d4e157',\n        '#ffee58',\n        '#ffca28',\n        '#ffa726',\n        '#ff7043',\n        '#8d6e63',\n        '#bdbdbd',\n        '#78909c',\n    ];\n\n    private const string VALID_REGEX = '/^#[0-9a-f]{6}$/';\n\n    public function isBuiltInColor(string $color): bool\n    {\n        return in_array($color, self::COLORS, true);\n    }\n\n    public function getRandomColor(?string $seed = null): string\n    {\n        if ($seed !== null) {\n            srand(crc32($seed));\n        }\n\n        return self::COLORS[array_rand(self::COLORS)];\n    }\n\n    public function isValid(string $color): bool\n    {\n        return preg_match(self::VALID_REGEX, $color) === 1;\n    }\n}\n"
  },
  {
    "path": "app/Service/CurrencyService.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse Brick\\Money\\ISOCurrencyProvider;\nuse Brick\\Money\\Money;\n\nclass CurrencyService\n{\n    /**\n     * @source https://gist.github.com/stephenfrank/a8245c2486f3e546107c5363706ac93e\n     *\n     * @const array<string, array<{ symbol: string }>>\n     */\n    private const array CURRENCIES = [\n        'ALL' => [\n            'symbol' => 'L',\n        ],\n        'AFN' => [\n            'symbol' => '؋',\n        ],\n        'ARS' => [\n            'symbol' => '$',\n        ],\n        'AWG' => [\n            'symbol' => 'ƒ',\n        ],\n        'AUD' => [\n            'symbol' => '$',\n        ],\n        'AZN' => [\n            'symbol' => '₼',\n        ],\n        'BSD' => [\n            'symbol' => '$',\n        ],\n        'BBD' => [\n            'symbol' => '$',\n        ],\n        'BDT' => [\n            'symbol' => '৳',\n        ],\n        'BYR' => [\n            'symbol' => 'Br',\n        ],\n        'BZD' => [\n            'symbol' => 'BZ$',\n        ],\n        'BMD' => [\n            'symbol' => '$',\n        ],\n        'BOB' => [\n            'symbol' => '$b',\n        ],\n        'BAM' => [\n            'symbol' => 'KM',\n        ],\n        'BWP' => [\n            'symbol' => 'P',\n        ],\n        'BGN' => [\n            'symbol' => 'лв',\n        ],\n        'BRL' => [\n            'symbol' => 'R$',\n        ],\n        'BND' => [\n            'symbol' => '$',\n        ],\n        'KHR' => [\n            'symbol' => '៛',\n        ],\n        'CAD' => [\n            'symbol' => '$',\n        ],\n        'KYD' => [\n            'symbol' => '$',\n        ],\n        'CLP' => [\n            'symbol' => '$',\n        ],\n        'CNY' => [\n            'symbol' => '¥',\n        ],\n        'COP' => [\n            'symbol' => '$',\n        ],\n        'CRC' => [\n            'symbol' => '₡',\n        ],\n        'HRK' => [\n            'symbol' => 'kn',\n        ],\n        'CUP' => [\n            'symbol' => '₱',\n        ],\n        'CZK' => [\n            'symbol' => 'Kč',\n        ],\n        'DKK' => [\n            'symbol' => 'kr',\n        ],\n        'DOP' => [\n            'symbol' => 'RD$',\n        ],\n        'XCD' => [\n            'symbol' => '$',\n        ],\n        'EGP' => [\n            'symbol' => '£',\n        ],\n        'SVC' => [\n            'symbol' => '$',\n        ],\n        'EEK' => [\n            'symbol' => 'kr',\n        ],\n        'EUR' => [\n            'symbol' => '€',\n        ],\n        'FKP' => [\n            'symbol' => '£',\n        ],\n        'FJD' => [\n            'symbol' => '$',\n        ],\n        'GHC' => [\n            'symbol' => '₵',\n        ],\n        'GIP' => [\n            'symbol' => '£',\n        ],\n        'GTQ' => [\n            'symbol' => 'Q',\n        ],\n        'GGP' => [\n            'symbol' => '£',\n        ],\n        'GYD' => [\n            'symbol' => '$',\n        ],\n        'HNL' => [\n            'symbol' => 'L',\n        ],\n        'HKD' => [\n            'symbol' => '$',\n        ],\n        'HUF' => [\n            'symbol' => 'Ft',\n        ],\n        'ISK' => [\n            'symbol' => 'kr',\n        ],\n        'INR' => [\n            'symbol' => '₹',\n        ],\n        'IDR' => [\n            'symbol' => 'Rp',\n        ],\n        'IRR' => [\n            'symbol' => '﷼',\n        ],\n        'IMP' => [\n            'symbol' => '£',\n        ],\n        'ILS' => [\n            'symbol' => '₪',\n        ],\n        'JMD' => [\n            'symbol' => 'J$',\n        ],\n        'JPY' => [\n            'symbol' => '¥',\n        ],\n        'JEP' => [\n            'symbol' => '£',\n        ],\n        'KZT' => [\n            'symbol' => 'лв',\n        ],\n        'KPW' => [\n            'symbol' => '₩',\n        ],\n        'KRW' => [\n            'symbol' => '₩',\n        ],\n        'KGS' => [\n            'symbol' => 'лв',\n        ],\n        'LAK' => [\n            'symbol' => '₭',\n        ],\n        'LVL' => [\n            'symbol' => 'Ls',\n        ],\n        'LBP' => [\n            'symbol' => '£',\n        ],\n        'LRD' => [\n            'symbol' => '$',\n        ],\n        'LTL' => [\n            'symbol' => 'Lt',\n        ],\n        'MKD' => [\n            'symbol' => 'ден',\n        ],\n        'MYR' => [\n            'symbol' => 'RM',\n        ],\n        'MUR' => [\n            'symbol' => '₨',\n        ],\n        'MXN' => [\n            'symbol' => '$',\n        ],\n        'MNT' => [\n            'symbol' => '₮',\n        ],\n        'MZN' => [\n            'symbol' => 'MT',\n        ],\n        'NAD' => [\n            'symbol' => '$',\n        ],\n        'NPR' => [\n            'symbol' => '₨',\n        ],\n        'ANG' => [\n            'symbol' => 'ƒ',\n        ],\n        'NZD' => [\n            'symbol' => '$',\n        ],\n        'NIO' => [\n            'symbol' => 'C$',\n        ],\n        'NGN' => [\n            'symbol' => '₦',\n        ],\n        'NOK' => [\n            'symbol' => 'kr',\n        ],\n        'OMR' => [\n            'symbol' => '﷼',\n        ],\n        'PKR' => [\n            'symbol' => '₨',\n        ],\n        'PAB' => [\n            'symbol' => 'B/.',\n        ],\n        'PYG' => [\n            'symbol' => 'Gs',\n        ],\n        'PEN' => [\n            'symbol' => 'S/.',\n        ],\n        'PHP' => [\n            'symbol' => '₱',\n        ],\n        'PLN' => [\n            'symbol' => 'zł',\n        ],\n        'QAR' => [\n            'symbol' => '﷼',\n        ],\n        'RON' => [\n            'symbol' => 'lei',\n        ],\n        'RUB' => [\n            'symbol' => '₽',\n        ],\n        'SHP' => [\n            'symbol' => '£',\n        ],\n        'SAR' => [\n            'symbol' => '﷼',\n        ],\n        'RSD' => [\n            'symbol' => 'Дин.',\n        ],\n        'SCR' => [\n            'symbol' => '₨',\n        ],\n        'SGD' => [\n            'symbol' => '$',\n        ],\n        'SBD' => [\n            'symbol' => '$',\n        ],\n        'SOS' => [\n            'symbol' => 'S',\n        ],\n        'ZAR' => [\n            'symbol' => 'R',\n        ],\n        'LKR' => [\n            'symbol' => '₨',\n        ],\n        'SEK' => [\n            'symbol' => 'kr',\n        ],\n        'CHF' => [\n            'symbol' => 'CHF',\n        ],\n        'SRD' => [\n            'symbol' => '$',\n        ],\n        'SYP' => [\n            'symbol' => '£',\n        ],\n        'TWD' => [\n            'symbol' => 'NT$',\n        ],\n        'THB' => [\n            'symbol' => '฿',\n        ],\n        'TTD' => [\n            'symbol' => 'TT$',\n        ],\n        'TRY' => [\n            'symbol' => '₺',\n        ],\n        'TRL' => [\n            'symbol' => '₤',\n        ],\n        'TVD' => [\n            'symbol' => '$',\n        ],\n        'UAH' => [\n            'symbol' => '₴',\n        ],\n        'GBP' => [\n            'symbol' => '£',\n        ],\n        'UGX' => [\n            'symbol' => 'USh',\n        ],\n        'USD' => [\n            'symbol' => '$',\n        ],\n        'UYU' => [\n            'symbol' => '$U',\n        ],\n        'UZS' => [\n            'symbol' => 'лв',\n        ],\n        'VEF' => [\n            'symbol' => 'Bs',\n        ],\n        'VND' => [\n            'symbol' => '₫',\n        ],\n        'YER' => [\n            'symbol' => '﷼',\n        ],\n        'ZWD' => [\n            'symbol' => 'Z$',\n        ],\n    ];\n\n    public function getCurrencySymbolForMoney(Money $money): string\n    {\n        return $this->getCurrencySymbol($money->getCurrency()->getCurrencyCode());\n    }\n\n    public function getCurrencySymbol(string $currencyCode): string\n    {\n        if (isset(self::CURRENCIES[$currencyCode]['symbol'])) {\n            return self::CURRENCIES[$currencyCode]['symbol'];\n        }\n\n        return $currencyCode;\n    }\n\n    public function getRandomCurrencyCode(): string\n    {\n        $currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();\n        $currencyCodes = array_keys($currencies);\n\n        return $currencyCodes[array_rand($currencyCodes)];\n    }\n}\n"
  },
  {
    "path": "app/Service/DashboardService.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Enums\\Weekday;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\Task;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse Carbon\\CarbonTimeZone;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\DB;\n\nclass DashboardService\n{\n    private TimezoneService $timezoneService;\n\n    public function __construct(TimezoneService $timezoneService)\n    {\n        $this->timezoneService = $timezoneService;\n    }\n\n    /**\n     * @return Collection<int, string>\n     */\n    private function lastDays(int $days, CarbonTimeZone $timeZone): Collection\n    {\n        $result = new Collection;\n        $date = Carbon::now($timeZone)->subDays($days);\n        for ($i = 0; $i < $days; $i++) {\n            $date->addDay();\n            $result->push($date->format('Y-m-d'));\n        }\n\n        return $result;\n    }\n\n    /**\n     * @return array{start: string, end: string, dates: array<string, array<string>>}\n     */\n    private function lastDaysSplitInWindows(int $days, CarbonTimeZone $timeZone, int $windows): array\n    {\n        $result = [];\n        $windowSize = 24 / $windows;\n        $end = Carbon::now($timeZone)->startOfDay()->addDay()->subHours(3)->utc()->toDateTimeString();\n        $start = Carbon::now($timeZone)->subDays($days)->startOfDay()->utc()->toDateTimeString();\n\n        $date = Carbon::now($timeZone)->startOfDay();\n        $dateUtc = Carbon::now($timeZone)->startOfDay()->utc();\n        for ($i = 0; $i < $days; $i++) {\n            $dateString = $date->format('Y-m-d');\n            $tempDate = $dateUtc->copy();\n            $start = $tempDate->copy()->utc()->toDateTimeString();\n            $tempWindows = [];\n            for ($j = 0; $j < $windows; $j++) {\n                $tempWindow = $tempDate->toDateTimeString();\n                $tempWindows[] = $tempWindow;\n                $tempDate->addHours($windowSize);\n            }\n            $result[$dateString] = $tempWindows;\n            $date->subDay();\n            $dateUtc->subDay();\n        }\n\n        return [\n            'start' => $start,\n            'end' => $end,\n            'dates' => $result,\n        ];\n    }\n\n    /**\n     * @return Collection<int, string>\n     */\n    private function daysOfThisWeek(CarbonTimeZone $timeZone, Weekday $startOfWeek): Collection\n    {\n        $result = new Collection;\n        $date = Carbon::now($timeZone);\n        $start = $date->startOfWeek($startOfWeek->carbonWeekDay());\n        for ($i = 0; $i < 7; $i++) {\n            $result->push($start->format('Y-m-d'));\n            $start->addDay();\n        }\n\n        return $result;\n    }\n\n    /**\n     * @param  Collection<int, string>  $possibleDates\n     * @param  Builder<TimeEntry>  $builder\n     * @return Builder<TimeEntry>\n     */\n    private function constrainDateByPossibleDates(Builder $builder, Collection $possibleDates, CarbonTimeZone $timeZone): Builder\n    {\n        $value1 = Carbon::createFromFormat('Y-m-d', $possibleDates->first(), $timeZone);\n        $value2 = Carbon::createFromFormat('Y-m-d', $possibleDates->last(), $timeZone);\n        if ($value2 === null || $value1 === null) {\n            throw new \\RuntimeException('Provided date is not valid');\n        }\n        if ($value1->gt($value2)) {\n            $last = $value1;\n            $first = $value2;\n        } else {\n            $last = $value2;\n            $first = $value1;\n        }\n\n        return $builder->whereBetween('start', [\n            $first->startOfDay()->utc(),\n            $last->endOfDay()->utc(),\n        ]);\n    }\n\n    /**\n     * @param  Builder<TimeEntry>  $builder\n     * @return Builder<TimeEntry>\n     */\n    private function constrainDateByCurrentWeek(Builder $builder, CarbonTimeZone $timeZone, Weekday $startOfWeek): Builder\n    {\n        return $builder->whereBetween('start', [\n            Carbon::now($timeZone)->startOfWeek($startOfWeek->carbonWeekDay())->utc(),\n            Carbon::now($timeZone)->endOfWeek($startOfWeek->toEndOfWeek()->carbonWeekDay())->utc(),\n        ]);\n    }\n\n    /**\n     * Get the daily tracked hours for the user\n     * First value: date\n     * Second value: seconds\n     *\n     * @return array<int, array{date: string, duration: int}>\n     */\n    public function getDailyTrackedHours(User $user, Organization $organization, int $days): array\n    {\n        $timezone = $this->timezoneService->getTimezoneFromUser($user);\n        $timezoneShift = $this->timezoneService->getShiftFromUtc($timezone);\n\n        if ($timezoneShift > 0) {\n            $dateWithTimeZone = 'start + INTERVAL \\''.$timezoneShift.' second\\'';\n        } elseif ($timezoneShift < 0) {\n            $dateWithTimeZone = 'start - INTERVAL \\''.abs($timezoneShift).' second\\'';\n        } else {\n            $dateWithTimeZone = 'start';\n        }\n\n        $possibleDays = $this->lastDays($days, $timezone);\n\n        $query = TimeEntry::query()\n            ->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce(\"end\", now()) - start)))) as aggregate'))\n            ->where('user_id', '=', $user->getKey())\n            ->where('organization_id', '=', $organization->getKey())\n            ->groupBy(DB::raw('DATE('.$dateWithTimeZone.')'))\n            ->orderBy('date');\n\n        $query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);\n        $resultDb = $query->get()\n            ->pluck('aggregate', 'date');\n\n        $result = [];\n\n        foreach ($possibleDays as $possibleDay) {\n            $result[] = [\n                'date' => $possibleDay,\n                'duration' => (int) ($resultDb->get($possibleDay) ?? 0),\n            ];\n        }\n\n        return $result;\n    }\n\n    /**\n     * Statistics for the current week starting at weekday of users preference\n     *\n     * @return array<int, array{date: string, duration: int}>\n     */\n    public function getWeeklyHistory(User $user, Organization $organization): array\n    {\n        $timezone = $this->timezoneService->getTimezoneFromUser($user);\n        $timezoneShift = $this->timezoneService->getShiftFromUtc($timezone);\n        if ($timezoneShift > 0) {\n            $dateWithTimeZone = 'start + INTERVAL \\''.$timezoneShift.' second\\'';\n        } elseif ($timezoneShift < 0) {\n            $dateWithTimeZone = 'start - INTERVAL \\''.abs($timezoneShift).' second\\'';\n        } else {\n            $dateWithTimeZone = 'start';\n        }\n        $possibleDays = $this->daysOfThisWeek($timezone, $user->week_start);\n\n        $query = TimeEntry::query()\n            ->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce(\"end\", now()) - start)))) as aggregate'))\n            ->where('user_id', '=', $user->getKey())\n            ->where('organization_id', '=', $organization->getKey())\n            ->groupBy(DB::raw('DATE('.$dateWithTimeZone.')'))\n            ->orderBy('date');\n\n        $query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);\n        $resultDb = $query->get()\n            ->pluck('aggregate', 'date');\n\n        $result = [];\n\n        foreach ($possibleDays as $possibleDay) {\n            $result[] = [\n                'date' => $possibleDay,\n                'duration' => (int) ($resultDb->get($possibleDay) ?? 0),\n            ];\n        }\n\n        return $result;\n    }\n\n    public function totalWeeklyTime(User $user, Organization $organization): int\n    {\n        $timezone = $this->timezoneService->getTimezoneFromUser($user);\n        $possibleDays = $this->daysOfThisWeek($timezone, $user->week_start);\n\n        $query = TimeEntry::query()\n            ->select(DB::raw('round(sum(extract(epoch from (coalesce(\"end\", now()) - start)))) as aggregate'))\n            ->where('user_id', '=', $user->getKey())\n            ->where('organization_id', '=', $organization->getKey());\n\n        $query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);\n        /** @var Collection<int, object{aggregate: int}> $resultDb */\n        $resultDb = $query->get();\n\n        return (int) $resultDb->get(0)->aggregate;\n    }\n\n    public function totalWeeklyBillableTime(User $user, Organization $organization): int\n    {\n        $timezone = $this->timezoneService->getTimezoneFromUser($user);\n        $possibleDays = $this->daysOfThisWeek($timezone, $user->week_start);\n\n        $query = TimeEntry::query()\n            ->select(DB::raw('round(sum(extract(epoch from (coalesce(\"end\", now()) - start)))) as aggregate'))\n            ->where('billable', '=', true)\n            ->where('user_id', '=', $user->getKey())\n            ->where('organization_id', '=', $organization->getKey());\n\n        $query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);\n        /** @var Collection<int, object{aggregate: int}> $resultDb */\n        $resultDb = $query->get();\n\n        return (int) $resultDb->get(0)->aggregate;\n    }\n\n    /**\n     * @return array{value: int, currency: string}\n     */\n    public function totalWeeklyBillableAmount(User $user, Organization $organization): array\n    {\n        $timezone = $this->timezoneService->getTimezoneFromUser($user);\n        $possibleDays = $this->daysOfThisWeek($timezone, $user->week_start);\n\n        $query = TimeEntry::query()\n            ->select(DB::raw('\n               round(\n                    sum(\n                        extract(epoch from (coalesce(\"end\", now()) - start)) * (billable_rate::float/60/60)\n                    )\n               ) as aggregate'))\n            ->where('billable', '=', true)\n            ->whereNotNull('billable_rate')\n            ->where('user_id', '=', $user->getKey())\n            ->where('organization_id', '=', $organization->getKey());\n\n        $query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);\n        /** @var Collection<int, object{aggregate: int}> $resultDb */\n        $resultDb = $query->get();\n\n        return [\n            'value' => (int) $resultDb->get(0)->aggregate,\n            'currency' => $organization->currency,\n        ];\n    }\n\n    /**\n     * @return array<int, array{value: int, name: string, color: string}>\n     */\n    public function weeklyProjectOverview(User $user, Organization $organization): array\n    {\n        $timezone = $this->timezoneService->getTimezoneFromUser($user);\n\n        $query = TimeEntry::query()\n            ->select(DB::raw('project_id, round(sum(extract(epoch from (coalesce(\"end\", now()) - start)))) as aggregate'))\n            ->where('user_id', '=', $user->getKey())\n            ->where('organization_id', '=', $organization->getKey())\n            ->groupBy('project_id');\n\n        $query = $this->constrainDateByCurrentWeek($query, $timezone, $user->week_start);\n        /** @var Collection<int, object{project_id: string, aggregate: int}> $entries */\n        $entries = $query->get();\n\n        $projectIds = $entries->pluck('project_id')->whereNotNull()->all();\n        $projectsMap = Project::query()\n            ->select(['id', 'name', 'color'])\n            ->whereBelongsTo($organization, 'organization')\n            ->whereIn('id', $projectIds)\n            ->get()\n            ->keyBy('id');\n\n        $response = [];\n\n        $aggregateOther = 0;\n\n        foreach ($entries as $entry) {\n            $project = $projectsMap->get($entry->project_id);\n            if ($project === null) {\n                $aggregateOther += (int) $entry->aggregate;\n\n                continue;\n            }\n\n            $response[] = [\n                'value' => (int) $entry->aggregate,\n                'id' => $entry->project_id,\n                'name' => $project->name,\n                'color' => $project->color,\n            ];\n        }\n\n        if ($aggregateOther > 0 || count($response) === 0) {\n            $response[] = [\n                'value' => $aggregateOther,\n                'id' => null,\n                'name' => 'No project',\n                'color' => '#cccccc',\n            ];\n\n        }\n\n        return $response;\n    }\n\n    /**\n     * Rhe 4 most recently active members of your team with member_id, name, description of the latest time entry, time_entry_id, task_id and a boolean status if the team member is currently working\n     *\n     * @return array<int, array{member_id: string, name: string, description: string|null, time_entry_id: string, task_id: string|null, status: bool }>\n     */\n    public function latestTeamActivity(Organization $organization): array\n    {\n        $timeEntries = TimeEntry::query()\n            ->select(DB::raw('distinct on (member_id) member_id, description, id, task_id, start, \"end\"'))\n            ->whereBelongsTo($organization, 'organization')\n            ->orderBy('member_id')\n            ->orderBy('start', 'desc')\n            // Note: limit here does not work because of the distinct on\n            ->with([\n                'member' => [\n                    'user',\n                ],\n            ])\n            ->get()\n            ->sortByDesc('start')\n            ->slice(0, 4);\n\n        $response = [];\n\n        foreach ($timeEntries as $timeEntry) {\n            $response[] = [\n                'member_id' => $timeEntry->member_id,\n                'name' => $timeEntry->member->user->name,\n                'description' => $timeEntry->description,\n                'time_entry_id' => $timeEntry->id,\n                'task_id' => $timeEntry->task_id,\n                'status' => $timeEntry->end === null,\n            ];\n        }\n\n        return $response;\n    }\n\n    /**\n     * The 4 tasks with the most recent time entries\n     *\n     * @return array<int, array{id: string, name: string, project_name: string|null, project_id: string }>\n     */\n    public function latestTasks(User $user, Organization $organization): array\n    {\n        $tasks = Task::query()\n            ->where('organization_id', '=', $organization->getKey())\n            ->with([\n                'project',\n            ])\n            ->whereHas('timeEntries', function (Builder $builder) use ($user, $organization): void {\n                /** @var Builder<TimeEntry> $builder */\n                $builder->where('user_id', '=', $user->getKey())\n                    ->where('organization_id', '=', $organization->getKey());\n            })\n            ->orderByDesc(\n                TimeEntry::select('start')\n                    ->whereColumn('task_id', 'tasks.id')\n                    ->orderBy('start', 'desc')\n                    ->limit(1)\n            )\n            ->limit(4)\n            ->get();\n\n        $response = [];\n\n        foreach ($tasks as $task) {\n            $response[] = [\n                'id' => $task->id,\n                'name' => $task->name,\n                'project_name' => $task->project->name,\n                'project_id' => $task->project->id,\n            ];\n        }\n\n        return $response;\n    }\n\n    /**\n     * The last 7 days with statistics for the time entries\n     *\n     * @return array<int, array{ date: string, duration: int, history: array<int> }>\n     */\n    public function lastSevenDays(User $user, Organization $organization): array\n    {\n        $timezone = $this->timezoneService->getTimezoneFromUser($user);\n        $lastDaysSplitInWindows = $this->lastDaysSplitInWindows(7, $timezone, 8);\n        $data = collect(DB::select('\n            SELECT time_ranges.start, EXTRACT(epoch FROM sum(LEAST(time_ranges.\"end\", coalesce(time_entries.\"end\", :now::timestamp)) - GREATEST(time_ranges.start, time_entries.start))) AS aggregate\n            FROM  (\n               SELECT time_range_starts.start AS start, time_range_starts.start + interval \\'3 hours\\' AS \"end\"\n               FROM generate_series(:start_time_ranges::timestamp, :end_time_ranges::timestamp + interval \\'3 hours\\', interval \\'3 hours\\') as time_range_starts (start)\n            ) time_ranges\n            JOIN   time_entries ON time_entries.start < time_ranges.\"end\"\n                      AND coalesce(time_entries.\"end\", :now::timestamp) > time_ranges.start\n            WHERE time_entries.user_id = :user_id and\n                  time_entries.organization_id = :organization_id\n            GROUP BY time_ranges.start\n            ORDER BY time_ranges.start\n        ', [\n            'start_time_ranges' => $lastDaysSplitInWindows['start'],\n            'end_time_ranges' => $lastDaysSplitInWindows['end'],\n            'user_id' => $user->getKey(),\n            'organization_id' => $organization->getKey(),\n            'now' => Carbon::now()->toDateTimeString(),\n        ]))->pluck('aggregate', 'start');\n\n        $response = [];\n\n        foreach ($lastDaysSplitInWindows['dates'] as $date => $windows) {\n            $history = [];\n            $duration = 0;\n            foreach ($windows as $window) {\n                $value = (int) ($data->get($window, null) ?? 0);\n                $history[] = $value;\n                $duration += $value;\n            }\n            $response[] = [\n                'date' => $date,\n                'duration' => $duration,\n                'history' => $history,\n            ];\n        }\n\n        return $response;\n    }\n}\n"
  },
  {
    "path": "app/Service/DeletionService.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Enums\\Role;\nuse App\\Events\\BeforeOrganizationDeletion;\nuse App\\Exceptions\\Api\\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;\nuse App\\Models\\Client;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\OrganizationInvitation;\nuse App\\Models\\Project;\nuse App\\Models\\ProjectMember;\nuse App\\Models\\Report;\nuse App\\Models\\Tag;\nuse App\\Models\\Task;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Support\\Facades\\Log;\n\nclass DeletionService\n{\n    private UserService $userService;\n\n    private MemberService $memberService;\n\n    public function __construct(UserService $userService, MemberService $memberService)\n    {\n        $this->userService = $userService;\n        $this->memberService = $memberService;\n    }\n\n    public function deleteOrganization(Organization $organization, bool $inTransaction = true, ?User $ignoreUser = null): void\n    {\n        if ($inTransaction) {\n            DB::transaction(function () use ($organization): void {\n                $this->deleteOrganization($organization, false);\n            });\n\n            return;\n        }\n\n        Log::debug('Start deleting organization', [\n            'organization_id' => $organization->getKey(),\n            'name' => $organization->name,\n            'owner_id' => $organization->user_id,\n        ]);\n\n        BeforeOrganizationDeletion::dispatch($organization);\n\n        // Delete all organization invitations\n        OrganizationInvitation::query()->whereBelongsTo($organization, 'organization')->delete();\n\n        // Delete all time entries\n        TimeEntry::query()->whereBelongsTo($organization, 'organization')->delete();\n\n        // Delete all tags\n        Tag::query()->whereBelongsTo($organization, 'organization')->delete();\n\n        // Delete all tasks\n        Task::query()->whereBelongsTo($organization, 'organization')->delete();\n\n        // Delete all project members\n        ProjectMember::query()->whereBelongsToOrganization($organization)->delete();\n\n        // Delete all projects\n        Project::query()->whereBelongsTo($organization, 'organization')->delete();\n\n        // Delete all clients\n        Client::query()->whereBelongsTo($organization, 'organization')->delete();\n\n        // Delete all reports\n        Report::query()->whereBelongsTo($organization, 'organization')->delete();\n\n        // Reset the current organization\n        $organization->owner()\n            ->where('current_team_id', $organization->getKey())\n            ->update(['current_team_id' => null]);\n\n        $organization->users()\n            ->where('current_team_id', $organization->getKey())\n            ->update(['current_team_id' => null]);\n\n        // Delete all members\n        $users = $organization->users()\n            ->with([\n                'currentOrganization',\n            ])\n            ->get();\n\n        $members = Member::query()\n            ->whereBelongsTo($organization, 'organization')\n            ->get();\n        foreach ($members as $member) {\n            $member->delete();\n        }\n\n        // Make sure all users have at least one organization and delete placeholders\n        foreach ($users as $user) {\n            /** @var User $user */\n            if ($ignoreUser !== null && $user->is($ignoreUser)) {\n                continue;\n            }\n            if ($user->is_placeholder) {\n                $user->delete();\n            } else {\n                if ($user->current_team_id === $organization->getKey()) {\n                    $user->currentOrganization()->disassociate();\n                    $user->save();\n                }\n\n                $this->userService->makeSureUserHasAtLeastOneOrganization($user);\n                $this->userService->makeSureUserHasCurrentOrganization($user);\n            }\n        }\n\n        // Delete organization\n        $organization->delete();\n\n        Log::debug('Finished deleting organization', [\n            'organization_id' => $organization->getKey(),\n            'name' => $organization->name,\n            'owner_id' => $organization->user_id,\n        ]);\n    }\n\n    /**\n     * @throws CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers\n     */\n    public function deleteUser(User $user, bool $inTransaction = true): void\n    {\n        if ($inTransaction) {\n            DB::transaction(function () use ($user): void {\n                $this->deleteUser($user, false);\n            });\n\n            return;\n        }\n\n        Log::debug('Start deleting user', [\n            'id' => $user->getKey(),\n            'name' => $user->name,\n            'email' => $user->email,\n        ]);\n\n        $members = Member::query()->whereBelongsTo($user, 'user')\n            ->with([\n                'organization',\n                'user',\n            ])\n            ->get();\n\n        foreach ($members as $member) {\n            /** @var Member $member */\n            if ($member->role === Role::Owner->value && $member->organization->users()->count() > 1) {\n                throw new CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;\n            }\n        }\n\n        /** @var Member $member */\n        foreach ($members as $member) {\n            if ($member->role === Role::Owner->value) {\n                $this->deleteOrganization($member->organization, false, $user);\n            } else {\n                $this->memberService->makeMemberToPlaceholder($member, false);\n            }\n        }\n\n        $user->accessTokens()->delete();\n        $user->authCodes()->delete();\n\n        // Note: Since the deletion of the profile photo is not reversible via a database rollback this needs to be done last\n        $user->deleteProfilePhoto();\n\n        $user->delete();\n\n        Log::debug('Finished deleting user', [\n            'id' => $user->getKey(),\n            'name' => $user->name,\n            'email' => $user->email,\n        ]);\n    }\n}\n"
  },
  {
    "path": "app/Service/Dto/ReportPropertiesDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Dto;\n\nuse App\\Enums\\TimeEntryAggregationType;\nuse App\\Enums\\TimeEntryAggregationTypeInterval;\nuse App\\Enums\\TimeEntryRoundingType;\nuse App\\Enums\\Weekday;\nuse App\\Service\\TimeEntryFilter;\nuse Illuminate\\Contracts\\Database\\Eloquent\\Castable;\nuse Illuminate\\Contracts\\Database\\Eloquent\\CastsAttributes;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Str;\n\nclass ReportPropertiesDto implements Castable\n{\n    public TimeEntryAggregationType $group;\n\n    public TimeEntryAggregationType $subGroup;\n\n    public TimeEntryAggregationTypeInterval $historyGroup;\n\n    public Weekday $weekStart;\n\n    public string $timezone;\n\n    public Carbon $start;\n\n    public Carbon $end;\n\n    public ?bool $active = null;\n\n    /**\n     * @var Collection<int, string>|null\n     */\n    public ?Collection $memberIds = null;\n\n    public ?bool $billable = null;\n\n    /**\n     * @var Collection<int, string>|null\n     */\n    public ?Collection $clientIds = null;\n\n    /**\n     * @var Collection<int, string>|null\n     */\n    public ?Collection $projectIds = null;\n\n    /**\n     * @var Collection<int, string>|null\n     */\n    public ?Collection $tagIds = null;\n\n    /**\n     * @var Collection<int, string>|null\n     */\n    public ?Collection $taskIds = null;\n\n    public ?TimeEntryRoundingType $roundingType = null;\n\n    public ?int $roundingMinutes = null;\n\n    /**\n     * Get the caster class to use when casting from / to this cast target.\n     *\n     * @param  array<string, mixed>  $arguments\n     * @return CastsAttributes<ReportPropertiesDto, ReportPropertiesDto>\n     */\n    public static function castUsing(array $arguments): CastsAttributes\n    {\n        return new class implements CastsAttributes\n        {\n            private const array REQUIRED_PROPERTIES = [\n                'group',\n                'subGroup',\n                'historyGroup',\n                'weekStart',\n                'timezone',\n                'start',\n                'end',\n                'active',\n                'memberIds',\n                'billable',\n                'clientIds',\n                'projectIds',\n                'tagIds',\n                'taskIds',\n            ];\n\n            public function get(Model $model, string $key, mixed $value, array $attributes): ReportPropertiesDto\n            {\n                if (! is_string($value)) {\n                    throw new \\InvalidArgumentException('The given value is not a string');\n                }\n                $data = json_decode($value, false);\n                if ($data === null) {\n                    throw new \\InvalidArgumentException('The given value is not a JSON string');\n                }\n                foreach (self::REQUIRED_PROPERTIES as $property) {\n                    if (! property_exists($data, $property)) {\n                        throw new \\InvalidArgumentException('The given JSON string does not contain the required property \"'.$property.'\"');\n                    }\n                }\n                $dto = new ReportPropertiesDto;\n                $dto->end = $data->end !== null ? Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $data->end) : null;\n                $dto->start = $data->start !== null ? Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $data->start) : null;\n                $dto->active = $data->active;\n                $dto->memberIds = $data->memberIds !== null ? ReportPropertiesDto::idArrayToCollection($data->memberIds) : null;\n                $dto->billable = $data->billable;\n                $dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null;\n                $dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null;\n                $dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null;\n                $dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null;\n                $dto->group = TimeEntryAggregationType::from($data->group);\n                $dto->subGroup = TimeEntryAggregationType::from($data->subGroup);\n                $dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);\n                $dto->weekStart = Weekday::from($data->weekStart);\n                $dto->timezone = $data->timezone;\n                // Note: roundingType was added later so it is possible that the value is missing in persisted reports in the DB\n                $dto->roundingType = isset($data->roundingType) ? TimeEntryRoundingType::from($data->roundingType) : null;\n                // Note: roundingMinutes was added later so it is possible that the value is missing in persisted reports in the DB\n                $dto->roundingMinutes = isset($data->roundingMinutes) ? (int) $data->roundingMinutes : null;\n\n                return $dto;\n            }\n\n            public function set(Model $model, string $key, mixed $value, array $attributes): string\n            {\n                if (! ($value instanceof ReportPropertiesDto)) {\n                    throw new \\InvalidArgumentException('The given value is not an instance of ReportPropertiesDto');\n                }\n\n                $data = (object) [\n                    'end' => $value->end->toIso8601ZuluString(),\n                    'start' => $value->start->toIso8601ZuluString(),\n                    'active' => $value->active,\n                    'memberIds' => $value->memberIds?->toArray(),\n                    'billable' => $value->billable,\n                    'clientIds' => $value->clientIds?->toArray(),\n                    'projectIds' => $value->projectIds?->toArray(),\n                    'tagIds' => $value->tagIds?->toArray(),\n                    'taskIds' => $value->taskIds?->toArray(),\n                    'group' => $value->group->value,\n                    'subGroup' => $value->subGroup->value,\n                    'historyGroup' => $value->historyGroup->value,\n                    'weekStart' => $value->weekStart->value,\n                    'timezone' => $value->timezone,\n                    'roundingType' => $value->roundingType?->value,\n                    'roundingMinutes' => $value->roundingMinutes,\n                ];\n\n                $jsonString = json_encode($data);\n                if ($jsonString === false) {\n                    throw new \\InvalidArgumentException('Could not encode the given data to a JSON string');\n                }\n\n                return $jsonString;\n            }\n        };\n    }\n\n    /**\n     * @param  array<mixed>  $ids\n     * @return Collection<int, string>\n     */\n    public static function idArrayToCollection(array $ids): Collection\n    {\n        $collection = new Collection;\n        foreach ($ids as $id) {\n            if (! is_string($id)) {\n                throw new \\InvalidArgumentException('The given ID is not a string');\n            }\n            if ($id !== TimeEntryFilter::NONE_VALUE && ! Str::isUuid($id)) {\n                throw new \\InvalidArgumentException('The given ID is not a valid UUID');\n            }\n            $collection->push($id);\n        }\n\n        return $collection;\n    }\n\n    /**\n     * @param  array<mixed>|null  $memberIds\n     */\n    public function setMemberIds(?array $memberIds): void\n    {\n        $this->memberIds = $memberIds !== null ? ReportPropertiesDto::idArrayToCollection($memberIds) : null;\n    }\n\n    /**\n     * @param  array<mixed>|null  $clientIds\n     */\n    public function setClientIds(?array $clientIds): void\n    {\n        $this->clientIds = $clientIds !== null ? ReportPropertiesDto::idArrayToCollection($clientIds) : null;\n    }\n\n    /**\n     * @param  array<mixed>|null  $projectIds\n     */\n    public function setProjectIds(?array $projectIds): void\n    {\n        $this->projectIds = $projectIds !== null ? ReportPropertiesDto::idArrayToCollection($projectIds) : null;\n    }\n\n    /**\n     * @param  array<mixed>|null  $tagIds\n     */\n    public function setTagIds(?array $tagIds): void\n    {\n        $this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null;\n    }\n\n    /**\n     * @param  array<mixed>|null  $taskIds\n     */\n    public function setTaskIds(?array $taskIds): void\n    {\n        $this->taskIds = $taskIds !== null ? ReportPropertiesDto::idArrayToCollection($taskIds) : null;\n    }\n}\n"
  },
  {
    "path": "app/Service/Export/ExportException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Export;\n\nuse App\\Exceptions\\Api\\ApiException;\n\nclass ExportException extends ApiException\n{\n    public const string KEY = 'export';\n}\n"
  },
  {
    "path": "app/Service/Export/ExportService.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Export;\n\nuse App\\Models\\Client;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\OrganizationInvitation;\nuse App\\Models\\Project;\nuse App\\Models\\ProjectMember;\nuse App\\Models\\Tag;\nuse App\\Models\\Task;\nuse App\\Models\\TimeEntry;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Collection;\nuse Illuminate\\Http\\File;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Facades\\Storage;\nuse Illuminate\\Support\\Str;\nuse League\\Csv\\CannotInsertRecord;\nuse League\\Csv\\Exception as LeagueCsvException;\nuse League\\Csv\\UnavailableStream;\nuse League\\Csv\\Writer;\nuse Spatie\\TemporaryDirectory\\TemporaryDirectory;\nuse ZipArchive;\n\nclass ExportService\n{\n    public const string VERSION = '1.0';\n\n    /**\n     * @throws ExportException\n     */\n    public function export(Organization $organization): string\n    {\n        $exportId = Str::uuid();\n        $timeStamp = Carbon::now();\n        $temporaryDirectory = TemporaryDirectory::make();\n        Log::debug('Start exporting organization', [\n            'organization_id' => $organization->getKey(),\n            'export_id' => $exportId,\n        ]);\n\n        // Organizations\n        try {\n            $writer = Writer::createFromPath($temporaryDirectory->path('organizations.csv'), 'w+');\n            $writer->setDelimiter(',');\n            $writer->setEnclosure('\"');\n            $writer->setEscape('');\n            $writer->insertOne([\n                'id',\n                'name',\n                'billable_rate',\n                'currency',\n                'created_at',\n                'updated_at',\n            ]);\n            $writer->insertOne([\n                $organization->id,\n                $organization->name,\n                $organization->billable_rate ?? '',\n                $organization->currency,\n                $organization->created_at?->toIso8601ZuluString() ?? '',\n                $organization->updated_at?->toIso8601ZuluString() ?? '',\n            ]);\n\n            // Organization invitations\n            $writer = Writer::createFromPath($temporaryDirectory->path('organization_invitations.csv'), 'w+');\n            $writer->setDelimiter(',');\n            $writer->setEnclosure('\"');\n            $writer->setEscape('');\n            $writer->insertOne([\n                'id',\n                'email',\n                'organization_id',\n                'role',\n                'created_at',\n                'updated_at',\n            ]);\n            OrganizationInvitation::query()\n                ->whereBelongsTo($organization, 'organization')\n                ->chunk(1000, function (Collection $organizationInvitations) use (&$writer): void {\n                    $organizationInvitations->each(function (OrganizationInvitation $organizationInvitation) use (&$writer): void {\n                        $writer->insertOne([\n                            $organizationInvitation->id,\n                            $organizationInvitation->email,\n                            $organizationInvitation->organization_id,\n                            $organizationInvitation->role,\n                            $organizationInvitation->created_at?->toIso8601ZuluString() ?? '',\n                            $organizationInvitation->updated_at?->toIso8601ZuluString() ?? '',\n                        ]);\n                    });\n                });\n\n            // Time entries\n            $writer = Writer::createFromPath($temporaryDirectory->path('time_entries.csv'), 'w+');\n            $writer->setDelimiter(',');\n            $writer->setEnclosure('\"');\n            $writer->setEscape('');\n            $writer->insertOne([\n                'id',\n                'description',\n                'start',\n                'end',\n                'billable_rate',\n                'billable',\n                'member_id',\n                'user_id',\n                'organization_id',\n                'client_id',\n                'project_id',\n                'task_id',\n                'tags',\n                'is_imported',\n                'still_active_email_sent_at',\n                'created_at',\n                'updated_at',\n            ]);\n            TimeEntry::query()\n                ->whereBelongsTo($organization, 'organization')\n                ->chunk(1000, function (Collection $timeEntries) use (&$writer): void {\n                    $timeEntries->each(function (TimeEntry $timeEntry) use (&$writer): void {\n                        $tags = json_encode($timeEntry->tags);\n                        $writer->insertOne([\n                            $timeEntry->id,\n                            $timeEntry->description,\n                            $timeEntry->start->toIso8601ZuluString(),\n                            $timeEntry->end?->toIso8601ZuluString() ?? '',\n                            $timeEntry->billable_rate ?? '',\n                            $timeEntry->billable ? 'true' : 'false',\n                            $timeEntry->member_id,\n                            $timeEntry->user_id,\n                            $timeEntry->organization_id,\n                            $timeEntry->client_id ?? '',\n                            $timeEntry->project_id ?? '',\n                            $timeEntry->task_id ?? '',\n                            $tags === false ? '' : $tags,\n                            $timeEntry->is_imported ? 'true' : 'false',\n                            $timeEntry->still_active_email_sent_at?->toIso8601ZuluString() ?? '',\n                            $timeEntry->created_at?->toIso8601ZuluString() ?? '',\n                            $timeEntry->updated_at?->toIso8601ZuluString() ?? '',\n                        ]);\n                    });\n                });\n\n            // Clients\n            $writer = Writer::createFromPath($temporaryDirectory->path('clients.csv'), 'w+');\n            $writer->setDelimiter(',');\n            $writer->setEnclosure('\"');\n            $writer->setEscape('');\n            $writer->insertOne([\n                'id',\n                'name',\n                'organization_id',\n                'archived_at',\n                'created_at',\n                'updated_at',\n            ]);\n            Client::query()\n                ->whereBelongsTo($organization, 'organization')\n                ->chunk(1000, function (Collection $clients) use (&$writer): void {\n                    $clients->each(function (Client $client) use (&$writer): void {\n                        $writer->insertOne([\n                            $client->id,\n                            $client->name,\n                            $client->organization_id,\n                            $client->archived_at?->toIso8601ZuluString() ?? '',\n                            $client->created_at?->toIso8601ZuluString() ?? '',\n                            $client->updated_at?->toIso8601ZuluString() ?? '',\n                        ]);\n                    });\n                });\n\n            // Projects\n            $writer = Writer::createFromPath($temporaryDirectory->path('projects.csv'), 'w+');\n            $writer->setDelimiter(',');\n            $writer->setEnclosure('\"');\n            $writer->setEscape('');\n            $writer->insertOne([\n                'id',\n                'name',\n                'color',\n                'billable_rate',\n                'is_public',\n                'client_id',\n                'organization_id',\n                'is_billable',\n                'archived_at',\n                'created_at',\n                'updated_at',\n            ]);\n            Project::query()\n                ->whereBelongsTo($organization, 'organization')\n                ->chunk(1000, function (Collection $projects) use (&$writer): void {\n                    $projects->each(function (Project $project) use (&$writer): void {\n                        $writer->insertOne([\n                            $project->id,\n                            $project->name,\n                            $project->color,\n                            $project->billable_rate ?? '',\n                            $project->is_public ? 'true' : 'false',\n                            $project->client_id ?? '',\n                            $project->organization_id,\n                            $project->is_billable ? 'true' : 'false',\n                            $project->archived_at?->toIso8601ZuluString() ?? '',\n                            $project->created_at?->toIso8601ZuluString() ?? '',\n                            $project->updated_at?->toIso8601ZuluString() ?? '',\n                        ]);\n                    });\n                });\n\n            // Project members\n            $writer = Writer::createFromPath($temporaryDirectory->path('project_members.csv'), 'w+');\n            $writer->setDelimiter(',');\n            $writer->setEnclosure('\"');\n            $writer->setEscape('');\n            $writer->insertOne([\n                'id',\n                'billable_rate',\n                'project_id',\n                'user_id',\n                'member_id',\n                'created_at',\n                'updated_at',\n            ]);\n            ProjectMember::query()\n                ->whereBelongsToOrganization($organization)\n                ->chunk(1000, function (Collection $projectMembers) use (&$writer): void {\n                    $projectMembers->each(function (ProjectMember $projectMember) use (&$writer): void {\n                        $writer->insertOne([\n                            $projectMember->id,\n                            $projectMember->billable_rate ?? '',\n                            $projectMember->project_id,\n                            $projectMember->user_id,\n                            $projectMember->member_id,\n                            $projectMember->created_at?->toIso8601ZuluString() ?? '',\n                            $projectMember->updated_at?->toIso8601ZuluString() ?? '',\n                        ]);\n                    });\n                });\n\n            // Members\n            $writer = Writer::createFromPath($temporaryDirectory->path('members.csv'), 'w+');\n            $writer->setDelimiter(',');\n            $writer->setEnclosure('\"');\n            $writer->setEscape('');\n            $writer->insertOne([\n                'id',\n                'user_id',\n                'name',\n                'email',\n                'organization_id',\n                'billable_rate',\n                'role',\n                'created_at',\n                'updated_at',\n            ]);\n            Member::query()\n                ->whereBelongsTo($organization, 'organization')\n                ->with([\n                    'user',\n                ])\n                ->chunk(1000, function (Collection $members) use (&$writer): void {\n                    $members->each(function (Member $member) use (&$writer): void {\n                        $writer->insertOne([\n                            $member->id,\n                            $member->user_id,\n                            $member->user->name,\n                            $member->user->email,\n                            $member->organization_id,\n                            $member->billable_rate ?? '',\n                            $member->role,\n                            $member->created_at?->toIso8601ZuluString() ?? '',\n                            $member->updated_at?->toIso8601ZuluString() ?? '',\n                        ]);\n                    });\n                });\n\n            // Tasks\n            $writer = Writer::createFromPath($temporaryDirectory->path('tasks.csv'), 'w+');\n            $writer->setDelimiter(',');\n            $writer->setEnclosure('\"');\n            $writer->setEscape('');\n            $writer->insertOne([\n                'id',\n                'name',\n                'project_id',\n                'organization_id',\n                'done_at',\n                'created_at',\n                'updated_at',\n            ]);\n            Task::query()\n                ->whereBelongsTo($organization, 'organization')\n                ->chunk(1000, function (Collection $tasks) use (&$writer): void {\n                    $tasks->each(function (Task $task) use (&$writer): void {\n                        $writer->insertOne([\n                            $task->id,\n                            $task->name,\n                            $task->project_id,\n                            $task->organization_id,\n                            $task->done_at?->toIso8601ZuluString() ?? '',\n                            $task->created_at?->toIso8601ZuluString() ?? '',\n                            $task->updated_at?->toIso8601ZuluString() ?? '',\n                        ]);\n                    });\n                });\n\n            // Tags\n            $writer = Writer::createFromPath($temporaryDirectory->path('tags.csv'), 'w+');\n            $writer->setDelimiter(',');\n            $writer->setEnclosure('\"');\n            $writer->setEscape('');\n            $writer->insertOne([\n                'id',\n                'name',\n                'organization_id',\n                'created_at',\n                'updated_at',\n            ]);\n            Tag::query()\n                ->whereBelongsTo($organization, 'organization')\n                ->chunk(1000, function (Collection $tags) use (&$writer): void {\n                    $tags->each(function (Tag $tag) use (&$writer): void {\n                        $writer->insertOne([\n                            $tag->id,\n                            $tag->name,\n                            $tag->organization_id,\n                            $tag->created_at?->toIso8601ZuluString() ?? '',\n                            $tag->updated_at?->toIso8601ZuluString() ?? '',\n                        ]);\n                    });\n                });\n\n            // Meta data file\n            $metaData = (object) [\n                'id' => $exportId,\n                'version' => self::VERSION,\n                'organizations' => [$organization->getKey()],\n                'exported_at' => $timeStamp->toIso8601ZuluString(),\n            ];\n            file_put_contents($temporaryDirectory->path('meta.json'), json_encode($metaData));\n\n            // Create ZIP file\n            $temporaryDirectoryZip = TemporaryDirectory::make();\n            $zip = new ZipArchive;\n            if ($zip->open($temporaryDirectoryZip->path('export.zip'), ZipArchive::CREATE) !== true) {\n                throw new Exception('Cannot create ZIP file');\n            }\n            $zip->addFile($temporaryDirectory->path('organizations.csv'), 'organizations.csv');\n            $zip->addFile($temporaryDirectory->path('organization_invitations.csv'), 'organization_invitations.csv');\n            $zip->addFile($temporaryDirectory->path('time_entries.csv'), 'time_entries.csv');\n            $zip->addFile($temporaryDirectory->path('clients.csv'), 'clients.csv');\n            $zip->addFile($temporaryDirectory->path('projects.csv'), 'projects.csv');\n            $zip->addFile($temporaryDirectory->path('project_members.csv'), 'project_members.csv');\n            $zip->addFile($temporaryDirectory->path('members.csv'), 'members.csv');\n            $zip->addFile($temporaryDirectory->path('tasks.csv'), 'tasks.csv');\n            $zip->addFile($temporaryDirectory->path('tags.csv'), 'tags.csv');\n            $zip->addFile($temporaryDirectory->path('meta.json'), 'meta.json');\n            $zip->close();\n\n            // Upload ZIP file to private storage\n            $filename = 'export_'.$organization->getKey().'_'.$timeStamp->format('Y-m-d_H-i-s').'_'.$exportId.'.zip';\n            Storage::disk(config('filesystems.private'))->putFileAs(\n                'exports',\n                new File($temporaryDirectoryZip->path('export.zip')),\n                $filename\n            );\n\n            // Delete temp files\n            $temporaryDirectoryZip->delete();\n            $temporaryDirectory->delete();\n\n            Log::debug('Finished exporting organization', [\n                'organization_id' => $organization->getKey(),\n                'export_id' => $exportId,\n            ]);\n\n            return 'exports/'.$filename;\n        } catch (UnavailableStream|CannotInsertRecord|Exception|LeagueCsvException $exception) {\n            report($exception);\n\n            throw new ExportException;\n        }\n    }\n}\n"
  },
  {
    "path": "app/Service/Import/ImportDatabaseHelper.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Import;\n\nuse App\\Service\\Import\\Importers\\ImportException;\nuse Closure;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Support\\Facades\\Validator;\n\n/**\n * @template TModel of Model\n */\nclass ImportDatabaseHelper\n{\n    /**\n     * @var class-string<TModel>\n     */\n    private string $model;\n\n    /**\n     * @var string[]\n     */\n    private array $identifiers;\n\n    /**\n     * @var array<string, string>|null\n     */\n    private ?array $mapIdentifierToKey = null;\n\n    /**\n     * @var array<string, TModel|null>|null\n     */\n    private ?array $mapKeyToModel = null;\n\n    /**\n     * @var array<string, TModel|null>|null\n     */\n    private ?array $mapIdentifierToModel = null;\n\n    /**\n     * @var array<string, string>\n     */\n    private array $mapExternalIdentifierToInternalIdentifier = [];\n\n    private bool $attachToExisting;\n\n    private ?Closure $queryModifier;\n\n    private ?Closure $afterCreate;\n\n    private int $createdCount;\n\n    /**\n     * @var array<string, array<int, string>>\n     */\n    private array $validate;\n\n    private ?Closure $beforeSave;\n\n    /**\n     * @param  class-string<TModel>  $model\n     * @param  array<string>  $identifiers\n     * @param  array<string, array<int, string>>  $validate\n     */\n    public function __construct(string $model, array $identifiers, bool $attachToExisting = false, ?Closure $queryModifier = null, ?Closure $afterCreate = null, array $validate = [], ?Closure $beforeSave = null)\n    {\n        $this->model = $model;\n        $this->identifiers = $identifiers;\n        $this->attachToExisting = $attachToExisting;\n        $this->queryModifier = $queryModifier;\n        $this->afterCreate = $afterCreate;\n        $this->createdCount = 0;\n        $this->validate = $validate;\n        $this->beforeSave = $beforeSave;\n    }\n\n    /**\n     * @return Builder<TModel>\n     */\n    private function getModelInstance(): Builder\n    {\n        return (new $this->model)->query();\n    }\n\n    /**\n     * @param  array<string, mixed>  $identifierData\n     * @param  array<string, mixed>  $createValues\n     */\n    private function createEntity(array $identifierData, array $createValues, ?string $externalIdentifier): string\n    {\n        $data = array_merge($identifierData, $createValues);\n\n        $validator = Validator::make($data, $this->validate);\n        if ($validator->fails()) {\n            throw new ImportException('Invalid data: '.implode(', ', $validator->errors()->all()));\n        }\n\n        /** @var TModel $model */\n        $model = new $this->model;\n        foreach ($data as $key => $value) {\n            $model->{$key} = $value;\n        }\n        if ($this->beforeSave !== null) {\n            ($this->beforeSave)($model);\n        }\n        if (method_exists($model, 'disableAuditing')) {\n            $model->disableAuditing();\n        }\n        $model->save();\n\n        if ($this->afterCreate !== null) {\n            ($this->afterCreate)($model);\n        }\n\n        $hash = $this->getHash($identifierData);\n        $this->mapIdentifierToKey[$hash] = $model->getKey();\n        $this->createdCount++;\n\n        if ($externalIdentifier !== null) {\n            $this->mapExternalIdentifierToInternalIdentifier[$externalIdentifier] = $hash;\n        }\n\n        return $model->getKey();\n    }\n\n    /**\n     * @param  array<string, mixed>  $data\n     */\n    private function getHash(array $data): string\n    {\n        $jsonData = json_encode($data);\n        if ($jsonData === false) {\n            throw new \\RuntimeException('Failed to encode data to JSON');\n        }\n\n        return md5($jsonData);\n    }\n\n    /**\n     * @param  array<string, mixed>  $identifierData\n     * @param  array<string, mixed>  $createValues\n     *\n     * @throws ImportException\n     */\n    public function getKey(array $identifierData, array $createValues = [], ?string $externalIdentifier = null): string\n    {\n        $this->checkMap();\n\n        $this->validateIdentifierData($identifierData);\n\n        $hash = $this->getHash($identifierData);\n        if ($this->attachToExisting) {\n            $key = $this->mapIdentifierToKey[$hash] ?? null;\n            if ($key !== null) {\n                if ($externalIdentifier !== null) {\n                    $this->mapExternalIdentifierToInternalIdentifier[$externalIdentifier] = $hash;\n                }\n\n                return $key;\n            }\n\n            return $this->createEntity($identifierData, $createValues, $externalIdentifier);\n        } else {\n            throw new \\RuntimeException('Not implemented');\n        }\n    }\n\n    /**\n     * @return TModel\n     */\n    public function getModelById(string $id): ?Model\n    {\n        if ($this->mapKeyToModel === null) {\n            $this->mapKeyToModel = [];\n        }\n        if (isset($this->mapKeyToModel[$id])) {\n            return $this->mapKeyToModel[$id];\n        }\n        /** @var TModel|null $model */\n        $model = $this->getModelInstance()->find($id);\n        if ($model !== null) {\n            $this->mapKeyToModel[$id] = $model;\n        }\n\n        return $model;\n    }\n\n    /**\n     * @return array<TModel>\n     */\n    public function getCachedModels(): array\n    {\n        if ($this->mapKeyToModel === null) {\n            return [];\n        }\n\n        return array_values($this->mapKeyToModel);\n    }\n\n    /**\n     * @param  array<string, mixed>  $identifierData\n     * @return TModel|null\n     */\n    public function getModel(array $identifierData): ?Model\n    {\n        if ($this->mapIdentifierToModel === null) {\n            $this->mapIdentifierToModel = [];\n        }\n        $hash = $this->getHash($identifierData);\n        if (isset($this->mapIdentifierToModel[$hash])) {\n            return $this->mapIdentifierToModel[$hash];\n        }\n        $model = $this->getModelInstance()->where($identifierData)->first();\n        if ($model !== null) {\n            $this->mapIdentifierToModel[$hash] = $model;\n        }\n\n        return $model;\n    }\n\n    /**\n     * @param  array<string, mixed>  $identifierData\n     *\n     * @throws ImportException\n     */\n    private function validateIdentifierData(array $identifierData): void\n    {\n        if (array_keys($identifierData) !== $this->identifiers) {\n            throw new ImportException('Invalid identifier data');\n        }\n    }\n\n    public function getKeyByExternalIdentifier(string $externalIdentifier): ?string\n    {\n        $hash = $this->mapExternalIdentifierToInternalIdentifier[$externalIdentifier] ?? null;\n        if ($hash === null) {\n            return null;\n        }\n\n        return $this->mapIdentifierToKey[$hash] ?? null;\n    }\n\n    /**\n     * @return array<string>\n     */\n    public function getExternalIds(): array\n    {\n        // Note: Otherwise the external ids are integers\n        return array_map(fn ($value) => (string) $value, array_keys($this->mapExternalIdentifierToInternalIdentifier));\n    }\n\n    private function checkMap(): void\n    {\n        if ($this->mapIdentifierToKey === null) {\n            $select = $this->identifiers;\n            $select[] = (new $this->model)->getKeyName();\n            $builder = $this->getModelInstance();\n\n            if ($this->queryModifier !== null) {\n                $builder = ($this->queryModifier)($builder);\n            }\n\n            $databaseEntries = $builder->select($select)\n                ->get();\n            $this->mapIdentifierToKey = [];\n            foreach ($databaseEntries as $databaseEntry) {\n                $identifierData = [];\n                foreach ($this->identifiers as $identifier) {\n                    $identifierData[$identifier] = $databaseEntry->{$identifier};\n                }\n                $hash = $this->getHash($identifierData);\n                $this->mapIdentifierToKey[$hash] = $databaseEntry->getKey();\n            }\n        }\n    }\n\n    public function getCreatedCount(): int\n    {\n        return $this->createdCount;\n    }\n}\n"
  },
  {
    "path": "app/Service/Import/ImportService.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Import;\n\nuse App\\Models\\Organization;\nuse App\\Service\\Import\\Importers\\ImporterContract;\nuse App\\Service\\Import\\Importers\\ImporterProvider;\nuse App\\Service\\Import\\Importers\\ImportException;\nuse App\\Service\\Import\\Importers\\ReportDto;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Support\\Facades\\Storage;\nuse Illuminate\\Support\\Str;\n\nclass ImportService\n{\n    /**\n     * @throws ImportException\n     */\n    public function import(Organization $organization, string $importerType, string $data, string $timezone): ReportDto\n    {\n        /** @var ImporterContract $importer */\n        $importer = app(ImporterProvider::class)->getImporter($importerType);\n        $importer->init($organization);\n        Storage::disk(config('filesystems.default'))\n            ->put('import/'.Carbon::now()->toDateString().'-'.$organization->getKey().'-'.Str::uuid(), $data);\n\n        $lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1);\n\n        if ($lock->get()) {\n            try {\n                DB::transaction(function () use (&$importer, &$data, &$timezone): void {\n                    $importer->importData($data, $timezone);\n                });\n            } finally {\n                $lock->release();\n            }\n        } else {\n            throw new ImportException('Import is already in progress');\n        }\n\n        return $importer->getReport();\n    }\n}\n"
  },
  {
    "path": "app/Service/Import/Importers/ClockifyProjectsImporter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Import\\Importers;\n\nuse Exception;\nuse Illuminate\\Support\\Str;\nuse League\\Csv\\Exception as CsvException;\nuse League\\Csv\\Reader;\n\nclass ClockifyProjectsImporter extends DefaultImporter\n{\n    /**\n     * @throws ImportException\n     */\n    #[\\Override]\n    public function importData(string $data, string $timezone): void\n    {\n        try {\n            $reader = Reader::createFromString($data);\n            $reader->setHeaderOffset(0);\n            $reader->setDelimiter(',');\n            $header = $reader->getHeader();\n            $this->validateHeader($header);\n            $billableRateKey = $this->getBillableRateKey($header);\n            $records = $reader->getRecords();\n            foreach ($records as $record) {\n                $clientId = null;\n                if ($record['Client'] !== '') {\n                    $clientId = $this->clientImportHelper->getKey([\n                        'name' => $record['Client'],\n                        'organization_id' => $this->organization->id,\n                    ]);\n                }\n                $projectId = null;\n                if ($record['Project'] !== '') {\n                    $projectId = $this->projectImportHelper->getKey([\n                        'name' => $record['Project'],\n                        'client_id' => $clientId,\n                        'organization_id' => $this->organization->id,\n                    ], [\n                        'color' => $this->colorService->getRandomColor(),\n                        'is_billable' => $record['Billability'] === 'Yes',\n                        'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,\n                        'estimated_time' => $record['Estimated (h)'] !== '' && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,\n                    ]);\n                }\n\n                if ($record['Task'] !== '') {\n                    $tasks = explode(', ', $record['Task']);\n                    foreach ($tasks as $task) {\n                        $this->taskImportHelper->getKey([\n                            'name' => $task,\n                            'project_id' => $projectId,\n                            'organization_id' => $this->organization->id,\n                        ]);\n                    }\n                }\n            }\n        } catch (ImportException $exception) {\n            throw $exception;\n        } catch (CsvException $exception) {\n            throw new ImportException('Invalid CSV data');\n        } catch (Exception $exception) {\n            report($exception);\n            throw new ImportException('Unknown error');\n        }\n    }\n\n    /**\n     * @param  array<string>  $header\n     *\n     * @throws ImportException\n     */\n    private function validateHeader(array $header): void\n    {\n        $requiredFields = [\n            'Project',\n            'Client',\n            'Status',\n            'Visibility',\n            'Billability',\n            'Task',\n        ];\n        foreach ($requiredFields as $requiredField) {\n            if (! in_array($requiredField, $header, true)) {\n                throw new ImportException('Invalid CSV header, missing field: '.$requiredField);\n            }\n        }\n    }\n\n    /**\n     * @param  array<string>  $header\n     */\n    private function getBillableRateKey(array $header): ?string\n    {\n        $billableRateKey = null;\n        foreach ($header as $value) {\n            if (Str::startsWith($value, 'Billable Rate (')) {\n                $billableRateKey = $value;\n                break;\n            }\n        }\n\n        return $billableRateKey;\n    }\n\n    #[\\Override]\n    public function getName(): string\n    {\n        return __('importer.clockify_projects.name');\n    }\n\n    #[\\Override]\n    public function getDescription(): string\n    {\n        return __('importer.clockify_projects.description');\n    }\n}\n"
  },
  {
    "path": "app/Service/Import/Importers/ClockifyTimeEntriesImporter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Import\\Importers;\n\nuse App\\Enums\\Role;\nuse App\\Jobs\\RecalculateSpentTimeForProject;\nuse App\\Jobs\\RecalculateSpentTimeForTask;\nuse App\\Models\\TimeEntry;\nuse Carbon\\Exceptions\\InvalidFormatException;\nuse Exception;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Str;\nuse League\\Csv\\Exception as CsvException;\nuse League\\Csv\\Reader;\n\nclass ClockifyTimeEntriesImporter extends DefaultImporter\n{\n    /**\n     * @return array<string>\n     *\n     * @throws ImportException\n     */\n    private function getTags(string $tags): array\n    {\n        if (Str::trim($tags) === '') {\n            return [];\n        }\n        $tagsParsed = explode(', ', $tags);\n        $tagIds = [];\n        foreach ($tagsParsed as $tagParsed) {\n            $tagId = $this->tagImportHelper->getKey([\n                'name' => $tagParsed,\n                'organization_id' => $this->organization->id,\n            ]);\n            $tagIds[] = $tagId;\n        }\n\n        return $tagIds;\n    }\n\n    /**\n     * @throws ImportException\n     */\n    #[\\Override]\n    public function importData(string $data, string $timezone): void\n    {\n        try {\n            $reader = Reader::createFromString($data);\n            $reader->setHeaderOffset(0);\n            $reader->setDelimiter(',');\n            $reader->setEnclosure('\"');\n            $reader->setEscape('');\n            $header = $reader->getHeader();\n            $this->validateHeader($header);\n            $records = $reader->getRecords();\n            foreach ($records as $record) {\n                $userId = $this->userImportHelper->getKey([\n                    'email' => $record['Email'],\n                ], [\n                    'name' => $record['User'],\n                    'timezone' => 'UTC',\n                    'is_placeholder' => true,\n                ]);\n                $memberId = $this->memberImportHelper->getKey([\n                    'user_id' => $userId,\n                    'organization_id' => $this->organization->getKey(),\n                ], [\n                    'role' => Role::Placeholder->value,\n                ]);\n                $member = $this->memberImportHelper->getModelById($memberId);\n                $clientId = null;\n                if ($record['Client'] !== '') {\n                    $clientId = $this->clientImportHelper->getKey([\n                        'name' => $record['Client'],\n                        'organization_id' => $this->organization->id,\n                    ]);\n                }\n                $projectId = null;\n                $project = null;\n                $projectMember = null;\n                if ($record['Project'] !== '') {\n                    $projectId = $this->projectImportHelper->getKey([\n                        'name' => $record['Project'],\n                        'client_id' => $clientId,\n                        'organization_id' => $this->organization->id,\n                    ], [\n                        'color' => $this->colorService->getRandomColor(),\n                        'is_billable' => false,\n                    ]);\n                    $project = $this->projectImportHelper->getModelById($projectId);\n                    $projectMember = $this->projectMemberImportHelper->getModel([\n                        'project_id' => $projectId,\n                        'member_id' => $memberId,\n                    ]);\n                }\n                $taskId = null;\n                if ($record['Task'] !== '') {\n                    $taskId = $this->taskImportHelper->getKey([\n                        'name' => $record['Task'],\n                        'project_id' => $projectId,\n                        'organization_id' => $this->organization->id,\n                    ]);\n                    $this->taskImportHelper->getModelById($taskId);\n                }\n                $timeEntry = new TimeEntry;\n                $timeEntry->disableAuditing();\n                $timeEntry->user_id = $userId;\n                $timeEntry->member_id = $memberId;\n                $timeEntry->task_id = $taskId;\n                $timeEntry->project_id = $projectId;\n                $timeEntry->client_id = $clientId;\n                $timeEntry->organization_id = $this->organization->id;\n                if (strlen($record['Description']) > 5000) {\n                    throw new ImportException('Time entry description is too long');\n                }\n                $timeEntry->description = $record['Description'];\n                if (! in_array($record['Billable'], ['Yes', 'No'], true)) {\n                    throw new ImportException('Invalid billable value');\n                }\n                $timeEntry->billable = $record['Billable'] === 'Yes';\n                $timeEntry->tags = $this->getTags($record['Tags']);\n                $timeEntry->is_imported = true;\n\n                // Start\n                $start = null;\n                try {\n                    $startDateStr = $record['Start Date'];\n                    $startTimeStr = $record['Start Time'];\n                    $startStr = $startDateStr.' '.$startTimeStr;\n                    $matches = [];\n                    $checkResult = preg_match('/^([0-9]{1,2})\\/([0-9]{1,2})\\/([0-9]{4}) ([0-9]{1,2}):([0-9]{1,2})(:[0-9]{1,2})? (AM|PM)$/', $startStr, $matches);\n\n                    if ($checkResult === 1) {\n                        if ((int) $matches[1] > 12) {\n                            throw new ImportException('Start date (\"'.$startDateStr.'\") is invalid, please select the correct date format before exporting from Clockify');\n                        }\n                        if ($matches[6] === '') {\n                            $start = Carbon::createFromFormat('m/d/Y h:i A', $startStr, $timezone);\n                        } else {\n                            $start = Carbon::createFromFormat('m/d/Y H:i:s A', $startStr, $timezone);\n                        }\n                    }\n                } catch (InvalidFormatException) {\n                    throw new ImportException('Start date (\"'.$startDateStr.'\") or time (\"'.$startTimeStr.'\") are invalid');\n                }\n                if ($start === null) {\n                    throw new ImportException('Start date (\"'.$startDateStr.'\") or time (\"'.$startTimeStr.'\") are invalid');\n                }\n                $timeEntry->start = $start->utc();\n\n                // End\n                $end = null;\n                try {\n                    $endDateStr = $record['End Date'];\n                    $endTimeStr = $record['End Time'];\n                    $endStr = $endDateStr.' '.$endTimeStr;\n                    $matches = [];\n                    $checkResult = preg_match('/^([0-9]{1,2})\\/([0-9]{1,2})\\/([0-9]{4}) ([0-9]{1,2}):([0-9]{1,2})(:[0-9]{1,2})? (AM|PM)$/', $endStr, $matches);\n\n                    if ($checkResult === 1) {\n                        if ((int) $matches[1] > 12) {\n                            throw new ImportException('Start date (\"'.$endDateStr.'\") is invalid, please select the correct date format before exporting from Clockify');\n                        }\n                        if ($matches[6] === '') {\n                            $end = Carbon::createFromFormat('m/d/Y h:i A', $endStr, $timezone);\n                        } else {\n                            $end = Carbon::createFromFormat('m/d/Y H:i:s A', $endStr, $timezone);\n                        }\n                    }\n                } catch (InvalidFormatException) {\n                    throw new ImportException('End date (\"'.$endDateStr.'\") or time (\"'.$endTimeStr.'\") are invalid');\n                }\n                if ($end === null) {\n                    throw new ImportException('End date (\"'.$endDateStr.'\") or time (\"'.$endTimeStr.'\") are invalid');\n                }\n                $timeEntry->end = $end->utc();\n\n                $timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(\n                    $timeEntry,\n                    $projectMember,\n                    $project,\n                    $member,\n                    $this->organization\n                );\n                $timeEntry->save();\n                $this->timeEntriesCreated++;\n            }\n            foreach ($this->projectImportHelper->getCachedModels() as $usedProject) {\n                RecalculateSpentTimeForProject::dispatch($usedProject);\n            }\n            foreach ($this->taskImportHelper->getCachedModels() as $usedTask) {\n                RecalculateSpentTimeForTask::dispatch($usedTask);\n            }\n        } catch (ImportException $exception) {\n            throw $exception;\n        } catch (CsvException $exception) {\n            throw new ImportException('Invalid CSV data');\n        } catch (Exception $exception) {\n            report($exception);\n            throw new ImportException('Unknown error');\n        }\n    }\n\n    /**\n     * @param  array<string>  $header\n     *\n     * @throws ImportException\n     */\n    private function validateHeader(array $header): void\n    {\n        $requiredFields = [\n            'Project',\n            'Client',\n            'Description',\n            'Task',\n            'User',\n            'Group',\n            'Email',\n            'Tags',\n            'Billable',\n            'Start Date',\n            'Start Time',\n            'End Date',\n            'End Time',\n        ];\n        foreach ($requiredFields as $requiredField) {\n            if (! in_array($requiredField, $header, true)) {\n                throw new ImportException('Invalid CSV header, missing field: '.$requiredField);\n            }\n        }\n    }\n\n    #[\\Override]\n    public function getName(): string\n    {\n        return __('importer.clockify_time_entries.name');\n    }\n\n    #[\\Override]\n    public function getDescription(): string\n    {\n        return __('importer.clockify_time_entries.description');\n    }\n}\n"
  },
  {
    "path": "app/Service/Import/Importers/DefaultImporter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Import\\Importers;\n\nuse App\\Models\\Client;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\OrganizationInvitation;\nuse App\\Models\\Project;\nuse App\\Models\\ProjectMember;\nuse App\\Models\\Tag;\nuse App\\Models\\Task;\nuse App\\Models\\User;\nuse App\\Service\\BillableRateService;\nuse App\\Service\\ColorService;\nuse App\\Service\\Import\\ImportDatabaseHelper;\nuse App\\Service\\TimezoneService;\nuse Illuminate\\Database\\Eloquent\\Builder;\n\nabstract class DefaultImporter implements ImporterContract\n{\n    protected Organization $organization;\n\n    /**\n     * @var ImportDatabaseHelper<User>\n     */\n    protected ImportDatabaseHelper $userImportHelper;\n\n    /**\n     * @var ImportDatabaseHelper<Member>\n     */\n    protected ImportDatabaseHelper $memberImportHelper;\n\n    /**\n     * @var ImportDatabaseHelper<Project>\n     */\n    protected ImportDatabaseHelper $projectImportHelper;\n\n    /**\n     * @var ImportDatabaseHelper<Tag>\n     */\n    protected ImportDatabaseHelper $tagImportHelper;\n\n    /**\n     * @var ImportDatabaseHelper<Client>\n     */\n    protected ImportDatabaseHelper $clientImportHelper;\n\n    /**\n     * @var ImportDatabaseHelper<Task>\n     */\n    protected ImportDatabaseHelper $taskImportHelper;\n\n    protected int $timeEntriesCreated;\n\n    protected ColorService $colorService;\n\n    protected TimezoneService $timezoneService;\n\n    /**\n     * @var ImportDatabaseHelper<ProjectMember>\n     */\n    protected ImportDatabaseHelper $projectMemberImportHelper;\n\n    /**\n     * @var ImportDatabaseHelper<OrganizationInvitation>\n     */\n    protected ImportDatabaseHelper $organizationInvitationsImportHelper;\n\n    protected BillableRateService $billableRateService;\n\n    public function init(Organization $organization): void\n    {\n        $this->organization = $organization;\n        $this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) {\n            /** @var Builder<User> $builder */\n            return $builder->belongsToOrganization($this->organization);\n        }, null, validate: [\n            'name' => [\n                'required',\n                'max:255',\n            ],\n            'timezone' => [\n                'required',\n                'timezone:all',\n            ],\n        ]);\n        $this->memberImportHelper = new ImportDatabaseHelper(Member::class, ['user_id', 'organization_id'], true, function (Builder $builder) {\n            /** @var Builder<Member> $builder */\n            return $builder->whereBelongsTo($this->organization, 'organization');\n        }, null, validate: [\n            'role' => [\n                'required',\n                'string',\n                'in:placeholder',\n            ],\n        ]);\n        $this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'client_id', 'organization_id'], true, function (Builder $builder) {\n            /** @var Builder<Project> $builder */\n            return $builder->where('organization_id', $this->organization->id);\n        }, validate: [\n            'name' => [\n                'required',\n                'max:255',\n            ],\n            'is_billable' => [\n                'required',\n                'boolean',\n            ],\n            'billable_rate' => [\n                'nullable',\n                'integer',\n                'max:2147483647',\n            ],\n            'client_id' => [\n                'nullable',\n                'string',\n                'uuid',\n            ],\n        ], beforeSave: function (Project $project): void {\n            if ($project->billable_rate === 0) {\n                $project->billable_rate = null;\n            }\n        });\n        $this->projectMemberImportHelper = new ImportDatabaseHelper(ProjectMember::class, ['project_id', 'member_id'], true, function (Builder $builder): Builder {\n            /** @var Builder<ProjectMember> $builder */\n            return $builder->whereBelongsToOrganization($this->organization);\n        }, validate: [\n            'billable_rate' => [\n                'nullable',\n                'integer',\n                'max:2147483647',\n            ],\n        ], beforeSave: function (ProjectMember $projectMember): void {\n            if ($projectMember->billable_rate === 0) {\n                $projectMember->billable_rate = null;\n            }\n        });\n        $this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder): Builder {\n            /** @var Builder<Tag> $builder */\n            return $builder->where('organization_id', $this->organization->id);\n        }, validate: [\n            'name' => [\n                'required',\n                'max:255',\n            ],\n        ]);\n        $this->clientImportHelper = new ImportDatabaseHelper(Client::class, ['name', 'organization_id'], true, function (Builder $builder): Builder {\n            /** @var Builder<Client> $builder */\n            return $builder->where('organization_id', $this->organization->id);\n        }, validate: [\n            'name' => [\n                'required',\n                'max:255',\n            ],\n        ]);\n        $this->taskImportHelper = new ImportDatabaseHelper(Task::class, ['name', 'project_id', 'organization_id'], true, function (Builder $builder): Builder {\n            /** @var Builder<Task> $builder */\n            return $builder->where('organization_id', $this->organization->id);\n        }, validate: [\n            'name' => [\n                'required',\n                'max:500',\n            ],\n        ]);\n        $this->organizationInvitationsImportHelper = new ImportDatabaseHelper(OrganizationInvitation::class, ['email', 'organization_id'], true, function (Builder $builder) {\n            /** @var Builder<OrganizationInvitation> $builder */\n            return $builder->where('organization_id', $this->organization->id);\n        }, validate: [\n            'email' => [\n                'required',\n                'email',\n                'max:255',\n            ],\n        ]);\n        $this->timeEntriesCreated = 0;\n        $this->colorService = app(ColorService::class);\n        $this->timezoneService = app(TimezoneService::class);\n        $this->billableRateService = app(BillableRateService::class);\n    }\n\n    #[\\Override]\n    public function getReport(): ReportDto\n    {\n        return new ReportDto(\n            clientsCreated: $this->clientImportHelper->getCreatedCount(),\n            projectsCreated: $this->projectImportHelper->getCreatedCount(),\n            tasksCreated: $this->taskImportHelper->getCreatedCount(),\n            timeEntriesCreated: $this->timeEntriesCreated,\n            tagsCreated: $this->tagImportHelper->getCreatedCount(),\n            usersCreated: $this->userImportHelper->getCreatedCount(),\n        );\n    }\n}\n"
  },
  {
    "path": "app/Service/Import/Importers/GenericProjectsImporter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Import\\Importers;\n\nuse App\\Service\\ColorService;\nuse Carbon\\Exceptions\\InvalidFormatException;\nuse Exception;\nuse Illuminate\\Support\\Carbon;\nuse League\\Csv\\Exception as CsvException;\nuse League\\Csv\\Reader;\nuse Override;\n\nclass GenericProjectsImporter extends DefaultImporter\n{\n    /**\n     * @var array<string>\n     */\n    private const array REQUIRED_FIELDS = [\n        'name',\n    ];\n\n    /**\n     * @throws ImportException\n     */\n    #[Override]\n    public function importData(string $data, string $timezone): void\n    {\n        try {\n            $reader = Reader::createFromString($data);\n            $reader->setHeaderOffset(0);\n            $reader->setDelimiter(',');\n            $reader->setEnclosure('\"');\n            $reader->setEscape('');\n            $header = $reader->getHeader();\n            $this->validateHeader($header);\n            $records = $reader->getRecords();\n            foreach ($records as $record) {\n                $clientId = null;\n                if (isset($record['client']) && $record['client'] !== '') {\n                    $clientId = $this->clientImportHelper->getKey([\n                        'name' => $record['client'],\n                        'organization_id' => $this->organization->id,\n                    ]);\n                }\n                if ($record['name'] !== '') {\n                    $archivedAt = null;\n                    if (isset($record['archived_at']) && $record['archived_at'] !== '') {\n                        try {\n                            $archivedAt = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $record['archived_at'], 'UTC');\n                        } catch (InvalidFormatException) {\n                            throw new ImportException('Value of archived_at (\"'.$record['archived_at'].'\") is invalid');\n                        }\n                    }\n                    $this->projectImportHelper->getKey([\n                        'name' => $record['name'],\n                        'client_id' => $clientId,\n                        'organization_id' => $this->organization->id,\n                    ], [\n                        'color' => isset($record['color']) && $record['color'] !== '' ? $record['color'] : app(ColorService::class)->getRandomColor(),\n                        'billable_rate' => isset($record['billable_rate']) && $record['billable_rate'] !== '' ? (int) $record['billable_rate'] : null,\n                        'is_public' => isset($record['is_public']) && $record['is_public'] === 'true',\n                        'is_billable' => isset($record['billable_default']) && $record['billable_default'] === 'true',\n                        'estimated_time' => isset($record['estimated_time']) && $record['estimated_time'] !== '' && is_numeric($record['estimated_time']) && ((int) $record['estimated_time'] !== 0) ? (int) $record['estimated_time'] : null,\n                        'archived_at' => $archivedAt,\n                    ]);\n                }\n            }\n        } catch (ImportException $exception) {\n            throw $exception;\n        } catch (CsvException $exception) {\n            throw new ImportException('Invalid CSV data');\n        } catch (Exception $exception) {\n            report($exception);\n            throw new ImportException('Unknown error');\n        }\n    }\n\n    /**\n     * @param  array<string>  $header\n     *\n     * @throws ImportException\n     */\n    private function validateHeader(array $header): void\n    {\n        foreach (self::REQUIRED_FIELDS as $requiredField) {\n            if (! in_array($requiredField, $header, true)) {\n                throw new ImportException('Invalid CSV header, missing field: '.$requiredField);\n            }\n        }\n    }\n\n    #[Override]\n    public function getName(): string\n    {\n        return __('importer.generic_projects.name');\n    }\n\n    #[Override]\n    public function getDescription(): string\n    {\n        return __('importer.generic_projects.description');\n    }\n}\n"
  },
  {
    "path": "app/Service/Import/Importers/GenericTimeEntriesImporter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Import\\Importers;\n\nuse App\\Enums\\Role;\nuse App\\Jobs\\RecalculateSpentTimeForProject;\nuse App\\Jobs\\RecalculateSpentTimeForTask;\nuse App\\Models\\TimeEntry;\nuse Carbon\\Exceptions\\InvalidFormatException;\nuse Exception;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Str;\nuse League\\Csv\\Exception as CsvException;\nuse League\\Csv\\Reader;\n\nclass GenericTimeEntriesImporter extends DefaultImporter\n{\n    /**\n     * @var array<string>\n     */\n    private const array REQUIRED_FIELDS = [\n        'description',\n        'billable',\n        'client',\n        'project',\n        'tags',\n        'start',\n        'end',\n        'task',\n        'user_name',\n        'user_email',\n    ];\n\n    /**\n     * @return array<string>\n     *\n     * @throws ImportException\n     */\n    private function getTags(string $tags): array\n    {\n        if (Str::trim($tags) === '') {\n            return [];\n        }\n        $tagsParsed = explode(',', $tags);\n        $tagIds = [];\n        foreach ($tagsParsed as $tagParsed) {\n            $tagId = $this->tagImportHelper->getKey([\n                'name' => Str::trim($tagParsed),\n                'organization_id' => $this->organization->id,\n            ]);\n            $tagIds[] = $tagId;\n        }\n\n        return $tagIds;\n    }\n\n    /**\n     * @throws ImportException\n     */\n    #[\\Override]\n    public function importData(string $data, string $timezone): void\n    {\n        try {\n            $reader = Reader::createFromString($data);\n            $reader->setHeaderOffset(0);\n            $reader->setDelimiter(',');\n            $reader->setEnclosure('\"');\n            $reader->setEscape('');\n            $header = $reader->getHeader();\n            $this->validateHeader($header);\n            $records = $reader->getRecords();\n            foreach ($records as $record) {\n                $userId = $this->userImportHelper->getKey([\n                    'email' => $record['user_email'],\n                ], [\n                    'name' => $record['user_name'],\n                    'timezone' => 'UTC',\n                    'is_placeholder' => true,\n                ]);\n                $memberId = $this->memberImportHelper->getKey([\n                    'user_id' => $userId,\n                    'organization_id' => $this->organization->getKey(),\n                ], [\n                    'role' => Role::Placeholder->value,\n                ]);\n                $member = $this->memberImportHelper->getModelById($memberId);\n                $clientId = null;\n                if ($record['client'] !== '') {\n                    $clientId = $this->clientImportHelper->getKey([\n                        'name' => $record['client'],\n                        'organization_id' => $this->organization->id,\n                    ]);\n                }\n                $projectId = null;\n                $project = null;\n                $projectMember = null;\n                if ($record['project'] !== '') {\n                    $projectId = $this->projectImportHelper->getKey([\n                        'name' => $record['project'],\n                        'client_id' => $clientId,\n                        'organization_id' => $this->organization->id,\n                    ], [\n                        'is_billable' => false,\n                        'color' => $this->colorService->getRandomColor(),\n                    ]);\n                    $project = $this->projectImportHelper->getModelById($projectId);\n                    $projectMember = $this->projectMemberImportHelper->getModel([\n                        'project_id' => $projectId,\n                        'member_id' => $memberId,\n                    ]);\n                }\n                $taskId = null;\n                if ($record['task'] !== '') {\n                    $taskId = $this->taskImportHelper->getKey([\n                        'name' => $record['task'],\n                        'project_id' => $projectId,\n                        'organization_id' => $this->organization->id,\n                    ]);\n                    $this->taskImportHelper->getModelById($taskId);\n                }\n                $timeEntry = new TimeEntry;\n                $timeEntry->disableAuditing();\n                $timeEntry->user_id = $userId;\n                $timeEntry->member_id = $memberId;\n                $timeEntry->task_id = $taskId;\n                $timeEntry->project_id = $projectId;\n                $timeEntry->client_id = $clientId;\n                $timeEntry->organization_id = $this->organization->id;\n                $timeEntry->description = $record['description'];\n                if (! in_array($record['billable'], ['true', 'false'], true)) {\n                    throw new ImportException('Invalid billable value');\n                }\n                $timeEntry->billable = $record['billable'] === 'true';\n                $timeEntry->tags = $this->getTags($record['tags']);\n                $timeEntry->is_imported = true;\n                try {\n                    $start = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $record['start'], 'UTC');\n                } catch (InvalidFormatException) {\n                    throw new ImportException('Value of start (\"'.$record['start'].'\") is invalid');\n                }\n                if ($start === null) {\n                    throw new ImportException('Value of start (\"'.$record['start'].'\") is invalid');\n                }\n                $timeEntry->start = $start->utc();\n\n                try {\n                    $end = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $record['end'], 'UTC');\n                } catch (InvalidFormatException) {\n                    throw new ImportException('Value of end (\"'.$record['end'].'\") is invalid');\n                }\n                if ($end === null) {\n                    throw new ImportException('Value of end (\"'.$record['end'].'\") is invalid');\n                }\n                $timeEntry->end = $end->utc();\n                $timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(\n                    $timeEntry,\n                    $projectMember,\n                    $project,\n                    $member,\n                    $this->organization\n                );\n                $timeEntry->save();\n                $this->timeEntriesCreated++;\n            }\n            foreach ($this->projectImportHelper->getCachedModels() as $usedProject) {\n                RecalculateSpentTimeForProject::dispatch($usedProject);\n            }\n            foreach ($this->taskImportHelper->getCachedModels() as $usedTask) {\n                RecalculateSpentTimeForTask::dispatch($usedTask);\n            }\n        } catch (ImportException $exception) {\n            throw $exception;\n        } catch (CsvException $exception) {\n            throw new ImportException('Invalid CSV data');\n        } catch (Exception $exception) {\n            report($exception);\n            throw new ImportException('Unknown error');\n        }\n    }\n\n    /**\n     * @param  array<string>  $header\n     *\n     * @throws ImportException\n     */\n    private function validateHeader(array $header): void\n    {\n        foreach (self::REQUIRED_FIELDS as $requiredField) {\n            if (! in_array($requiredField, $header, true)) {\n                throw new ImportException('Invalid CSV header, missing field: '.$requiredField);\n            }\n        }\n    }\n\n    #[\\Override]\n    public function getName(): string\n    {\n        return __('importer.generic_time_entries.name');\n    }\n\n    #[\\Override]\n    public function getDescription(): string\n    {\n        return __('importer.generic_time_entries.description');\n    }\n}\n"
  },
  {
    "path": "app/Service/Import/Importers/HarvestClientsImporter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Import\\Importers;\n\nuse Exception;\nuse League\\Csv\\Exception as CsvException;\nuse League\\Csv\\Reader;\n\nclass HarvestClientsImporter extends DefaultImporter\n{\n    /**\n     * @var array<string>\n     */\n    private const array REQUIRED_FIELDS = [\n        'Client Name',\n    ];\n\n    /**\n     * @throws ImportException\n     */\n    #[\\Override]\n    public function importData(string $data, string $timezone): void\n    {\n        try {\n            $reader = Reader::createFromString($data);\n            $reader->setHeaderOffset(0);\n            $reader->setDelimiter(',');\n            $reader->setEnclosure('\"');\n            $reader->setEscape('');\n            $header = $reader->getHeader();\n            $this->validateHeader($header);\n            $records = $reader->getRecords();\n            foreach ($records as $record) {\n                $this->clientImportHelper->getKey([\n                    'name' => $record['Client Name'],\n                    'organization_id' => $this->organization->id,\n                ]);\n            }\n        } catch (ImportException $exception) {\n            throw $exception;\n        } catch (CsvException $exception) {\n            throw new ImportException('Invalid CSV data');\n        } catch (Exception $exception) {\n            report($exception);\n            throw new ImportException('Unknown error');\n        }\n    }\n\n    /**\n     * @param  array<string>  $header\n     *\n     * @throws ImportException\n     */\n    private function validateHeader(array $header): void\n    {\n        foreach (self::REQUIRED_FIELDS as $requiredField) {\n            if (! in_array($requiredField, $header, true)) {\n                throw new ImportException('Invalid CSV header, missing field: '.$requiredField);\n            }\n        }\n    }\n\n    #[\\Override]\n    public function getName(): string\n    {\n        return __('importer.harvest_clients.name');\n    }\n\n    #[\\Override]\n    public function getDescription(): string\n    {\n        return __('importer.harvest_clients.description');\n    }\n}\n"
  },
  {
    "path": "app/Service/Import/Importers/HarvestProjectsImporter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Import\\Importers;\n\nuse Exception;\nuse Illuminate\\Support\\Str;\nuse League\\Csv\\Exception as CsvException;\nuse League\\Csv\\Reader;\n\nclass HarvestProjectsImporter extends DefaultImporter\n{\n    /**\n     * @var array<string>\n     */\n    private const array REQUIRED_FIELDS = [\n        'Client',\n        'Project',\n        'Budget',\n        'Billable Hours',\n    ];\n\n    /**\n     * @throws ImportException\n     */\n    #[\\Override]\n    public function importData(string $data, string $timezone): void\n    {\n        try {\n            $reader = Reader::createFromString($data);\n            $reader->setHeaderOffset(0);\n            $reader->setDelimiter(',');\n            $reader->setEnclosure('\"');\n            $reader->setEscape('');\n            $header = $reader->getHeader();\n            $this->validateHeader($header);\n            $records = $reader->getRecords();\n            foreach ($records as $record) {\n                $clientId = null;\n                if ($record['Client'] !== '') {\n                    $clientId = $this->clientImportHelper->getKey([\n                        'name' => $record['Client'],\n                        'organization_id' => $this->organization->id,\n                    ]);\n                }\n                if ($record['Project'] !== '') {\n                    if (! isset($record['Budget']) || ! is_string($record['Budget'])) {\n                        throw new ImportException('The value for \"Budget\" is invalid');\n                    }\n                    $estimatedTimeField = Str::replace(',', '.', $record['Budget']);\n                    $estimatedTime = $estimatedTimeField !== '' && is_numeric($estimatedTimeField) ? (int) (((float) $estimatedTimeField) * 60 * 60) : null;\n                    if ($estimatedTime === 0) {\n                        $estimatedTime = null;\n                    }\n                    if (! isset($record['Billable Hours']) || ! is_string($record['Billable Hours'])) {\n                        throw new ImportException('The value for \"Billable Hours\" is invalid');\n                    }\n                    $billableHoursField = Str::replace(',', '.', $record['Billable Hours']);\n                    $billableHours = $billableHoursField !== '' && is_numeric($billableHoursField) ? (int) ((float) $billableHoursField) : null;\n                    $this->projectImportHelper->getKey([\n                        'name' => $record['Project'],\n                        'client_id' => $clientId,\n                        'organization_id' => $this->organization->id,\n                    ], [\n                        'color' => $this->colorService->getRandomColor(),\n                        'estimated_time' => $estimatedTime,\n                        'is_billable' => $billableHours > 0,\n                    ]);\n                }\n            }\n        } catch (ImportException $exception) {\n            throw $exception;\n        } catch (CsvException $exception) {\n            throw new ImportException('Invalid CSV data');\n        } catch (Exception $exception) {\n            report($exception);\n            throw new ImportException('Unknown error');\n        }\n    }\n\n    /**\n     * @param  array<string>  $header\n     *\n     * @throws ImportException\n     */\n    private function validateHeader(array $header): void\n    {\n        foreach (self::REQUIRED_FIELDS as $requiredField) {\n            if (! in_array($requiredField, $header, true)) {\n                throw new ImportException('Invalid CSV header, missing field: '.$requiredField);\n            }\n        }\n    }\n\n    #[\\Override]\n    public function getName(): string\n    {\n        return __('importer.harvest_projects.name');\n    }\n\n    #[\\Override]\n    public function getDescription(): string\n    {\n        return __('importer.harvest_projects.description');\n    }\n}\n"
  },
  {
    "path": "app/Service/Import/Importers/HarvestTimeEntriesImporter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Import\\Importers;\n\nuse App\\Enums\\Role;\nuse App\\Jobs\\RecalculateSpentTimeForProject;\nuse App\\Jobs\\RecalculateSpentTimeForTask;\nuse App\\Models\\TimeEntry;\nuse Carbon\\Exceptions\\InvalidFormatException;\nuse Exception;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Str;\nuse League\\Csv\\Exception as CsvException;\nuse League\\Csv\\Reader;\nuse Override;\n\nclass HarvestTimeEntriesImporter extends DefaultImporter\n{\n    /**\n     * @var array<string>\n     */\n    private const array REQUIRED_FIELDS = [\n        'Date',\n        'Hours',\n        'Client',\n        'Project',\n        'Task',\n        'Billable?',\n        'First Name',\n        'Last Name',\n        'Notes',\n    ];\n\n    /**\n     * @throws ImportException\n     */\n    #[Override]\n    public function importData(string $data, string $timezone): void\n    {\n        try {\n            $reader = Reader::createFromString($data);\n            $reader->setHeaderOffset(0);\n            $reader->setDelimiter(',');\n            $reader->setEnclosure('\"');\n            $reader->setEscape('');\n            $header = $reader->getHeader();\n            $this->validateHeader($header);\n            $records = $reader->getRecords();\n            foreach ($records as $record) {\n                $firstname = $record['First Name'];\n                $lastname = $record['Last Name'];\n                $userId = $this->userImportHelper->getKey([\n                    'email' => Str::slug($firstname).'.'.Str::slug($lastname).'@solidtime-import.test',\n                ], [\n                    'name' => $firstname.' '.$lastname,\n                    'timezone' => 'UTC',\n                    'is_placeholder' => true,\n                ]);\n                $memberId = $this->memberImportHelper->getKey([\n                    'user_id' => $userId,\n                    'organization_id' => $this->organization->getKey(),\n                ], [\n                    'role' => Role::Placeholder->value,\n                ]);\n                $member = $this->memberImportHelper->getModelById($memberId);\n                $clientId = null;\n                if ($record['Client'] !== '') {\n                    $clientId = $this->clientImportHelper->getKey([\n                        'name' => $record['Client'],\n                        'organization_id' => $this->organization->id,\n                    ]);\n                }\n                $projectId = null;\n                $project = null;\n                $projectMember = null;\n                if ($record['Project'] !== '') {\n                    $projectId = $this->projectImportHelper->getKey([\n                        'name' => $record['Project'],\n                        'client_id' => $clientId,\n                        'organization_id' => $this->organization->id,\n                    ], [\n                        'color' => $this->colorService->getRandomColor(),\n                        'is_billable' => true,\n                    ]);\n                    $project = $this->projectImportHelper->getModelById($projectId);\n                    $projectMember = $this->projectMemberImportHelper->getModel([\n                        'project_id' => $projectId,\n                        'member_id' => $memberId,\n                    ]);\n                }\n                $taskId = null;\n                if ($record['Task'] !== '') {\n                    $taskId = $this->taskImportHelper->getKey([\n                        'name' => $record['Task'],\n                        'project_id' => $projectId,\n                        'organization_id' => $this->organization->id,\n                    ]);\n                    $this->taskImportHelper->getModelById($taskId);\n                }\n                $timeEntry = new TimeEntry;\n                $timeEntry->disableAuditing();\n                $timeEntry->user_id = $userId;\n                $timeEntry->member_id = $memberId;\n                $timeEntry->task_id = $taskId;\n                $timeEntry->project_id = $projectId;\n                $timeEntry->client_id = $clientId;\n                $timeEntry->organization_id = $this->organization->id;\n                if (strlen($record['Notes']) > 5000) {\n                    throw new ImportException('Time entry note is too long');\n                }\n                $timeEntry->description = $record['Notes'];\n                if (! in_array($record['Billable?'], ['Yes', 'No'], true)) {\n                    throw new ImportException('Invalid billable value');\n                }\n                $timeEntry->billable = $record['Billable?'] === 'Yes';\n                $timeEntry->tags = [];\n                $timeEntry->is_imported = true;\n\n                // Start & End\n                try {\n                    $date = Carbon::createFromFormat('Y-m-d', $record['Date'], $timezone);\n                } catch (InvalidFormatException) {\n                    throw new ImportException('Date (\"'.$record['Date'].'\") is invalid');\n                }\n                if ($date === null) {\n                    throw new ImportException('Date (\"'.$record['Date'].'\") is invalid');\n                }\n                if (! isset($record['Hours']) || ! is_string($record['Hours'])) {\n                    throw new ImportException('Hours (\"'.($record['Hours'] ?? '<null>').'\") is invalid');\n                }\n                $hoursField = Str::replace(',', '.', $record['Hours']);\n                if (! is_numeric($hoursField)) {\n                    throw new ImportException('Hours (\"'.$record['Hours'].'\") is invalid');\n                }\n                $hours = (float) $hoursField;\n                $timeEntry->start = $date->copy()->startOfDay()->utc();\n                $timeEntry->end = $date->copy()->startOfDay()->addHours($hours)->utc();\n                $timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(\n                    $timeEntry,\n                    $projectMember,\n                    $project,\n                    $member,\n                    $this->organization\n                );\n                $timeEntry->save();\n                $this->timeEntriesCreated++;\n            }\n            foreach ($this->projectImportHelper->getCachedModels() as $usedProject) {\n                RecalculateSpentTimeForProject::dispatch($usedProject);\n            }\n            foreach ($this->taskImportHelper->getCachedModels() as $usedTask) {\n                RecalculateSpentTimeForTask::dispatch($usedTask);\n            }\n        } catch (ImportException $exception) {\n            throw $exception;\n        } catch (CsvException $exception) {\n            throw new ImportException('Invalid CSV data');\n        } catch (Exception $exception) {\n            report($exception);\n            throw new ImportException('Unknown error');\n        }\n    }\n\n    /**\n     * @param  array<string>  $header\n     *\n     * @throws ImportException\n     */\n    private function validateHeader(array $header): void\n    {\n        foreach (self::REQUIRED_FIELDS as $requiredField) {\n            if (! in_array($requiredField, $header, true)) {\n                throw new ImportException('Invalid CSV header, missing field: '.$requiredField);\n            }\n        }\n    }\n\n    #[Override]\n    public function getName(): string\n    {\n        return __('importer.harvest_time_entries.name');\n    }\n\n    #[Override]\n    public function getDescription(): string\n    {\n        return __('importer.harvest_time_entries.description');\n    }\n}\n"
  },
  {
    "path": "app/Service/Import/Importers/ImportException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Import\\Importers;\n\nclass ImportException extends \\Exception {}\n"
  },
  {
    "path": "app/Service/Import/Importers/ImporterContract.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Import\\Importers;\n\nuse App\\Models\\Organization;\n\ninterface ImporterContract\n{\n    public function init(Organization $organization): void;\n\n    public function importData(string $data, string $timezone): void;\n\n    public function getReport(): ReportDto;\n\n    public function getName(): string;\n\n    public function getDescription(): string;\n}\n"
  },
  {
    "path": "app/Service/Import/Importers/ImporterProvider.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Import\\Importers;\n\nclass ImporterProvider\n{\n    /**\n     * @var array<string, class-string<ImporterContract>>\n     */\n    private array $importers = [\n        'toggl_time_entries' => TogglTimeEntriesImporter::class,\n        'toggl_data_importer' => TogglDataImporter::class,\n        'clockify_time_entries' => ClockifyTimeEntriesImporter::class,\n        'clockify_projects' => ClockifyProjectsImporter::class,\n        'solidtime' => SolidtimeImporter::class,\n        'harvest_projects' => HarvestProjectsImporter::class,\n        'harvest_time_entries' => HarvestTimeEntriesImporter::class,\n        'harvest_clients' => HarvestClientsImporter::class,\n        'generic_projects' => GenericProjectsImporter::class,\n        'generic_time_entries' => GenericTimeEntriesImporter::class,\n    ];\n\n    /**\n     * @param  class-string<ImporterContract>  $importer\n     */\n    public function registerImporter(string $type, string $importer): void\n    {\n        $this->importers[$type] = $importer;\n    }\n\n    /**\n     * @return array<string>\n     */\n    public function getImporterKeys(): array\n    {\n        return array_keys($this->importers);\n    }\n\n    /**\n     * @return array<string, class-string<ImporterContract>>\n     */\n    public function getImporters(): array\n    {\n        return $this->importers;\n    }\n\n    public function getImporter(string $type): ImporterContract\n    {\n        if (! array_key_exists($type, $this->importers)) {\n            throw new \\InvalidArgumentException('Invalid importer type');\n        }\n\n        return new $this->importers[$type];\n    }\n}\n"
  },
  {
    "path": "app/Service/Import/Importers/ReportDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Import\\Importers;\n\nclass ReportDto\n{\n    public int $clientsCreated;\n\n    public int $projectsCreated;\n\n    public int $tasksCreated;\n\n    public int $timeEntriesCreated;\n\n    public int $tagsCreated;\n\n    public int $usersCreated;\n\n    public function __construct(int $clientsCreated, int $projectsCreated, int $tasksCreated, int $timeEntriesCreated, int $tagsCreated, int $usersCreated)\n    {\n        $this->clientsCreated = $clientsCreated;\n        $this->projectsCreated = $projectsCreated;\n        $this->tasksCreated = $tasksCreated;\n        $this->timeEntriesCreated = $timeEntriesCreated;\n        $this->tagsCreated = $tagsCreated;\n        $this->usersCreated = $usersCreated;\n    }\n\n    /**\n     * @return array{\n     *    clients: array{\n     *       created: int,\n     *    },\n     *    projects: array{\n     *       created: int,\n     *    },\n     *    tasks: array{\n     *       created: int,\n     *    },\n     *    time_entries: array{\n     *       created: int,\n     *    },\n     *    tags: array{\n     *       created: int,\n     *    },\n     *    users: array{\n     *       created: int,\n     *    }\n     * }\n     */\n    public function toArray(): array\n    {\n        return [\n            'clients' => [\n                'created' => $this->clientsCreated,\n            ],\n            'projects' => [\n                'created' => $this->projectsCreated,\n            ],\n            'tasks' => [\n                'created' => $this->tasksCreated,\n            ],\n            'time_entries' => [\n                'created' => $this->timeEntriesCreated,\n            ],\n            'tags' => [\n                'created' => $this->tagsCreated,\n            ],\n            'users' => [\n                'created' => $this->usersCreated,\n            ],\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Service/Import/Importers/SolidtimeImporter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Import\\Importers;\n\nuse App\\Enums\\Role;\nuse App\\Jobs\\RecalculateSpentTimeForProject;\nuse App\\Jobs\\RecalculateSpentTimeForTask;\nuse App\\Models\\TimeEntry;\nuse Carbon\\Exceptions\\InvalidFormatException;\nuse Exception;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Str;\nuse League\\Csv\\Reader;\nuse Override;\nuse Spatie\\TemporaryDirectory\\TemporaryDirectory;\nuse ZipArchive;\n\nclass SolidtimeImporter extends DefaultImporter\n{\n    /**\n     * @var array<string>\n     */\n    public const array SUPPORTED_VERSIONS = ['1.0'];\n\n    /**\n     * @throws ImportException\n     */\n    #[Override]\n    public function importData(string $data, string $timezone): void\n    {\n        $temporaryDirectoryZip = null;\n        $temporaryDirectory = null;\n        try {\n            $zip = new ZipArchive;\n            $temporaryDirectoryZip = TemporaryDirectory::make();\n            file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);\n            $res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY);\n            if ($res !== true) {\n                throw new ImportException('Invalid ZIP, error code: '.$res);\n            }\n            $temporaryDirectory = TemporaryDirectory::make();\n            $zip->extractTo($temporaryDirectory->path());\n            $zip->close();\n\n            if (! file_exists($temporaryDirectory->path('meta.json'))) {\n                throw new ImportException('File \"meta.json\" missing in ZIP');\n            }\n            $metaFileContentRaw = file_get_contents($temporaryDirectory->path('meta.json'));\n            if ($metaFileContentRaw === false) {\n                throw new ImportException('File \"meta.json\" can not read');\n            }\n            $metaFileContent = json_decode($metaFileContentRaw);\n            if ($metaFileContent === false || ! isset($metaFileContent->version) || ! in_array($metaFileContent->version, self::SUPPORTED_VERSIONS, true)) {\n                throw new ImportException('Invalid version');\n            }\n\n            if (! file_exists($temporaryDirectory->path('clients.csv'))) {\n                throw new ImportException('File \"clients.csv\" missing in ZIP');\n            }\n            $clientsReader = Reader::createFromPath($temporaryDirectory->path('clients.csv'));\n            $clientsReader->setHeaderOffset(0);\n            $clientsReader->setDelimiter(',');\n            $clientsReader->setEnclosure('\"');\n            $clientsReader->setEscape('');\n\n            if (! file_exists($temporaryDirectory->path('members.csv'))) {\n                throw new ImportException('File \"members.csv\" missing in ZIP');\n            }\n            $membersReader = Reader::createFromPath($temporaryDirectory->path('members.csv'));\n            $membersReader->setHeaderOffset(0);\n            $membersReader->setDelimiter(',');\n            $membersReader->setEnclosure('\"');\n            $membersReader->setEscape('');\n\n            if (! file_exists($temporaryDirectory->path('organization_invitations.csv'))) {\n                throw new ImportException('File \"organization_invitations.csv\" missing in ZIP');\n            }\n            $organizationInvitationsReader = Reader::createFromPath($temporaryDirectory->path('organization_invitations.csv'));\n            $organizationInvitationsReader->setHeaderOffset(0);\n            $organizationInvitationsReader->setDelimiter(',');\n            $organizationInvitationsReader->setEnclosure('\"');\n            $organizationInvitationsReader->setEscape('');\n\n            if (! file_exists($temporaryDirectory->path('project_members.csv'))) {\n                throw new ImportException('File \"project_members.csv\" missing in ZIP');\n            }\n            $projectMembersReader = Reader::createFromPath($temporaryDirectory->path('project_members.csv'));\n            $projectMembersReader->setHeaderOffset(0);\n            $projectMembersReader->setDelimiter(',');\n            $projectMembersReader->setEnclosure('\"');\n            $projectMembersReader->setEscape('');\n\n            if (! file_exists($temporaryDirectory->path('projects.csv'))) {\n                throw new ImportException('File \"projects.csv\" missing in ZIP');\n            }\n            $projectsReader = Reader::createFromPath($temporaryDirectory->path('projects.csv'));\n            $projectsReader->setHeaderOffset(0);\n            $projectsReader->setDelimiter(',');\n            $projectsReader->setEnclosure('\"');\n            $projectsReader->setEscape('');\n\n            if (! file_exists($temporaryDirectory->path('tags.csv'))) {\n                throw new ImportException('File \"tags.csv\" missing in ZIP');\n            }\n            $tagsReader = Reader::createFromPath($temporaryDirectory->path('tags.csv'));\n            $tagsReader->setHeaderOffset(0);\n            $tagsReader->setDelimiter(',');\n            $tagsReader->setEnclosure('\"');\n            $tagsReader->setEscape('');\n\n            if (! file_exists($temporaryDirectory->path('tasks.csv'))) {\n                throw new ImportException('File \"tasks.csv\" missing in ZIP');\n            }\n            $tasksReader = Reader::createFromPath($temporaryDirectory->path('tasks.csv'));\n            $tasksReader->setHeaderOffset(0);\n            $tasksReader->setDelimiter(',');\n            $tasksReader->setEnclosure('\"');\n            $tasksReader->setEscape('');\n\n            if (! file_exists($temporaryDirectory->path('time_entries.csv'))) {\n                throw new ImportException('File \"time_entries.csv\" missing in ZIP');\n            }\n            $timeEntriesReader = Reader::createFromPath($temporaryDirectory->path('time_entries.csv'));\n            $timeEntriesReader->setHeaderOffset(0);\n            $timeEntriesReader->setDelimiter(',');\n            $timeEntriesReader->setEnclosure('\"');\n            $timeEntriesReader->setEscape('');\n\n            foreach ($clientsReader as $client) {\n                $this->clientImportHelper->getKey([\n                    'name' => $client['name'],\n                    'organization_id' => $this->organization->id,\n                ], [\n                    'archived_at' => $client['archived_at'] !== '' ? Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $client['archived_at'], 'UTC') : null,\n                ], $client['id']);\n            }\n\n            foreach ($tagsReader as $tag) {\n                $this->tagImportHelper->getKey([\n                    'name' => $tag['name'],\n                    'organization_id' => $this->organization->id,\n                ], [], $tag['id']);\n            }\n\n            foreach ($membersReader as $member) {\n                $userId = $this->userImportHelper->getKey([\n                    'email' => $member['email'],\n                ], [\n                    'name' => $member['name'],\n                    'timezone' => 'UTC',\n                    'is_placeholder' => true,\n                ], $member['user_id']);\n                $this->memberImportHelper->getKey([\n                    'user_id' => $userId,\n                    'organization_id' => $this->organization->getKey(),\n                ], [\n                    'role' => Role::Placeholder->value,\n                    'billable_rate' => $member['billable_rate'] === '' ? null : (int) $member['billable_rate'],\n                ], $member['id']);\n            }\n\n            foreach ($projectsReader as $project) {\n                $clientId = null;\n                if ($project['client_id'] !== '') {\n                    $clientId = $this->clientImportHelper->getKeyByExternalIdentifier($project['client_id']);\n                    if ($clientId === null) {\n                        throw new Exception('Client does not exist');\n                    }\n                }\n\n                if (! $this->colorService->isValid($project['color'])) {\n                    throw new ImportException('Invalid color');\n                }\n\n                $this->projectImportHelper->getKey([\n                    'name' => $project['name'],\n                    'client_id' => $clientId,\n                    'organization_id' => $this->organization->getKey(),\n                ], [\n                    'color' => $project['color'],\n                    'billable_rate' => $project['billable_rate'] === '' ? null : (int) $project['billable_rate'],\n                    'is_public' => $project['is_public'] === 'true',\n                    'is_billable' => $project['is_billable'] === 'true',\n                    'archived_at' => $project['archived_at'] !== '' ? Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $project['archived_at'], 'UTC') : null,\n                ], $project['id']);\n            }\n\n            foreach ($projectMembersReader as $projectMember) {\n                $userId = $this->userImportHelper->getKeyByExternalIdentifier($projectMember['user_id']);\n                $memberId = $this->memberImportHelper->getKeyByExternalIdentifier($projectMember['member_id']);\n                $projectId = $this->projectImportHelper->getKeyByExternalIdentifier($projectMember['project_id']);\n                $this->projectMemberImportHelper->getKey([\n                    'project_id' => $projectId,\n                    'member_id' => $memberId,\n                ], [\n                    'user_id' => $userId,\n                    'billable_rate' => $projectMember['billable_rate'] === '' ? null : (int) $projectMember['billable_rate'],\n                ], $projectMember['id']);\n            }\n\n            foreach ($tasksReader as $task) {\n                $projectId = $this->projectImportHelper->getKeyByExternalIdentifier($task['project_id']);\n                if ($projectId === null) {\n                    throw new Exception('Project does not exist');\n                }\n                $this->taskImportHelper->getKey([\n                    'name' => $task['name'],\n                    'project_id' => $projectId,\n                    'organization_id' => $this->organization->getKey(),\n                ], [\n                    'done_at' => $task['done_at'] !== '' ? Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $task['done_at'], 'UTC') : null,\n                ], (string) $task['id']);\n            }\n\n            // Time entries\n            foreach ($timeEntriesReader as $timeEntryRow) {\n                $userId = $this->userImportHelper->getKeyByExternalIdentifier($timeEntryRow['user_id']);\n                $memberId = $this->memberImportHelper->getKeyByExternalIdentifier($timeEntryRow['member_id']);\n                $member = $this->memberImportHelper->getModelById($memberId);\n                $clientId = null;\n                if ($timeEntryRow['client_id'] !== '') {\n                    $clientId = $this->clientImportHelper->getKeyByExternalIdentifier($timeEntryRow['client_id']);\n                }\n                $project = null;\n                $projectId = null;\n                $projectMember = null;\n                if ($timeEntryRow['project_id'] !== '') {\n                    $projectId = $this->projectImportHelper->getKeyByExternalIdentifier($timeEntryRow['project_id']);\n                    $project = $this->projectImportHelper->getModelById($projectId);\n                    $projectMember = $this->projectMemberImportHelper->getModel([\n                        'project_id' => $projectId,\n                        'member_id' => $memberId,\n                    ]);\n                }\n                $taskId = null;\n                if ($timeEntryRow['task_id'] !== '') {\n                    $taskId = $this->taskImportHelper->getKeyByExternalIdentifier($timeEntryRow['task_id']);\n                    $this->taskImportHelper->getModelById($taskId);\n                }\n                $timeEntry = new TimeEntry;\n                $timeEntry->disableAuditing();\n                $timeEntry->user_id = $userId;\n                $timeEntry->member_id = $memberId;\n                $timeEntry->task_id = $taskId;\n                $timeEntry->project_id = $projectId;\n                $timeEntry->client_id = $clientId;\n                $timeEntry->organization_id = $this->organization->id;\n                if (strlen($timeEntryRow['description']) > 5000) {\n                    throw new ImportException('Time entry description is too long');\n                }\n                $timeEntry->description = $timeEntryRow['description'];\n                if (! in_array($timeEntryRow['billable'], ['true', 'false'], true)) {\n                    throw new ImportException('Invalid billable value');\n                }\n                $timeEntry->billable = $timeEntryRow['billable'] === 'true';\n                $timeEntry->tags = $this->getTags($timeEntryRow['tags']);\n                $timeEntry->is_imported = true;\n\n                try {\n                    $start = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $timeEntryRow['start'], 'UTC');\n                } catch (InvalidFormatException) {\n                    throw new ImportException('Start date (\"'.$timeEntryRow['start'].'\") is invalid');\n                }\n                if ($start === null) {\n                    throw new ImportException('Start date (\"'.$timeEntryRow['start'].'\") is invalid');\n                }\n                $timeEntry->start = $start->utc();\n\n                if ($timeEntryRow['end'] !== '') {\n                    try {\n                        $end = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $timeEntryRow['end'], 'UTC');\n                    } catch (InvalidFormatException) {\n                        throw new ImportException('End date (\"'.$timeEntryRow['end'].'\") is invalid');\n                    }\n                    if ($end === null) {\n                        throw new ImportException('End date (\"'.$timeEntryRow['end'].'\") is invalid');\n                    }\n                    $timeEntry->end = $end->utc();\n                } else {\n                    $timeEntry->end = null;\n                }\n\n                if ($timeEntryRow['still_active_email_sent_at'] !== '') {\n                    try {\n                        $stillActiveEmailSentAt = Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $timeEntryRow['still_active_email_sent_at'], 'UTC');\n                    } catch (InvalidFormatException) {\n                        throw new ImportException('Still active email timestamp (\"'.$timeEntryRow['still_active_email_sent_at'].'\") is invalid');\n                    }\n                    if ($stillActiveEmailSentAt === null) {\n                        throw new ImportException('Still active email timestamp (\"'.$timeEntryRow['still_active_email_sent_at'].'\") is invalid');\n                    }\n                    $timeEntry->still_active_email_sent_at = $stillActiveEmailSentAt->utc();\n                } else {\n                    $timeEntry->still_active_email_sent_at = null;\n                }\n\n                $timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(\n                    $timeEntry,\n                    $projectMember,\n                    $project,\n                    $member,\n                    $this->organization\n                );\n                $timeEntry->save();\n                $this->timeEntriesCreated++;\n            }\n            foreach ($this->projectImportHelper->getCachedModels() as $usedProject) {\n                RecalculateSpentTimeForProject::dispatch($usedProject);\n            }\n            foreach ($this->taskImportHelper->getCachedModels() as $usedTask) {\n                RecalculateSpentTimeForTask::dispatch($usedTask);\n            }\n        } catch (ImportException $exception) {\n            throw $exception;\n        } catch (Exception $exception) {\n            report($exception);\n            throw new ImportException('Unknown error');\n        } finally {\n            $temporaryDirectory?->delete();\n            $temporaryDirectoryZip?->delete();\n        }\n    }\n\n    /**\n     * @return array<string>\n     */\n    private function getTags(string $tags): array\n    {\n        if (Str::trim($tags) === '') {\n            return [];\n        }\n        $tagsParsed = json_decode($tags);\n        if ($tagsParsed === false || ! is_array($tagsParsed)) {\n            return [];\n        }\n        $tagIds = [];\n        foreach ($tagsParsed as $tagParsed) {\n            if (! is_string($tagParsed) || ! Str::isUuid($tagParsed)) {\n                continue;\n            }\n            $tagId = $this->tagImportHelper->getKeyByExternalIdentifier($tagParsed);\n            $tagIds[] = $tagId;\n        }\n\n        return $tagIds;\n    }\n\n    #[Override]\n    public function getName(): string\n    {\n        return __('importer.solidtime_importer.name');\n    }\n\n    #[Override]\n    public function getDescription(): string\n    {\n        return __('importer.solidtime_importer.description');\n    }\n}\n"
  },
  {
    "path": "app/Service/Import/Importers/TogglDataImporter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Import\\Importers;\n\nuse App\\Enums\\Role;\nuse App\\Service\\TimezoneService;\nuse Exception;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Override;\nuse Spatie\\TemporaryDirectory\\TemporaryDirectory;\nuse ValueError;\nuse ZipArchive;\n\nclass TogglDataImporter extends DefaultImporter\n{\n    /**\n     * @throws ImportException\n     */\n    #[Override]\n    public function importData(string $data, string $timezone): void\n    {\n        $temporaryDirectoryZip = null;\n        $temporaryDirectory = null;\n        try {\n            $zip = new ZipArchive;\n            $temporaryDirectoryZip = TemporaryDirectory::make();\n            file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);\n            $res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY);\n            if ($res !== true) {\n                throw new ImportException('Invalid ZIP, error code: '.$res);\n            }\n            $temporaryDirectory = TemporaryDirectory::make();\n            $zip->extractTo($temporaryDirectory->path());\n            $zip->close();\n            if (! file_exists($temporaryDirectory->path('clients.json'))) {\n                throw new ImportException('File \"clients.json\" missing in ZIP');\n            }\n            $clientsFileContent = file_get_contents($temporaryDirectory->path('clients.json'));\n            if ($clientsFileContent === false) {\n                throw new ImportException('File \"clients.json\" can not be opened');\n            }\n            $clients = json_decode($clientsFileContent);\n            if ($clients === null) {\n                throw new ImportException('File \"clients.json\" is empty');\n            }\n            if (! file_exists($temporaryDirectory->path('projects.json'))) {\n                throw new ImportException('File \"projects.json\" missing in ZIP');\n            }\n            $projectsFileContent = file_get_contents($temporaryDirectory->path('projects.json'));\n            if ($projectsFileContent === false) {\n                throw new ImportException('File \"projects.json\" can not be opened');\n            }\n            $projects = json_decode($projectsFileContent);\n            if ($projects === null) {\n                throw new ImportException('File \"projects.json\" is empty');\n            }\n            if (! file_exists($temporaryDirectory->path('tags.json'))) {\n                throw new ImportException('File \"tags.json\" missing in ZIP');\n            }\n            $tagsFileContent = file_get_contents($temporaryDirectory->path('tags.json'));\n            if ($tagsFileContent === false) {\n                throw new ImportException('File \"tags.json\" can not be opened');\n            }\n            $tags = json_decode($tagsFileContent);\n            if ($tags === null) {\n                throw new ImportException('File \"tags.json\" is empty');\n            }\n            if (! file_exists($temporaryDirectory->path('workspace_users.json'))) {\n                throw new ImportException('File \"workspace_users.json\" missing in ZIP');\n            }\n            $workspaceUsersFileContent = file_get_contents($temporaryDirectory->path('workspace_users.json'));\n            if ($workspaceUsersFileContent === false) {\n                throw new ImportException('File \"workspace_users.json\" can not be opened');\n            }\n            $workspaceUsers = json_decode($workspaceUsersFileContent);\n            if ($workspaceUsers === null) {\n                throw new ImportException('File \"workspace_users.json\" is empty');\n            }\n            foreach ($clients as $client) {\n                $this->clientImportHelper->getKey([\n                    'name' => $client->name,\n                    'organization_id' => $this->organization->id,\n                ], [\n                    'archived_at' => $client->archived === true ? Carbon::now() : null,\n                ], (string) $client->id);\n            }\n            foreach ($tags as $tag) {\n                $this->tagImportHelper->getKey([\n                    'name' => $tag->name,\n                    'organization_id' => $this->organization->id,\n                ], [], (string) $tag->id);\n            }\n\n            foreach ($workspaceUsers as $workspaceUser) {\n                $timezone = Str::trim($workspaceUser->timezone);\n                if ($timezone === '') {\n                    $timezone = 'UTC';\n                }\n                if (! app(TimezoneService::class)->isValid($timezone)) {\n                    Log::warning('TogglDateImporter: Invalid timezone', [\n                        'timezone' => $timezone,\n                    ]);\n                    $timezone = 'UTC';\n                }\n\n                $userId = $this->userImportHelper->getKey([\n                    'email' => $workspaceUser->email,\n                ], [\n                    'name' => $workspaceUser->name,\n                    'timezone' => $timezone,\n                    'is_placeholder' => true,\n                ], (string) $workspaceUser->uid);\n                $this->memberImportHelper->getKey([\n                    'user_id' => $userId,\n                    'organization_id' => $this->organization->getKey(),\n                ], [\n                    'role' => Role::Placeholder->value,\n                ], $userId);\n            }\n\n            foreach ($projects as $project) {\n                $clientId = null;\n                if ($project->client_id !== null) {\n                    $clientId = $this->clientImportHelper->getKeyByExternalIdentifier((string) $project->client_id);\n                    if ($clientId === null) {\n                        throw new Exception('Client does not exist');\n                    }\n                }\n\n                if (! $this->colorService->isValid($project->color)) {\n                    throw new ImportException('Invalid color');\n                }\n\n                $projectId = $this->projectImportHelper->getKey([\n                    'name' => $project->name,\n                    'client_id' => $clientId,\n                    'organization_id' => $this->organization->getKey(),\n                ], [\n                    'color' => $project->color,\n                    'is_billable' => $project->billable,\n                    'is_public' => ! $project->is_private,\n                    'billable_rate' => $project->rate !== null ? (int) ($project->rate * 100) : null,\n                ], (string) $project->id);\n\n                if (! file_exists($temporaryDirectory->path('projects_users/'.$project->id.'.json'))) {\n                    throw new ImportException('File \"projects_users/'.$project->id.'.json\" missing in ZIP');\n                }\n                $projectMembersFileContent = file_get_contents($temporaryDirectory->path('projects_users/'.$project->id.'.json'));\n                if ($projectMembersFileContent === false) {\n                    throw new ImportException('File \"projects_users/'.$project->id.'.json\" can not be opened');\n                }\n                $projectMembers = json_decode($projectMembersFileContent);\n                if ($projectMembers === null) {\n                    throw new ImportException('File \"projects_users/'.$project->id.'.json\" is empty');\n                }\n                foreach ($projectMembers as $projectMember) {\n                    $userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id);\n                    $this->projectMemberImportHelper->getKey([\n                        'project_id' => $projectId,\n                        'member_id' => $this->memberImportHelper->getKeyByExternalIdentifier($userId),\n                    ], [\n                        'user_id' => $userId,\n                        'billable_rate' => $projectMember->rate !== null ? (int) ($projectMember->rate * 100) : null,\n                    ]);\n                }\n            }\n            $projectIds = $this->projectImportHelper->getExternalIds();\n            foreach ($projectIds as $projectIdExternal) {\n                if (! file_exists($temporaryDirectory->path('tasks/'.$projectIdExternal.'.json'))) {\n                    continue;\n                }\n                $tasksFileContent = file_get_contents($temporaryDirectory->path('tasks/'.$projectIdExternal.'.json'));\n                if ($tasksFileContent === false) {\n                    throw new ImportException('File \"tasks/'.$projectIdExternal.'.json\" can not be opened');\n                }\n                $tasks = json_decode($tasksFileContent);\n                if ($tasks === null) {\n                    throw new ImportException('File \"tasks/'.$projectIdExternal.'.json\" is empty');\n                }\n                foreach ($tasks as $task) {\n                    $projectId = $this->projectImportHelper->getKeyByExternalIdentifier((string) $projectIdExternal);\n\n                    if ($projectId === null) {\n                        throw new Exception('Project does not exist');\n                    }\n                    $this->taskImportHelper->getKey([\n                        'name' => $task->name,\n                        'project_id' => $projectId,\n                        'organization_id' => $this->organization->getKey(),\n                    ], [\n                        'done_at' => $task->active === false ? Carbon::now() : null,\n                    ], (string) $task->id);\n                }\n            }\n        } catch (ValueError $exception) {\n\n        } catch (ImportException $exception) {\n            throw $exception;\n        } catch (Exception $exception) {\n            report($exception);\n            throw new ImportException('Unknown error');\n        } finally {\n            $temporaryDirectory?->delete();\n            $temporaryDirectoryZip?->delete();\n        }\n    }\n\n    #[Override]\n    public function getName(): string\n    {\n        return __('importer.toggl_data_importer.name');\n    }\n\n    #[Override]\n    public function getDescription(): string\n    {\n        return __('importer.toggl_data_importer.description');\n    }\n}\n"
  },
  {
    "path": "app/Service/Import/Importers/TogglTimeEntriesImporter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Import\\Importers;\n\nuse App\\Enums\\Role;\nuse App\\Jobs\\RecalculateSpentTimeForProject;\nuse App\\Jobs\\RecalculateSpentTimeForTask;\nuse App\\Models\\TimeEntry;\nuse Carbon\\Exceptions\\InvalidFormatException;\nuse Exception;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Str;\nuse League\\Csv\\Exception as CsvException;\nuse League\\Csv\\Reader;\n\nclass TogglTimeEntriesImporter extends DefaultImporter\n{\n    /**\n     * @return array<string>\n     *\n     * @throws ImportException\n     */\n    private function getTags(string $tags): array\n    {\n        if (Str::trim($tags) === '') {\n            return [];\n        }\n        $tagsParsed = explode(', ', $tags);\n        $tagIds = [];\n        foreach ($tagsParsed as $tagParsed) {\n            $tagId = $this->tagImportHelper->getKey([\n                'name' => $tagParsed,\n                'organization_id' => $this->organization->id,\n            ]);\n            $tagIds[] = $tagId;\n        }\n\n        return $tagIds;\n    }\n\n    /**\n     * @throws ImportException\n     */\n    #[\\Override]\n    public function importData(string $data, string $timezone): void\n    {\n        try {\n            $reader = Reader::createFromString($data);\n            $reader->setHeaderOffset(0);\n            $reader->setDelimiter(',');\n            $reader->setEnclosure('\"');\n            $reader->setEscape('');\n            $header = $reader->getHeader();\n            $this->validateHeader($header);\n            $records = $reader->getRecords();\n            foreach ($records as $record) {\n                $userId = $this->userImportHelper->getKey([\n                    'email' => $record['Email'],\n                ], [\n                    'name' => $record['User'],\n                    'timezone' => 'UTC',\n                    'is_placeholder' => true,\n                ]);\n                $memberId = $this->memberImportHelper->getKey([\n                    'user_id' => $userId,\n                    'organization_id' => $this->organization->getKey(),\n                ], [\n                    'role' => Role::Placeholder->value,\n                ]);\n                $member = $this->memberImportHelper->getModelById($memberId);\n                $clientId = null;\n                if ($record['Client'] !== '') {\n                    $clientId = $this->clientImportHelper->getKey([\n                        'name' => $record['Client'],\n                        'organization_id' => $this->organization->id,\n                    ]);\n                }\n                $projectId = null;\n                $project = null;\n                $projectMember = null;\n                if ($record['Project'] !== '') {\n                    $projectId = $this->projectImportHelper->getKey([\n                        'name' => $record['Project'],\n                        'client_id' => $clientId,\n                        'organization_id' => $this->organization->id,\n                    ], [\n                        'is_billable' => false,\n                        'color' => $this->colorService->getRandomColor(),\n                    ]);\n                    $project = $this->projectImportHelper->getModelById($projectId);\n                    $projectMember = $this->projectMemberImportHelper->getModel([\n                        'project_id' => $projectId,\n                        'member_id' => $memberId,\n                    ]);\n                }\n                $taskId = null;\n                if ($record['Task'] !== '') {\n                    $taskId = $this->taskImportHelper->getKey([\n                        'name' => $record['Task'],\n                        'project_id' => $projectId,\n                        'organization_id' => $this->organization->id,\n                    ]);\n                    $this->taskImportHelper->getModelById($taskId);\n                }\n                $timeEntry = new TimeEntry;\n                $timeEntry->disableAuditing();\n                $timeEntry->user_id = $userId;\n                $timeEntry->member_id = $memberId;\n                $timeEntry->task_id = $taskId;\n                $timeEntry->project_id = $projectId;\n                $timeEntry->client_id = $clientId;\n                $timeEntry->organization_id = $this->organization->id;\n                $timeEntry->description = $record['Description'];\n                if (! in_array($record['Billable'], ['Yes', 'No'], true)) {\n                    throw new ImportException('Invalid billable value');\n                }\n                $timeEntry->billable = $record['Billable'] === 'Yes';\n                $timeEntry->tags = $this->getTags($record['Tags']);\n                $timeEntry->is_imported = true;\n                try {\n                    $start = Carbon::createFromFormat('Y-m-d H:i:s', $record['Start date'].' '.$record['Start time'], $timezone);\n                } catch (InvalidFormatException) {\n                    throw new ImportException('Start date (\"'.$record['Start date'].'\") or time (\"'.$record['Start time'].'\") are invalid');\n                }\n                if ($start === null) {\n                    throw new ImportException('Start date (\"'.$record['Start date'].'\") or time (\"'.$record['Start time'].'\") are invalid');\n                }\n                $timeEntry->start = $start->utc();\n\n                try {\n                    $end = Carbon::createFromFormat('Y-m-d H:i:s', $record['End date'].' '.$record['End time'], $timezone);\n                } catch (InvalidFormatException) {\n                    throw new ImportException('End date (\"'.$record['End date'].'\") or time (\"'.$record['End time'].'\") are invalid');\n                }\n                if ($end === null) {\n                    throw new ImportException('End date (\"'.$record['End date'].'\") or time (\"'.$record['End time'].'\") are invalid');\n                }\n                $timeEntry->end = $end->utc();\n                $timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(\n                    $timeEntry,\n                    $projectMember,\n                    $project,\n                    $member,\n                    $this->organization\n                );\n                $timeEntry->save();\n                $this->timeEntriesCreated++;\n            }\n            foreach ($this->projectImportHelper->getCachedModels() as $usedProject) {\n                RecalculateSpentTimeForProject::dispatch($usedProject);\n            }\n            foreach ($this->taskImportHelper->getCachedModels() as $usedTask) {\n                RecalculateSpentTimeForTask::dispatch($usedTask);\n            }\n        } catch (ImportException $exception) {\n            throw $exception;\n        } catch (CsvException $exception) {\n            throw new ImportException('Invalid CSV data');\n        } catch (Exception $exception) {\n            report($exception);\n            throw new ImportException('Unknown error');\n        }\n    }\n\n    /**\n     * @param  array<string>  $header\n     *\n     * @throws ImportException\n     */\n    private function validateHeader(array $header): void\n    {\n        $requiredFields = [\n            'User',\n            'Email',\n            'Client',\n            'Project',\n            'Task',\n            'Description',\n            'Billable',\n            'Start date',\n            'Start time',\n            'End date',\n            'End time',\n            'Tags',\n        ];\n        foreach ($requiredFields as $requiredField) {\n            if (! in_array($requiredField, $header, true)) {\n                throw new ImportException('Invalid CSV header, missing field: '.$requiredField);\n            }\n        }\n    }\n\n    #[\\Override]\n    public function getName(): string\n    {\n        return __('importer.toggl_time_entries.name');\n    }\n\n    #[\\Override]\n    public function getDescription(): string\n    {\n        return __('importer.toggl_time_entries.description');\n    }\n}\n"
  },
  {
    "path": "app/Service/IntervalService.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse Carbon\\CarbonInterval;\n\nclass IntervalService\n{\n    public function format(CarbonInterval $interval): string\n    {\n        $interval->cascade();\n\n        return ((int) floor($interval->totalHours)).':'.$interval->format('%I:%S');\n    }\n}\n"
  },
  {
    "path": "app/Service/InvitationService.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Enums\\Role;\nuse App\\Exceptions\\Api\\InvitationForTheEmailAlreadyExistsApiException;\nuse App\\Exceptions\\Api\\UserIsAlreadyMemberOfOrganizationApiException;\nuse App\\Mail\\OrganizationInvitationMail;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\OrganizationInvitation;\nuse Illuminate\\Support\\Facades\\Mail;\nuse Laravel\\Jetstream\\Events\\InvitingTeamMember;\n\nclass InvitationService\n{\n    /**\n     * @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException\n     */\n    public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation\n    {\n        if (Member::query()\n            ->whereBelongsTo($organization, 'organization')\n            ->whereRelation('user', 'email', '=', $email)\n            ->where('role', '!=', Role::Placeholder->value)\n            ->exists()) {\n            throw new UserIsAlreadyMemberOfOrganizationApiException;\n        }\n\n        if (OrganizationInvitation::query()\n            ->where('email', $email)\n            ->whereBelongsTo($organization, 'organization')\n            ->exists()) {\n            throw new InvitationForTheEmailAlreadyExistsApiException;\n        }\n\n        InvitingTeamMember::dispatch($organization, $email, $role->value);\n\n        $invitation = new OrganizationInvitation;\n        $invitation->email = $email;\n        $invitation->role = $role->value;\n        $invitation->organization()->associate($organization);\n        $invitation->save();\n\n        Mail::to($email)->queue(new OrganizationInvitationMail($invitation));\n\n        return $invitation;\n    }\n}\n"
  },
  {
    "path": "app/Service/IpLookup/IpLookupResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\IpLookup;\n\nuse App\\Enums\\Weekday;\n\nclass IpLookupResponseDto\n{\n    public ?string $timezone;\n\n    public ?Weekday $startOfWeek;\n\n    public ?string $currency;\n\n    public function __construct(?string $timezone, ?Weekday $startOfWeek, ?string $currency)\n    {\n        $this->timezone = $timezone;\n        $this->startOfWeek = $startOfWeek;\n        $this->currency = $currency;\n    }\n}\n"
  },
  {
    "path": "app/Service/IpLookup/IpLookupServiceContract.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\IpLookup;\n\ninterface IpLookupServiceContract\n{\n    public function lookup(string $ip): ?IpLookupResponseDto;\n}\n"
  },
  {
    "path": "app/Service/IpLookup/NoIpLookupService.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\IpLookup;\n\nclass NoIpLookupService implements IpLookupServiceContract\n{\n    public function lookup(string $ip): ?IpLookupResponseDto\n    {\n        return null;\n    }\n}\n"
  },
  {
    "path": "app/Service/LocalizationService.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Enums\\CurrencyFormat;\nuse App\\Enums\\DateFormat;\nuse App\\Enums\\IntervalFormat;\nuse App\\Enums\\NumberFormat;\nuse App\\Enums\\TimeFormat;\nuse App\\Models\\Organization;\nuse Brick\\Math\\BigDecimal;\nuse Brick\\Money\\Money;\nuse Carbon\\CarbonInterface;\nuse Carbon\\CarbonInterval;\n\nclass LocalizationService\n{\n    private CurrencyFormat $currencyFormat;\n\n    private IntervalFormat $intervalFormat;\n\n    private DateFormat $dateFormat;\n\n    private TimeFormat $timeFormat;\n\n    private NumberFormat $numberFormat;\n\n    public function __construct(CurrencyFormat $currencyFormat, DateFormat $dateFormat, TimeFormat $timeFormat, NumberFormat $numberFormat, IntervalFormat $intervalFormat)\n    {\n        $this->currencyFormat = $currencyFormat;\n        $this->dateFormat = $dateFormat;\n        $this->timeFormat = $timeFormat;\n        $this->numberFormat = $numberFormat;\n        $this->intervalFormat = $intervalFormat;\n    }\n\n    public static function forOrganization(Organization $organization): self\n    {\n        return new LocalizationService(\n            $organization->currency_format,\n            $organization->date_format,\n            $organization->time_format,\n            $organization->number_format,\n            $organization->interval_format\n        );\n    }\n\n    public function formatNumber(BigDecimal|float $number): string\n    {\n        $numberFloat = $number instanceof BigDecimal ? $number->toFloat() : $number;\n\n        if ($this->numberFormat === NumberFormat::ThousandsPointDecimalComma) {\n            return number_format($numberFloat, 2, ',', '.');\n        } elseif ($this->numberFormat === NumberFormat::ThousandsSpaceDecimalPoint) {\n            return number_format($numberFloat, 2, '.', ' ');\n        } elseif ($this->numberFormat === NumberFormat::ThousandsCommaDecimalPoint) {\n            return number_format($numberFloat, 2, '.', ',');\n        } elseif ($this->numberFormat === NumberFormat::ThousandsSpaceDecimalComma) {\n            return number_format($numberFloat, 2, ',', ' ');\n        } elseif ($this->numberFormat === NumberFormat::ThousandsApostropheDecimalPoint) {\n            return number_format($numberFloat, 2, '.', '\\'');\n        }\n    }\n\n    public function formatNumberWithoutTrailingZeros(BigDecimal|float $number): string\n    {\n        $number = $this->formatNumber($number);\n\n        $number = rtrim($number, '0');\n        $number = rtrim($number, '.');\n        $number = rtrim($number, ',');\n\n        return $number;\n    }\n\n    public function formatInterval(CarbonInterval $interval): string\n    {\n        if ($this->intervalFormat === IntervalFormat::Decimal) {\n            $interval->cascade();\n\n            return $this->formatNumber($interval->totalHours).' h';\n        } elseif ($this->intervalFormat === IntervalFormat::HoursMinutes) {\n            $interval->cascade();\n\n            return ((int) floor($interval->totalHours)).'h '.$interval->format('%I').'m';\n        } elseif ($this->intervalFormat === IntervalFormat::HoursMinutesColonSeparated) {\n            $interval->cascade();\n\n            return ((int) floor($interval->totalHours)).':'.$interval->format('%I');\n        } elseif ($this->intervalFormat === IntervalFormat::HoursMinutesSecondsColonSeparated) {\n            $interval->cascade();\n\n            return ((int) floor($interval->totalHours)).':'.$interval->format('%I:%S');\n        }\n    }\n\n    public function formatCurrency(Money $money): string\n    {\n        $currencyService = app(CurrencyService::class);\n        if ($this->currencyFormat === CurrencyFormat::ISOCodeAfterWithSpace) {\n            return $this->formatNumber($money->getAmount()).' '.$money->getCurrency()->getCurrencyCode();\n        } elseif ($this->currencyFormat === CurrencyFormat::ISOCodeBeforeWithSpace) {\n            return $money->getCurrency()->getCurrencyCode().' '.$this->formatNumber($money->getAmount());\n        } elseif ($this->currencyFormat === CurrencyFormat::SymbolAfter) {\n            return $this->formatNumber($money->getAmount()).$currencyService->getCurrencySymbolForMoney($money);\n        } elseif ($this->currencyFormat === CurrencyFormat::SymbolBefore) {\n            return $currencyService->getCurrencySymbolForMoney($money).$this->formatNumber($money->getAmount());\n        } elseif ($this->currencyFormat === CurrencyFormat::SymbolBeforeWithSpace) {\n            return $currencyService->getCurrencySymbolForMoney($money).' '.$this->formatNumber($money->getAmount());\n        } elseif ($this->currencyFormat === CurrencyFormat::SymbolAfterWithSpace) {\n            return $this->formatNumber($money->getAmount()).' '.$currencyService->getCurrencySymbolForMoney($money);\n        }\n    }\n\n    public function formatTime(CarbonInterface $time): string\n    {\n        if ($this->timeFormat === TimeFormat::TwelveHours) {\n            return $time->format('h:i a'); // Examples: \"11:01 am\", \"1:02 am\"\n        } elseif ($this->timeFormat === TimeFormat::TwentyFourHours) {\n            return $time->format('H:i'); // Examples: \"23:01\", \"01:02\"\n        }\n    }\n\n    public function formatDate(CarbonInterface $date): string\n    {\n        return $date->format($this->dateFormat->toCarbonFormat());\n    }\n\n    public function setDateFormat(DateFormat $dateFormat): void\n    {\n        $this->dateFormat = $dateFormat;\n    }\n\n    public function setCurrencyFormat(CurrencyFormat $currencyFormat): void\n    {\n        $this->currencyFormat = $currencyFormat;\n    }\n\n    public function setIntervalFormat(IntervalFormat $intervalFormat): void\n    {\n        $this->intervalFormat = $intervalFormat;\n    }\n\n    public function setTimeFormat(TimeFormat $timeFormat): void\n    {\n        $this->timeFormat = $timeFormat;\n    }\n\n    public function setNumberFormat(NumberFormat $numberFormat): void\n    {\n        $this->numberFormat = $numberFormat;\n    }\n}\n"
  },
  {
    "path": "app/Service/MemberService.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Enums\\Role;\nuse App\\Events\\MemberRemoved;\nuse App\\Exceptions\\Api\\CanNotRemoveOwnerFromOrganization;\nuse App\\Exceptions\\Api\\ChangingRoleOfPlaceholderIsNotAllowed;\nuse App\\Exceptions\\Api\\ChangingRoleToPlaceholderIsNotAllowed;\nuse App\\Exceptions\\Api\\EntityStillInUseApiException;\nuse App\\Exceptions\\Api\\OnlyOwnerCanChangeOwnership;\nuse App\\Exceptions\\Api\\OrganizationNeedsAtLeastOneOwner;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\ProjectMember;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Support\\Facades\\DB;\nuse InvalidArgumentException;\nuse Laravel\\Jetstream\\Events\\AddingTeamMember;\nuse Laravel\\Jetstream\\Events\\TeamMemberAdded;\n\nclass MemberService\n{\n    private UserService $userService;\n\n    public function __construct(UserService $userService)\n    {\n        $this->userService = $userService;\n    }\n\n    public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member\n    {\n        if (! $asSuperAdmin) {\n            AddingTeamMember::dispatch($organization, $user);\n        }\n\n        $member = new Member;\n        DB::transaction(function () use ($organization, $user, $role, &$member): void {\n            $member->user()->associate($user);\n            $member->organization()->associate($organization);\n            $member->role = $role->value;\n            $member->save();\n\n            $user->currentOrganization()->associate($organization);\n            $user->save();\n        });\n\n        if (! $asSuperAdmin) {\n            TeamMemberAdded::dispatch($organization, $user);\n        }\n\n        return $member;\n    }\n\n    /**\n     * @throws CanNotRemoveOwnerFromOrganization\n     * @throws EntityStillInUseApiException\n     */\n    public function removeMember(Member $member, Organization $organization, bool $withRelations = false): void\n    {\n        if ($member->role === Role::Owner->value) {\n            throw new CanNotRemoveOwnerFromOrganization;\n        }\n\n        $user = $member->user;\n        $isPlaceholder = $user->is_placeholder;\n\n        if (! $isPlaceholder && $user->current_team_id === $member->organization_id) {\n            $user->currentTeam()->disassociate();\n            $user->save();\n        }\n\n        if ($withRelations) {\n            TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->delete();\n            ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->delete();\n        } else {\n            if (TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->exists()) {\n                throw new EntityStillInUseApiException('member', 'time_entry');\n            }\n            if (ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->exists()) {\n                throw new EntityStillInUseApiException('member', 'project_member');\n            }\n        }\n\n        $member->delete();\n\n        if ($isPlaceholder) {\n            $user->delete();\n        } else {\n            $this->userService->makeSureUserHasAtLeastOneOrganization($user);\n            $this->userService->makeSureUserHasCurrentOrganization($user);\n        }\n\n        MemberRemoved::dispatch($member, $organization);\n    }\n\n    /**\n     * @throws ChangingRoleToPlaceholderIsNotAllowed\n     * @throws OnlyOwnerCanChangeOwnership\n     * @throws OrganizationNeedsAtLeastOneOwner\n     * @throws ChangingRoleOfPlaceholderIsNotAllowed\n     */\n    public function changeRole(Member $member, Organization $organization, Role $newRole, bool $allowOwnerChange): void\n    {\n        $oldRole = Role::from($member->role);\n        if ($oldRole === Role::Owner) {\n            throw new OrganizationNeedsAtLeastOneOwner;\n        }\n        if ($oldRole === Role::Placeholder) {\n            throw new ChangingRoleOfPlaceholderIsNotAllowed;\n        }\n        if ($newRole === Role::Placeholder) {\n            throw new ChangingRoleToPlaceholderIsNotAllowed;\n        }\n        if ($newRole === Role::Owner) {\n            if ($allowOwnerChange) {\n                $this->changeOwnership($organization, $member);\n            } else {\n                throw new OnlyOwnerCanChangeOwnership;\n            }\n        } else {\n            $member->role = $newRole->value;\n        }\n    }\n\n    public function assignOrganizationEntitiesToDifferentMember(Organization $organization, Member $fromMember, Member $toMember): void\n    {\n        // Time entries\n        TimeEntry::query()\n            ->whereBelongsTo($organization, 'organization')\n            ->whereBelongsTo($fromMember, 'member')\n            ->update([\n                'user_id' => $toMember->user_id,\n                'member_id' => $toMember->getKey(),\n            ]);\n\n        // Project members\n        ProjectMember::query()\n            ->whereBelongsToOrganization($organization)\n            ->whereBelongsTo($fromMember, 'member')\n            ->whereDoesntHave('project', function (Builder $builder) use ($toMember): void {\n                /** @var Builder<Project> $builder */\n                $builder->whereHas('members', function (Builder $builder) use ($toMember): void {\n                    /** @var Builder<ProjectMember> $builder */\n                    $builder->where('member_id', $toMember->getKey());\n                });\n            })\n            ->update([\n                'user_id' => $toMember->user_id,\n                'member_id' => $toMember->getKey(),\n            ]);\n\n        ProjectMember::query()\n            ->whereBelongsToOrganization($organization)\n            ->whereBelongsTo($fromMember, 'member')\n            ->delete();\n    }\n\n    /**\n     * Change the ownership of an organization to a new user.\n     * The previous owner will be demoted to an admin.\n     */\n    public function changeOwnership(Organization $organization, Member $newOwner): void\n    {\n        $organization->update([\n            'user_id' => $newOwner->user_id,\n        ]);\n        if ($newOwner->organization_id !== $organization->getKey()) {\n            throw new InvalidArgumentException('Member is not part of the organization');\n        }\n        $newOwner->role = Role::Owner->value;\n        $newOwner->save();\n        $oldOwners = Member::query()\n            ->whereBelongsTo($organization, 'organization')\n            ->where('role', '=', Role::Owner->value)\n            ->where('id', '!=', $newOwner->getKey())\n            ->get();\n        foreach ($oldOwners as $oldOwner) {\n            $oldOwner->role = Role::Admin->value;\n            $oldOwner->save();\n        }\n    }\n\n    public function makeMemberToPlaceholder(Member $member, bool $makeSureUserHasAtLeastOneOrganization = true): void\n    {\n        $user = $member->user;\n        if ($user->current_team_id === $member->organization_id) {\n            $user->currentTeam()->disassociate();\n            $user->save();\n        }\n\n        $placeholderUser = $user->replicate();\n        $placeholderUser->is_placeholder = true;\n        $placeholderUser->current_team_id = $member->organization_id;\n        $placeholderUser->save();\n\n        $member->user()->associate($placeholderUser);\n        $member->role = Role::Placeholder->value;\n        $member->save();\n\n        $this->userService->assignOrganizationEntitiesToDifferentUser($member->organization, $user, $placeholderUser);\n        if ($makeSureUserHasAtLeastOneOrganization) {\n            $this->userService->makeSureUserHasAtLeastOneOrganization($user);\n            $this->userService->makeSureUserHasCurrentOrganization($user);\n        }\n    }\n}\n"
  },
  {
    "path": "app/Service/OrganizationInvitationService.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Mail\\OrganizationInvitationMail;\nuse App\\Models\\OrganizationInvitation;\nuse Illuminate\\Support\\Facades\\Mail;\n\nclass OrganizationInvitationService\n{\n    public function resend(OrganizationInvitation $invitation): void\n    {\n        Mail::to($invitation->email)\n            ->queue(new OrganizationInvitationMail($invitation));\n    }\n}\n"
  },
  {
    "path": "app/Service/OrganizationService.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Enums\\CurrencyFormat;\nuse App\\Enums\\DateFormat;\nuse App\\Enums\\IntervalFormat;\nuse App\\Enums\\NumberFormat;\nuse App\\Enums\\Role;\nuse App\\Enums\\TimeFormat;\nuse App\\Models\\Organization;\nuse App\\Models\\User;\n\nclass OrganizationService\n{\n    public function createOrganization(\n        string $name,\n        User $owner,\n        bool $personalOrganization,\n        ?string $currency = null,\n        ?NumberFormat $numberFormat = null,\n        ?CurrencyFormat $currencyFormat = null,\n        ?DateFormat $dateFormat = null,\n        ?IntervalFormat $intervalFormat = null,\n        ?TimeFormat $timeFormat = null,\n    ): Organization {\n\n        $organization = new Organization;\n        $organization->name = $name;\n        $organization->personal_team = $personalOrganization;\n        if ($currency === null) {\n            $currency = config('app.localization.default_currency');\n        }\n        $organization->currency = $currency;\n        if ($numberFormat === null) {\n            $numberFormat = NumberFormat::from(config('app.localization.default_number_format'));\n        }\n        $organization->number_format = $numberFormat;\n        if ($currencyFormat === null) {\n            $currencyFormat = CurrencyFormat::from(config('app.localization.default_currency_format'));\n        }\n        $organization->currency_format = $currencyFormat;\n        if ($dateFormat === null) {\n            $dateFormat = DateFormat::from(config('app.localization.default_date_format'));\n        }\n        $organization->date_format = $dateFormat;\n        if ($intervalFormat === null) {\n            $intervalFormat = IntervalFormat::from(config('app.localization.default_interval_format'));\n        }\n        $organization->interval_format = $intervalFormat;\n        if ($timeFormat === null) {\n            $timeFormat = TimeFormat::from(config('app.localization.default_time_format'));\n        }\n        $organization->time_format = $timeFormat;\n        $organization->owner()->associate($owner);\n        $organization->save();\n\n        $organization->users()->attach(\n            $owner, [\n                'role' => Role::Owner->value,\n            ]\n        );\n\n        return $organization;\n    }\n}\n"
  },
  {
    "path": "app/Service/PermissionStore.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse Illuminate\\Support\\Facades\\Auth;\nuse Laravel\\Jetstream\\Jetstream;\nuse Laravel\\Jetstream\\Role;\n\nclass PermissionStore\n{\n    /**\n     * @var array<string, array<string>>\n     */\n    private array $permissionCache = [];\n\n    public function clear(): void\n    {\n        $this->permissionCache = [];\n    }\n\n    public function has(Organization $organization, string $permission): bool\n    {\n        /** @var User|null $user */\n        $user = Auth::user();\n        if ($user === null) {\n            return false;\n        }\n\n        return $this->userHas($organization, $user, $permission);\n    }\n\n    public function userHas(Organization $organization, User $user, string $permission): bool\n    {\n        if (! isset($this->permissionCache[$user->getKey().'|'.$organization->getKey()])) {\n            if (! $user->belongsToTeam($organization)) {\n                return false;\n            }\n\n            $permissions = $this->getPermissionsByUser($organization, $user);\n            $this->permissionCache[$user->getKey().'|'.$organization->getKey()] = $permissions;\n        } else {\n            $permissions = $this->permissionCache[$user->getKey().'|'.$organization->getKey()];\n        }\n\n        return in_array($permission, $permissions, true);\n    }\n\n    /**\n     * @return array<string>\n     */\n    private function getPermissionsByUser(Organization $organization, User $user): array\n    {\n        if (! $user->belongsToTeam($organization)) {\n            return [];\n        }\n\n        $role = $organization->users\n            ->where('id', $user->getKey())\n            ->first()\n            ?->membership\n            ?->role;\n\n        if ($role === null) {\n            return [];\n        }\n\n        /** @var Role|null $roleObj */\n        $roleObj = Jetstream::findRole($role);\n\n        $permissions = $roleObj->permissions ?? [];\n\n        // If the organization allows employees to manage tasks and the user is an employee,\n        // add the task management permissions for accessible projects\n        if ($role === \\App\\Enums\\Role::Employee->value && $organization->employees_can_manage_tasks) {\n            $permissions = array_merge($permissions, [\n                'tasks:create',\n                'tasks:update',\n                'tasks:delete',\n            ]);\n        }\n\n        return $permissions;\n    }\n\n    /**\n     * @return array<string>\n     */\n    public function getPermissions(Organization $organization): array\n    {\n        /** @var User|null $user */\n        $user = Auth::user();\n        if ($user === null) {\n            return [];\n        }\n\n        return $this->getPermissionsByUser($organization, $user);\n    }\n}\n"
  },
  {
    "path": "app/Service/ReportExport/CsvExport.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ReportExport;\n\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Database\\Eloquent\\Collection;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Http\\File;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Facades\\Storage;\nuse League\\Csv\\Writer;\nuse Spatie\\TemporaryDirectory\\TemporaryDirectory;\n\n/**\n * @template T of Model\n */\nabstract class CsvExport\n{\n    private string $disk;\n\n    private string $filename;\n\n    private int $chunk;\n\n    /**\n     * @var string[]\n     */\n    public const array HEADER = [];\n\n    /**\n     * @var Builder<T>\n     */\n    private Builder $builder;\n\n    private string $folderPath;\n\n    protected const string CARBON_FORMAT = 'Y-m-d\\TH:i:sP';\n\n    /**\n     * @param  Builder<T>  $builder\n     */\n    public function __construct(string $disk, string $folderPath, string $filename, Builder $builder, int $chunk)\n    {\n\n        $this->disk = $disk;\n        $this->filename = $filename;\n        $this->chunk = $chunk;\n        $this->builder = $builder;\n        $this->folderPath = $folderPath;\n    }\n\n    /**\n     * @param  T  $model\n     * @return array<string, string|float|Carbon|null>\n     */\n    abstract public function mapRow(Model $model): array;\n\n    /**\n     * @throws \\League\\Csv\\CannotInsertRecord\n     * @throws \\League\\Csv\\Exception\n     * @throws \\League\\Csv\\UnavailableStream\n     */\n    public function export(): void\n    {\n        $tempDirectory = TemporaryDirectory::make();\n        $writer = Writer::createFromPath($tempDirectory->path($this->filename), 'w+');\n        $writer->setDelimiter(',');\n        $writer->setEnclosure('\"');\n        $writer->setEscape('');\n        $writer->insertOne(static::HEADER);\n\n        $this->builder->chunk($this->chunk, function (Collection $models) use ($writer): void {\n            foreach ($models as $model) {\n                $data = $this->mapRow($model);\n                $row = $this->convertRow($data);\n                $this->validateRow($row);\n\n                $writer->insertOne(array_values($row));\n            }\n        });\n        Storage::disk($this->disk)->putFileAs($this->folderPath, new File($tempDirectory->path($this->filename)), $this->filename);\n        $tempDirectory->delete();\n    }\n\n    /**\n     * @param  array<string, string|float|Carbon|null>  $data\n     * @return array<string, string>\n     */\n    private function convertRow(array $data): array\n    {\n        $convertedRow = [];\n        foreach ($data as $key => $value) {\n            if ($value instanceof Carbon) {\n                $convertedRow[$key] = $value->format(static::CARBON_FORMAT);\n            } elseif (is_float($value)) {\n                $convertedRow[$key] = (string) $value;\n            } elseif ($value === null) {\n                $convertedRow[$key] = '';\n            } else {\n                $convertedRow[$key] = $value;\n            }\n        }\n\n        return $convertedRow;\n    }\n\n    /**\n     * @param  array<string, string>  $row\n     */\n    private function validateRow(array $row): void\n    {\n        if (array_keys($row) !== static::HEADER) {\n            throw new \\LogicException('Invalid row');\n        }\n    }\n}\n"
  },
  {
    "path": "app/Service/ReportExport/TimeEntriesDetailedCsvExport.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ReportExport;\n\nuse App\\Models\\TimeEntry;\nuse App\\Service\\IntervalService;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Database\\Eloquent\\Model;\n\n/**\n * @extends CsvExport<TimeEntry>\n */\nclass TimeEntriesDetailedCsvExport extends CsvExport\n{\n    public const array HEADER = [\n        'Description',\n        'Task',\n        'Project',\n        'Client',\n        'User',\n        'Start',\n        'End',\n        'Duration',\n        'Duration (decimal)',\n        'Billable',\n        'Tags',\n    ];\n\n    protected const string CARBON_FORMAT = 'Y-m-d H:i:s';\n\n    private string $timezone;\n\n    public function __construct(string $disk, string $folderPath, string $filename, Builder $builder, int $chunk, string $timezone)\n    {\n        parent::__construct($disk, $folderPath, $filename, $builder, $chunk);\n\n        $this->timezone = $timezone;\n    }\n\n    /**\n     * @param  TimeEntry  $model\n     */\n    public function mapRow(Model $model): array\n    {\n        $interval = app(IntervalService::class);\n        $duration = $model->getDuration();\n\n        return [\n            'Description' => $model->description,\n            'Task' => $model->task?->name,\n            'Project' => $model->project?->name,\n            'Client' => $model->client?->name,\n            'User' => $model->user->name,\n            'Start' => $model->start->timezone($this->timezone),\n            'End' => $model->end->timezone($this->timezone),\n            'Duration' => $duration !== null ? $interval->format($model->getDuration()) : null,\n            'Duration (decimal)' => $duration?->totalHours,\n            'Billable' => $model->billable ? 'Yes' : 'No',\n            'Tags' => $model->tagsRelation->pluck('name')->implode(', '),\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Service/ReportExport/TimeEntriesDetailedExport.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ReportExport;\n\nuse App\\Enums\\ExportFormat;\nuse App\\Models\\TimeEntry;\nuse App\\Service\\LocalizationService;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse LogicException;\nuse Maatwebsite\\Excel\\Concerns\\Exportable;\nuse Maatwebsite\\Excel\\Concerns\\FromQuery;\nuse Maatwebsite\\Excel\\Concerns\\ShouldAutoSize;\nuse Maatwebsite\\Excel\\Concerns\\WithColumnFormatting;\nuse Maatwebsite\\Excel\\Concerns\\WithHeadings;\nuse Maatwebsite\\Excel\\Concerns\\WithMapping;\nuse Maatwebsite\\Excel\\Concerns\\WithStyles;\nuse PhpOffice\\PhpSpreadsheet\\Shared\\Date;\nuse PhpOffice\\PhpSpreadsheet\\Style\\NumberFormat;\nuse PhpOffice\\PhpSpreadsheet\\Style\\Style;\nuse PhpOffice\\PhpSpreadsheet\\Worksheet\\Worksheet;\n\n/**\n * @implements WithMapping<TimeEntry>\n */\nclass TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumnFormatting, WithHeadings, WithMapping, WithStyles\n{\n    use Exportable;\n\n    /**\n     * @var Builder<TimeEntry>\n     */\n    private Builder $builder;\n\n    private ExportFormat $exportFormat;\n\n    private string $timezone;\n\n    private LocalizationService $localizationService;\n\n    /**\n     * @param  Builder<TimeEntry>  $builder\n     */\n    public function __construct(Builder $builder, ExportFormat $exportFormat, string $timezone, LocalizationService $localizationService)\n    {\n        $this->builder = $builder;\n        $this->exportFormat = $exportFormat;\n        $this->timezone = $timezone;\n        $this->localizationService = $localizationService;\n    }\n\n    /**\n     * @return Builder<TimeEntry>\n     */\n    public function query(): Builder\n    {\n        return $this->builder;\n    }\n\n    /**\n     * @return array<string, string>\n     */\n    public function columnFormats(): array\n    {\n        if ($this->exportFormat === ExportFormat::XLSX) {\n            return [\n                'F' => 'yyyy-mm-dd hh:mm:ss',\n                'G' => 'yyyy-mm-dd hh:mm:ss',\n                'I' => NumberFormat::FORMAT_NUMBER_00,\n            ];\n        } elseif ($this->exportFormat === ExportFormat::ODS) {\n            return [\n                'I' => NumberFormat::FORMAT_NUMBER_00,\n            ];\n        } else {\n            throw new LogicException('Unsupported export format.');\n        }\n\n    }\n\n    /**\n     * @return array<int|string, array<string, array<string, bool>>>\n     */\n    public function styles(Worksheet $sheet): array\n    {\n        return [\n            // Style the first row as bold text.\n            1 => ['font' => ['bold' => true]],\n        ];\n    }\n\n    /**\n     * @return string[]\n     */\n    public function headings(): array\n    {\n        return [\n            'Description',\n            'Task',\n            'Project',\n            'Client',\n            'User',\n            'Start',\n            'End',\n            'Duration',\n            'Duration (decimal)',\n            'Billable',\n            'Tags',\n        ];\n    }\n\n    /**\n     * @param  TimeEntry  $model\n     * @return array<int, string|float|null>\n     */\n    public function map($model): array\n    {\n        $duration = $model->getDuration();\n\n        if ($this->exportFormat === ExportFormat::XLSX) {\n            return [\n                $model->description,\n                $model->task?->name,\n                $model->project?->name,\n                $model->client?->name,\n                $model->user->name,\n                Date::dateTimeToExcel($model->start->timezone($this->timezone)),\n                $model->end !== null ? Date::dateTimeToExcel($model->end->timezone($this->timezone)) : null,\n                $duration !== null ? $this->localizationService->formatInterval($duration) : null,\n                $duration?->totalHours,\n                $model->billable ? 'Yes' : 'No',\n                $model->tagsRelation->pluck('name')->implode(', '),\n            ];\n        } elseif ($this->exportFormat === ExportFormat::ODS) {\n            return [\n                $model->description,\n                $model->task?->name,\n                $model->project?->name,\n                $model->client?->name,\n                $model->user->name,\n                $model->start->timezone($this->timezone)->format('Y-m-d H:i:s'),\n                $model->end?->timezone($this->timezone)?->format('Y-m-d H:i:s'),\n                $duration !== null ? $this->localizationService->formatInterval($duration) : null,\n                $duration?->totalHours,\n                $model->billable ? 'Yes' : 'No',\n                $model->tagsRelation->pluck('name')->implode(', '),\n            ];\n        } else {\n            throw new LogicException('Unsupported export format.');\n        }\n    }\n}\n"
  },
  {
    "path": "app/Service/ReportExport/TimeEntriesReportExport.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ReportExport;\n\nuse App\\Enums\\ExportFormat;\nuse App\\Enums\\TimeEntryAggregationType;\nuse Illuminate\\View\\View;\nuse Maatwebsite\\Excel\\Concerns\\Exportable;\nuse Maatwebsite\\Excel\\Concerns\\FromView;\nuse Maatwebsite\\Excel\\Concerns\\ShouldAutoSize;\nuse Maatwebsite\\Excel\\Concerns\\WithCustomCsvSettings;\n\nclass TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsvSettings\n{\n    use Exportable;\n\n    /**\n     * @var array{\n     *        grouped_type: string|null,\n     *        grouped_data: null|array<array{\n     *            key: string|null,\n     *            seconds: int,\n     *            cost: int|null,\n     *            grouped_type: string|null,\n     *            grouped_data: null|array<array{\n     *                key: string|null,\n     *                seconds: int,\n     *                cost: int|null,\n     *                grouped_type: null,\n     *                grouped_data: null\n     *            }>\n     *        }>,\n     *        seconds: int,\n     *        cost: int|null\n     *  }\n     */\n    private array $data;\n\n    private ExportFormat $exportFormat;\n\n    private string $currency;\n\n    private TimeEntryAggregationType $group;\n\n    private TimeEntryAggregationType $subGroup;\n\n    private bool $showBillableRate;\n\n    /**\n     * @param array{\n     *         grouped_type: string|null,\n     *         grouped_data: null|array<array{\n     *             key: string|null,\n     *             seconds: int,\n     *             cost: int|null,\n     *             grouped_type: string|null,\n     *             grouped_data: null|array<array{\n     *                 key: string|null,\n     *                 seconds: int,\n     *                 cost: int|null,\n     *                 grouped_type: null,\n     *                 grouped_data: null\n     *             }>\n     *         }>,\n     *         seconds: int,\n     *         cost: int|null\n     *   } $data\n     */\n    public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup, bool $showBillableRate)\n    {\n        $this->data = $data;\n        $this->exportFormat = $exportFormat;\n        $this->currency = $currency;\n        $this->group = $group;\n        $this->subGroup = $subGroup;\n        $this->showBillableRate = $showBillableRate;\n    }\n\n    public function view(): View\n    {\n        return view('reports.time-entry-aggregate.spreadsheet', [\n            'data' => $this->data,\n            'currency' => $this->currency,\n            'group' => $this->group,\n            'subGroup' => $this->subGroup,\n            'exportFormat' => $this->exportFormat,\n            'showBillableRate' => $this->showBillableRate,\n        ]);\n    }\n\n    /**\n     * @return array<string, string>\n     */\n    public function getCsvSettings(): array\n    {\n        return [\n            'delimiter' => ',',\n            'enclosure' => '\"',\n            'escape_character' => '',\n        ];\n    }\n}\n"
  },
  {
    "path": "app/Service/ReportService.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse Illuminate\\Support\\Str;\n\nclass ReportService\n{\n    public function generateSecret(): string\n    {\n        return Str::random(40);\n    }\n}\n"
  },
  {
    "path": "app/Service/TimeEntryAggregationService.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Enums\\TimeEntryAggregationType;\nuse App\\Enums\\TimeEntryAggregationTypeInterval;\nuse App\\Enums\\TimeEntryRoundingType;\nuse App\\Enums\\Weekday;\nuse App\\Models\\Client;\nuse App\\Models\\Project;\nuse App\\Models\\Tag;\nuse App\\Models\\Task;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse Carbon\\CarbonTimeZone;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Support\\Facades\\Log;\n\nclass TimeEntryAggregationService\n{\n    /**\n     * @param  Builder<TimeEntry>  $timeEntriesQuery\n     * @return array{\n     *       grouped_type: string|null,\n     *       grouped_data: null|array<array{\n     *           key: string|null,\n     *           seconds: int,\n     *           cost: int|null,\n     *           grouped_type: string|null,\n     *           grouped_data: null|array<array{\n     *               key: string|null,\n     *               seconds: int,\n     *               cost: int|null,\n     *               grouped_type: null,\n     *               grouped_data: null\n     *           }>\n     *       }>,\n     *       seconds: int,\n     *       cost: int|null\n     * }\n     */\n    public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array\n    {\n        $fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;\n        /** @var Builder<TimeEntry> $baseTotalsQuery */\n        $baseTotalsQuery = $timeEntriesQuery->clone();\n        $group1Select = null;\n        $group2Select = null;\n        $groupBy = null;\n        // If any grouping is by tag, expand rows per tag and ensure a NULL row for entries without tags\n        if (($group1Type === TimeEntryAggregationType::Tag) || ($group2Type === TimeEntryAggregationType::Tag)) {\n            $timeEntriesQuery->crossJoin(DB::raw(\n                \"LATERAL (\\n\".\n                \"  SELECT jsonb_array_elements_text(coalesce(tags, '[]'::jsonb)) AS tag\\n\".\n                \"  UNION ALL\\n\".\n                \"  SELECT ''::text AS tag WHERE coalesce(jsonb_array_length(tags), 0) = 0\\n\".\n                ') AS tag(tag)'\n            ));\n        }\n        if ($group1Type !== null) {\n            $group1Select = $this->getGroupByQuery($group1Type, $timezone, $startOfWeek);\n            $groupBy = ['group_1'];\n            if ($group2Type !== null) {\n                $group2Select = $this->getGroupByQuery($group2Type, $timezone, $startOfWeek);\n                $groupBy = ['group_1', 'group_2'];\n            }\n        }\n\n        $startRawSelect = app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes);\n        $endRawSelect = app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes);\n\n        $timeEntriesQuery->selectRaw(\n            ($group1Select !== null ? $group1Select.' as group_1,' : '').\n            ($group2Select !== null ? $group2Select.' as group_2,' : '').\n            ' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.\n            ' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'\n        );\n        if ($groupBy !== null) {\n            $timeEntriesQuery->groupBy($groupBy);\n        }\n        if ($group1Select !== null) {\n            $timeEntriesQuery->orderBy('group_1');\n            if ($group2Select !== null) {\n                $timeEntriesQuery->orderBy('group_2');\n            }\n        }\n\n        $timeEntriesAggregates = $timeEntriesQuery->get();\n\n        if ($group1Select !== null) {\n            $groupedAggregates = $timeEntriesAggregates->groupBy($group2Select !== null ? ['group_1', 'group_2'] : ['group_1']);\n\n            $group1Response = [];\n            $group1ResponseSum = 0;\n            $group1ResponseCost = 0;\n            // If Tag is subgroup, prepare base totals per primary group without tag expansion\n            $baseTotalsPerGroup1Map = [];\n            if ($group2Type === TimeEntryAggregationType::Tag) {\n                $baseTotalsPerGroup1Query = $baseTotalsQuery->clone();\n                $baseTotalsPerGroup1 = $baseTotalsPerGroup1Query\n                    ->selectRaw(\n                        $group1Select.' as group_1,'.\n                        ' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.\n                        ' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'\n                    )\n                    ->groupBy('group_1')\n                    ->get();\n                foreach ($baseTotalsPerGroup1 as $row) {\n                    /** @var object{group_1: mixed, aggregate: int|null, cost: int|null} $row */\n                    $baseTotalsPerGroup1Map[(string) ($row->group_1 ?? '')] = [\n                        'aggregate' => (int) ($row->aggregate ?? 0),\n                        'cost' => (int) ($row->cost ?? 0),\n                    ];\n                }\n            }\n            foreach ($groupedAggregates as $group1 => $group1Aggregates) {\n                /** @var string|int $group1 */\n                $group2Response = [];\n                if ($group2Select !== null) {\n                    $group2ResponseSum = 0;\n                    $group2ResponseCost = 0;\n                    foreach ($group1Aggregates as $group2 => $aggregate) {\n                        /** @var string|int $group2 */\n                        /** @var Collection<int, object{aggregate: int, cost: int}> $aggregate */\n                        $group2Response[] = [\n                            'key' => $group2 === '' ? null : (string) $group2,\n                            'seconds' => (int) $aggregate->get(0)->aggregate,\n                            'cost' => $showBillableRate ? (int) $aggregate->get(0)->cost : null,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ];\n                        $group2ResponseSum += (int) $aggregate->get(0)->aggregate;\n                        $group2ResponseCost += (int) $aggregate->get(0)->cost;\n                    }\n                    // Override primary group totals when Tag is subgroup to avoid double counting\n                    if ($group2Type === TimeEntryAggregationType::Tag) {\n                        $keyForMap = (string) $group1;\n                        if (array_key_exists($keyForMap, $baseTotalsPerGroup1Map)) {\n                            $group2ResponseSum = $baseTotalsPerGroup1Map[$keyForMap]['aggregate'];\n                            $group2ResponseCost = $baseTotalsPerGroup1Map[$keyForMap]['cost'];\n                        }\n                    }\n                } else {\n                    /** @var Collection<int, object{aggregate: int, cost: int}> $group1Aggregates */\n                    $group2ResponseSum = (int) $group1Aggregates->get(0)->aggregate;\n                    $group2ResponseCost = (int) $group1Aggregates->get(0)->cost;\n                    $group2Response = null;\n                }\n\n                $group1Response[] = [\n                    'key' => $group1 === '' ? null : (string) $group1,\n                    'seconds' => $group2ResponseSum,\n                    'cost' => $showBillableRate ? $group2ResponseCost : null,\n                    'grouped_type' => $group2Type?->value,\n                    'grouped_data' => $group2Response,\n                ];\n                $group1ResponseSum += $group2ResponseSum;\n                $group1ResponseCost += $group2ResponseCost;\n            }\n\n            // If Tag is selected in any grouping, compute overall totals from base (non-tag-expanded) query to avoid double counting\n            $hasTagGrouping = ($group1Type === TimeEntryAggregationType::Tag) || ($group2Type === TimeEntryAggregationType::Tag);\n            if ($hasTagGrouping) {\n                // Reset selects and ordering on the cloned base query\n                $baseTotals = $baseTotalsQuery\n                    ->selectRaw(\n                        ' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.\n                        ' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'\n                    )\n                    ->first();\n                if ($baseTotals !== null) {\n                    /** @var object{aggregate: int|null, cost: int|null} $baseTotals */\n                    $group1ResponseSum = (int) ($baseTotals->aggregate ?? 0);\n                    $group1ResponseCost = (int) ($baseTotals->cost ?? 0);\n                }\n            }\n\n            if ($fillGapsInTimeGroupsIsPossible) {\n                $group1Response = $this->fillGapsInTimeGroups($group1Response, $group1Type, $group2Type, $timezone, $startOfWeek, $start, $end);\n            }\n        } else {\n            $group1Response = null;\n            /** @var Collection<int, object{aggregate: int, cost: int}> $timeEntriesAggregates */\n            $group1ResponseSum = (int) $timeEntriesAggregates->get(0)->aggregate;\n            $group1ResponseCost = (int) $timeEntriesAggregates->get(0)->cost;\n        }\n\n        return [\n            'seconds' => $group1ResponseSum,\n            'cost' => $showBillableRate ? $group1ResponseCost : null,\n            'grouped_type' => $group1Type?->value,\n            'grouped_data' => $group1Response,\n        ];\n    }\n\n    /**\n     * @param  Builder<TimeEntry>  $timeEntriesQuery\n     * @return array{\n     *       grouped_type: string|null,\n     *       grouped_data: null|array<array{\n     *           key: string|null,\n     *           description: string|null,\n     *           color: string|null,\n     *           seconds: int,\n     *           cost: int|null,\n     *           grouped_type: string|null,\n     *           grouped_data: null|array<array{\n     *               key: string|null,\n     *               description: string|null,\n     *               color: string|null,\n     *               seconds: int,\n     *               cost: int|null,\n     *               grouped_type: null,\n     *               grouped_data: null\n     *           }>\n     *       }>,\n     *       seconds: int,\n     *       cost: int|null\n     * }\n     */\n    public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array\n    {\n        $aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate, $roundingType, $roundingMinutes);\n\n        $keysGroup1 = [];\n        $keysGroup2 = [];\n\n        if ($aggregatedTimeEntries['grouped_data'] !== null) {\n            foreach ($aggregatedTimeEntries['grouped_data'] as $group1) {\n                $keysGroup1[] = $group1['key'];\n                if ($group1['grouped_data'] !== null) {\n                    foreach ($group1['grouped_data'] as $group2) {\n                        $keysGroup2[] = $group2['key'];\n                    }\n                }\n            }\n        }\n\n        $descriptionMapGroup1 = $group1Type !== null ? $this->loadDescriptorsMap($keysGroup1, $group1Type) : [];\n        $descriptionMapGroup2 = $group2Type !== null ? $this->loadDescriptorsMap($keysGroup2, $group2Type) : [];\n\n        if ($aggregatedTimeEntries['grouped_data'] !== null) {\n            foreach ($aggregatedTimeEntries['grouped_data'] as $keyGroup1 => $group1) {\n                $aggregatedTimeEntries['grouped_data'][$keyGroup1]['description'] = $group1['key'] !== null ? ($descriptionMapGroup1[$group1['key']]['description'] ?? null) : null;\n                $aggregatedTimeEntries['grouped_data'][$keyGroup1]['color'] = $group1['key'] !== null ? ($descriptionMapGroup1[$group1['key']]['color'] ?? null) : null;\n                if ($aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'] !== null) {\n                    foreach ($aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'] as $keyGroup2 => $group2) {\n                        $aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'][$keyGroup2]['description'] = $group2['key'] !== null ? ($descriptionMapGroup2[$group2['key']]['description'] ?? null) : null;\n                        $aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'][$keyGroup2]['color'] = $group2['key'] !== null ? ($descriptionMapGroup2[$group2['key']]['color'] ?? null) : null;\n                    }\n                }\n            }\n        }\n\n        /**\n         * @var array{\n         *        grouped_type: string|null,\n         *        grouped_data: null|array<array{\n         *            key: string|null,\n         *            description: string|null,\n         *            color: string|null,\n         *            seconds: int,\n         *            cost: int,\n         *            grouped_type: string|null,\n         *            grouped_data: null|array<array{\n         *                key: string|null,\n         *                description: string|null,\n         *                color: string|null,\n         *                seconds: int,\n         *                cost: int,\n         *                grouped_type: null,\n         *                grouped_data: null\n         *            }>\n         *        }>,\n         *        seconds: int,\n         *        cost: int\n         *  } $aggregatedTimeEntries\n         */\n\n        return $aggregatedTimeEntries;\n    }\n\n    /**\n     * @param  array<int, string>  $keys\n     * @return array<string, array{\n     *     description: string,\n     *     color: string|null\n     * }>\n     */\n    private function loadDescriptorsMap(array $keys, TimeEntryAggregationType $type): array\n    {\n        $descriptorMap = [];\n        if ($type === TimeEntryAggregationType::Client) {\n            $clients = Client::query()\n                ->whereIn('id', $keys)\n                ->select('id', 'name')\n                ->get();\n            foreach ($clients as $client) {\n                $descriptorMap[$client->id] = [\n                    'description' => $client->name,\n                    'color' => null,\n                ];\n            }\n        } elseif ($type === TimeEntryAggregationType::User) {\n            $users = User::query()\n                ->whereIn('id', $keys)\n                ->select('id', 'name')\n                ->get();\n            foreach ($users as $user) {\n                $descriptorMap[$user->id] = [\n                    'description' => $user->name,\n                    'color' => null,\n                ];\n            }\n        } elseif ($type === TimeEntryAggregationType::Project) {\n            $projects = Project::query()\n                ->whereIn('id', $keys)\n                ->select('id', 'name', 'color')\n                ->get();\n            foreach ($projects as $project) {\n                $descriptorMap[$project->id] = [\n                    'description' => $project->name,\n                    'color' => $project->color,\n                ];\n            }\n        } elseif ($type === TimeEntryAggregationType::Task) {\n            $tasks = Task::query()\n                ->whereIn('id', $keys)\n                ->select('id', 'name')\n                ->get();\n            foreach ($tasks as $task) {\n                $descriptorMap[$task->id] = [\n                    'description' => $task->name,\n                    'color' => null,\n                ];\n            }\n        } elseif ($type === TimeEntryAggregationType::Description) {\n            foreach ($keys as $key) {\n                $descriptorMap[$key] = [\n                    'description' => $key,\n                    'color' => null,\n                ];\n            }\n        } elseif ($type === TimeEntryAggregationType::Billable) {\n            foreach ($keys as $key) {\n                $descriptorMap[$key] = [\n                    'description' => $key === '0' ? 'Non-billable' : 'Billable',\n                    'color' => null,\n                ];\n            }\n        } elseif ($type === TimeEntryAggregationType::Tag) {\n            $tags = Tag::query()\n                ->whereIn('id', $keys)\n                ->select('id', 'name')\n                ->get();\n            foreach ($tags as $tag) {\n                $descriptorMap[$tag->id] = [\n                    'description' => $tag->name,\n                    'color' => null,\n                ];\n            }\n        }\n\n        return $descriptorMap;\n    }\n\n    /**\n     * @param array<array{\n     *            key: string|null,\n     *            seconds: int,\n     *            cost: int|null,\n     *            grouped_type: string|null,\n     *            grouped_data: null|array<array{\n     *                key: string|null,\n     *                seconds: int,\n     *                cost: int|null,\n     *                grouped_type: null|mixed,\n     *                grouped_data: null|mixed\n     *            }>\n     *        }> $data\n     * @return array<array{\n     *            key: string|null,\n     *            seconds: int,\n     *            cost: int|null,\n     *            grouped_type: string|null,\n     *            grouped_data: null|array<array{\n     *                key: string|null,\n     *                seconds: int,\n     *                cost: int|null,\n     *                grouped_type: null|mixed,\n     *                grouped_data: null|mixed\n     *            }>\n     *        }>\n     */\n    public function fillGapsInTimeGroups(array $data, TimeEntryAggregationType $groupType, ?TimeEntryAggregationType $subGroupType, string $timezone, Weekday $startOfWeek, Carbon $start, Carbon $end): array\n    {\n        $interval = $groupType->toInterval();\n        if ($interval === null) {\n            foreach ($data as $key => $item) {\n                $data[$key]['grouped_data'] = $this->fillGapsInTimeGroups(\n                    $item['grouped_data'],\n                    $subGroupType,\n                    null,\n                    $timezone,\n                    $startOfWeek,\n                    $start,\n                    $end\n                );\n            }\n\n            return $data;\n        } else {\n            $format = match ($interval) {\n                TimeEntryAggregationTypeInterval::Day, TimeEntryAggregationTypeInterval::Week => 'Y-m-d',\n                TimeEntryAggregationTypeInterval::Month => 'Y-m',\n                TimeEntryAggregationTypeInterval::Year => 'Y',\n            };\n            $slots = $this->timeSlotsBetween($start, $end, $timezone, $startOfWeek, $interval, $format);\n            $foundEntries = [];\n            $filledData = [];\n            foreach ($slots as $slot) {\n                $foundDataSet = null;\n                foreach ($data as $item) {\n                    if ($item['key'] === $slot) {\n                        $foundDataSet = $item;\n                        $foundEntries[] = $item['key'];\n                        break;\n                    }\n                }\n                if ($foundDataSet !== null) {\n                    $filledData[] = [\n                        'key' => $slot,\n                        'seconds' => $foundDataSet['seconds'],\n                        'cost' => $foundDataSet['cost'],\n                        'grouped_type' => $subGroupType?->value,\n                        'grouped_data' => $subGroupType === null\n                            ? null\n                            : $this->fillGapsInTimeGroups(\n                                $foundDataSet['grouped_data'],\n                                $subGroupType,\n                                null,\n                                $timezone,\n                                $startOfWeek,\n                                $start,\n                                $end\n                            ),\n                    ];\n                } else {\n                    $filledData[] = [\n                        'key' => $slot,\n                        'seconds' => 0,\n                        'cost' => 0,\n                        'grouped_type' => $subGroupType?->value,\n                        'grouped_data' => $subGroupType === null ? null : [],\n                    ];\n                }\n            }\n\n            if (count($foundEntries) !== count($data)) {\n                foreach ($data as $item) {\n                    if (! in_array($item['key'], $foundEntries, true)) {\n                        Log::error('Problem with filling gaps in time groups', [\n                            'item' => $item,\n                        ]);\n                    }\n                }\n            }\n\n            return $filledData;\n        }\n    }\n\n    private function getGroupByQuery(TimeEntryAggregationType $group, string $timezone, Weekday $startOfWeek): string\n    {\n        $timezoneShift = app(TimezoneService::class)->getShiftFromUtc(new CarbonTimeZone($timezone));\n        if ($timezoneShift > 0) {\n            $dateWithTimeZone = 'start + INTERVAL \\''.$timezoneShift.' second\\'';\n        } elseif ($timezoneShift < 0) {\n            $dateWithTimeZone = 'start - INTERVAL \\''.abs($timezoneShift).' second\\'';\n        } else {\n            $dateWithTimeZone = 'start';\n        }\n        $startOfWeek = Carbon::now()->setTimezone($timezone)->startOfWeek($startOfWeek->carbonWeekDay())->toDateTimeString();\n        if ($group === TimeEntryAggregationType::Day) {\n            return 'date('.$dateWithTimeZone.')';\n        } elseif ($group === TimeEntryAggregationType::Week) {\n            return \"to_char(date_bin('7 days', \".$dateWithTimeZone.\", timestamp '\".$startOfWeek.\"'), 'YYYY-MM-DD')\";\n        } elseif ($group === TimeEntryAggregationType::Month) {\n            return 'to_char('.$dateWithTimeZone.', \\'YYYY-MM\\')';\n        } elseif ($group === TimeEntryAggregationType::Year) {\n            return 'to_char('.$dateWithTimeZone.', \\'YYYY\\')';\n        } elseif ($group === TimeEntryAggregationType::User) {\n            return 'user_id';\n        } elseif ($group === TimeEntryAggregationType::Project) {\n            return 'project_id';\n        } elseif ($group === TimeEntryAggregationType::Task) {\n            return 'task_id';\n        } elseif ($group === TimeEntryAggregationType::Client) {\n            return 'client_id';\n        } elseif ($group === TimeEntryAggregationType::Billable) {\n            return 'billable';\n        } elseif ($group === TimeEntryAggregationType::Description) {\n            return 'description';\n        } elseif ($group === TimeEntryAggregationType::Tag) {\n            return 'tag';\n        }\n    }\n\n    /**\n     * @return Collection<int, string>\n     */\n    public function timeSlotsBetween(Carbon $start, Carbon $end, string $timezone, Weekday $startOfWeek, TimeEntryAggregationTypeInterval $interval, string $format): Collection\n    {\n        if ($start->gt($end)) {\n            throw new \\InvalidArgumentException('Start date must be before end date');\n        }\n        $slots = new Collection;\n        $current = $start->copy()->timezone($timezone);\n        if ($interval === TimeEntryAggregationTypeInterval::Day) {\n            $current->startOfDay();\n        } elseif ($interval === TimeEntryAggregationTypeInterval::Week) {\n            $current->startOfWeek($startOfWeek->carbonWeekDay());\n        } elseif ($interval === TimeEntryAggregationTypeInterval::Month) {\n            $current->startOfMonth();\n        } elseif ($interval === TimeEntryAggregationTypeInterval::Year) {\n            $current->startOfYear();\n        } else {\n            throw new \\InvalidArgumentException('Invalid interval');\n        }\n\n        while ($current->lt($end)) {\n            $slots->push($current->format($format));\n            if ($interval === TimeEntryAggregationTypeInterval::Day) {\n                $current->addDay();\n            } elseif ($interval === TimeEntryAggregationTypeInterval::Week) {\n                $current->addWeek();\n            } elseif ($interval === TimeEntryAggregationTypeInterval::Month) {\n                $current->addMonth();\n            } elseif ($interval === TimeEntryAggregationTypeInterval::Year) {\n                $current->addYear();\n            }\n        }\n\n        return $slots;\n    }\n}\n"
  },
  {
    "path": "app/Service/TimeEntryFilter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Models\\Member;\nuse App\\Models\\TimeEntry;\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Facades\\Log;\n\nclass TimeEntryFilter\n{\n    public const string NONE_VALUE = 'none';\n\n    /**\n     * @var Builder<TimeEntry>\n     */\n    private Builder $builder;\n\n    /**\n     * @param  Builder<TimeEntry>  $builder\n     */\n    public function __construct(Builder $builder)\n    {\n        $this->builder = $builder;\n    }\n\n    public function addEndFilter(?string $dateTime): self\n    {\n        if ($dateTime === null) {\n            return $this;\n        }\n        $this->addEnd(Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $dateTime, 'UTC'));\n\n        return $this;\n    }\n\n    public function addEnd(?Carbon $end): self\n    {\n        if ($end === null) {\n            return $this;\n        }\n        $this->builder->where('start', '<', $end);\n\n        return $this;\n    }\n\n    public function addStartFilter(?string $dateTime): self\n    {\n        if ($dateTime === null) {\n            return $this;\n        }\n        $this->addStart(Carbon::createFromFormat('Y-m-d\\TH:i:s\\Z', $dateTime, 'UTC'));\n\n        return $this;\n    }\n\n    public function addStart(?Carbon $start): self\n    {\n        if ($start === null) {\n            return $this;\n        }\n        $this->builder->where('start', '>', $start);\n\n        return $this;\n    }\n\n    public function addActiveFilter(?string $active): self\n    {\n        if ($active === null) {\n            return $this;\n        }\n        if ($active === 'true') {\n            $this->addActive(true);\n        } elseif ($active === 'false') {\n            $this->addActive(false);\n        } else {\n            Log::warning('Invalid active filter value', ['value' => $active]);\n        }\n\n        return $this;\n    }\n\n    public function addActive(?bool $active): self\n    {\n        if ($active) {\n            $this->builder->whereNull('end');\n        } else {\n            $this->builder->whereNotNull('end');\n        }\n\n        return $this;\n    }\n\n    public function addMemberIdFilter(?Member $member): self\n    {\n        if ($member === null) {\n            return $this;\n        }\n        $this->builder->where('member_id', $member->getKey());\n\n        return $this;\n    }\n\n    /**\n     * @param  array<string>|null  $memberIds\n     */\n    public function addMemberIdsFilter(?array $memberIds): self\n    {\n        if ($memberIds === null) {\n            return $this;\n        }\n        $this->builder->whereIn('member_id', $memberIds);\n\n        return $this;\n    }\n\n    public function addBillableFilter(?string $billable): self\n    {\n        if ($billable === null) {\n            return $this;\n        }\n        if ($billable === 'true') {\n            $this->addBillable(true);\n        } elseif ($billable === 'false') {\n            $this->addBillable(false);\n        } else {\n            Log::warning('Invalid billable filter value', ['value' => $billable]);\n        }\n\n        return $this;\n    }\n\n    public function addBillable(?bool $billable): self\n    {\n        if ($billable === null) {\n            return $this;\n        }\n        $this->builder->where('billable', '=', $billable);\n\n        return $this;\n    }\n\n    /**\n     * @param  array<string>|null  $clientIds\n     */\n    public function addClientIdsFilter(?array $clientIds): self\n    {\n        if ($clientIds === null) {\n            return $this;\n        }\n        $includeNone = in_array(self::NONE_VALUE, $clientIds, true);\n        $clientIds = array_values(array_filter($clientIds, fn (string $id): bool => $id !== self::NONE_VALUE));\n\n        $this->builder->where(function (Builder $builder) use ($clientIds, $includeNone): void {\n            if (count($clientIds) > 0) {\n                $builder->whereIn('client_id', $clientIds);\n            }\n            if ($includeNone) {\n                $builder->orWhereNull('client_id');\n            }\n        });\n\n        return $this;\n    }\n\n    /**\n     * @param  array<string>|null  $projectIds\n     */\n    public function addProjectIdsFilter(?array $projectIds): self\n    {\n        if ($projectIds === null) {\n            return $this;\n        }\n        $includeNone = in_array(self::NONE_VALUE, $projectIds, true);\n        $projectIds = array_values(array_filter($projectIds, fn (string $id): bool => $id !== self::NONE_VALUE));\n\n        $this->builder->where(function (Builder $builder) use ($projectIds, $includeNone): void {\n            if (count($projectIds) > 0) {\n                $builder->whereIn('project_id', $projectIds);\n            }\n            if ($includeNone) {\n                $builder->orWhereNull('project_id');\n            }\n        });\n\n        return $this;\n    }\n\n    /**\n     * @param  array<string>|null  $tagIds\n     */\n    public function addTagIdsFilter(?array $tagIds): self\n    {\n        if ($tagIds === null) {\n            return $this;\n        }\n        $includeNone = in_array(self::NONE_VALUE, $tagIds, true);\n        $tagIds = array_values(array_filter($tagIds, fn (string $id): bool => $id !== self::NONE_VALUE));\n\n        $this->builder->where(function (Builder $builder) use ($tagIds, $includeNone): void {\n            foreach ($tagIds as $tagId) {\n                $builder->orWhereJsonContains('tags', $tagId);\n            }\n            if ($includeNone) {\n                $builder->orWhere(function (Builder $query): void {\n                    $query->whereJsonLength('tags', 0)->orWhereNull('tags');\n                });\n            }\n        });\n\n        return $this;\n    }\n\n    /**\n     * @param  array<string>|null  $taskIds\n     */\n    public function addTaskIdsFilter(?array $taskIds): self\n    {\n        if ($taskIds === null) {\n            return $this;\n        }\n        $includeNone = in_array(self::NONE_VALUE, $taskIds, true);\n        $taskIds = array_values(array_filter($taskIds, fn (string $id): bool => $id !== self::NONE_VALUE));\n\n        $this->builder->where(function (Builder $builder) use ($taskIds, $includeNone): void {\n            if (count($taskIds) > 0) {\n                $builder->whereIn('task_id', $taskIds);\n            }\n            if ($includeNone) {\n                $builder->orWhereNull('task_id');\n            }\n        });\n\n        return $this;\n    }\n\n    /**\n     * @return Builder<TimeEntry>\n     */\n    public function get(): Builder\n    {\n        return $this->builder;\n    }\n}\n"
  },
  {
    "path": "app/Service/TimeEntryService.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Enums\\TimeEntryRoundingType;\nuse Illuminate\\Support\\Carbon;\nuse LogicException;\n\nclass TimeEntryService\n{\n    public function getStartSelectRawForRounding(?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): string\n    {\n        if ($roundingType === null || $roundingMinutes === null) {\n            return 'start';\n        }\n        if ($roundingMinutes < 1) {\n            throw new LogicException('Rounding minutes must be greater than 0');\n        }\n\n        return 'date_bin(\\'1 minutes\\', start, TIMESTAMP \\'1970-01-01\\')';\n    }\n\n    public function getEndSelectRawForRounding(?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): string\n    {\n        if ($roundingType === null || $roundingMinutes === null) {\n            return 'coalesce(\"end\", \\''.Carbon::now()->toDateTimeString().'\\')';\n        }\n        if ($roundingMinutes < 1) {\n            throw new LogicException('Rounding minutes must be greater than 0');\n        }\n        $end = 'coalesce(\"end\", \\''.Carbon::now()->toDateTimeString().'\\')';\n        $start = $this->getStartSelectRawForRounding($roundingType, $roundingMinutes);\n        if ($roundingType === TimeEntryRoundingType::Down) {\n            return 'date_bin(\\''.$roundingMinutes.' minutes\\', '.$end.', '.$start.')';\n        } elseif ($roundingType === TimeEntryRoundingType::Up) {\n            // If end is already on a boundary, keep it; otherwise round up to next boundary\n            return 'CASE WHEN '.$end.' = date_bin(\\''.$roundingMinutes.' minutes\\', '.$end.', '.$start.') '.\n                   'THEN '.$end.' '.\n                   'ELSE date_bin(\\''.$roundingMinutes.' minutes\\', '.$end.' + interval \\''.$roundingMinutes.' minutes\\', '.$start.') '.\n                   'END';\n        } elseif ($roundingType === TimeEntryRoundingType::Nearest) {\n            return 'date_bin(\\''.$roundingMinutes.' minutes\\', '.$end.' + interval \\''.($roundingMinutes / 2).' minutes\\', '.$start.')';\n        }\n    }\n}\n"
  },
  {
    "path": "app/Service/TimezoneService.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Models\\User;\nuse Carbon\\CarbonTimeZone;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Facades\\Log;\n\nclass TimezoneService\n{\n    /**\n     * @source https://data.iana.org/time-zones/tzdb/backward\n     */\n    private const array LEGACY_TIMEZONES_MAP = [\n        'Australia/ACT' => 'Australia/Sydney',\n        'Australia/LHI' => 'Australia/Lord_Howe',\n        'Australia/NSW' => 'Australia/Sydney',\n        'Australia/North' => 'Australia/Darwin',\n        'Australia/Queensland' => 'Australia/Brisbane',\n        'Australia/South' => 'Australia/Adelaide',\n        'Australia/Tasmania' => 'Australia/Hobart',\n        'Australia/Victoria' => 'Australia/Melbourne',\n        'Australia/West' => 'Australia/Perth',\n        'Australia/Yancowinna' => 'Australia/Broken_Hill',\n        'Brazil/Acre' => 'America/Rio_Branco',\n        'Brazil/DeNoronha' => 'America/Noronha',\n        'Brazil/East' => 'America/Sao_Paulo',\n        'Brazil/West' => 'America/Manaus',\n        'CET' => 'Europe/Brussels',\n        'CST6CDT' => 'America/Chicago',\n        'Canada/Atlantic' => 'America/Halifax',\n        'Canada/Central' => 'America/Winnipeg',\n        'Canada/Eastern' => 'America/Toronto',\n        'Canada/Mountain' => 'America/Edmonton',\n        'Canada/Newfoundland' => 'America/St_Johns',\n        'Canada/Pacific' => 'America/Vancouver',\n        'Canada/Saskatchewan' => 'America/Regina',\n        'Canada/Yukon' => 'America/Whitehorse',\n        'Chile/Continental' => 'America/Santiago',\n        'Chile/EasterIsland' => 'Pacific/Easter',\n        'Cuba' => 'America/Havana',\n        'EET' => 'Europe/Athens',\n        'EST' => 'America/Panama',\n        'EST5EDT' => 'America/New_York',\n        'Egypt' => 'Africa/Cairo',\n        'Eire' => 'Europe/Dublin',\n        'Etc/GMT+0' => 'Etc/GMT\t',\n        'Etc/GMT-0' => 'Etc/GMT\t',\n        'Etc/GMT0' => 'Etc/GMT\t',\n        'Etc/Greenwich' => 'Etc/GMT\t',\n        'Etc/UCT' => 'Etc/UTC\t',\n        'Etc/Universal' => 'Etc/UTC\t',\n        'Etc/Zulu' => 'Etc/UTC\t',\n        'GB' => 'Europe/London',\n        'GB-Eire' => 'Europe/London',\n        'GMT+0' => 'Etc/GMT\t',\n        'GMT-0' => 'Etc/GMT\t',\n        'GMT0' => 'Etc/GMT\t',\n        'Greenwich' => 'Etc/GMT\t',\n        'Hongkong' => 'Asia/Hong_Kong',\n        'Iceland' => 'Africa/Abidjan',\n        'Iran' => 'Asia/Tehran',\n        'Israel' => 'Asia/Jerusalem',\n        'Jamaica' => 'America/Jamaica',\n        'Japan' => 'Asia/Tokyo',\n        'Kwajalein' => 'Pacific/Kwajalein',\n        'Libya' => 'Africa/Tripoli',\n        'MET' => 'Europe/Brussels',\n        'MST' => 'America/Phoenix',\n        'MST7MDT' => 'America/Denver',\n        'Mexico/BajaNorte' => 'America/Tijuana',\n        'Mexico/BajaSur' => 'America/Mazatlan',\n        'Mexico/General' => 'America/Mexico_City',\n        'NZ' => 'Pacific/Auckland',\n        'NZ-CHAT' => 'Pacific/Chatham',\n        'Navajo' => 'America/Denver',\n        'PRC' => 'Asia/Shanghai',\n        'Poland' => 'Europe/Warsaw',\n        'Portugal' => 'Europe/Lisbon',\n        'ROC' => 'Asia/Taipei',\n        'ROK' => 'Asia/Seoul',\n        'Singapore' => 'Asia/Singapore',\n        'Turkey' => 'Europe/Istanbul',\n        'UCT' => 'Etc/UTC\t',\n        'US/Alaska' => 'America/Anchorage',\n        'US/Aleutian' => 'America/Adak',\n        'US/Arizona' => 'America/Phoenix',\n        'US/Central' => 'America/Chicago',\n        'US/East-Indiana' => 'America/Indiana/Indianapolis',\n        'US/Eastern' => 'America/New_York',\n        'US/Hawaii' => 'Pacific/Honolulu',\n        'US/Indiana-Starke' => 'America/Indiana/Knox',\n        'US/Michigan' => 'America/Detroit',\n        'US/Mountain' => 'America/Denver',\n        'US/Pacific' => 'America/Los_Angeles',\n        'US/Samoa' => 'Pacific/Pago_Pago',\n        'UTC' => 'Etc/UTC\t',\n        'Universal' => 'Etc/UTC\t',\n        'W-SU' => 'Europe/Moscow',\n        'Zulu' => 'Etc/UTC\t',\n        'America/Buenos_Aires' => 'America/Argentina/Buenos_Aires',\n        'America/Catamarca' => 'America/Argentina/Catamarca',\n        'America/Cordoba' => 'America/Argentina/Cordoba',\n        'America/Indianapolis' => 'America/Indiana/Indianapolis',\n        'America/Jujuy' => 'America/Argentina/Jujuy',\n        'America/Knox_IN' => 'America/Indiana/Knox',\n        'America/Louisville' => 'America/Kentucky/Louisville',\n        'America/Mendoza' => 'America/Argentina/Mendoza',\n        'America/Virgin' => 'America/Puerto_Rico',\n        'Pacific/Samoa' => 'Pacific/Pago_Pago',\n        'Africa/Accra' => 'Africa/Abidjan',\n        'Africa/Addis_Ababa' => 'Africa/Nairobi',\n        'Africa/Asmara' => 'Africa/Nairobi',\n        'Africa/Bamako' => 'Africa/Abidjan',\n        'Africa/Bangui' => 'Africa/Lagos',\n        'Africa/Banjul' => 'Africa/Abidjan',\n        'Africa/Blantyre' => 'Africa/Maputo',\n        'Africa/Brazzaville' => 'Africa/Lagos',\n        'Africa/Bujumbura' => 'Africa/Maputo',\n        'Africa/Conakry' => 'Africa/Abidjan',\n        'Africa/Dakar' => 'Africa/Abidjan',\n        'Africa/Dar_es_Salaam' => 'Africa/Nairobi',\n        'Africa/Djibouti' => 'Africa/Nairobi',\n        'Africa/Douala' => 'Africa/Lagos',\n        'Africa/Freetown' => 'Africa/Abidjan',\n        'Africa/Gaborone' => 'Africa/Maputo',\n        'Africa/Harare' => 'Africa/Maputo',\n        'Africa/Kampala' => 'Africa/Nairobi',\n        'Africa/Kigali' => 'Africa/Maputo',\n        'Africa/Kinshasa' => 'Africa/Lagos',\n        'Africa/Libreville' => 'Africa/Lagos',\n        'Africa/Lome' => 'Africa/Abidjan',\n        'Africa/Luanda' => 'Africa/Lagos',\n        'Africa/Lubumbashi' => 'Africa/Maputo',\n        'Africa/Lusaka' => 'Africa/Maputo',\n        'Africa/Malabo' => 'Africa/Lagos',\n        'Africa/Maseru' => 'Africa/Johannesburg',\n        'Africa/Mbabane' => 'Africa/Johannesburg',\n        'Africa/Mogadishu' => 'Africa/Nairobi',\n        'Africa/Niamey' => 'Africa/Lagos',\n        'Africa/Nouakchott' => 'Africa/Abidjan',\n        'Africa/Ouagadougou' => 'Africa/Abidjan',\n        'Africa/Porto-Novo' => 'Africa/Lagos',\n        'America/Anguilla' => 'America/Puerto_Rico',\n        'America/Antigua' => 'America/Puerto_Rico',\n        'America/Aruba' => 'America/Puerto_Rico',\n        'America/Atikokan' => 'America/Panama',\n        'America/Blanc-Sablon' => 'America/Puerto_Rico',\n        'America/Cayman' => 'America/Panama',\n        'America/Creston' => 'America/Phoenix',\n        'America/Curacao' => 'America/Puerto_Rico',\n        'America/Dominica' => 'America/Puerto_Rico',\n        'America/Grenada' => 'America/Puerto_Rico',\n        'America/Guadeloupe' => 'America/Puerto_Rico',\n        'America/Kralendijk' => 'America/Puerto_Rico',\n        'America/Lower_Princes' => 'America/Puerto_Rico',\n        'America/Marigot' => 'America/Puerto_Rico',\n        'America/Montserrat' => 'America/Puerto_Rico',\n        'America/Nassau' => 'America/Toronto',\n        'America/Port_of_Spain' => 'America/Puerto_Rico',\n        'America/St_Barthelemy' => 'America/Puerto_Rico',\n        'America/St_Kitts' => 'America/Puerto_Rico',\n        'America/St_Lucia' => 'America/Puerto_Rico',\n        'America/St_Thomas' => 'America/Puerto_Rico',\n        'America/St_Vincent' => 'America/Puerto_Rico',\n        'America/Tortola' => 'America/Puerto_Rico',\n        'Antarctica/DumontDUrville' => 'Pacific/Port_Moresby',\n        'Antarctica/McMurdo' => 'Pacific/Auckland',\n        'Antarctica/Syowa' => 'Asia/Riyadh',\n        'Arctic/Longyearbyen' => 'Europe/Berlin',\n        'Asia/Aden' => 'Asia/Riyadh',\n        'Asia/Bahrain' => 'Asia/Qatar',\n        'Asia/Brunei' => 'Asia/Kuching',\n        'Asia/Kuala_Lumpur' => 'Asia/Singapore',\n        'Asia/Kuwait' => 'Asia/Riyadh',\n        'Asia/Muscat' => 'Asia/Dubai',\n        'Asia/Phnom_Penh' => 'Asia/Bangkok',\n        'Asia/Vientiane' => 'Asia/Bangkok',\n        'Atlantic/Reykjavik' => 'Africa/Abidjan',\n        'Atlantic/St_Helena' => 'Africa/Abidjan',\n        'Europe/Amsterdam' => 'Europe/Brussels',\n        'Europe/Bratislava' => 'Europe/Prague',\n        'Europe/Busingen' => 'Europe/Zurich',\n        'Europe/Copenhagen' => 'Europe/Berlin',\n        'Europe/Guernsey' => 'Europe/London',\n        'Europe/Isle_of_Man' => 'Europe/London',\n        'Europe/Jersey' => 'Europe/London',\n        'Europe/Ljubljana' => 'Europe/Belgrade',\n        'Europe/Luxembourg' => 'Europe/Brussels',\n        'Europe/Mariehamn' => 'Europe/Helsinki',\n        'Europe/Monaco' => 'Europe/Paris',\n        'Europe/Oslo' => 'Europe/Berlin',\n        'Europe/Podgorica' => 'Europe/Belgrade',\n        'Europe/San_Marino' => 'Europe/Rome',\n        'Europe/Sarajevo' => 'Europe/Belgrade',\n        'Europe/Skopje' => 'Europe/Belgrade',\n        'Europe/Stockholm' => 'Europe/Berlin',\n        'Europe/Vaduz' => 'Europe/Zurich',\n        'Europe/Vatican' => 'Europe/Rome',\n        'Europe/Zagreb' => 'Europe/Belgrade',\n        'Indian/Antananarivo' => 'Africa/Nairobi',\n        'Indian/Christmas' => 'Asia/Bangkok',\n        'Indian/Cocos' => 'Asia/Yangon',\n        'Indian/Comoro' => 'Africa/Nairobi',\n        'Indian/Kerguelen' => 'Indian/Maldives',\n        'Indian/Mahe' => 'Asia/Dubai',\n        'Indian/Mayotte' => 'Africa/Nairobi',\n        'Indian/Reunion' => 'Asia/Dubai',\n        'Pacific/Chuuk' => 'Pacific/Port_Moresby',\n        'Pacific/Funafuti' => 'Pacific/Tarawa',\n        'Pacific/Majuro' => 'Pacific/Tarawa',\n        'Pacific/Midway' => 'Pacific/Pago_Pago',\n        'Pacific/Pohnpei' => 'Pacific/Guadalcanal',\n        'Pacific/Saipan' => 'Pacific/Guam',\n        'Pacific/Wake' => 'Pacific/Tarawa',\n        'Pacific/Wallis' => 'Pacific/Tarawa',\n        'Africa/Timbuktu' => 'Africa/Abidjan',\n        'America/Argentina/ComodRivadavia' => 'America/Argentina/Catamarca',\n        'America/Atka' => 'America/Adak',\n        'America/Coral_Harbour' => 'America/Panama',\n        'America/Ensenada' => 'America/Tijuana',\n        'America/Fort_Wayne' => 'America/Indiana/Indianapolis',\n        'America/Montreal' => 'America/Toronto',\n        'America/Nipigon' => 'America/Toronto',\n        'America/Pangnirtung' => 'America/Iqaluit',\n        'America/Porto_Acre' => 'America/Rio_Branco',\n        'America/Rainy_River' => 'America/Winnipeg',\n        'America/Rosario' => 'America/Argentina/Cordoba',\n        'America/Santa_Isabel' => 'America/Tijuana',\n        'America/Shiprock' => 'America/Denver',\n        'America/Thunder_Bay' => 'America/Toronto',\n        'America/Yellowknife' => 'America/Edmonton',\n        'Antarctica/South_Pole' => 'Pacific/Auckland',\n        'Asia/Choibalsan' => 'Asia/Ulaanbaatar',\n        'Asia/Chongqing' => 'Asia/Shanghai',\n        'Asia/Harbin' => 'Asia/Shanghai',\n        'Asia/Kashgar' => 'Asia/Urumqi',\n        'Asia/Tel_Aviv' => 'Asia/Jerusalem',\n        'Atlantic/Jan_Mayen' => 'Europe/Berlin',\n        'Australia/Canberra' => 'Australia/Sydney',\n        'Australia/Currie' => 'Australia/Hobart',\n        'Europe/Belfast' => 'Europe/London',\n        'Europe/Tiraspol' => 'Europe/Chisinau',\n        'Europe/Uzhgorod' => 'Europe/Kyiv',\n        'Europe/Zaporozhye' => 'Europe/Kyiv',\n        'Pacific/Enderbury' => 'Pacific/Kanton',\n        'Pacific/Johnston' => 'Pacific/Honolulu',\n        'Pacific/Yap' => 'Pacific/Port_Moresby',\n        'WET' => 'Europe/Lisbon',\n        'Africa/Asmera' => 'Africa/Nairobi',\n        'America/Godthab' => 'America/Nuuk',\n        'Asia/Ashkhabad' => 'Asia/Ashgabat',\n        'Asia/Calcutta' => 'Asia/Kolkata',\n        'Asia/Chungking' => 'Asia/Shanghai',\n        'Asia/Dacca' => 'Asia/Dhaka',\n        'Asia/Istanbul' => 'Europe/Istanbul',\n        'Asia/Katmandu' => 'Asia/Kathmandu',\n        'Asia/Macao' => 'Asia/Macau',\n        'Asia/Rangoon' => 'Asia/Yangon',\n        'Asia/Saigon' => 'Asia/Ho_Chi_Minh',\n        'Asia/Thimbu' => 'Asia/Thimphu',\n        'Asia/Ujung_Pandang' => 'Asia/Makassar',\n        'Asia/Ulan_Bator' => 'Asia/Ulaanbaatar',\n        'Atlantic/Faeroe' => 'Atlantic/Faroe',\n        'Europe/Kiev' => 'Europe/Kyiv',\n        'Europe/Nicosia' => 'Asia/Nicosia',\n        'HST' => 'Pacific/Honolulu',\n        'PST8PDT' => 'America/Los_Angeles',\n        'Pacific/Ponape' => 'Pacific/Guadalcanal',\n        'Pacific/Truk' => 'Pacific/Port_Moresby',\n    ];\n\n    /**\n     * @return array<string>\n     */\n    public function getTimezones(bool $inclLegacy = false): array\n    {\n        return $inclLegacy ?\n            CarbonTimeZone::listIdentifiers(CarbonTimeZone::ALL_WITH_BC) :\n            CarbonTimeZone::listIdentifiers();\n    }\n\n    public function getTimezoneFromUser(User $user): CarbonTimeZone\n    {\n        try {\n            return new CarbonTimeZone($user->timezone);\n        } catch (\\Exception $e) {\n            Log::error('User has a invalid timezone', [\n                'user_id' => $user->getKey(),\n                'timezone' => $user->timezone,\n            ]);\n\n            return new CarbonTimeZone('UTC');\n        }\n    }\n\n    /**\n     * @return array<string, string>\n     */\n    public function getSelectOptions(): array\n    {\n        $tzlist = $this->getTimezones();\n        $options = [];\n        foreach ($tzlist as $tz) {\n            $options[$tz] = $tz;\n        }\n\n        return $options;\n    }\n\n    public function isValid(string $timezone): bool\n    {\n        return in_array($timezone, $this->getTimezones(), true);\n    }\n\n    public function mapLegacyTimezone(string $timezone): ?string\n    {\n        return self::LEGACY_TIMEZONES_MAP[$timezone] ?? null;\n    }\n\n    public function getShiftFromUtc(CarbonTimeZone $timeZone): int\n    {\n        return $timeZone->getOffset(Carbon::now());\n    }\n}\n"
  },
  {
    "path": "app/Service/UserService.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Enums\\CurrencyFormat;\nuse App\\Enums\\DateFormat;\nuse App\\Enums\\IntervalFormat;\nuse App\\Enums\\NumberFormat;\nuse App\\Enums\\Role;\nuse App\\Enums\\TimeFormat;\nuse App\\Enums\\Weekday;\nuse App\\Events\\AfterCreateOrganization;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\ProjectMember;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Facades\\Hash;\n\nclass UserService\n{\n    public function createUser(\n        string $name,\n        string $email,\n        string $password,\n        string $timezone,\n        Weekday $weekStart,\n        ?string $currency,\n        ?NumberFormat $numberFormat = null,\n        ?CurrencyFormat $currencyFormat = null,\n        ?DateFormat $dateFormat = null,\n        ?IntervalFormat $intervalFormat = null,\n        ?TimeFormat $timeFormat = null,\n        bool $verifyEmail = false\n    ): User {\n        $user = new User;\n        $user->name = $name;\n        $user->email = $email;\n        $user->password = Hash::make($password);\n        $user->timezone = $timezone;\n        $user->week_start = $weekStart;\n        if ($verifyEmail) {\n            $user->email_verified_at = Carbon::now();\n        }\n        $user->save();\n\n        $organization = app(OrganizationService::class)->createOrganization(\n            $this->getOrganizationNameForUserName($user->name),\n            $user,\n            true,\n            $currency,\n            $numberFormat,\n            $currencyFormat,\n            $dateFormat,\n            $intervalFormat,\n            $timeFormat,\n        );\n\n        $user->ownedTeams()->save($organization);\n\n        return $user;\n    }\n\n    /**\n     * This does NOT change the member id.\n     * This should only be used in if you want to change a member to a placeholder!\n     */\n    public function assignOrganizationEntitiesToDifferentUser(Organization $organization, User $fromUser, User $toUser): void\n    {\n        // Time entries\n        TimeEntry::query()\n            ->whereBelongsTo($organization, 'organization')\n            ->whereBelongsTo($fromUser, 'user')\n            ->update([\n                'user_id' => $toUser->getKey(),\n            ]);\n\n        // Project members\n        ProjectMember::query()\n            ->whereBelongsToOrganization($organization)\n            ->whereBelongsTo($fromUser, 'user')\n            ->update([\n                'user_id' => $toUser->getKey(),\n            ]);\n    }\n\n    public function makeSureUserHasAtLeastOneOrganization(User $user): void\n    {\n        if ($user->organizations()->count() > 0) {\n            return;\n        }\n\n        // Create a new organization\n        $organization = app(OrganizationService::class)->createOrganization(\n            $this->getOrganizationNameForUserName($user->name),\n            $user,\n            true\n        );\n\n        // Set the organization as the user's current organization\n        $user->currentOrganization()->associate($organization);\n        $user->save();\n\n        AfterCreateOrganization::dispatch($organization);\n    }\n\n    public function getOrganizationNameForUserName(string $username): string\n    {\n        return explode(' ', $username, 2)[0].\"'s Organization\";\n    }\n\n    public function makeSureUserHasCurrentOrganization(User $user): void\n    {\n        if ($user->current_team_id !== null) {\n            return;\n        }\n\n        $organization = $user->organizations()->first();\n        if ($organization !== null) {\n            $user->currentOrganization()->associate($organization);\n            $user->save();\n        }\n    }\n\n    /**\n     * Change the ownership of an organization to a new user.\n     * The previous owner will be demoted to an admin.\n     */\n    public function changeOwnership(Organization $organization, User $newOwner): void\n    {\n        $organization->update([\n            'user_id' => $newOwner->getKey(),\n        ]);\n        /** @var Member|null $userMembership */\n        $userMembership = Member::query()\n            ->whereBelongsTo($organization, 'organization')\n            ->whereBelongsTo($newOwner, 'user')\n            ->first();\n        if ($userMembership === null) {\n            throw new \\InvalidArgumentException('User is not a member of the organization');\n        }\n        $userMembership->role = Role::Owner->value;\n        $userMembership->save();\n        $oldOwners = Member::query()\n            ->whereBelongsTo($organization, 'organization')\n            ->where('role', '=', Role::Owner->value)\n            ->where('user_id', '!=', $newOwner->getKey())\n            ->get();\n        foreach ($oldOwners as $oldOwner) {\n            $oldOwner->role = Role::Admin->value;\n            $oldOwner->save();\n        }\n    }\n}\n"
  },
  {
    "path": "artisan",
    "content": "#!/usr/bin/env php\n<?php\n\ndefine('LARAVEL_START', microtime(true));\n\n/*\n|--------------------------------------------------------------------------\n| Register The Auto Loader\n|--------------------------------------------------------------------------\n|\n| Composer provides a convenient, automatically generated class loader\n| for our application. We just need to utilize it! We'll require it\n| into the script here so that we do not have to worry about the\n| loading of any of our classes manually. It's great to relax.\n|\n*/\n\nrequire __DIR__.'/vendor/autoload.php';\n\n$app = require_once __DIR__.'/bootstrap/app.php';\n\n/*\n|--------------------------------------------------------------------------\n| Run The Artisan Application\n|--------------------------------------------------------------------------\n|\n| When we run the console application, the current CLI command will be\n| executed in this console and the response sent back to a terminal\n| or another output device for the developers. Here goes nothing!\n|\n*/\n\n$kernel = $app->make(Illuminate\\Contracts\\Console\\Kernel::class);\n\n$status = $kernel->handle(\n    $input = new Symfony\\Component\\Console\\Input\\ArgvInput,\n    new Symfony\\Component\\Console\\Output\\ConsoleOutput\n);\n\n/*\n|--------------------------------------------------------------------------\n| Shutdown The Application\n|--------------------------------------------------------------------------\n|\n| Once Artisan has finished running, we will fire off the shutdown events\n| so that any final work may be done by the application before we shut\n| down the process. This is the last thing to happen to the request.\n|\n*/\n\n$kernel->terminate($input, $status);\n\nexit($status);\n"
  },
  {
    "path": "bootstrap/app.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/*\n|--------------------------------------------------------------------------\n| Create The Application\n|--------------------------------------------------------------------------\n|\n| The first thing we will do is create a new Laravel application instance\n| which serves as the \"glue\" for all the components of Laravel, and is\n| the IoC container for the system binding all of the various parts.\n|\n*/\n\n$app = new Illuminate\\Foundation\\Application(\n    $_ENV['APP_BASE_PATH'] ?? dirname(__DIR__)\n);\n\n/*\n|--------------------------------------------------------------------------\n| Bind Important Interfaces\n|--------------------------------------------------------------------------\n|\n| Next, we need to bind some important interfaces into the container so\n| we will be able to resolve them when needed. The kernels serve the\n| incoming requests to this application from both the web and CLI.\n|\n*/\n\n$app->singleton(\n    Illuminate\\Contracts\\Http\\Kernel::class,\n    App\\Http\\Kernel::class\n);\n\n$app->singleton(\n    Illuminate\\Contracts\\Console\\Kernel::class,\n    App\\Console\\Kernel::class\n);\n\n$app->singleton(\n    Illuminate\\Contracts\\Debug\\ExceptionHandler::class,\n    App\\Exceptions\\Handler::class\n);\n\n/*\n|--------------------------------------------------------------------------\n| Return The Application\n|--------------------------------------------------------------------------\n|\n| This script returns the application instance. The instance is given to\n| the calling script so we can separate the building of the instances\n| from the actual running of the application and sending responses.\n|\n*/\n\nreturn $app;\n"
  },
  {
    "path": "bootstrap/cache/.gitignore",
    "content": "*\n!.gitignore\n"
  },
  {
    "path": "components.json",
    "content": "{\n  \"$schema\": \"https://shadcn-vue.com/schema.json\",\n  \"style\": \"new-york\",\n  \"typescript\": true,\n  \"tailwind\": {\n    \"config\": \"tailwind.config.js\",\n    \"css\": \"resources/css/app.css\",\n    \"baseColor\": \"neutral\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"composables\": \"@/composables\",\n    \"utils\": \"@/lib/utils\",\n    \"ui\": \"@/components/ui\",\n    \"lib\": \"@/lib\"\n  },\n  \"iconLibrary\": \"lucide\"\n}"
  },
  {
    "path": "composer.json",
    "content": "{\n    \"name\": \"solidtime-io/solidtime\",\n    \"type\": \"project\",\n    \"description\": \"An open-source time-tracking app\",\n    \"keywords\": [],\n    \"license\": \"AGPL-3.0-or-later\",\n    \"require\": {\n        \"php\": \"8.3.*\",\n        \"ext-zip\": \"*\",\n        \"brick/money\": \"^0.10.0\",\n        \"datomatic/laravel-enum-helper\": \"^2.0.0\",\n        \"dedoc/scramble\": \"^0.12.2\",\n        \"filament/filament\": \"^3.2\",\n        \"flowframe/laravel-trend\": \"^0.4.0\",\n        \"gotenberg/gotenberg-php\": \"^2.8\",\n        \"guzzlehttp/guzzle\": \"^7.2\",\n        \"inertiajs/inertia-laravel\": \"^2.0.3\",\n        \"korridor/laravel-computed-attributes\": \"^3.1\",\n        \"korridor/laravel-has-many-sync\": \"^3.1\",\n        \"korridor/laravel-model-validation-rules\": \"^3.0\",\n        \"laravel/framework\": \"^12.19.3\",\n        \"laravel/jetstream\": \"^5.0\",\n        \"laravel/octane\": \"^2.3\",\n        \"laravel/passport\": \"^13.0.5\",\n        \"laravel/tinker\": \"^2.8\",\n        \"league/csv\": \"^9.16.0\",\n        \"league/flysystem-aws-s3-v3\": \"^3.0\",\n        \"league/iso3166\": \"^4.3\",\n        \"maatwebsite/excel\": \"^3.1\",\n        \"novadaemon/filament-pretty-json\": \"^2.2\",\n        \"nwidart/laravel-modules\": \"^12.0.4\",\n        \"owen-it/laravel-auditing\": \"^14.0.0\",\n        \"pxlrbt/filament-environment-indicator\": \"^2.1.0\",\n        \"spatie/temporary-directory\": \"^2.2\",\n        \"staudenmeir/eloquent-json-relations\": \"^1.1\",\n        \"stechstudio/filament-impersonate\": \"^3.8\",\n        \"tightenco/ziggy\": \"^2.1.0\",\n        \"tpetry/laravel-postgresql-enhanced\": \"^3.0.0\",\n        \"wikimedia/composer-merge-plugin\": \"^2.1.0\"\n    },\n    \"require-dev\": {\n        \"barryvdh/laravel-ide-helper\": \"^3.0\",\n        \"brianium/paratest\": \"^7.3\",\n        \"fakerphp/faker\": \"^1.9.1\",\n        \"fumeapp/modeltyper\": \"^3.0\",\n        \"larastan/larastan\": \"^3.5.0\",\n        \"laravel/pint\": \"^1.0\",\n        \"laravel/sail\": \"^1.18\",\n        \"laravel/telescope\": \"^5.0\",\n        \"mockery/mockery\": \"^1.4.4\",\n        \"nunomaduro/collision\": \"^8.1\",\n        \"phpunit/phpunit\": \"^12\",\n        \"spatie/laravel-ignition\": \"^2.0\",\n        \"timacdonald/log-fake\": \"^2.1\"\n    },\n    \"autoload\": {\n        \"psr-4\": {\n            \"App\\\\\": \"app/\",\n            \"Database\\\\Factories\\\\\": \"database/factories/\",\n            \"Database\\\\Seeders\\\\\": \"database/seeders/\"\n        },\n        \"files\": [\n            \"extensions/extensions_autoload.php\"\n        ]\n    },\n    \"autoload-dev\": {\n        \"psr-4\": {\n            \"Tests\\\\\": \"tests/\"\n        }\n    },\n    \"scripts\": {\n        \"post-autoload-dump\": [\n            \"Illuminate\\\\Foundation\\\\ComposerScripts::postAutoloadDump\",\n            \"@php artisan package:discover --ansi\",\n            \"@php artisan filament:upgrade\"\n        ],\n        \"post-update-cmd\": [\n            \"@php artisan vendor:publish --tag=laravel-assets --ansi --force\"\n        ],\n        \"post-root-package-install\": [\n            \"@php -r \\\"file_exists('.env') || copy('.env.example', '.env');\\\"\"\n        ],\n        \"post-create-project-cmd\": [\n            \"@php artisan key:generate --ansi\"\n        ],\n        \"analyse\": [\n            \"@php ./vendor/bin/phpstan analyse --memory-limit=2G --configuration=phpstan.neon\"\n        ],\n        \"generate-typescript\": [\n            \"@php artisan model:typer > ./resources/js/types/models.ts\"\n        ],\n        \"ptest\": [\n            \"@php artisan test --parallel --stop-on-failure\"\n        ],\n        \"test\": [\n            \"@php artisan test --stop-on-failure\"\n        ],\n        \"test:coverage\": [\n            \"@php artisan test --coverage --stop-on-failure\"\n        ],\n        \"test:coverage:report\": [\n            \"@php vendor/bin/phpunit --coverage-html=coverage\"\n        ],\n        \"coverage-report\": [\n            \"@test:coverage:report\"\n        ],\n        \"fix\": [\n            \"@php pint\"\n        ],\n        \"ide-helper\": [\n            \"@php artisan ide-helper:generate\",\n            \"@php artisan ide-helper:meta\"\n        ],\n        \"refresh-schema-dump\": [\n            \"@php artisan schema:dump --database=\\\"pgsql_test\\\"\"\n        ]\n    },\n    \"extra\": {\n        \"laravel\": {\n            \"dont-discover\": [\n                \"laravel/telescope\",\n                \"nwidart/laravel-modules\"\n            ]\n        }\n    },\n    \"config\": {\n        \"optimize-autoloader\": true,\n        \"preferred-install\": \"dist\",\n        \"sort-packages\": true,\n        \"allow-plugins\": {\n            \"pestphp/pest-plugin\": true,\n            \"php-http/discovery\": true,\n            \"wikimedia/composer-merge-plugin\": true\n        }\n    },\n    \"minimum-stability\": \"stable\",\n    \"prefer-stable\": true\n}\n"
  },
  {
    "path": "config/app.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse App\\Enums\\CurrencyFormat;\nuse App\\Enums\\DateFormat;\nuse App\\Enums\\IntervalFormat;\nuse App\\Enums\\NumberFormat;\nuse App\\Enums\\TimeFormat;\nuse Illuminate\\Support\\Facades\\Facade;\nuse Illuminate\\Support\\ServiceProvider;\nuse Nwidart\\Modules\\LaravelModulesServiceProvider;\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Application Name\n    |--------------------------------------------------------------------------\n    |\n    | This value is the name of your application. This value is used when the\n    | framework needs to place the application's name in a notification or\n    | any other location as required by the application or its packages.\n    |\n    */\n\n    'name' => 'solidtime',\n\n    'version' => env('APP_VERSION'),\n\n    'build' => env('APP_BUILD'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Application Environment\n    |--------------------------------------------------------------------------\n    |\n    | This value determines the \"environment\" your application is currently\n    | running in. This may determine how you prefer to configure various\n    | services the application utilizes. Set this in your \".env\" file.\n    |\n    */\n\n    'env' => env('APP_ENV', 'production'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Application Debug Mode\n    |--------------------------------------------------------------------------\n    |\n    | When your application is in debug mode, detailed error messages with\n    | stack traces will be shown on every error that occurs within your\n    | application. If disabled, a simple generic error page is shown.\n    |\n    */\n\n    'debug' => (bool) env('APP_DEBUG', false),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Application URL\n    |--------------------------------------------------------------------------\n    |\n    | This URL is used by the console to properly generate URLs when using\n    | the Artisan command line tool. You should set this to the root of\n    | your application so that it is used when running Artisan tasks.\n    |\n    */\n\n    'url' => env('APP_URL', 'http://localhost'),\n\n    'asset_url' => env('ASSET_URL'),\n\n    'force_https' => (bool) env('APP_FORCE_HTTPS', false),\n\n    'enable_registration' => (bool) env('APP_ENABLE_REGISTRATION', false),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Application Timezone\n    |--------------------------------------------------------------------------\n    |\n    | Here you may specify the default timezone for your application, which\n    | will be used by the PHP date and date-time functions. We have gone\n    | ahead and set this to a sensible default for you out of the box.\n    |\n    */\n\n    'timezone' => 'UTC',\n\n    /*\n    |--------------------------------------------------------------------------\n    | Application Locale Configuration\n    |--------------------------------------------------------------------------\n    |\n    | The application locale determines the default locale that will be used\n    | by the translation service provider. You are free to set this value\n    | to any of the locales which will be supported by the application.\n    |\n    */\n\n    'locale' => 'en',\n\n    /*\n    |--------------------------------------------------------------------------\n    | Application Fallback Locale\n    |--------------------------------------------------------------------------\n    |\n    | The fallback locale determines the locale to use when the current one\n    | is not available. You may change the value to correspond to any of\n    | the language folders that are provided through your application.\n    |\n    */\n\n    'fallback_locale' => 'en',\n\n    /*\n    |--------------------------------------------------------------------------\n    | Faker Locale\n    |--------------------------------------------------------------------------\n    |\n    | This locale will be used by the Faker PHP library when generating fake\n    | data for your database seeds. For example, this will be used to get\n    | localized telephone numbers, street address information and more.\n    |\n    */\n\n    'faker_locale' => 'en_US',\n\n    'pagination_per_page_default' => (int) env('PAGINATION_PER_PAGE_DEFAULT', 15),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Encryption Key\n    |--------------------------------------------------------------------------\n    |\n    | This key is used by the Illuminate encrypter service and should be set\n    | to a random, 32 character string, otherwise these encrypted strings\n    | will not be safe. Please do this before deploying an application!\n    |\n    */\n\n    'key' => env('APP_KEY'),\n\n    'cipher' => 'AES-256-CBC',\n\n    'localization' => [\n        'default_currency' => env('LOCALIZATION_DEFAULT_CURRENCY', 'EUR'),\n        'default_number_format' => env('LOCALIZATION_DEFAULT_NUMBER_FORMAT', NumberFormat::ThousandsPointDecimalComma->value),\n        'default_currency_format' => env('LOCALIZATION_DEFAULT_CURRENCY_FORMAT', CurrencyFormat::ISOCodeAfterWithSpace->value),\n        'default_date_format' => env('LOCALIZATION_DEFAULT_DATE_FORMAT', DateFormat::HyphenSeparatedYYYYMMDD->value),\n        'default_time_format' => env('LOCALIZATION_DEFAULT_TIME_FORMAT', TimeFormat::TwentyFourHours->value),\n        'default_interval_format' => env('LOCALIZATION_DEFAULT_INTERVAL_FORMAT', IntervalFormat::HoursMinutes->value),\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Maintenance Mode Driver\n    |--------------------------------------------------------------------------\n    |\n    | These configuration options determine the driver used to determine and\n    | manage Laravel's \"maintenance mode\" status. The \"cache\" driver will\n    | allow maintenance mode to be controlled across multiple machines.\n    |\n    | Supported drivers: \"file\", \"cache\"\n    |\n    */\n\n    'maintenance' => [\n        'driver' => 'file',\n        // 'store' => 'redis',\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Autoloaded Service Providers\n    |--------------------------------------------------------------------------\n    |\n    | The service providers listed here will be automatically loaded on the\n    | request to your application. Feel free to add your own services to\n    | this array to grant expanded functionality to your applications.\n    |\n    */\n\n    'providers' => ServiceProvider::defaultProviders()->merge([\n        /*\n         * Package Service Providers...\n         */\n\n        /*\n         * Application Service Providers...\n         */\n        App\\Providers\\AppServiceProvider::class,\n        App\\Providers\\AuthServiceProvider::class,\n        App\\Providers\\EventServiceProvider::class,\n        App\\Providers\\Filament\\AdminPanelProvider::class,\n        App\\Providers\\RouteServiceProvider::class,\n        App\\Providers\\FortifyServiceProvider::class,\n        App\\Providers\\JetstreamServiceProvider::class,\n        // Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider\n        LaravelModulesServiceProvider::class,\n    ])->toArray(),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Class Aliases\n    |--------------------------------------------------------------------------\n    |\n    | This array of class aliases will be registered when this application\n    | is started. However, feel free to register as many as you wish as\n    | the aliases are \"lazy\" loaded so they don't hinder performance.\n    |\n    */\n\n    'aliases' => Facade::defaultAliases()->merge([\n        // 'Example' => App\\Facades\\Example::class,\n    ])->toArray(),\n];\n"
  },
  {
    "path": "config/audit.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nreturn [\n\n    'enabled' => env('AUDITING_ENABLED', false),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Audit Implementation\n    |--------------------------------------------------------------------------\n    |\n    | Define which Audit model implementation should be used.\n    |\n    */\n\n    'implementation' => OwenIt\\Auditing\\Models\\Audit::class,\n\n    /*\n    |--------------------------------------------------------------------------\n    | User Morph prefix & Guards\n    |--------------------------------------------------------------------------\n    |\n    | Define the morph prefix and authentication guards for the User resolver.\n    |\n    */\n\n    'user' => [\n        'morph_prefix' => 'user',\n        'guards' => [\n            'web',\n            'api',\n        ],\n        'resolver' => OwenIt\\Auditing\\Resolvers\\UserResolver::class,\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Audit Resolvers\n    |--------------------------------------------------------------------------\n    |\n    | Define the IP Address, User Agent and URL resolver implementations.\n    |\n    */\n    'resolvers' => [\n        'ip_address' => App\\Extensions\\Auditing\\Resolvers\\CustomIpAddressResolver::class,\n        'user_agent' => OwenIt\\Auditing\\Resolvers\\UserAgentResolver::class,\n        'url' => OwenIt\\Auditing\\Resolvers\\UrlResolver::class,\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Audit Events\n    |--------------------------------------------------------------------------\n    |\n    | The Eloquent events that trigger an Audit.\n    |\n    */\n\n    'events' => [\n        'created',\n        'updated',\n        'deleted',\n        'restored',\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Strict Mode\n    |--------------------------------------------------------------------------\n    |\n    | Enable the strict mode when auditing?\n    |\n    */\n\n    'strict' => true,\n\n    /*\n    |--------------------------------------------------------------------------\n    | Global exclude\n    |--------------------------------------------------------------------------\n    |\n    | Have something you always want to exclude by default? - add it here.\n    | Note that this is overwritten (not merged) with local exclude\n    |\n    */\n\n    'exclude' => [],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Empty Values\n    |--------------------------------------------------------------------------\n    |\n    | Should Audit records be stored when the recorded old_values & new_values\n    | are both empty?\n    |\n    | Some events may be empty on purpose. Use allowed_empty_values to exclude\n    | those from the empty values check. For example when auditing\n    | model retrieved events which will never have new and old values.\n    |\n    |\n    */\n\n    'empty_values' => false,\n    'allowed_empty_values' => [\n        'retrieved',\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Allowed Array Values\n    |--------------------------------------------------------------------------\n    |\n    | Should the array values be audited?\n    |\n    | By default, array values are not allowed. This is to prevent performance\n    | issues when storing large amounts of data. You can override this by\n    | setting allow_array_values to true.\n    */\n    'allowed_array_values' => true,\n\n    /*\n    |--------------------------------------------------------------------------\n    | Audit Timestamps\n    |--------------------------------------------------------------------------\n    |\n    | Should the created_at, updated_at and deleted_at timestamps be audited?\n    |\n    */\n\n    'timestamps' => false,\n\n    /*\n    |--------------------------------------------------------------------------\n    | Audit Threshold\n    |--------------------------------------------------------------------------\n    |\n    | Specify a threshold for the amount of Audit records a model can have.\n    | Zero means no limit.\n    |\n    */\n\n    'threshold' => 0,\n\n    /*\n    |--------------------------------------------------------------------------\n    | Audit Driver\n    |--------------------------------------------------------------------------\n    |\n    | The default audit driver used to keep track of changes.\n    |\n    */\n\n    'driver' => 'database',\n\n    /*\n    |--------------------------------------------------------------------------\n    | Audit Driver Configurations\n    |--------------------------------------------------------------------------\n    |\n    | Available audit drivers and respective configurations.\n    |\n    */\n\n    'drivers' => [\n        'database' => [\n            'table' => 'audits',\n            'connection' => null,\n        ],\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Audit Queue Configurations\n    |--------------------------------------------------------------------------\n    |\n    | Available audit queue configurations.\n    |\n    */\n\n    'queue' => [\n        'enable' => false,\n        'connection' => 'sync',\n        'queue' => 'default',\n        'delay' => 0,\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Audit Console\n    |--------------------------------------------------------------------------\n    |\n    | Whether console events should be audited (eg. php artisan db:seed).\n    |\n    */\n\n    'console' => true,\n];\n"
  },
  {
    "path": "config/auth.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Authentication Defaults\n    |--------------------------------------------------------------------------\n    |\n    | This option controls the default authentication \"guard\" and password\n    | reset options for your application. You may change these defaults\n    | as required, but they're a perfect start for most applications.\n    |\n    */\n\n    'defaults' => [\n        'guard' => 'web',\n        'passwords' => 'users',\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Authentication Guards\n    |--------------------------------------------------------------------------\n    |\n    | Next, you may define every authentication guard for your application.\n    | Of course, a great default configuration has been defined for you\n    | here which uses session storage and the Eloquent user provider.\n    |\n    | All authentication drivers have a user provider. This defines how the\n    | users are actually retrieved out of your database or other storage\n    | mechanisms used by this application to persist your user's data.\n    |\n    | Supported: \"session\"\n    |\n    */\n\n    'guards' => [\n        'web' => [\n            'driver' => 'session',\n            'provider' => 'users',\n        ],\n\n        'api' => [\n            'driver' => 'passport',\n            'provider' => 'users',\n        ],\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | User Providers\n    |--------------------------------------------------------------------------\n    |\n    | All authentication drivers have a user provider. This defines how the\n    | users are actually retrieved out of your database or other storage\n    | mechanisms used by this application to persist your user's data.\n    |\n    | If you have multiple user tables or models you may configure multiple\n    | sources which represent each model / table. These sources may then\n    | be assigned to any extra authentication guards you have defined.\n    |\n    | Supported: \"database\", \"eloquent\"\n    |\n    */\n\n    'providers' => [\n        'users' => [\n            'driver' => 'eloquent',\n            'model' => App\\Models\\User::class,\n        ],\n\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Resetting Passwords\n    |--------------------------------------------------------------------------\n    |\n    | You may specify multiple password reset configurations if you have more\n    | than one user table or model in the application and you want to have\n    | separate password reset settings based on the specific user types.\n    |\n    | The expiry time is the number of minutes that each reset token will be\n    | considered valid. This security feature keeps tokens short-lived so\n    | they have less time to be guessed. You may change this as needed.\n    |\n    | The throttle setting is the number of seconds a user must wait before\n    | generating more password reset tokens. This prevents the user from\n    | quickly generating a very large amount of password reset tokens.\n    |\n    */\n\n    'passwords' => [\n        'users' => [\n            'provider' => 'users',\n            'table' => 'password_reset_tokens',\n            'expire' => 60,\n            'throttle' => 60,\n        ],\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Password Confirmation Timeout\n    |--------------------------------------------------------------------------\n    |\n    | Here you may define the amount of seconds before a password confirmation\n    | times out and the user is prompted to re-enter their password via the\n    | confirmation screen. By default, the timeout lasts for three hours.\n    |\n    */\n\n    'password_timeout' => 10800,\n\n    'super_admins' => ! is_string(env('SUPER_ADMINS', null)) ? [] : explode(',', env('SUPER_ADMINS')),\n\n    'terms_url' => env('TERMS_URL', ''),\n\n    'privacy_policy_url' => env('PRIVACY_POLICY_URL', ''),\n\n    'newsletter_consent' => env('NEWSLETTER_CONSENT', false),\n\n];\n"
  },
  {
    "path": "config/broadcasting.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Default Broadcaster\n    |--------------------------------------------------------------------------\n    |\n    | This option controls the default broadcaster that will be used by the\n    | framework when an event needs to be broadcast. You may set this to\n    | any of the connections defined in the \"connections\" array below.\n    |\n    | Supported: \"pusher\", \"ably\", \"redis\", \"log\", \"null\"\n    |\n    */\n\n    'default' => env('BROADCAST_DRIVER', 'null'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Broadcast Connections\n    |--------------------------------------------------------------------------\n    |\n    | Here you may define all of the broadcast connections that will be used\n    | to broadcast events to other systems or over websockets. Samples of\n    | each available type of connection are provided inside this array.\n    |\n    */\n\n    'connections' => [\n\n        'pusher' => [\n            'driver' => 'pusher',\n            'key' => env('PUSHER_APP_KEY'),\n            'secret' => env('PUSHER_APP_SECRET'),\n            'app_id' => env('PUSHER_APP_ID'),\n            'options' => [\n                'cluster' => env('PUSHER_APP_CLUSTER'),\n                'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',\n                'port' => env('PUSHER_PORT', 443),\n                'scheme' => env('PUSHER_SCHEME', 'https'),\n                'encrypted' => true,\n                'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',\n            ],\n            'client_options' => [\n                // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html\n            ],\n        ],\n\n        'ably' => [\n            'driver' => 'ably',\n            'key' => env('ABLY_KEY'),\n        ],\n\n        'redis' => [\n            'driver' => 'redis',\n            'connection' => 'default',\n        ],\n\n        'log' => [\n            'driver' => 'log',\n        ],\n\n        'null' => [\n            'driver' => 'null',\n        ],\n\n    ],\n\n];\n"
  },
  {
    "path": "config/cache.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Support\\Str;\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Default Cache Store\n    |--------------------------------------------------------------------------\n    |\n    | This option controls the default cache connection that gets used while\n    | using this caching library. This connection is used when another is\n    | not explicitly specified when executing a given caching function.\n    |\n    */\n\n    'default' => env('CACHE_DRIVER', 'file'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Cache Stores\n    |--------------------------------------------------------------------------\n    |\n    | Here you may define all of the cache \"stores\" for your application as\n    | well as their drivers. You may even define multiple stores for the\n    | same cache driver to group types of items stored in your caches.\n    |\n    | Supported drivers: \"apc\", \"array\", \"database\", \"file\",\n    |         \"memcached\", \"redis\", \"dynamodb\", \"octane\", \"null\"\n    |\n    */\n\n    'stores' => [\n\n        'apc' => [\n            'driver' => 'apc',\n        ],\n\n        'array' => [\n            'driver' => 'array',\n            'serialize' => false,\n        ],\n\n        'database' => [\n            'driver' => 'database',\n            'table' => 'cache',\n            'connection' => null,\n            'lock_connection' => null,\n        ],\n\n        'file' => [\n            'driver' => 'file',\n            'path' => storage_path('framework/cache/data'),\n            'lock_path' => storage_path('framework/cache/data'),\n        ],\n\n        'memcached' => [\n            'driver' => 'memcached',\n            'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),\n            'sasl' => [\n                env('MEMCACHED_USERNAME'),\n                env('MEMCACHED_PASSWORD'),\n            ],\n            'options' => [\n                // Memcached::OPT_CONNECT_TIMEOUT => 2000,\n            ],\n            'servers' => [\n                [\n                    'host' => env('MEMCACHED_HOST', '127.0.0.1'),\n                    'port' => env('MEMCACHED_PORT', 11211),\n                    'weight' => 100,\n                ],\n            ],\n        ],\n\n        'redis' => [\n            'driver' => 'redis',\n            'connection' => 'cache',\n            'lock_connection' => 'default',\n        ],\n\n        'dynamodb' => [\n            'driver' => 'dynamodb',\n            'key' => env('AWS_ACCESS_KEY_ID'),\n            'secret' => env('AWS_SECRET_ACCESS_KEY'),\n            'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n            'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),\n            'endpoint' => env('DYNAMODB_ENDPOINT'),\n        ],\n\n        'octane' => [\n            'driver' => 'octane',\n        ],\n\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Cache Key Prefix\n    |--------------------------------------------------------------------------\n    |\n    | When utilizing the APC, database, memcached, Redis, or DynamoDB cache\n    | stores there might be other applications using the same cache. For\n    | that reason, you may prefix every cache key to avoid collisions.\n    |\n    */\n\n    'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'),\n\n];\n"
  },
  {
    "path": "config/cors.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Cross-Origin Resource Sharing (CORS) Configuration\n    |--------------------------------------------------------------------------\n    |\n    | Here you may configure your settings for cross-origin resource sharing\n    | or \"CORS\". This determines what cross-origin operations may execute\n    | in web browsers. You are free to adjust these settings as needed.\n    |\n    | To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS\n    |\n    */\n\n    'paths' => ['api/*', 'sanctum/csrf-cookie'],\n\n    'allowed_methods' => ['*'],\n\n    'allowed_origins' => ['*'],\n\n    'allowed_origins_patterns' => [],\n\n    'allowed_headers' => ['*'],\n\n    'exposed_headers' => [],\n\n    'max_age' => 0,\n\n    'supports_credentials' => false,\n\n];\n"
  },
  {
    "path": "config/database.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Support\\Str;\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Default Database Connection Name\n    |--------------------------------------------------------------------------\n    |\n    | Here you may specify which of the database connections below you wish\n    | to use as your default connection for all database work. Of course\n    | you may use many connections at once using the Database library.\n    |\n    */\n\n    'default' => env('DB_CONNECTION', 'mysql'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Database Connections\n    |--------------------------------------------------------------------------\n    |\n    | Here are each of the database connections setup for your application.\n    | Of course, examples of configuring each database platform that is\n    | supported by Laravel is shown below to make development simple.\n    |\n    |\n    | All database work in Laravel is done through the PHP PDO facilities\n    | so make sure you have the driver for your particular database of\n    | choice installed on your machine before you begin development.\n    |\n    */\n\n    'connections' => [\n\n        'sqlite' => [\n            'driver' => 'sqlite',\n            'url' => env('DATABASE_URL'),\n            'database' => env('DB_DATABASE', database_path('database.sqlite')),\n            'prefix' => '',\n            'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),\n        ],\n\n        'mysql' => [\n            'driver' => 'mysql',\n            'url' => env('DATABASE_URL'),\n            'host' => env('DB_HOST', '127.0.0.1'),\n            'port' => env('DB_PORT', '3306'),\n            'database' => env('DB_DATABASE', 'forge'),\n            'username' => env('DB_USERNAME', 'forge'),\n            'password' => env('DB_PASSWORD', ''),\n            'unix_socket' => env('DB_SOCKET', ''),\n            'charset' => 'utf8mb4',\n            'collation' => 'utf8mb4_unicode_ci',\n            'prefix' => '',\n            'prefix_indexes' => true,\n            'strict' => true,\n            'engine' => null,\n            'options' => extension_loaded('pdo_mysql') ? array_filter([\n                PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),\n            ]) : [],\n        ],\n\n        'pgsql' => [\n            'driver' => 'pgsql',\n            'url' => env('DATABASE_URL'),\n            'host' => env('DB_HOST', '127.0.0.1'),\n            'port' => env('DB_PORT', '5432'),\n            'database' => env('DB_DATABASE', 'forge'),\n            'username' => env('DB_USERNAME', 'forge'),\n            'password' => env('DB_PASSWORD', ''),\n            'charset' => 'utf8',\n            'prefix' => '',\n            'prefix_indexes' => true,\n            'search_path' => 'public',\n            'sslmode' => 'prefer',\n        ],\n\n        'pgsql_test' => [\n            'driver' => 'pgsql',\n            'url' => env('DATABASE_URL'),\n            'host' => env('DB_TEST_HOST', '127.0.0.1'),\n            'port' => env('DB_TEST_PORT', '5432'),\n            'database' => env('DB_TEST_DATABASE', 'forge'),\n            'username' => env('DB_TEST_USERNAME', 'forge'),\n            'password' => env('DB_TEST_PASSWORD', ''),\n            'charset' => 'utf8',\n            'prefix' => '',\n            'prefix_indexes' => true,\n            'search_path' => 'public',\n            'sslmode' => 'prefer',\n        ],\n\n        'sqlsrv' => [\n            'driver' => 'sqlsrv',\n            'url' => env('DATABASE_URL'),\n            'host' => env('DB_HOST', 'localhost'),\n            'port' => env('DB_PORT', '1433'),\n            'database' => env('DB_DATABASE', 'forge'),\n            'username' => env('DB_USERNAME', 'forge'),\n            'password' => env('DB_PASSWORD', ''),\n            'charset' => 'utf8',\n            'prefix' => '',\n            'prefix_indexes' => true,\n            // 'encrypt' => env('DB_ENCRYPT', 'yes'),\n            // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),\n        ],\n\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Migration Repository Table\n    |--------------------------------------------------------------------------\n    |\n    | This table keeps track of all the migrations that have already run for\n    | your application. Using this information, we can determine which of\n    | the migrations on disk haven't actually been run in the database.\n    |\n    */\n\n    'migrations' => 'migrations',\n\n    /*\n    |--------------------------------------------------------------------------\n    | Redis Databases\n    |--------------------------------------------------------------------------\n    |\n    | Redis is an open source, fast, and advanced key-value store that also\n    | provides a richer body of commands than a typical key-value system\n    | such as APC or Memcached. Laravel makes it easy to dig right in.\n    |\n    */\n\n    'redis' => [\n\n        'client' => env('REDIS_CLIENT', 'phpredis'),\n\n        'options' => [\n            'cluster' => env('REDIS_CLUSTER', 'redis'),\n            'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),\n        ],\n\n        'default' => [\n            'url' => env('REDIS_URL'),\n            'host' => env('REDIS_HOST', '127.0.0.1'),\n            'username' => env('REDIS_USERNAME'),\n            'password' => env('REDIS_PASSWORD'),\n            'port' => env('REDIS_PORT', '6379'),\n            'database' => env('REDIS_DB', '0'),\n        ],\n\n        'cache' => [\n            'url' => env('REDIS_URL'),\n            'host' => env('REDIS_HOST', '127.0.0.1'),\n            'username' => env('REDIS_USERNAME'),\n            'password' => env('REDIS_PASSWORD'),\n            'port' => env('REDIS_PORT', '6379'),\n            'database' => env('REDIS_CACHE_DB', '1'),\n        ],\n\n    ],\n\n];\n"
  },
  {
    "path": "config/excel.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Maatwebsite\\Excel\\Excel;\nuse PhpOffice\\PhpSpreadsheet\\Reader\\Csv;\n\nreturn [\n    'exports' => [\n\n        /*\n        |--------------------------------------------------------------------------\n        | Chunk size\n        |--------------------------------------------------------------------------\n        |\n        | When using FromQuery, the query is automatically chunked.\n        | Here you can specify how big the chunk should be.\n        |\n        */\n        'chunk_size' => 1000,\n\n        /*\n        |--------------------------------------------------------------------------\n        | Pre-calculate formulas during export\n        |--------------------------------------------------------------------------\n        */\n        'pre_calculate_formulas' => false,\n\n        /*\n        |--------------------------------------------------------------------------\n        | Enable strict null comparison\n        |--------------------------------------------------------------------------\n        |\n        | When enabling strict null comparison empty cells ('') will\n        | be added to the sheet.\n        */\n        'strict_null_comparison' => false,\n\n        /*\n        |--------------------------------------------------------------------------\n        | CSV Settings\n        |--------------------------------------------------------------------------\n        |\n        | Configure e.g. delimiter, enclosure and line ending for CSV exports.\n        |\n        */\n        'csv' => [\n            'delimiter' => ',',\n            'enclosure' => '\"',\n            'line_ending' => PHP_EOL,\n            'use_bom' => false,\n            'include_separator_line' => false,\n            'excel_compatibility' => false,\n            'output_encoding' => '',\n            'test_auto_detect' => true,\n        ],\n\n        /*\n        |--------------------------------------------------------------------------\n        | Worksheet properties\n        |--------------------------------------------------------------------------\n        |\n        | Configure e.g. default title, creator, subject,...\n        |\n        */\n        'properties' => [\n            'creator' => '',\n            'lastModifiedBy' => '',\n            'title' => '',\n            'description' => '',\n            'subject' => '',\n            'keywords' => '',\n            'category' => '',\n            'manager' => '',\n            'company' => '',\n        ],\n    ],\n\n    'imports' => [\n\n        /*\n        |--------------------------------------------------------------------------\n        | Read Only\n        |--------------------------------------------------------------------------\n        |\n        | When dealing with imports, you might only be interested in the\n        | data that the sheet exists. By default we ignore all styles,\n        | however if you want to do some logic based on style data\n        | you can enable it by setting read_only to false.\n        |\n        */\n        'read_only' => true,\n\n        /*\n        |--------------------------------------------------------------------------\n        | Ignore Empty\n        |--------------------------------------------------------------------------\n        |\n        | When dealing with imports, you might be interested in ignoring\n        | rows that have null values or empty strings. By default rows\n        | containing empty strings or empty values are not ignored but can be\n        | ignored by enabling the setting ignore_empty to true.\n        |\n        */\n        'ignore_empty' => false,\n\n        /*\n        |--------------------------------------------------------------------------\n        | Heading Row Formatter\n        |--------------------------------------------------------------------------\n        |\n        | Configure the heading row formatter.\n        | Available options: none|slug|custom\n        |\n        */\n        'heading_row' => [\n            'formatter' => 'slug',\n        ],\n\n        /*\n        |--------------------------------------------------------------------------\n        | CSV Settings\n        |--------------------------------------------------------------------------\n        |\n        | Configure e.g. delimiter, enclosure and line ending for CSV imports.\n        |\n        */\n        'csv' => [\n            'delimiter' => null,\n            'enclosure' => '\"',\n            'escape_character' => '\\\\',\n            'contiguous' => false,\n            'input_encoding' => Csv::GUESS_ENCODING,\n        ],\n\n        /*\n        |--------------------------------------------------------------------------\n        | Worksheet properties\n        |--------------------------------------------------------------------------\n        |\n        | Configure e.g. default title, creator, subject,...\n        |\n        */\n        'properties' => [\n            'creator' => '',\n            'lastModifiedBy' => '',\n            'title' => '',\n            'description' => '',\n            'subject' => '',\n            'keywords' => '',\n            'category' => '',\n            'manager' => '',\n            'company' => '',\n        ],\n\n        /*\n       |--------------------------------------------------------------------------\n       | Cell Middleware\n       |--------------------------------------------------------------------------\n       |\n       | Configure middleware that is executed on getting a cell value\n       |\n       */\n        'cells' => [\n            'middleware' => [\n                // \\Maatwebsite\\Excel\\Middleware\\TrimCellValue::class,\n                // \\Maatwebsite\\Excel\\Middleware\\ConvertEmptyCellValuesToNull::class,\n            ],\n        ],\n\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Extension detector\n    |--------------------------------------------------------------------------\n    |\n    | Configure here which writer/reader type should be used when the package\n    | needs to guess the correct type based on the extension alone.\n    |\n    */\n    'extension_detector' => [\n        'xlsx' => Excel::XLSX,\n        'xlsm' => Excel::XLSX,\n        'xltx' => Excel::XLSX,\n        'xltm' => Excel::XLSX,\n        'xls' => Excel::XLS,\n        'xlt' => Excel::XLS,\n        'ods' => Excel::ODS,\n        'ots' => Excel::ODS,\n        'slk' => Excel::SLK,\n        'xml' => Excel::XML,\n        'gnumeric' => Excel::GNUMERIC,\n        'htm' => Excel::HTML,\n        'html' => Excel::HTML,\n        'csv' => Excel::CSV,\n        'tsv' => Excel::TSV,\n\n        /*\n        |--------------------------------------------------------------------------\n        | PDF Extension\n        |--------------------------------------------------------------------------\n        |\n        | Configure here which Pdf driver should be used by default.\n        | Available options: Excel::MPDF | Excel::TCPDF | Excel::DOMPDF\n        |\n        */\n        'pdf' => Excel::DOMPDF,\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Value Binder\n    |--------------------------------------------------------------------------\n    |\n    | PhpSpreadsheet offers a way to hook into the process of a value being\n    | written to a cell. In there some assumptions are made on how the\n    | value should be formatted. If you want to change those defaults,\n    | you can implement your own default value binder.\n    |\n    | Possible value binders:\n    |\n    | [x] Maatwebsite\\Excel\\DefaultValueBinder::class\n    | [x] PhpOffice\\PhpSpreadsheet\\Cell\\StringValueBinder::class\n    | [x] PhpOffice\\PhpSpreadsheet\\Cell\\AdvancedValueBinder::class\n    |\n    */\n    'value_binder' => [\n        'default' => Maatwebsite\\Excel\\DefaultValueBinder::class,\n    ],\n\n    'cache' => [\n        /*\n        |--------------------------------------------------------------------------\n        | Default cell caching driver\n        |--------------------------------------------------------------------------\n        |\n        | By default PhpSpreadsheet keeps all cell values in memory, however when\n        | dealing with large files, this might result into memory issues. If you\n        | want to mitigate that, you can configure a cell caching driver here.\n        | When using the illuminate driver, it will store each value in the\n        | cache store. This can slow down the process, because it needs to\n        | store each value. You can use the \"batch\" store if you want to\n        | only persist to the store when the memory limit is reached.\n        |\n        | Drivers: memory|illuminate|batch\n        |\n        */\n        'driver' => 'memory',\n\n        /*\n        |--------------------------------------------------------------------------\n        | Batch memory caching\n        |--------------------------------------------------------------------------\n        |\n        | When dealing with the \"batch\" caching driver, it will only\n        | persist to the store when the memory limit is reached.\n        | Here you can tweak the memory limit to your liking.\n        |\n        */\n        'batch' => [\n            'memory_limit' => 60000,\n        ],\n\n        /*\n        |--------------------------------------------------------------------------\n        | Illuminate cache\n        |--------------------------------------------------------------------------\n        |\n        | When using the \"illuminate\" caching driver, it will automatically use\n        | your default cache store. However if you prefer to have the cell\n        | cache on a separate store, you can configure the store name here.\n        | You can use any store defined in your cache config. When leaving\n        | at \"null\" it will use the default store.\n        |\n        */\n        'illuminate' => [\n            'store' => null,\n        ],\n\n        /*\n        |--------------------------------------------------------------------------\n        | Cache Time-to-live (TTL)\n        |--------------------------------------------------------------------------\n        |\n        | The TTL of items written to cache. If you want to keep the items cached\n        | indefinitely, set this to null.  Otherwise, set a number of seconds,\n        | a \\DateInterval, or a callable.\n        |\n        | Allowable types: callable|\\DateInterval|int|null\n        |\n         */\n        'default_ttl' => 10800,\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Transaction Handler\n    |--------------------------------------------------------------------------\n    |\n    | By default the import is wrapped in a transaction. This is useful\n    | for when an import may fail and you want to retry it. With the\n    | transactions, the previous import gets rolled-back.\n    |\n    | You can disable the transaction handler by setting this to null.\n    | Or you can choose a custom made transaction handler here.\n    |\n    | Supported handlers: null|db\n    |\n    */\n    'transactions' => [\n        'handler' => 'db',\n        'db' => [\n            'connection' => null,\n        ],\n    ],\n\n    'temporary_files' => [\n\n        /*\n        |--------------------------------------------------------------------------\n        | Local Temporary Path\n        |--------------------------------------------------------------------------\n        |\n        | When exporting and importing files, we use a temporary file, before\n        | storing reading or downloading. Here you can customize that path.\n        | permissions is an array with the permission flags for the directory (dir)\n        | and the create file (file).\n        |\n        */\n        'local_path' => storage_path('framework/cache/laravel-excel'),\n\n        /*\n        |--------------------------------------------------------------------------\n        | Local Temporary Path Permissions\n        |--------------------------------------------------------------------------\n        |\n        | Permissions is an array with the permission flags for the directory (dir)\n        | and the create file (file).\n        | If omitted the default permissions of the filesystem will be used.\n        |\n        */\n        'local_permissions' => [\n            // 'dir'  => 0755,\n            // 'file' => 0644,\n        ],\n\n        /*\n        |--------------------------------------------------------------------------\n        | Remote Temporary Disk\n        |--------------------------------------------------------------------------\n        |\n        | When dealing with a multi server setup with queues in which you\n        | cannot rely on having a shared local temporary path, you might\n        | want to store the temporary file on a shared disk. During the\n        | queue executing, we'll retrieve the temporary file from that\n        | location instead. When left to null, it will always use\n        | the local path. This setting only has effect when using\n        | in conjunction with queued imports and exports.\n        |\n        */\n        'remote_disk' => null,\n        'remote_prefix' => null,\n\n        /*\n        |--------------------------------------------------------------------------\n        | Force Resync\n        |--------------------------------------------------------------------------\n        |\n        | When dealing with a multi server setup as above, it's possible\n        | for the clean up that occurs after entire queue has been run to only\n        | cleanup the server that the last AfterImportJob runs on. The rest of the server\n        | would still have the local temporary file stored on it. In this case your\n        | local storage limits can be exceeded and future imports won't be processed.\n        | To mitigate this you can set this config value to be true, so that after every\n        | queued chunk is processed the local temporary file is deleted on the server that\n        | processed it.\n        |\n        */\n        'force_resync_remote' => null,\n    ],\n];\n"
  },
  {
    "path": "config/filament.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Broadcasting\n    |--------------------------------------------------------------------------\n    |\n    | By uncommenting the Laravel Echo configuration, you may connect Filament\n    | to any Pusher-compatible websockets server.\n    |\n    | This will allow your users to receive real-time notifications.\n    |\n    */\n\n    'broadcasting' => [\n\n        // 'echo' => [\n        //     'broadcaster' => 'pusher',\n        //     'key' => env('VITE_PUSHER_APP_KEY'),\n        //     'cluster' => env('VITE_PUSHER_APP_CLUSTER'),\n        //     'wsHost' => env('VITE_PUSHER_HOST'),\n        //     'wsPort' => env('VITE_PUSHER_PORT'),\n        //     'wssPort' => env('VITE_PUSHER_PORT'),\n        //     'authEndpoint' => '/api/v1/broadcasting/auth',\n        //     'disableStats' => true,\n        //     'encrypted' => true,\n        // ],\n\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Default Filesystem Disk\n    |--------------------------------------------------------------------------\n    |\n    | This is the storage disk Filament will use to put media. You may use any\n    | of the disks defined in the `config/filesystems.php`.\n    |\n    */\n\n    'default_filesystem_disk' => env('FILAMENT_FILESYSTEM_DISK', 'public'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Assets Path\n    |--------------------------------------------------------------------------\n    |\n    | This is the directory where Filament's assets will be published to. It\n    | is relative to the `public` directory of your Laravel application.\n    |\n    | After changing the path, you should run `php artisan filament:assets`.\n    |\n    */\n\n    'assets_path' => null,\n\n    /*\n    |--------------------------------------------------------------------------\n    | Livewire Loading Delay\n    |--------------------------------------------------------------------------\n    |\n    | This sets the delay before loading indicators appear.\n    |\n    | Setting this to 'none' makes indicators appear immediately, which can be\n    | desirable for high-latency connections. Setting it to 'default' applies\n    | Livewire's standard 200ms delay.\n    |\n    */\n\n    'livewire_loading_delay' => 'default',\n\n];\n"
  },
  {
    "path": "config/filesystems.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Default Filesystem Disk\n    |--------------------------------------------------------------------------\n    |\n    | Here you may specify the default filesystem disk that should be used\n    | by the framework. The \"local\" disk, as well as a variety of cloud\n    | based disks are available to your application. Just store away!\n    |\n    */\n\n    'default' => env('FILESYSTEM_DISK', 'local'),\n\n    'public' => env('PUBLIC_FILESYSTEM_DISK', 'public'),\n\n    'private' => env('FILESYSTEM_DISK', 'local'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Filesystem Disks\n    |--------------------------------------------------------------------------\n    |\n    | Here you may configure as many filesystem \"disks\" as you wish, and you\n    | may even configure multiple disks of the same driver. Defaults have\n    | been set up for each driver as an example of the required values.\n    |\n    | Supported Drivers: \"local\", \"ftp\", \"sftp\", \"s3\"\n    |\n    */\n\n    'disks' => [\n\n        'local' => [\n            'driver' => 'local',\n            'root' => storage_path('app'),\n            'serve' => true,\n            'throw' => true,\n        ],\n\n        'public' => [\n            'driver' => 'local',\n            'root' => storage_path('app/public'),\n            'url' => env('APP_URL').'/storage',\n            'visibility' => 'public',\n            'throw' => true,\n        ],\n\n        's3' => [\n            'driver' => 's3',\n            'key' => env('S3_ACCESS_KEY_ID'),\n            'secret' => env('S3_SECRET_ACCESS_KEY'),\n            'region' => env('S3_REGION'),\n            'bucket' => env('S3_BUCKET'),\n            'url' => env('S3_URL'),\n            'temporary_url' => env('S3_URL'),\n            'endpoint' => env('S3_ENDPOINT'),\n            'use_path_style_endpoint' => env('S3_USE_PATH_STYLE_ENDPOINT', false),\n            'throw' => true,\n        ],\n\n        'testfiles' => [\n            'driver' => 'local',\n            'root' => resource_path('testfiles'),\n            'throw' => true,\n        ],\n\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Symbolic Links\n    |--------------------------------------------------------------------------\n    |\n    | Here you may configure the symbolic links that will be created when the\n    | `storage:link` Artisan command is executed. The array keys should be\n    | the locations of the links and the values should be their targets.\n    |\n    */\n\n    'links' => [\n        public_path('storage') => storage_path('app/public'),\n    ],\n\n];\n"
  },
  {
    "path": "config/fortify.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse App\\Providers\\RouteServiceProvider;\nuse Laravel\\Fortify\\Features;\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Fortify Guard\n    |--------------------------------------------------------------------------\n    |\n    | Here you may specify which authentication guard Fortify will use while\n    | authenticating users. This value should correspond with one of your\n    | guards that is already present in your \"auth\" configuration file.\n    |\n    */\n\n    'guard' => 'web',\n\n    /*\n    |--------------------------------------------------------------------------\n    | Fortify Password Broker\n    |--------------------------------------------------------------------------\n    |\n    | Here you may specify which password broker Fortify can use when a user\n    | is resetting their password. This configured value should match one\n    | of your password brokers setup in your \"auth\" configuration file.\n    |\n    */\n\n    'passwords' => 'users',\n\n    /*\n    |--------------------------------------------------------------------------\n    | Username / Email\n    |--------------------------------------------------------------------------\n    |\n    | This value defines which model attribute should be considered as your\n    | application's \"username\" field. Typically, this might be the email\n    | address of the users but you are free to change this value here.\n    |\n    | Out of the box, Fortify expects forgot password and reset password\n    | requests to have a field named 'email'. If the application uses\n    | another name for the field you may define it below as needed.\n    |\n    */\n\n    'username' => 'email',\n\n    'email' => 'email',\n\n    /*\n    |--------------------------------------------------------------------------\n    | Lowercase Usernames\n    |--------------------------------------------------------------------------\n    |\n    | This value defines whether usernames should be lowercased before saving\n    | them in the database, as some database system string fields are case\n    | sensitive. You may disable this for your application if necessary.\n    |\n    */\n\n    'lowercase_usernames' => true,\n\n    /*\n    |--------------------------------------------------------------------------\n    | Home Path\n    |--------------------------------------------------------------------------\n    |\n    | Here you may configure the path where users will get redirected during\n    | authentication or password reset when the operations are successful\n    | and the user is authenticated. You are free to change this value.\n    |\n    */\n\n    'home' => RouteServiceProvider::HOME,\n\n    /*\n    |--------------------------------------------------------------------------\n    | Fortify Routes Prefix / Subdomain\n    |--------------------------------------------------------------------------\n    |\n    | Here you may specify which prefix Fortify will assign to all the routes\n    | that it registers with the application. If necessary, you may change\n    | subdomain under which all of the Fortify routes will be available.\n    |\n    */\n\n    'prefix' => '',\n\n    'domain' => null,\n\n    /*\n    |--------------------------------------------------------------------------\n    | Fortify Routes Middleware\n    |--------------------------------------------------------------------------\n    |\n    | Here you may specify which middleware Fortify will assign to the routes\n    | that it registers with the application. If necessary, you may change\n    | these middleware but typically this provided default is preferred.\n    |\n    */\n\n    'middleware' => ['web'],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Rate Limiting\n    |--------------------------------------------------------------------------\n    |\n    | By default, Fortify will throttle logins to five requests per minute for\n    | every email and IP address combination. However, if you would like to\n    | specify a custom rate limiter to call then you may specify it here.\n    |\n    */\n\n    'limiters' => [\n        'login' => 'login',\n        'two-factor' => 'two-factor',\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Register View Routes\n    |--------------------------------------------------------------------------\n    |\n    | Here you may specify if the routes returning views should be disabled as\n    | you may not need them when building your own application. This may be\n    | especially true if you're writing a custom single-page application.\n    |\n    */\n\n    'views' => true,\n\n    /*\n    |--------------------------------------------------------------------------\n    | Features\n    |--------------------------------------------------------------------------\n    |\n    | Some of the Fortify features are optional. You may disable the features\n    | by removing them from this array. You're free to only remove some of\n    | these features or you can even remove all of these if you need to.\n    |\n    */\n\n    'features' => [\n        Features::registration(),\n        Features::resetPasswords(),\n        Features::emailVerification(),\n        Features::updateProfileInformation(),\n        Features::updatePasswords(),\n        Features::twoFactorAuthentication([\n            'confirm' => true,\n            'confirmPassword' => true,\n            // 'window' => 0,\n        ]),\n    ],\n\n];\n"
  },
  {
    "path": "config/hashing.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Default Hash Driver\n    |--------------------------------------------------------------------------\n    |\n    | This option controls the default hash driver that will be used to hash\n    | passwords for your application. By default, the bcrypt algorithm is\n    | used; however, you remain free to modify this option if you wish.\n    |\n    | Supported: \"bcrypt\", \"argon\", \"argon2id\"\n    |\n    */\n\n    'driver' => 'bcrypt',\n\n    /*\n    |--------------------------------------------------------------------------\n    | Bcrypt Options\n    |--------------------------------------------------------------------------\n    |\n    | Here you may specify the configuration options that should be used when\n    | passwords are hashed using the Bcrypt algorithm. This will allow you\n    | to control the amount of time it takes to hash the given password.\n    |\n    */\n\n    'bcrypt' => [\n        'rounds' => env('BCRYPT_ROUNDS', 12),\n        'verify' => true,\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Argon Options\n    |--------------------------------------------------------------------------\n    |\n    | Here you may specify the configuration options that should be used when\n    | passwords are hashed using the Argon algorithm. These will allow you\n    | to control the amount of time it takes to hash the given password.\n    |\n    */\n\n    'argon' => [\n        'memory' => 65536,\n        'threads' => 1,\n        'time' => 4,\n        'verify' => true,\n    ],\n\n];\n"
  },
  {
    "path": "config/jetstream.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Laravel\\Jetstream\\Features;\nuse Laravel\\Jetstream\\Http\\Middleware\\AuthenticateSession;\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Jetstream Stack\n    |--------------------------------------------------------------------------\n    |\n    | This configuration value informs Jetstream which \"stack\" you will be\n    | using for your application. In general, this value is set for you\n    | during installation and will not need to be changed after that.\n    |\n    */\n\n    'stack' => 'inertia',\n\n    /*\n     |--------------------------------------------------------------------------\n     | Jetstream Route Middleware\n     |--------------------------------------------------------------------------\n     |\n     | Here you may specify which middleware Jetstream will assign to the routes\n     | that it registers with the application. When necessary, you may modify\n     | these middleware; however, this default value is usually sufficient.\n     |\n     */\n\n    'middleware' => ['web'],\n\n    'auth_session' => AuthenticateSession::class,\n\n    /*\n    |--------------------------------------------------------------------------\n    | Jetstream Guard\n    |--------------------------------------------------------------------------\n    |\n    | Here you may specify the authentication guard Jetstream will use while\n    | authenticating users. This value should correspond with one of your\n    | guards that is already present in your \"auth\" configuration file.\n    |\n    */\n\n    'guard' => 'web',\n\n    /*\n    |--------------------------------------------------------------------------\n    | Features\n    |--------------------------------------------------------------------------\n    |\n    | Some of Jetstream's features are optional. You may disable the features\n    | by removing them from this array. You're free to only remove some of\n    | these features or you can even remove all of these if you need to.\n    |\n    */\n\n    'features' => [\n        Features::termsAndPrivacyPolicy(),\n        Features::profilePhotos(),\n        Features::teams(['invitations' => true]),\n        Features::accountDeletion(),\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Profile Photo Disk\n    |--------------------------------------------------------------------------\n    |\n    | This configuration value determines the default disk that will be used\n    | when storing profile photos for your application's users. Typically\n    | this will be the \"public\" disk but you may adjust this if needed.\n    |\n    */\n\n    'profile_photo_disk' => env('PROFILE_PHOTO_DISK', env('PUBLIC_FILESYSTEM_DISK', 'public')),\n\n];\n"
  },
  {
    "path": "config/logging.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Monolog\\Handler\\NullHandler;\nuse Monolog\\Handler\\StreamHandler;\nuse Monolog\\Handler\\SyslogUdpHandler;\nuse Monolog\\Processor\\PsrLogMessageProcessor;\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Default Log Channel\n    |--------------------------------------------------------------------------\n    |\n    | This option defines the default log channel that gets used when writing\n    | messages to the logs. The name specified in this option should match\n    | one of the channels defined in the \"channels\" configuration array.\n    |\n    */\n\n    'default' => env('LOG_CHANNEL', 'stack'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Deprecations Log Channel\n    |--------------------------------------------------------------------------\n    |\n    | This option controls the log channel that should be used to log warnings\n    | regarding deprecated PHP and library features. This allows you to get\n    | your application ready for upcoming major versions of dependencies.\n    |\n    */\n\n    'deprecations' => [\n        'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),\n        'trace' => false,\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Log Channels\n    |--------------------------------------------------------------------------\n    |\n    | Here you may configure the log channels for your application. Out of\n    | the box, Laravel uses the Monolog PHP logging library. This gives\n    | you a variety of powerful log handlers / formatters to utilize.\n    |\n    | Available Drivers: \"single\", \"daily\", \"slack\", \"syslog\",\n    |                    \"errorlog\", \"monolog\",\n    |                    \"custom\", \"stack\"\n    |\n    */\n\n    'channels' => [\n        'stack' => [\n            'driver' => 'stack',\n            'channels' => ['single'],\n            'ignore_exceptions' => false,\n        ],\n\n        'single' => [\n            'driver' => 'single',\n            'path' => storage_path('logs/laravel.log'),\n            'level' => env('LOG_LEVEL', 'debug'),\n            'replace_placeholders' => true,\n        ],\n\n        'stderr_daily' => [\n            'driver' => 'stack',\n            'channels' => ['stderr', 'daily'],\n        ],\n\n        'stack_production' => [\n            'driver' => 'stack',\n            'channels' => ['stderr', 'sentry'],\n        ],\n\n        'daily' => [\n            'driver' => 'daily',\n            'path' => storage_path('logs/laravel.log'),\n            'level' => env('LOG_LEVEL', 'debug'),\n            'days' => 14,\n            'replace_placeholders' => true,\n        ],\n\n        'slack' => [\n            'driver' => 'slack',\n            'url' => env('LOG_SLACK_WEBHOOK_URL'),\n            'username' => 'Laravel Log',\n            'emoji' => ':boom:',\n            'level' => env('LOG_LEVEL', 'critical'),\n            'replace_placeholders' => true,\n        ],\n\n        'sentry' => [\n            'driver' => 'sentry',\n            'level' => env('LOG_LEVEL_SENTRY', 'error'),\n            'bubble' => true,\n        ],\n\n        'papertrail' => [\n            'driver' => 'monolog',\n            'level' => env('LOG_LEVEL', 'debug'),\n            'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),\n            'handler_with' => [\n                'host' => env('PAPERTRAIL_URL'),\n                'port' => env('PAPERTRAIL_PORT'),\n                'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),\n            ],\n            'processors' => [PsrLogMessageProcessor::class],\n        ],\n\n        'stderr' => [\n            'driver' => 'monolog',\n            'level' => env('LOG_LEVEL', 'debug'),\n            'handler' => StreamHandler::class,\n            'formatter' => env('LOG_STDERR_FORMATTER'),\n            'with' => [\n                'stream' => 'php://stderr',\n            ],\n            'processors' => [PsrLogMessageProcessor::class],\n        ],\n\n        'syslog' => [\n            'driver' => 'syslog',\n            'level' => env('LOG_LEVEL', 'debug'),\n            'facility' => LOG_USER,\n            'replace_placeholders' => true,\n        ],\n\n        'errorlog' => [\n            'driver' => 'errorlog',\n            'level' => env('LOG_LEVEL', 'debug'),\n            'replace_placeholders' => true,\n        ],\n\n        'null' => [\n            'driver' => 'monolog',\n            'handler' => NullHandler::class,\n        ],\n\n        'emergency' => [\n            'path' => storage_path('logs/laravel.log'),\n        ],\n\n        'deprecation' => [\n            'driver' => 'single',\n            'path' => storage_path('logs/deprecation.log'),\n        ],\n    ],\n\n];\n"
  },
  {
    "path": "config/mail.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Default Mailer\n    |--------------------------------------------------------------------------\n    |\n    | This option controls the default mailer that is used to send any email\n    | messages sent by your application. Alternative mailers may be setup\n    | and used as needed; however, this mailer will be used by default.\n    |\n    */\n\n    'default' => env('MAIL_MAILER', 'smtp'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Mailer Configurations\n    |--------------------------------------------------------------------------\n    |\n    | Here you may configure all of the mailers used by your application plus\n    | their respective settings. Several examples have been configured for\n    | you and you are free to add your own as your application requires.\n    |\n    | Laravel supports a variety of mail \"transport\" drivers to be used while\n    | sending an e-mail. You will specify which one you are using for your\n    | mailers below. You are free to add additional mailers as required.\n    |\n    | Supported: \"smtp\", \"sendmail\", \"mailgun\", \"ses\", \"ses-v2\",\n    |            \"postmark\", \"log\", \"array\", \"failover\", \"roundrobin\"\n    |\n    */\n\n    'mailers' => [\n        'smtp' => [\n            'transport' => 'smtp',\n            'url' => env('MAIL_URL'),\n            'host' => env('MAIL_HOST', 'smtp.mailgun.org'),\n            'port' => env('MAIL_PORT', 587),\n            'encryption' => env('MAIL_ENCRYPTION', 'tls'),\n            'username' => env('MAIL_USERNAME'),\n            'password' => env('MAIL_PASSWORD'),\n            'timeout' => null,\n            'local_domain' => env('MAIL_EHLO_DOMAIN'),\n        ],\n\n        'ses' => [\n            'transport' => 'ses',\n        ],\n\n        'postmark' => [\n            'transport' => 'postmark',\n            // 'message_stream_id' => null,\n            // 'client' => [\n            //     'timeout' => 5,\n            // ],\n        ],\n\n        'mailgun' => [\n            'transport' => 'mailgun',\n            // 'client' => [\n            //     'timeout' => 5,\n            // ],\n        ],\n\n        'sendmail' => [\n            'transport' => 'sendmail',\n            'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),\n        ],\n\n        'log' => [\n            'transport' => 'log',\n            'channel' => env('MAIL_LOG_CHANNEL'),\n        ],\n\n        'array' => [\n            'transport' => 'array',\n        ],\n\n        'failover' => [\n            'transport' => 'failover',\n            'mailers' => [\n                'smtp',\n                'log',\n            ],\n        ],\n\n        'roundrobin' => [\n            'transport' => 'roundrobin',\n            'mailers' => [\n                'ses',\n                'postmark',\n            ],\n        ],\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Global \"From\" Address\n    |--------------------------------------------------------------------------\n    |\n    | You may wish for all e-mails sent by your application to be sent from\n    | the same address. Here, you may specify a name and address that is\n    | used globally for all e-mails that are sent by your application.\n    |\n    */\n\n    'from' => [\n        'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),\n        'name' => env('MAIL_FROM_NAME', 'Example'),\n    ],\n\n    'reply_to' => [\n        'address' => env('MAIL_REPLY_TO_ADDRESS'),\n        'name' => env('MAIL_REPLY_TO_NAME'),\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Markdown Mail Settings\n    |--------------------------------------------------------------------------\n    |\n    | If you are using Markdown based email rendering, you may configure your\n    | theme and component paths here, allowing you to customize the design\n    | of the emails. Or, you may simply stick with the Laravel defaults!\n    |\n    */\n\n    'markdown' => [\n        'theme' => 'default',\n\n        'paths' => [\n            resource_path('views/vendor/mail'),\n        ],\n    ],\n\n];\n"
  },
  {
    "path": "config/modules.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Nwidart\\Modules\\Activators\\FileActivator;\nuse Nwidart\\Modules\\Providers\\ConsoleServiceProvider;\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Module Namespace\n    |--------------------------------------------------------------------------\n    |\n    | Default module namespace.\n    |\n    */\n\n    'namespace' => 'Extensions',\n\n    /*\n    |--------------------------------------------------------------------------\n    | Module Stubs\n    |--------------------------------------------------------------------------\n    |\n    | Default module stubs.\n    |\n    */\n\n    'stubs' => [\n        'enabled' => false,\n        'path' => base_path('vendor/nwidart/laravel-modules/src/Commands/stubs'),\n        'files' => [\n            'routes/web' => 'routes/web.php',\n            'routes/api' => 'routes/api.php',\n            'views/index' => 'resources/views/index.blade.php',\n            'views/master' => 'resources/views/layouts/master.blade.php',\n            'scaffold/config' => 'config/config.php',\n            'composer' => 'composer.json',\n            'assets/js/app' => 'resources/assets/js/app.js',\n            'assets/sass/app' => 'resources/assets/sass/app.scss',\n            'vite' => 'vite.config.js',\n            'package' => 'package.json',\n        ],\n        'replacements' => [\n            'routes/web' => ['LOWER_NAME', 'STUDLY_NAME', 'MODULE_NAMESPACE', 'CONTROLLER_NAMESPACE'],\n            'routes/api' => ['LOWER_NAME', 'STUDLY_NAME', 'MODULE_NAMESPACE', 'CONTROLLER_NAMESPACE'],\n            'vite' => ['LOWER_NAME', 'STUDLY_NAME'],\n            'json' => ['LOWER_NAME', 'STUDLY_NAME', 'MODULE_NAMESPACE', 'PROVIDER_NAMESPACE'],\n            'views/index' => ['LOWER_NAME'],\n            'views/master' => ['LOWER_NAME', 'STUDLY_NAME'],\n            'scaffold/config' => ['STUDLY_NAME'],\n            'composer' => [\n                'LOWER_NAME',\n                'STUDLY_NAME',\n                'VENDOR',\n                'AUTHOR_NAME',\n                'AUTHOR_EMAIL',\n                'MODULE_NAMESPACE',\n                'PROVIDER_NAMESPACE',\n                'APP_FOLDER_NAME',\n            ],\n        ],\n        'gitkeep' => true,\n    ],\n    'paths' => [\n        /*\n        |--------------------------------------------------------------------------\n        | Modules path\n        |--------------------------------------------------------------------------\n        |\n        | This path is used to save the generated module.\n        | This path will also be added automatically to the list of scanned folders.\n        |\n        */\n\n        'modules' => base_path('extensions'),\n        /*\n        |--------------------------------------------------------------------------\n        | Modules assets path\n        |--------------------------------------------------------------------------\n        |\n        | Here you may update the modules' assets path.\n        |\n        */\n\n        'assets' => public_path('extensions'),\n        /*\n        |--------------------------------------------------------------------------\n        | The migrations' path\n        |--------------------------------------------------------------------------\n        |\n        | Where you run the 'module:publish-migration' command, where do you publish the\n        | the migration files?\n        |\n        */\n        'migration' => base_path('database/migrations'),\n\n        /*\n        |--------------------------------------------------------------------------\n        | The app path\n        |--------------------------------------------------------------------------\n        |\n        | app folder name\n        | for example can change it to 'src' or 'App'\n        */\n        'app_folder' => 'app/',\n\n        /*\n        |--------------------------------------------------------------------------\n        | Generator path\n        |--------------------------------------------------------------------------\n        | Customise the paths where the folders will be generated.\n        | Setting the generate key to false will not generate that folder\n        */\n        'generator' => [\n            // app/\n            'channels' => ['path' => 'app/Broadcasting', 'generate' => false],\n            'command' => ['path' => 'app/Console', 'generate' => false],\n            'emails' => ['path' => 'app/Emails', 'generate' => false],\n            'event' => ['path' => 'app/Events', 'generate' => false],\n            'jobs' => ['path' => 'app/Jobs', 'generate' => false],\n            'listener' => ['path' => 'app/Listeners', 'generate' => false],\n            'model' => ['path' => 'app/Models', 'generate' => false],\n            'notifications' => ['path' => 'app/Notifications', 'generate' => false],\n            'observer' => ['path' => 'app/Observers', 'generate' => false],\n            'policies' => ['path' => 'app/Policies', 'generate' => false],\n            'provider' => ['path' => 'app/Providers', 'generate' => true],\n            'route-provider' => ['path' => 'app/Providers', 'generate' => true],\n            'repository' => ['path' => 'app/Repositories', 'generate' => false],\n            'resource' => ['path' => 'app/Transformers', 'generate' => false],\n            'rules' => ['path' => 'app/Rules', 'generate' => false],\n            'component-class' => ['path' => 'app/View/Components', 'generate' => false],\n            'service' => ['path' => 'app/Services', 'generate' => false],\n\n            // app/Http/\n            'controller' => ['path' => 'app/Http/Controllers', 'generate' => true],\n            'filter' => ['path' => 'app/Http/Middleware', 'generate' => false],\n            'request' => ['path' => 'app/Http/Requests', 'generate' => false],\n\n            // config/\n            'config' => ['path' => 'config', 'generate' => true],\n\n            // database/\n            'migration' => ['path' => 'database/migrations', 'generate' => true],\n            'seeder' => ['path' => 'database/seeders', 'namespace' => 'Database\\Seeders', 'generate' => true],\n            'factory' => ['path' => 'database/factories', 'namespace' => 'Database\\Factories', 'generate' => true],\n\n            // lang/\n            'lang' => ['path' => 'lang', 'generate' => false],\n\n            // resource/\n            'assets' => ['path' => 'resources/assets', 'generate' => true],\n            'views' => ['path' => 'resources/views', 'generate' => true],\n            'component-view' => ['path' => 'resources/views/components', 'generate' => false],\n\n            // routes/\n            'routes' => ['path' => 'routes', 'generate' => true],\n\n            // tests/\n            'test-unit' => ['path' => 'tests/Unit', 'generate' => true],\n            'test-feature' => ['path' => 'tests/Feature', 'generate' => true],\n        ],\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Package commands\n    |--------------------------------------------------------------------------\n    |\n    | Here you can define which commands will be visible and used in your\n    | application. You can add your own commands to merge section.\n    |\n    */\n    'commands' => ConsoleServiceProvider::defaultCommands()\n        ->merge([\n            // New commands go here\n        ])->toArray(),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Scan Path\n    |--------------------------------------------------------------------------\n    |\n    | Here you define which folder will be scanned. By default will scan vendor\n    | directory. This is useful if you host the package in packagist website.\n    |\n    */\n    'scan' => [\n        'enabled' => false,\n        'paths' => [\n            base_path('vendor/*/*'),\n        ],\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Composer File Template\n    |--------------------------------------------------------------------------\n    |\n    | Here is the config for the composer.json file, generated by this package\n    |\n    */\n    'composer' => [\n        'vendor' => env('MODULE_VENDOR', 'solidtime-io'),\n        'author' => [\n            'name' => env('MODULE_AUTHOR_NAME', 'Nicolas Widart'),\n            'email' => env('MODULE_AUTHOR_EMAIL', 'n.widart@gmail.com'),\n        ],\n        'composer-output' => false,\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Caching\n    |--------------------------------------------------------------------------\n    |\n    | Here is the config for setting up the caching feature.\n    |\n    */\n    'cache' => [\n        'enabled' => env('MODULES_CACHE_ENABLED', false),\n        'driver' => env('MODULES_CACHE_DRIVER', 'file'),\n        'key' => env('MODULES_CACHE_KEY', 'laravel-modules'),\n        'lifetime' => env('MODULES_CACHE_LIFETIME', 60),\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Choose what laravel-modules will register as custom namespaces.\n    | Setting one to false will require you to register that part\n    | in your own Service Provider class.\n    |--------------------------------------------------------------------------\n    */\n    'register' => [\n        'translations' => true,\n        /**\n         * load files on boot or register method\n         */\n        'files' => 'register',\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Activators\n    |--------------------------------------------------------------------------\n    |\n    | You can define new types of activators here, file, database, etc. The only\n    | required parameter is 'class'.\n    | The file activator will store the activation status in storage/installed_modules\n    */\n    'activators' => [\n        'file' => [\n            'class' => FileActivator::class,\n            'statuses-file' => base_path('modules_statuses.json'),\n            'cache-key' => 'activator.installed',\n            'cache-lifetime' => 604800,\n        ],\n    ],\n\n    'activator' => 'file',\n];\n"
  },
  {
    "path": "config/octane.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Laravel\\Octane\\Contracts\\OperationTerminated;\nuse Laravel\\Octane\\Events\\RequestHandled;\nuse Laravel\\Octane\\Events\\RequestReceived;\nuse Laravel\\Octane\\Events\\RequestTerminated;\nuse Laravel\\Octane\\Events\\TaskReceived;\nuse Laravel\\Octane\\Events\\TaskTerminated;\nuse Laravel\\Octane\\Events\\TickReceived;\nuse Laravel\\Octane\\Events\\TickTerminated;\nuse Laravel\\Octane\\Events\\WorkerErrorOccurred;\nuse Laravel\\Octane\\Events\\WorkerStarting;\nuse Laravel\\Octane\\Events\\WorkerStopping;\nuse Laravel\\Octane\\Listeners\\CloseMonologHandlers;\nuse Laravel\\Octane\\Listeners\\CollectGarbage;\nuse Laravel\\Octane\\Listeners\\DisconnectFromDatabases;\nuse Laravel\\Octane\\Listeners\\EnsureUploadedFilesAreValid;\nuse Laravel\\Octane\\Listeners\\EnsureUploadedFilesCanBeMoved;\nuse Laravel\\Octane\\Listeners\\FlushOnce;\nuse Laravel\\Octane\\Listeners\\FlushTemporaryContainerInstances;\nuse Laravel\\Octane\\Listeners\\FlushUploadedFiles;\nuse Laravel\\Octane\\Listeners\\ReportException;\nuse Laravel\\Octane\\Listeners\\StopWorkerIfNecessary;\nuse Laravel\\Octane\\Octane;\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Octane Server\n    |--------------------------------------------------------------------------\n    |\n    | This value determines the default \"server\" that will be used by Octane\n    | when starting, restarting, or stopping your server via the CLI. You\n    | are free to change this to the supported server of your choosing.\n    |\n    | Supported: \"roadrunner\", \"swoole\", \"frankenphp\"\n    |\n    */\n\n    'server' => env('OCTANE_SERVER', 'frankenphp'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Force HTTPS\n    |--------------------------------------------------------------------------\n    |\n    | When this configuration value is set to \"true\", Octane will inform the\n    | framework that all absolute links must be generated using the HTTPS\n    | protocol. Otherwise your links may be generated using plain HTTP.\n    |\n    */\n\n    'https' => env('OCTANE_HTTPS', false),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Octane Listeners\n    |--------------------------------------------------------------------------\n    |\n    | All of the event listeners for Octane's events are defined below. These\n    | listeners are responsible for resetting your application's state for\n    | the next request. You may even add your own listeners to the list.\n    |\n    */\n\n    'listeners' => [\n        WorkerStarting::class => [\n            EnsureUploadedFilesAreValid::class,\n            EnsureUploadedFilesCanBeMoved::class,\n        ],\n\n        RequestReceived::class => [\n            ...Octane::prepareApplicationForNextOperation(),\n            ...Octane::prepareApplicationForNextRequest(),\n            //\n        ],\n\n        RequestHandled::class => [\n            //\n        ],\n\n        RequestTerminated::class => [\n            // FlushUploadedFiles::class,\n        ],\n\n        TaskReceived::class => [\n            ...Octane::prepareApplicationForNextOperation(),\n            //\n        ],\n\n        TaskTerminated::class => [\n            //\n        ],\n\n        TickReceived::class => [\n            ...Octane::prepareApplicationForNextOperation(),\n            //\n        ],\n\n        TickTerminated::class => [\n            //\n        ],\n\n        OperationTerminated::class => [\n            FlushOnce::class,\n            FlushTemporaryContainerInstances::class,\n            // DisconnectFromDatabases::class,\n            // CollectGarbage::class,\n        ],\n\n        WorkerErrorOccurred::class => [\n            ReportException::class,\n            StopWorkerIfNecessary::class,\n        ],\n\n        WorkerStopping::class => [\n            CloseMonologHandlers::class,\n        ],\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Warm / Flush Bindings\n    |--------------------------------------------------------------------------\n    |\n    | The bindings listed below will either be pre-warmed when a worker boots\n    | or they will be flushed before every new request. Flushing a binding\n    | will force the container to resolve that binding again when asked.\n    |\n    */\n\n    'warm' => [\n        ...Octane::defaultServicesToWarm(),\n    ],\n\n    'flush' => [\n        //\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Octane Swoole Tables\n    |--------------------------------------------------------------------------\n    |\n    | While using Swoole, you may define additional tables as required by the\n    | application. These tables can be used to store data that needs to be\n    | quickly accessed by other workers on the particular Swoole server.\n    |\n    */\n\n    'tables' => [\n        'example:1000' => [\n            'name' => 'string:1000',\n            'votes' => 'int',\n        ],\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Octane Swoole Cache Table\n    |--------------------------------------------------------------------------\n    |\n    | While using Swoole, you may leverage the Octane cache, which is powered\n    | by a Swoole table. You may set the maximum number of rows as well as\n    | the number of bytes per row using the configuration options below.\n    |\n    */\n\n    'cache' => [\n        'rows' => 1000,\n        'bytes' => 10000,\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | File Watching\n    |--------------------------------------------------------------------------\n    |\n    | The following list of files and directories will be watched when using\n    | the --watch option offered by Octane. If any of the directories and\n    | files are changed, Octane will automatically reload your workers.\n    |\n    */\n\n    'watch' => [\n        'app',\n        'bootstrap',\n        'config/**/*.php',\n        'database/**/*.php',\n        'public/**/*.php',\n        'resources/**/*.php',\n        'routes',\n        'composer.lock',\n        '.env',\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Garbage Collection Threshold\n    |--------------------------------------------------------------------------\n    |\n    | When executing long-lived PHP scripts such as Octane, memory can build\n    | up before being cleared by PHP. You can force Octane to run garbage\n    | collection if your application consumes this amount of megabytes.\n    |\n    */\n\n    'garbage' => 50,\n\n    /*\n    |--------------------------------------------------------------------------\n    | Maximum Execution Time\n    |--------------------------------------------------------------------------\n    |\n    | The following setting configures the maximum execution time for requests\n    | being handled by Octane. You may set this value to 0 to indicate that\n    | there isn't a specific time limit on Octane request execution time.\n    |\n    */\n\n    'max_execution_time' => 120,\n\n    /**\n     * Custom swoole config\n     *\n     * @source https://github.com/exaco/laravel-octane-dockerfile?tab=readme-ov-file#recommended-swoole-options-in-octanephp\n     */\n    'swoole' => [\n        'options' => [\n            'http_compression' => true,\n            'http_compression_level' => 6, // 1 - 9\n            'compression_min_length' => 20,\n            'package_max_length' => 20 * 1024 * 1024, // 20MB\n            'open_http2_protocol' => true,\n            'document_root' => public_path(),\n            'enable_static_handler' => true,\n        ],\n    ],\n\n];\n"
  },
  {
    "path": "config/passport.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Passport Guard\n    |--------------------------------------------------------------------------\n    |\n    | Here you may specify which authentication guard Passport will use when\n    | authenticating users. This value should correspond with one of your\n    | guards that is already present in your \"auth\" configuration file.\n    |\n    */\n\n    'guard' => 'web',\n\n    /*\n    |--------------------------------------------------------------------------\n    | Encryption Keys\n    |--------------------------------------------------------------------------\n    |\n    | Passport uses encryption keys while generating secure access tokens for\n    | your application. By default, the keys are stored as local files but\n    | can be set via environment variables when that is more convenient.\n    |\n    */\n\n    'private_key' => env('PASSPORT_PRIVATE_KEY'),\n\n    'public_key' => env('PASSPORT_PUBLIC_KEY'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Passport Database Connection\n    |--------------------------------------------------------------------------\n    |\n    | By default, Passport's models will utilize your application's default\n    | database connection. If you wish to use a different connection you\n    | may specify the configured name of the database connection here.\n    |\n    */\n\n    'connection' => env('PASSPORT_CONNECTION'),\n\n];\n"
  },
  {
    "path": "config/queue.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Default Queue Connection Name\n    |--------------------------------------------------------------------------\n    |\n    | Laravel's queue API supports an assortment of back-ends via a single\n    | API, giving you convenient access to each back-end using the same\n    | syntax for every one. Here you may define a default connection.\n    |\n    */\n\n    'default' => env('QUEUE_CONNECTION', 'sync'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Queue Connections\n    |--------------------------------------------------------------------------\n    |\n    | Here you may configure the connection information for each server that\n    | is used by your application. A default configuration has been added\n    | for each back-end shipped with Laravel. You are free to add more.\n    |\n    | Drivers: \"sync\", \"database\", \"beanstalkd\", \"sqs\", \"redis\", \"null\"\n    |\n    */\n\n    'connections' => [\n\n        'sync' => [\n            'driver' => 'sync',\n        ],\n\n        'database' => [\n            'driver' => 'database',\n            'table' => 'jobs',\n            'queue' => 'default',\n            'retry_after' => 90,\n            'after_commit' => false,\n        ],\n\n        'beanstalkd' => [\n            'driver' => 'beanstalkd',\n            'host' => 'localhost',\n            'queue' => 'default',\n            'retry_after' => 90,\n            'block_for' => 0,\n            'after_commit' => false,\n        ],\n\n        'sqs' => [\n            'driver' => 'sqs',\n            'key' => env('AWS_ACCESS_KEY_ID'),\n            'secret' => env('AWS_SECRET_ACCESS_KEY'),\n            'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),\n            'queue' => env('SQS_QUEUE', 'default'),\n            'suffix' => env('SQS_SUFFIX'),\n            'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n            'after_commit' => false,\n        ],\n\n        'redis' => [\n            'driver' => 'redis',\n            'connection' => 'default',\n            'queue' => env('REDIS_QUEUE', 'default'),\n            'retry_after' => 90,\n            'block_for' => null,\n            'after_commit' => false,\n        ],\n\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Job Batching\n    |--------------------------------------------------------------------------\n    |\n    | The following options configure the database and table that store job\n    | batching information. These options can be updated to any database\n    | connection and table which has been defined by your application.\n    |\n    */\n\n    'batching' => [\n        'database' => env('DB_CONNECTION', 'mysql'),\n        'table' => 'job_batches',\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Failed Queue Jobs\n    |--------------------------------------------------------------------------\n    |\n    | These options configure the behavior of failed queue job logging so you\n    | can control which database and table are used to store the jobs that\n    | have failed. You may change them to any database / table you wish.\n    |\n    */\n\n    'failed' => [\n        'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),\n        'database' => env('DB_CONNECTION', 'mysql'),\n        'table' => 'failed_jobs',\n    ],\n\n];\n"
  },
  {
    "path": "config/scheduling.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nreturn [\n\n    'tasks' => [\n        'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true),\n        'auth_send_mails_expiring_api_tokens' => (bool) env('SCHEDULING_TASK_AUTH_SEND_MAILS_EXPIRING_API_TOKENS', true),\n        'self_hosting_check_for_update' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_CHECK_FOR_UPDATE', true),\n        'self_hosting_telemetry' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_TELEMETRY', true),\n        'self_hosting_database_consistency' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_DATABASE_CONSISTENCY', false),\n    ],\n];\n"
  },
  {
    "path": "config/scramble.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse App\\Extensions\\Scramble\\ApiExceptionTypeToSchema;\nuse App\\Extensions\\Scramble\\PaginatedResourceCollectionTypeToSchema;\nuse Dedoc\\Scramble\\Http\\Middleware\\RestrictedDocsAccess;\n\nreturn [\n    /*\n     * Your API path. By default, all routes starting with this path will be added to the docs.\n     * If you need to change this behavior, you can add your custom routes resolver using `Scramble::routes()`.\n     */\n    'api_path' => 'api',\n\n    /*\n     * Your API domain. By default, app domain is used. This is also a part of the default API routes\n     * matcher, so when implementing your own, make sure you use this config if needed.\n     */\n    'api_domain' => null,\n\n    'info' => [\n        /*\n         * API version.\n         */\n        'version' => '0.0.1',\n\n        /*\n         * Description rendered on the home page of the API documentation (`/docs/api`).\n         */\n        'description' => '',\n    ],\n\n    /*\n     * Customize Stoplight Elements UI\n     */\n    'ui' => [\n        /*\n         * Hide the `Try It` feature. Enabled by default.\n         */\n        'hide_try_it' => false,\n\n        /*\n         * URL to an image that displays as a small square logo next to the title, above the table of contents.\n         */\n        'logo' => '',\n\n        /*\n         * Use to fetch the credential policy for the Try It feature. Options are: omit, include (default), and same-origin\n         */\n        'try_it_credentials_policy' => 'include',\n    ],\n\n    /*\n     * The list of servers of the API. By default, when `null`, server URL will be created from\n     * `scramble.api_path` and `scramble.api_domain` config variables. When providing an array, you\n     * will need to specify the local server URL manually (if needed).\n     *\n     * Example of non-default config (final URLs are generated using Laravel `url` helper):\n     *\n     * ```php\n     * 'servers' => [\n     *     'Live' => 'api',\n     *     'Prod' => 'https://scramble.dedoc.co/api',\n     * ],\n     * ```\n     */\n    'servers' => [\n        'Production' => 'https://app.solidtime.io/api',\n        'Staging' => 'https://app.staging.solidtime.io/api',\n        'Local' => 'https://solidtime.test/api',\n    ],\n\n    'middleware' => [\n        'web',\n        RestrictedDocsAccess::class,\n    ],\n\n    'extensions' => [\n        ApiExceptionTypeToSchema::class,\n        PaginatedResourceCollectionTypeToSchema::class,\n    ],\n];\n"
  },
  {
    "path": "config/services.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nreturn [\n    'gotenberg' => [\n        'url' => env('GOTENBERG_URL'),\n        'basic_auth_username' => env('GOTENBERG_BASIC_AUTH_USERNAME'),\n        'basic_auth_password' => env('GOTENBERG_BASIC_AUTH_PASSWORD'),\n    ],\n];\n"
  },
  {
    "path": "config/session.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Default Session Driver\n    |--------------------------------------------------------------------------\n    |\n    | This option controls the default session \"driver\" that will be used on\n    | requests. By default, we will use the lightweight native driver but\n    | you may specify any of the other wonderful drivers provided here.\n    |\n    | Supported: \"file\", \"cookie\", \"database\", \"apc\",\n    |            \"memcached\", \"redis\", \"dynamodb\", \"array\"\n    |\n    */\n\n    'driver' => env('SESSION_DRIVER', 'database'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Session Lifetime\n    |--------------------------------------------------------------------------\n    |\n    | Here you may specify the number of minutes that you wish the session\n    | to be allowed to remain idle before it expires. If you want them\n    | to immediately expire on the browser closing, set that option.\n    |\n    */\n\n    'lifetime' => env('SESSION_LIFETIME', 120),\n\n    'expire_on_close' => false,\n\n    /*\n    |--------------------------------------------------------------------------\n    | Session Encryption\n    |--------------------------------------------------------------------------\n    |\n    | This option allows you to easily specify that all of your session data\n    | should be encrypted before it is stored. All encryption will be run\n    | automatically by Laravel and you can use the Session like normal.\n    |\n    */\n\n    'encrypt' => false,\n\n    /*\n    |--------------------------------------------------------------------------\n    | Session File Location\n    |--------------------------------------------------------------------------\n    |\n    | When using the native session driver, we need a location where session\n    | files may be stored. A default has been set for you but a different\n    | location may be specified. This is only needed for file sessions.\n    |\n    */\n\n    'files' => storage_path('framework/sessions'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Session Database Connection\n    |--------------------------------------------------------------------------\n    |\n    | When using the \"database\" or \"redis\" session drivers, you may specify a\n    | connection that should be used to manage these sessions. This should\n    | correspond to a connection in your database configuration options.\n    |\n    */\n\n    'connection' => env('SESSION_CONNECTION'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Session Database Table\n    |--------------------------------------------------------------------------\n    |\n    | When using the \"database\" session driver, you may specify the table we\n    | should use to manage the sessions. Of course, a sensible default is\n    | provided for you; however, you are free to change this as needed.\n    |\n    */\n\n    'table' => 'sessions',\n\n    /*\n    |--------------------------------------------------------------------------\n    | Session Cache Store\n    |--------------------------------------------------------------------------\n    |\n    | While using one of the framework's cache driven session backends you may\n    | list a cache store that should be used for these sessions. This value\n    | must match with one of the application's configured cache \"stores\".\n    |\n    | Affects: \"apc\", \"dynamodb\", \"memcached\", \"redis\"\n    |\n    */\n\n    'store' => env('SESSION_STORE'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Session Sweeping Lottery\n    |--------------------------------------------------------------------------\n    |\n    | Some session drivers must manually sweep their storage location to get\n    | rid of old sessions from storage. Here are the chances that it will\n    | happen on a given request. By default, the odds are 2 out of 100.\n    |\n    */\n\n    'lottery' => [2, 100],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Session Cookie Name\n    |--------------------------------------------------------------------------\n    |\n    | Here you may change the name of the cookie used to identify a session\n    | instance by ID. The name specified here will get used every time a\n    | new session cookie is created by the framework for every driver.\n    |\n    */\n\n    'cookie' => env(\n        'SESSION_COOKIE',\n        'solidtime_session'\n    ),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Session Cookie Path\n    |--------------------------------------------------------------------------\n    |\n    | The session cookie path determines the path for which the cookie will\n    | be regarded as available. Typically, this will be the root path of\n    | your application but you are free to change this when necessary.\n    |\n    */\n\n    'path' => '/',\n\n    /*\n    |--------------------------------------------------------------------------\n    | Session Cookie Domain\n    |--------------------------------------------------------------------------\n    |\n    | Here you may change the domain of the cookie used to identify a session\n    | in your application. This will determine which domains the cookie is\n    | available to in your application. A sensible default has been set.\n    |\n    */\n\n    'domain' => env('SESSION_DOMAIN'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | HTTPS Only Cookies\n    |--------------------------------------------------------------------------\n    |\n    | By setting this option to true, session cookies will only be sent back\n    | to the server if the browser has a HTTPS connection. This will keep\n    | the cookie from being sent to you when it can't be done securely.\n    |\n    */\n\n    'secure' => env('SESSION_SECURE_COOKIE', env('APP_FORCE_HTTPS')),\n\n    /*\n    |--------------------------------------------------------------------------\n    | HTTP Access Only\n    |--------------------------------------------------------------------------\n    |\n    | Setting this value to true will prevent JavaScript from accessing the\n    | value of the cookie and the cookie will only be accessible through\n    | the HTTP protocol. You are free to modify this option if needed.\n    |\n    */\n\n    'http_only' => true,\n\n    /*\n    |--------------------------------------------------------------------------\n    | Same-Site Cookies\n    |--------------------------------------------------------------------------\n    |\n    | This option determines how your cookies behave when cross-site requests\n    | take place, and can be used to mitigate CSRF attacks. By default, we\n    | will set this value to \"lax\" since this is a secure default value.\n    |\n    | Supported: \"lax\", \"strict\", \"none\", null\n    |\n    */\n\n    'same_site' => 'lax',\n\n    /*\n    |--------------------------------------------------------------------------\n    | Partitioned Cookies\n    |--------------------------------------------------------------------------\n    |\n    | Setting this value to true will tie the cookie to the top-level site for\n    | a cross-site context. Partitioned cookies are accepted by the browser\n    | when flagged \"secure\" and the Same-Site attribute is set to \"none\".\n    |\n    */\n\n    'partitioned' => false,\n\n];\n"
  },
  {
    "path": "config/telescope.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Laravel\\Telescope\\Http\\Middleware\\Authorize;\nuse Laravel\\Telescope\\Watchers;\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Telescope Domain\n    |--------------------------------------------------------------------------\n    |\n    | This is the subdomain where Telescope will be accessible from. If the\n    | setting is null, Telescope will reside under the same domain as the\n    | application. Otherwise, this value will be used as the subdomain.\n    |\n    */\n\n    'domain' => env('TELESCOPE_DOMAIN'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Telescope Path\n    |--------------------------------------------------------------------------\n    |\n    | This is the URI path where Telescope will be accessible from. Feel free\n    | to change this path to anything you like. Note that the URI will not\n    | affect the paths of its internal API that aren't exposed to users.\n    |\n    */\n\n    'path' => env('TELESCOPE_PATH', 'telescope'),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Telescope Storage Driver\n    |--------------------------------------------------------------------------\n    |\n    | This configuration options determines the storage driver that will\n    | be used to store Telescope's data. In addition, you may set any\n    | custom options as needed by the particular driver you choose.\n    |\n    */\n\n    'driver' => env('TELESCOPE_DRIVER', 'database'),\n\n    'storage' => [\n        'database' => [\n            'connection' => env('DB_CONNECTION', 'mysql'),\n            'chunk' => 1000,\n        ],\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Telescope Master Switch\n    |--------------------------------------------------------------------------\n    |\n    | This option may be used to disable all Telescope watchers regardless\n    | of their individual configuration, which simply provides a single\n    | and convenient way to enable or disable Telescope data storage.\n    |\n    */\n\n    'enabled' => env('TELESCOPE_ENABLED', true),\n\n    /*\n    |--------------------------------------------------------------------------\n    | Telescope Route Middleware\n    |--------------------------------------------------------------------------\n    |\n    | These middleware will be assigned to every Telescope route, giving you\n    | the chance to add your own middleware to this list or change any of\n    | the existing middleware. Or, you can simply stick with this list.\n    |\n    */\n\n    'middleware' => [\n        'web',\n        Authorize::class,\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Allowed / Ignored Paths & Commands\n    |--------------------------------------------------------------------------\n    |\n    | The following array lists the URI paths and Artisan commands that will\n    | not be watched by Telescope. In addition to this list, some Laravel\n    | commands, like migrations and queue commands, are always ignored.\n    |\n    */\n\n    'only_paths' => [\n        // 'api/*'\n    ],\n\n    'ignore_paths' => [\n        'nova-api*',\n        'pulse*',\n    ],\n\n    'ignore_commands' => [\n        //\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Telescope Watchers\n    |--------------------------------------------------------------------------\n    |\n    | The following array lists the \"watchers\" that will be registered with\n    | Telescope. The watchers gather the application's profile data when\n    | a request or task is executed. Feel free to customize this list.\n    |\n    */\n\n    'watchers' => [\n        Watchers\\BatchWatcher::class => env('TELESCOPE_BATCH_WATCHER', true),\n\n        Watchers\\CacheWatcher::class => [\n            'enabled' => env('TELESCOPE_CACHE_WATCHER', true),\n            'hidden' => [],\n        ],\n\n        Watchers\\ClientRequestWatcher::class => env('TELESCOPE_CLIENT_REQUEST_WATCHER', true),\n\n        Watchers\\CommandWatcher::class => [\n            'enabled' => env('TELESCOPE_COMMAND_WATCHER', true),\n            'ignore' => [],\n        ],\n\n        Watchers\\DumpWatcher::class => [\n            'enabled' => env('TELESCOPE_DUMP_WATCHER', true),\n            'always' => env('TELESCOPE_DUMP_WATCHER_ALWAYS', false),\n        ],\n\n        Watchers\\EventWatcher::class => [\n            'enabled' => env('TELESCOPE_EVENT_WATCHER', true),\n            'ignore' => [],\n        ],\n\n        Watchers\\ExceptionWatcher::class => env('TELESCOPE_EXCEPTION_WATCHER', true),\n\n        Watchers\\GateWatcher::class => [\n            'enabled' => env('TELESCOPE_GATE_WATCHER', true),\n            'ignore_abilities' => [],\n            'ignore_packages' => true,\n            'ignore_paths' => [],\n        ],\n\n        Watchers\\JobWatcher::class => env('TELESCOPE_JOB_WATCHER', true),\n\n        Watchers\\LogWatcher::class => [\n            'enabled' => env('TELESCOPE_LOG_WATCHER', true),\n            'level' => 'debug',\n        ],\n\n        Watchers\\MailWatcher::class => env('TELESCOPE_MAIL_WATCHER', true),\n\n        Watchers\\ModelWatcher::class => [\n            'enabled' => env('TELESCOPE_MODEL_WATCHER', true),\n            'events' => ['eloquent.*'],\n            'hydrations' => true,\n        ],\n\n        Watchers\\NotificationWatcher::class => env('TELESCOPE_NOTIFICATION_WATCHER', true),\n\n        Watchers\\QueryWatcher::class => [\n            'enabled' => env('TELESCOPE_QUERY_WATCHER', true),\n            'ignore_packages' => true,\n            'ignore_paths' => [],\n            'slow' => 100,\n        ],\n\n        Watchers\\RedisWatcher::class => env('TELESCOPE_REDIS_WATCHER', true),\n\n        Watchers\\RequestWatcher::class => [\n            'enabled' => env('TELESCOPE_REQUEST_WATCHER', true),\n            'size_limit' => env('TELESCOPE_RESPONSE_SIZE_LIMIT', 64),\n            'ignore_http_methods' => [],\n            'ignore_status_codes' => [],\n        ],\n\n        Watchers\\ScheduleWatcher::class => env('TELESCOPE_SCHEDULE_WATCHER', true),\n        Watchers\\ViewWatcher::class => env('TELESCOPE_VIEW_WATCHER', true),\n    ],\n];\n"
  },
  {
    "path": "config/trustedproxy.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nreturn [\n    'proxies' => ! is_string(env('TRUSTED_PROXIES', null)) ? [] : explode(',', env('TRUSTED_PROXIES')),\n];\n"
  },
  {
    "path": "config/view.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | View Storage Paths\n    |--------------------------------------------------------------------------\n    |\n    | Most templating systems load templates from disk. Here you may specify\n    | an array of paths that should be checked for your views. Of course\n    | the usual Laravel view path has already been registered for you.\n    |\n    */\n\n    'paths' => [\n        resource_path('views'),\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Compiled View Path\n    |--------------------------------------------------------------------------\n    |\n    | This option determines where all the compiled Blade templates will be\n    | stored for your application. Typically, this is within the storage\n    | directory. However, as usual, you are free to change this value.\n    |\n    */\n\n    'compiled' => env(\n        'VIEW_COMPILED_PATH',\n        realpath(storage_path('framework/views'))\n    ),\n\n];\n"
  },
  {
    "path": "database/.gitignore",
    "content": "*.sqlite*\n"
  },
  {
    "path": "database/factories/AuditFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Database\\Factories;\n\nuse App\\Models\\Audit;\nuse App\\Models\\User;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Support\\Facades\\Config;\n\n/**\n * @extends Factory<Audit>\n */\nclass AuditFactory extends Factory\n{\n    /**\n     * Define the model's default state.\n     *\n     * @return array<string, mixed>\n     */\n    public function definition(): array\n    {\n        $morphPrefix = Config::get('audit.user.morph_prefix', 'user');\n\n        return [\n            $morphPrefix.'_id' => function () {\n                return User::factory()->create()->id;\n            },\n            $morphPrefix.'_type' => function () {\n                return (new User)->getMorphClass();\n            },\n            'event' => 'updated',\n            'auditable_id' => function () {\n                return User::factory()->create()->getKey();\n            },\n            'auditable_type' => function () {\n                return (new User)->getMorphClass();\n            },\n            'old_values' => [],\n            'new_values' => [],\n            'url' => $this->faker->url,\n            'ip_address' => $this->faker->ipv4,\n            'user_agent' => $this->faker->userAgent,\n            'tags' => implode(',', $this->faker->words(4)),\n        ];\n    }\n\n    public function auditUser(User $user): self\n    {\n        return $this->state(function (array $attributes) use ($user) {\n            $morphPrefix = Config::get('audit.user.morph_prefix', 'user');\n\n            return [\n                $morphPrefix.'_id' => $user->getKey(),\n                $morphPrefix.'_type' => $user->getMorphClass(),\n            ];\n        });\n    }\n\n    public function auditFor(Model $model): self\n    {\n        return $this->state(function (array $attributes) use ($model) {\n            return [\n                'auditable_id' => $model->getKey(),\n                'auditable_type' => $model->getMorphClass(),\n            ];\n        });\n    }\n}\n"
  },
  {
    "path": "database/factories/ClientFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Database\\Factories;\n\nuse App\\Models\\Client;\nuse App\\Models\\Organization;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\n\n/**\n * @extends Factory<Client>\n */\nclass ClientFactory extends Factory\n{\n    /**\n     * Define the model's default state.\n     *\n     * @return array<string, mixed>\n     */\n    public function definition(): array\n    {\n        return [\n            'name' => $this->faker->company(),\n            'archived_at' => null,\n            'organization_id' => Organization::factory(),\n        ];\n    }\n\n    public function forOrganization(Organization $organization): self\n    {\n        return $this->state(fn (array $attributes) => [\n            'organization_id' => $organization->getKey(),\n        ]);\n    }\n\n    public function randomCreatedAt(): self\n    {\n        return $this->state(function (array $attributes): array {\n            return [\n                'created_at' => $this->faker->dateTimeBetween('-1 day', 'now'),\n            ];\n        });\n    }\n\n    public function archived(): self\n    {\n        return $this->state(function (array $attributes): array {\n            return [\n                'archived_at' => $this->faker->dateTime(),\n            ];\n        });\n    }\n}\n"
  },
  {
    "path": "database/factories/MemberFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Database\\Factories;\n\nuse App\\Enums\\Role;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\n\n/**\n * @extends Factory<Member>\n */\nclass MemberFactory extends Factory\n{\n    /**\n     * Define the model's default state.\n     *\n     * @return array<string, mixed>\n     */\n    public function definition(): array\n    {\n        return [\n            'billable_rate' => null,\n            'role' => Role::Employee,\n            'organization_id' => Organization::factory(),\n            'user_id' => User::factory(),\n        ];\n    }\n\n    public function role(Role $role): static\n    {\n        return $this->state(function (array $attributes) use ($role): array {\n            return [\n                'role' => $role->value,\n            ];\n        });\n    }\n\n    public function forOrganization(Organization $organization): static\n    {\n        return $this->state(fn (array $attributes): array => [\n            'organization_id' => $organization->getKey(),\n        ]);\n    }\n\n    public function forUser(User $user): static\n    {\n        return $this->state(fn (array $attributes): array => [\n            'user_id' => $user->getKey(),\n        ]);\n    }\n\n    /**\n     * Indicate that the model's email address should be unverified.\n     */\n    public function unverified(): static\n    {\n        return $this->state(function (array $attributes) {\n            return [\n                'email_verified_at' => null,\n            ];\n        });\n    }\n\n    public function billableRate(?int $billableRate): self\n    {\n        return $this->state(fn (array $attributes) => [\n            'billable_rate' => $billableRate,\n        ]);\n    }\n\n    public function withBillableRate(): self\n    {\n        return $this->state(fn (array $attributes) => [\n            'billable_rate' => $this->faker->numberBetween(50, 1000) * 100,\n        ]);\n    }\n\n    public function attachToOrganization(Organization $organization, array $pivot = []): static\n    {\n        return $this->afterCreating(function (User $user) use ($organization, $pivot): void {\n            $user->organizations()->attach($organization, $pivot);\n        });\n    }\n}\n"
  },
  {
    "path": "database/factories/OrganizationFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Database\\Factories;\n\nuse App\\Enums\\CurrencyFormat;\nuse App\\Enums\\DateFormat;\nuse App\\Enums\\IntervalFormat;\nuse App\\Enums\\NumberFormat;\nuse App\\Enums\\TimeFormat;\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse App\\Service\\CurrencyService;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\n\n/**\n * @extends Factory<Organization>\n */\nclass OrganizationFactory extends Factory\n{\n    /**\n     * Define the model's default state.\n     *\n     * @return array<string, mixed>\n     */\n    public function definition(): array\n    {\n        return [\n            'name' => $this->faker->unique()->company(),\n            'currency' => app(CurrencyService::class)->getRandomCurrencyCode(),\n            'billable_rate' => null,\n            'user_id' => User::factory(),\n            'personal_team' => true,\n            'employees_can_see_billable_rates' => false,\n            'number_format' => $this->faker->randomElement(NumberFormat::values()),\n            'currency_format' => $this->faker->randomElement(CurrencyFormat::values()),\n            'date_format' => $this->faker->randomElement(DateFormat::values()),\n            'interval_format' => $this->faker->randomElement(IntervalFormat::values()),\n            'time_format' => $this->faker->randomElement(TimeFormat::values()),\n        ];\n    }\n\n    public function billableRate(?int $billableRate): self\n    {\n        return $this->state(fn (array $attributes) => [\n            'billable_rate' => $billableRate,\n        ]);\n    }\n\n    public function withBillableRate(): self\n    {\n        return $this->state(fn (array $attributes) => [\n            'billable_rate' => $this->faker->numberBetween(50, 1000) * 100,\n        ]);\n    }\n\n    public function withOwner(?User $owner = null): self\n    {\n        return $this->state(fn (array $attributes) => [\n            'user_id' => $owner === null ? User::factory() : $owner->getKey(),\n        ]);\n    }\n\n    public function withFakeId(): self\n    {\n        return $this->state(fn (array $attributes) => [\n            'id' => $this->faker->uuid(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "database/factories/OrganizationInvitationFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Database\\Factories;\n\nuse App\\Enums\\Role;\nuse App\\Models\\Organization;\nuse App\\Models\\OrganizationInvitation;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\n\n/**\n * @extends Factory<OrganizationInvitation>\n */\nclass OrganizationInvitationFactory extends Factory\n{\n    /**\n     * Define the model's default state.\n     *\n     * @return array<string, mixed>\n     */\n    public function definition(): array\n    {\n        return [\n            'email' => $this->faker->unique()->safeEmail(),\n            'role' => Role::Employee->value,\n            'organization_id' => Organization::factory(),\n        ];\n    }\n\n    public function forOrganization(Organization $organization): self\n    {\n        return $this->state(fn (array $attributes) => [\n            'organization_id' => $organization->getKey(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "database/factories/Passport/ClientFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Database\\Factories\\Passport;\n\nuse App\\Models\\Passport\\Client;\nuse App\\Models\\User;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\nuse Laravel\\Passport\\Database\\Factories\\ClientFactory as BaseClientFactory;\n\n/**\n * @extends Factory<Client>\n */\nclass ClientFactory extends BaseClientFactory\n{\n    /**\n     * Define the model's default state.\n     *\n     * @return array<string, mixed>\n     */\n    public function definition(): array\n    {\n        return [\n            'id' => $this->faker->uuid,\n            'owner_id' => null,\n            'owner_type' => null,\n            'name' => $this->faker->company(),\n            'secret' => $this->faker->regexify('[A-Za-z]{40}'),\n            'provider' => 'users',\n            'redirect_uris' => [$this->faker->url()],\n            'grant_types' => [],\n            'revoked' => false,\n            'created_at' => $this->faker->dateTime(),\n            'updated_at' => $this->faker->dateTime(),\n        ];\n    }\n\n    public function desktopClient(): self\n    {\n        return $this->state(fn (array $attributes) => [\n            'name' => 'Desktop',\n            'grant_types' => ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token', 'authorization_code', 'implicit'],\n        ]);\n    }\n\n    public function apiClient(): self\n    {\n        return $this->state(fn (array $attributes) => [\n            'name' => 'API',\n            'grant_types' => ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token', 'client_credentials', 'personal_access'],\n        ]);\n    }\n\n    public function personalAccessClient(): self\n    {\n        return $this->state(function (array $attributes) {\n            return [\n                'grant_types' => ['personal_access'],\n            ];\n        });\n    }\n\n    public function forUser(User $user): self\n    {\n        return $this->state(function (array $attributes) use ($user): array {\n            return [\n                'owner_id' => $user->getKey(),\n                'owner_type' => (new User)->getMorphClass(),\n            ];\n        });\n    }\n}\n"
  },
  {
    "path": "database/factories/Passport/TokenFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Database\\Factories\\Passport;\n\nuse App\\Models\\Passport\\Client;\nuse App\\Models\\Passport\\Token;\nuse App\\Models\\User;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\n\n/**\n * @extends Factory<Token>\n */\nclass TokenFactory extends Factory\n{\n    /**\n     * Define the model's default state.\n     *\n     * @return array<string, mixed>\n     */\n    public function definition(): array\n    {\n        return [\n            'id' => $this->faker->uuid,\n            'user_id' => null,\n            'client_id' => $this->faker->uuid,\n            'name' => null,\n            'scopes' => [],\n            'revoked' => false,\n            'created_at' => $this->faker->dateTime,\n            'updated_at' => $this->faker->dateTime,\n            'expires_at' => $this->faker->dateTime,\n            'reminder_sent_at' => null,\n            'expired_info_sent_at' => null,\n        ];\n    }\n\n    public function forUser(User $user): self\n    {\n        return $this->state(function (array $attributes) use ($user): array {\n            return [\n                'user_id' => $user->getKey(),\n            ];\n        });\n    }\n\n    public function forClient(Client $client): self\n    {\n        return $this->state(function (array $attributes) use ($client): array {\n            return [\n                'client_id' => $client->getKey(),\n            ];\n        });\n    }\n}\n"
  },
  {
    "path": "database/factories/ProjectFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Database\\Factories;\n\nuse App\\Models\\Client;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\ProjectMember;\nuse App\\Service\\ColorService;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\nuse Illuminate\\Support\\Carbon;\n\n/**\n * @extends Factory<Project>\n */\nclass ProjectFactory extends Factory\n{\n    /**\n     * Define the model's default state.\n     *\n     * @return array<string, mixed>\n     */\n    public function definition(): array\n    {\n        return [\n            'name' => $this->faker->company(),\n            'color' => app(ColorService::class)->getRandomColor(),\n            'is_billable' => false,\n            'billable_rate' => null,\n            'is_public' => false,\n            'archived_at' => null,\n            'client_id' => null,\n            'organization_id' => Organization::factory(),\n            'estimated_time' => null,\n        ];\n    }\n\n    public function withEstimatedTime(): self\n    {\n        return $this->state(function (array $attributes): array {\n            return [\n                'estimated_time' => $this->faker->randomNumber(3),\n            ];\n        });\n    }\n\n    public function billable(?int $billableRate = null): self\n    {\n        return $this->state(function (array $attributes) use ($billableRate): array {\n            return [\n                'is_billable' => true,\n                'billable_rate' => $billableRate === null ? $this->faker->numberBetween(50, 1000) * 100 : $billableRate,\n            ];\n        });\n    }\n\n    public function createdAt(Carbon $createdAt): self\n    {\n        return $this->state(function (array $attributes) use ($createdAt): array {\n            return [\n                'created_at' => $createdAt,\n            ];\n        });\n    }\n\n    public function archived(): self\n    {\n        return $this->state(function (array $attributes): array {\n            return [\n                'archived_at' => $this->faker->dateTime(),\n            ];\n        });\n    }\n\n    public function forOrganization(Organization $organization): self\n    {\n        return $this->state(function (array $attributes) use ($organization): array {\n            return [\n                'organization_id' => $organization->getKey(),\n            ];\n        });\n    }\n\n    public function isPublic(): self\n    {\n        return $this->state(function (array $attributes): array {\n            return [\n                'is_public' => true,\n            ];\n        });\n    }\n\n    public function isPrivate(): self\n    {\n        return $this->state(function (array $attributes): array {\n            return [\n                'is_public' => false,\n            ];\n        });\n    }\n\n    public function addMember(Member $member, array $attributes = []): self\n    {\n        return $this->afterCreating(function (Project $project) use ($member, $attributes): void {\n            ProjectMember::factory()\n                ->forProject($project)\n                ->forMember($member)\n                ->create($attributes);\n        });\n    }\n\n    public function withClient(): self\n    {\n        return $this->state(function (array $attributes): array {\n            return [\n                'client_id' => Client::factory(),\n            ];\n        });\n    }\n\n    public function forClient(?Client $client): self\n    {\n        return $this->state(function (array $attributes) use ($client): array {\n            return [\n                'client_id' => $client?->getKey(),\n            ];\n        });\n    }\n}\n"
  },
  {
    "path": "database/factories/ProjectMemberFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Database\\Factories;\n\nuse App\\Models\\Member;\nuse App\\Models\\Project;\nuse App\\Models\\ProjectMember;\nuse App\\Models\\User;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\n\n/**\n * @extends Factory<ProjectMember>\n */\nclass ProjectMemberFactory extends Factory\n{\n    /**\n     * Define the model's default state.\n     *\n     * @return array<string, mixed>\n     */\n    public function definition(): array\n    {\n        return [\n            'billable_rate' => $this->faker->numberBetween(10, 10000) * 100,\n            'project_id' => Project::factory(),\n            'user_id' => User::factory(),\n            'member_id' => Member::factory(),\n        ];\n    }\n\n    /**\n     * @deprecated Use forMember instead\n     */\n    public function forUser(User $user): self\n    {\n        return $this->state(function (array $attributes) use ($user): array {\n            return [\n                'user_id' => $user->getKey(),\n            ];\n        });\n    }\n\n    public function forMember(Member $member): self\n    {\n        return $this->state(function (array $attributes) use ($member): array {\n            return [\n                'member_id' => $member->getKey(),\n                'user_id' => $member->user_id, // Legacy\n            ];\n        });\n    }\n\n    public function forProject(Project $project): self\n    {\n        return $this->state(function (array $attributes) use ($project): array {\n            return [\n                'project_id' => $project->getKey(),\n            ];\n        });\n    }\n}\n"
  },
  {
    "path": "database/factories/ReportFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Database\\Factories;\n\nuse App\\Enums\\TimeEntryAggregationType;\nuse App\\Enums\\TimeEntryAggregationTypeInterval;\nuse App\\Enums\\Weekday;\nuse App\\Models\\Organization;\nuse App\\Models\\Report;\nuse App\\Service\\Dto\\ReportPropertiesDto;\nuse App\\Service\\ReportService;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\nuse Illuminate\\Support\\Carbon;\n\n/**\n * @extends Factory<Report>\n */\nclass ReportFactory extends Factory\n{\n    /**\n     * Define the model's default state.\n     *\n     * @return array<string, mixed>\n     */\n    public function definition(): array\n    {\n        $reportDto = new ReportPropertiesDto;\n        $reportDto->start = Carbon::createFromDate($this->faker->dateTimeBetween('-1 year', '-1 month'));\n        $reportDto->end = Carbon::createFromDate($this->faker->dateTimeBetween('-1 month', 'now'));\n        $reportDto->group = TimeEntryAggregationType::Project;\n        $reportDto->subGroup = TimeEntryAggregationType::Task;\n        $reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;\n        $reportDto->weekStart = Weekday::from($this->faker->randomElement(Weekday::values()));\n        $reportDto->timezone = $this->faker->timezone();\n\n        return [\n            'name' => $this->faker->company(),\n            'description' => $this->faker->paragraph(),\n            'is_public' => $this->faker->boolean(),\n            'properties' => $reportDto,\n            'organization_id' => Organization::factory(),\n        ];\n    }\n\n    public function randomCreatedAt(): self\n    {\n        return $this->state(fn (array $attributes): array => [\n            'created_at' => $this->faker->dateTimeBetween('-1 year', 'now'),\n        ]);\n    }\n\n    public function public(): self\n    {\n        return $this->state(fn (array $attributes): array => [\n            'is_public' => true,\n            'share_secret' => app(ReportService::class)->generateSecret(),\n        ]);\n    }\n\n    public function private(): self\n    {\n        return $this->state(fn (array $attributes): array => [\n            'is_public' => false,\n            'share_secret' => null,\n        ]);\n    }\n\n    public function forOrganization(Organization $organization): self\n    {\n        return $this->state(fn (array $attributes): array => [\n            'organization_id' => $organization->getKey(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "database/factories/TagFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Database\\Factories;\n\nuse App\\Models\\Organization;\nuse App\\Models\\Tag;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\n\n/**\n * @extends Factory<Tag>\n */\nclass TagFactory extends Factory\n{\n    /**\n     * Define the model's default state.\n     *\n     * @return array<string, mixed>\n     */\n    public function definition(): array\n    {\n        return [\n            'name' => $this->faker->name(),\n            'organization_id' => Organization::factory(),\n        ];\n    }\n\n    public function forOrganization(Organization $organization): self\n    {\n        return $this->state(function (array $attributes) use ($organization) {\n            return [\n                'organization_id' => $organization->getKey(),\n            ];\n        });\n    }\n\n    public function randomCreatedAt(): self\n    {\n        return $this->state(function (array $attributes): array {\n            return [\n                'created_at' => $this->faker->dateTimeBetween('-1 day', 'now'),\n            ];\n        });\n    }\n}\n"
  },
  {
    "path": "database/factories/TaskFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Database\\Factories;\n\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\Task;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\n\n/**\n * @extends Factory<Task>\n */\nclass TaskFactory extends Factory\n{\n    /**\n     * Define the model's default state.\n     *\n     * @return array<string, mixed>\n     */\n    public function definition(): array\n    {\n        return [\n            'name' => $this->faker->word(),\n            'project_id' => Project::factory(),\n            'organization_id' => Organization::factory(),\n            'done_at' => null,\n            'estimated_time' => null,\n        ];\n    }\n\n    public function forProject(Project $project): self\n    {\n        return $this->state(fn (array $attributes) => [\n            'project_id' => $project->getKey(),\n        ]);\n    }\n\n    public function isDone(): self\n    {\n        return $this->state(fn (array $attributes) => [\n            'done_at' => $this->faker->dateTime('now', 'UTC'),\n        ]);\n    }\n\n    public function forOrganization(Organization $organization): self\n    {\n        return $this->state(fn (array $attributes) => [\n            'organization_id' => $organization->getKey(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "database/factories/TimeEntryFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Database\\Factories;\n\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\Tag;\nuse App\\Models\\Task;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\nuse Illuminate\\Support\\Carbon;\n\n/**\n * @extends Factory<TimeEntry>\n */\nclass TimeEntryFactory extends Factory\n{\n    /**\n     * Define the model's default state.\n     *\n     * @return array<string, mixed>\n     */\n    public function definition(): array\n    {\n        $start = $this->faker->dateTimeBetween('-1 year', '-1 hour');\n\n        return [\n            'description' => $this->faker->sentence(),\n            'start' => $start,\n            'end' => $this->faker->dateTimeBetween($start, 'now'),\n            'billable' => $this->faker->boolean(),\n            'is_imported' => false,\n            'tags' => [],\n            'user_id' => User::factory(),\n            'member_id' => Member::factory(),\n            'task_id' => null,\n            'project_id' => null,\n            'organization_id' => Organization::factory(),\n            'billable_rate' => null,\n        ];\n    }\n\n    public function notBillable(): self\n    {\n        return $this->state(function (array $attributes): array {\n            return [\n                'billable' => false,\n            ];\n        });\n    }\n\n    public function billableRate(int $billableRate): self\n    {\n        return $this->state(function (array $attributes) use ($billableRate): array {\n            return [\n                'billable' => true,\n                'billable_rate' => $billableRate,\n            ];\n        });\n    }\n\n    public function withTask(Organization $organization): self\n    {\n        return $this->state(function (array $attributes) use (&$organization): array {\n            $project = Project::factory()->forOrganization($organization)->create();\n            $task = Task::factory()->forProject($project)->forOrganization($organization)->create();\n\n            return [\n                'task_id' => $task->getKey(),\n                'project_id' => $task->project->getKey(),\n            ];\n        });\n    }\n\n    public function withTags(Organization $organization): self\n    {\n        return $this->state(function (array $attributes) use ($organization): array {\n            return [\n                'tags' => [\n                    Tag::factory()->forOrganization($organization)->create()->getKey(),\n                    Tag::factory()->forOrganization($organization)->create()->getKey(),\n                ],\n            ];\n        });\n    }\n\n    public function startBetween(Carbon $rangeStart, Carbon $rangeEnd, bool $fixedValueForMultiple = false): self\n    {\n        $fixedStart = Carbon::instance($this->faker->dateTimeBetween($rangeStart, $rangeEnd));\n\n        return $this->state(function (array $attributes) use ($rangeStart, $rangeEnd, $fixedStart, $fixedValueForMultiple): array {\n            $start = $fixedValueForMultiple ? $fixedStart : Carbon::instance($this->faker->dateTimeBetween($rangeStart, $rangeEnd));\n\n            return [\n                'start' => $start->utc(),\n                'end' => $this->faker->dateTimeBetween($start, 'now'),\n            ];\n        });\n    }\n\n    public function active(): self\n    {\n        return $this->state(function (array $attributes): array {\n            return [\n                'end' => null,\n            ];\n        });\n    }\n\n    /**\n     * @deprecated Use forMember instead\n     */\n    public function forUser(User $user): self\n    {\n        return $this->state(function (array $attributes) use ($user) {\n            return [\n                'user_id' => $user->getKey(),\n            ];\n        });\n    }\n\n    public function forMember(Member $member): static\n    {\n        return $this->state(function (array $attributes) use ($member): array {\n            return [\n                'member_id' => $member->getKey(),\n                'user_id' => $member->user_id,\n                'organization_id' => $member->organization_id,\n            ];\n        });\n    }\n\n    public function billable(): self\n    {\n        return $this->state(function (array $attributes): array {\n            return [\n                'billable' => true,\n            ];\n        });\n    }\n\n    public function startWithDuration(Carbon $start, int $durationInSeconds): self\n    {\n        return $this->state(function (array $attributes) use ($start, $durationInSeconds): array {\n            return [\n                'start' => $start->copy()->utc(),\n                'end' => $start->copy()->utc()->addSeconds($durationInSeconds),\n            ];\n        });\n    }\n\n    public function endWithDuration(Carbon $end, int $durationInSeconds): self\n    {\n        return $this->state(function (array $attributes) use ($end, $durationInSeconds): array {\n            return [\n                'start' => $end->copy()->utc()->subSeconds($durationInSeconds),\n                'end' => $end->copy()->utc(),\n            ];\n        });\n    }\n\n    public function start(Carbon $start): self\n    {\n        return $this->state(function (array $attributes) use ($start): array {\n            return [\n                'start' => $start->copy()->utc(),\n            ];\n        });\n    }\n\n    public function forOrganization(Organization $organization): self\n    {\n        return $this->state(function (array $attributes) use ($organization) {\n            return [\n                'organization_id' => $organization->getKey(),\n            ];\n        });\n    }\n\n    public function forProject(?Project $project): self\n    {\n        return $this->state(fn (array $attributes) => [\n            'project_id' => $project?->getKey(),\n            'client_id' => $project?->client_id,\n        ]);\n    }\n\n    public function forTask(?Task $task): self\n    {\n        return $this->state(fn (array $attributes) => [\n            'task_id' => $task?->getKey(),\n            'project_id' => $task?->project?->getKey(),\n            'client_id' => $task?->project?->client?->getKey(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "database/factories/UserFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Database\\Factories;\n\nuse App\\Enums\\Role;\nuse App\\Enums\\Weekday;\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\nuse Illuminate\\Support\\Facades\\Storage;\nuse Illuminate\\Support\\Str;\n\n/**\n * @extends Factory<User>\n */\nclass UserFactory extends Factory\n{\n    /**\n     * Define the model's default state.\n     *\n     * @return array<string, mixed>\n     */\n    public function definition(): array\n    {\n        return [\n            'name' => $this->faker->name(),\n            'email' => $this->faker->unique()->safeEmail(),\n            'email_verified_at' => now(),\n            'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password\n            'two_factor_secret' => null,\n            'two_factor_confirmed_at' => null,\n            'two_factor_recovery_codes' => null,\n            'remember_token' => Str::random(10),\n            'profile_photo_path' => null,\n            'current_team_id' => null,\n            'is_placeholder' => false,\n            'timezone' => 'Europe/Vienna',\n            'week_start' => Weekday::Monday,\n        ];\n    }\n\n    public function forCurrentOrganization(Organization $organization): static\n    {\n        return $this->state(function (array $attributes) use ($organization): array {\n            return [\n                'current_team_id' => $organization->getKey(),\n            ];\n        });\n    }\n\n    public function randomTimeZone(): static\n    {\n        return $this->state(function (array $attributes) {\n            return [\n                'timezone' => $this->faker->timezone(),\n            ];\n        });\n    }\n\n    public function placeholder(bool $placeholder = true): static\n    {\n        return $this->state(function (array $attributes) use ($placeholder): array {\n            return [\n                'is_placeholder' => $placeholder,\n            ];\n        });\n    }\n\n    /**\n     * Indicate that the model's email address should be unverified.\n     */\n    public function unverified(): static\n    {\n        return $this->state(function (array $attributes) {\n            return [\n                'email_verified_at' => null,\n            ];\n        });\n    }\n\n    public function attachToOrganization(Organization $organization, array $pivot = []): static\n    {\n        return $this->afterCreating(function (User $user) use ($organization, $pivot): void {\n            $user->organizations()->attach($organization, $pivot);\n        });\n    }\n\n    public function withProfilePicture(): static\n    {\n        $profilePhoto = $this->faker->image(null, 500, 500);\n        /** @see \\Illuminate\\Http\\FileHelpers::hashName */\n        $path = 'profile-photos/'.Str::random(40).'.png';\n        Storage::disk(config('jetstream.profile_photo_disk', 'public'))->put($path, $profilePhoto);\n\n        return $this->state(function (array $attributes) use ($path): array {\n            return [\n                'profile_photo_path' => $path,\n            ];\n        });\n    }\n\n    /**\n     * Indicate that the user should have a personal team.\n     */\n    public function withPersonalOrganization(?callable $callback = null): static\n    {\n        return $this->afterCreating(function (User $user) use ($callback): void {\n            $organization = Organization::factory()\n                ->state(fn (array $attributes) => [\n                    'name' => $user->name.'\\'s Organization',\n                    'user_id' => $user->id,\n                    'personal_team' => true,\n                ])\n                ->when(is_callable($callback), $callback)\n                ->create();\n\n            $organization->owner()->associate($user);\n            $organization->users()->attach($user, ['role' => Role::Owner->value]);\n            $user->currentTeam()->associate($organization);\n            $user->save();\n        });\n    }\n}\n"
  },
  {
    "path": "database/migrations/2014_10_12_000000_create_users_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('users', function (Blueprint $table): void {\n            $table->uuid('id')->primary();\n            $table->string('name');\n            $table->string('email');\n            $table->timestamp('email_verified_at')->nullable();\n            $table->string('password')->nullable();\n            $table->rememberToken();\n            $table->boolean('is_placeholder')->default(false);\n            $table->foreignUuid('current_team_id')->nullable();\n            $table->string('profile_photo_path', 2048)->nullable();\n            $table->string('timezone');\n            $table->enum('week_start', [\n                'monday',\n                'tuesday',\n                'wednesday',\n                'thursday',\n                'friday',\n                'saturday',\n                'sunday',\n            ]);\n            $table->timestamps();\n\n            $table->uniqueIndex('email')\n                ->where('is_placeholder = false');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('users');\n    }\n};\n"
  },
  {
    "path": "database/migrations/2014_10_12_100000_create_password_reset_tokens_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('password_reset_tokens', function (Blueprint $table): void {\n            $table->string('email')->primary();\n            $table->string('token');\n            $table->timestamp('created_at')->nullable();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('password_reset_tokens');\n    }\n};\n"
  },
  {
    "path": "database/migrations/2014_10_12_200000_add_two_factor_columns_to_users_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\nuse Laravel\\Fortify\\Fortify;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('users', function (Blueprint $table): void {\n            $table->text('two_factor_secret')\n                ->after('password')\n                ->nullable();\n\n            $table->text('two_factor_recovery_codes')\n                ->after('two_factor_secret')\n                ->nullable();\n\n            if (Fortify::confirmsTwoFactorAuthentication()) {\n                $table->timestamp('two_factor_confirmed_at')\n                    ->after('two_factor_recovery_codes')\n                    ->nullable();\n            }\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('users', function (Blueprint $table): void {\n            $table->dropColumn(array_merge([\n                'two_factor_secret',\n                'two_factor_recovery_codes',\n            ], Fortify::confirmsTwoFactorAuthentication() ? [\n                'two_factor_confirmed_at',\n            ] : []));\n        });\n    }\n};\n"
  },
  {
    "path": "database/migrations/2016_06_01_000001_create_oauth_auth_codes_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('oauth_auth_codes', function (Blueprint $table): void {\n            $table->string('id', 100)->primary();\n            $table->foreignUuid('user_id')->index();\n            $table->uuid('client_id');\n            $table->text('scopes')->nullable();\n            $table->boolean('revoked');\n            $table->dateTime('expires_at')->nullable();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('oauth_auth_codes');\n    }\n};\n"
  },
  {
    "path": "database/migrations/2016_06_01_000002_create_oauth_access_tokens_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('oauth_access_tokens', function (Blueprint $table): void {\n            $table->string('id', 100)->primary();\n            $table->foreignUuid('user_id')->nullable()->index();\n            $table->uuid('client_id');\n            $table->string('name')->nullable();\n            $table->text('scopes')->nullable();\n            $table->boolean('revoked');\n            $table->timestamps();\n            $table->dateTime('expires_at')->nullable();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('oauth_access_tokens');\n    }\n};\n"
  },
  {
    "path": "database/migrations/2016_06_01_000003_create_oauth_refresh_tokens_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('oauth_refresh_tokens', function (Blueprint $table): void {\n            $table->string('id', 100)->primary();\n            $table->string('access_token_id', 100)->index();\n            $table->boolean('revoked');\n            $table->dateTime('expires_at')->nullable();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('oauth_refresh_tokens');\n    }\n};\n"
  },
  {
    "path": "database/migrations/2016_06_01_000004_create_oauth_clients_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('oauth_clients', function (Blueprint $table): void {\n            $table->uuid('id')->primary();\n            $table->foreignUuid('user_id')->nullable()->index();\n            $table->string('name');\n            $table->string('secret', 100)->nullable();\n            $table->string('provider')->nullable();\n            $table->text('redirect');\n            $table->boolean('personal_access_client');\n            $table->boolean('password_client');\n            $table->boolean('revoked');\n            $table->timestamps();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('oauth_clients');\n    }\n};\n"
  },
  {
    "path": "database/migrations/2016_06_01_000005_create_oauth_personal_access_clients_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('oauth_personal_access_clients', function (Blueprint $table): void {\n            $table->bigIncrements('id');\n            $table->uuid('client_id');\n            $table->timestamps();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('oauth_personal_access_clients');\n    }\n};\n"
  },
  {
    "path": "database/migrations/2018_08_08_100000_create_telescope_entries_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Get the migration connection name.\n     */\n    public function getConnection(): ?string\n    {\n        return config('telescope.storage.database.connection');\n    }\n\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        if (! App::isLocal()) {\n            return;\n        }\n        $schema = Schema::connection($this->getConnection());\n\n        $schema->create('telescope_entries', function (Blueprint $table): void {\n            $table->bigIncrements('sequence');\n            $table->uuid('uuid');\n            $table->uuid('batch_id');\n            $table->string('family_hash')->nullable();\n            $table->boolean('should_display_on_index')->default(true);\n            $table->string('type', 20);\n            $table->longText('content');\n            $table->dateTime('created_at')->nullable();\n\n            $table->unique('uuid');\n            $table->index('batch_id');\n            $table->index('family_hash');\n            $table->index('created_at');\n            $table->index(['type', 'should_display_on_index']);\n        });\n\n        $schema->create('telescope_entries_tags', function (Blueprint $table): void {\n            $table->uuid('entry_uuid');\n            $table->string('tag');\n\n            $table->primary(['entry_uuid', 'tag']);\n            $table->index('tag');\n\n            $table->foreign('entry_uuid')\n                ->references('uuid')\n                ->on('telescope_entries')\n                ->onDelete('cascade');\n        });\n\n        $schema->create('telescope_monitoring', function (Blueprint $table): void {\n            $table->string('tag')->primary();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        if (! App::isLocal()) {\n            return;\n        }\n        $schema = Schema::connection($this->getConnection());\n\n        $schema->dropIfExists('telescope_entries_tags');\n        $schema->dropIfExists('telescope_entries');\n        $schema->dropIfExists('telescope_monitoring');\n    }\n};\n"
  },
  {
    "path": "database/migrations/2019_08_19_000000_create_failed_jobs_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('failed_jobs', function (Blueprint $table): void {\n            $table->uuid('id')->primary();\n            $table->uuid('uuid')->unique();\n            $table->text('connection');\n            $table->text('queue');\n            $table->longText('payload');\n            $table->longText('exception');\n            $table->timestamp('failed_at')->useCurrent();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('failed_jobs');\n    }\n};\n"
  },
  {
    "path": "database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('personal_access_tokens', function (Blueprint $table): void {\n            $table->uuid('id')->primary();\n            $table->morphs('tokenable');\n            $table->string('name');\n            $table->string('token', 64)->unique();\n            $table->text('abilities')->nullable();\n            $table->timestamp('last_used_at')->nullable();\n            $table->timestamp('expires_at')->nullable();\n            $table->timestamps();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('personal_access_tokens');\n    }\n};\n"
  },
  {
    "path": "database/migrations/2020_05_21_100000_create_organizations_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('organizations', function (Blueprint $table): void {\n            $table->uuid('id')->primary();\n            $table->foreignUuid('user_id')->index();\n            $table->string('name');\n            $table->boolean('personal_team');\n            $table->integer('billable_rate')->unsigned()->nullable();\n            $table->string('currency', 3);\n            $table->timestamps();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('organizations');\n    }\n};\n"
  },
  {
    "path": "database/migrations/2020_05_21_200000_create_organization_user_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('organization_user', function (Blueprint $table): void {\n            $table->uuid('id')->primary();\n            $table->foreignUuid('organization_id');\n            $table->foreignUuid('user_id');\n            $table->string('role')->nullable();\n            $table->integer('billable_rate')->unsigned()->nullable();\n            $table->timestamps();\n\n            $table->unique(['organization_id', 'user_id']);\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('organization_user');\n    }\n};\n"
  },
  {
    "path": "database/migrations/2020_05_21_300000_create_organization_invitations_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('organization_invitations', function (Blueprint $table): void {\n            $table->uuid('id')->primary();\n            $table->foreignUuid('organization_id')\n                ->constrained()\n                ->cascadeOnDelete();\n            $table->string('email');\n            $table->string('role')->nullable();\n            $table->timestamps();\n\n            $table->unique(['organization_id', 'email']);\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('organization_invitations');\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_01_16_161030_create_sessions_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('sessions', function (Blueprint $table): void {\n            $table->string('id')->primary();\n            $table->foreignUuid('user_id')->nullable()->index();\n            $table->string('ip_address', 45)->nullable();\n            $table->text('user_agent')->nullable();\n            $table->longText('payload');\n            $table->integer('last_activity')->index();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('sessions');\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_01_20_110218_create_clients_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('clients', function (Blueprint $table): void {\n            $table->uuid('id')->primary();\n            $table->string('name', 255);\n            $table->uuid('organization_id');\n            $table->foreign('organization_id')\n                ->references('id')\n                ->on('organizations')\n                ->cascadeOnUpdate()\n                ->restrictOnDelete();\n            $table->timestamps();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('clients');\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_01_20_110439_create_projects_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('projects', function (Blueprint $table): void {\n            $table->uuid('id')->primary();\n            $table->string('name', 255);\n            $table->string('color', 16);\n            $table->integer('billable_rate')->unsigned()->nullable();\n            $table->boolean('is_public')->default(false);\n            $table->uuid('client_id')->nullable();\n            $table->foreign('client_id')\n                ->references('id')\n                ->on('clients')\n                ->cascadeOnUpdate()\n                ->restrictOnDelete();\n            $table->uuid('organization_id');\n            $table->foreign('organization_id')\n                ->references('id')\n                ->on('organizations')\n                ->cascadeOnUpdate()\n                ->restrictOnDelete();\n            $table->timestamps();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('projects');\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_01_20_110444_create_tasks_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('tasks', function (Blueprint $table): void {\n            $table->uuid('id')->primary();\n            $table->string('name', 500);\n            $table->uuid('project_id');\n            $table->foreign('project_id')\n                ->references('id')\n                ->on('projects')\n                ->cascadeOnUpdate()\n                ->restrictOnDelete();\n            $table->uuid('organization_id');\n            $table->foreign('organization_id')\n                ->references('id')\n                ->on('organizations')\n                ->cascadeOnUpdate()\n                ->restrictOnDelete();\n            $table->timestamps();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('tasks');\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_01_20_110452_create_tags_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('tags', function (Blueprint $table): void {\n            $table->uuid('id')->primary();\n            $table->string('name', 255);\n            $table->uuid('organization_id');\n            $table->foreign('organization_id')\n                ->references('id')\n                ->on('organizations')\n                ->cascadeOnUpdate()\n                ->restrictOnDelete();\n            $table->timestamps();\n\n            $table->index('created_at');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('tags');\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_01_20_110837_create_time_entries_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('time_entries', function (Blueprint $table): void {\n            $table->uuid('id')->primary();\n            $table->string('description', 500);\n            $table->dateTime('start');\n            $table->dateTime('end')->nullable();\n            $table->integer('billable_rate')->unsigned()->nullable();\n            $table->boolean('billable')->default(false);\n            $table->uuid('user_id');\n            $table->foreign('user_id')\n                ->references('id')\n                ->on('users')\n                ->cascadeOnUpdate()\n                ->restrictOnDelete();\n            $table->uuid('organization_id');\n            $table->foreign('organization_id')\n                ->references('id')\n                ->on('organizations')\n                ->cascadeOnUpdate()\n                ->restrictOnDelete();\n            $table->uuid('project_id')->nullable();\n            $table->foreign('project_id')\n                ->references('id')\n                ->on('projects')\n                ->cascadeOnUpdate()\n                ->restrictOnDelete();\n            $table->uuid('task_id')->nullable();\n            $table->foreign('task_id')\n                ->references('id')\n                ->on('tasks')\n                ->cascadeOnUpdate()\n                ->restrictOnDelete();\n            $table->jsonb('tags')->nullable();\n            $table->timestamps();\n\n            $table->index('start');\n            $table->index('end');\n            $table->index('billable');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('time_entries');\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_03_26_171253_create_project_members_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('project_members', function (Blueprint $table): void {\n            $table->uuid('id')->primary();\n            $table->integer('billable_rate')->unsigned()->nullable();\n            $table->uuid('project_id');\n            $table->foreign('project_id')\n                ->references('id')\n                ->on('projects')\n                ->restrictOnDelete()\n                ->cascadeOnUpdate();\n            $table->uuid('user_id');\n            $table->foreign('user_id')\n                ->references('id')\n                ->on('users')\n                ->restrictOnDelete()\n                ->cascadeOnUpdate();\n            $table->timestamps();\n            $table->unique(['project_id', 'user_id']);\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('project_members');\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_04_11_150130_create_jobs_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('jobs', function (Blueprint $table): void {\n            $table->bigIncrements('id');\n            $table->string('queue')->index();\n            $table->longText('payload');\n            $table->unsignedTinyInteger('attempts');\n            $table->unsignedInteger('reserved_at')->nullable();\n            $table->unsignedInteger('available_at');\n            $table->unsignedInteger('created_at');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('jobs');\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_04_12_095010_create_cache_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('cache', function (Blueprint $table): void {\n            $table->string('key')->primary();\n            $table->mediumText('value');\n            $table->integer('expiration');\n        });\n\n        Schema::create('cache_locks', function (Blueprint $table): void {\n            $table->string('key')->primary();\n            $table->string('owner');\n            $table->integer('expiration');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('cache');\n        Schema::dropIfExists('cache_locks');\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_05_07_134711_move_from_user_id_to_member_id_in_project_members_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('project_members', function (Blueprint $table): void {\n            $table->foreignUuid('member_id')\n                ->nullable()\n                ->constrained('organization_user')\n                ->cascadeOnDelete()\n                ->cascadeOnUpdate();\n        });\n        DB::statement('\n            update project_members\n            set member_id = organization_user.id\n            from projects\n            join organization_user on organization_user.organization_id = projects.organization_id\n            where projects.id = project_members.project_id and project_members.user_id = organization_user.user_id\n        ');\n        Schema::table('project_members', function (Blueprint $table): void {\n            $table->uuid('member_id')->nullable(false)->change();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('project_members', function (Blueprint $table): void {\n            $table->dropForeign(['member_id']);\n            $table->dropColumn('member_id');\n        });\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_05_07_141842_move_from_user_id_to_member_id_in_time_entries_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n\n        Schema::table('time_entries', function (Blueprint $table): void {\n            $table->foreignUuid('member_id')\n                ->nullable()\n                ->constrained('organization_user')\n                ->cascadeOnDelete()\n                ->cascadeOnUpdate();\n        });\n        DB::statement('\n            update time_entries\n            set member_id = organization_user.id\n            from organization_user\n            where time_entries.organization_id = organization_user.organization_id and\n                  time_entries.user_id = organization_user.user_id\n        ');\n        Schema::table('time_entries', function (Blueprint $table): void {\n            $table->uuid('member_id')->nullable(false)->change();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('time_entries', function (Blueprint $table): void {\n            $table->dropForeign(['member_id']);\n            $table->dropColumn('member_id');\n        });\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_05_13_171020_rename_table_organization_user_to_members.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::rename('organization_user', 'members');\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::rename('members', 'organization_user');\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_05_22_151226_add_client_id_to_time_entries_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('time_entries', function (Blueprint $table): void {\n            $table->foreignUuid('client_id')\n                ->nullable()\n                ->constrained('clients')\n                ->cascadeOnDelete()\n                ->cascadeOnUpdate();\n        });\n        DB::statement('\n            update time_entries\n            set client_id = clients.id\n            from projects\n            join clients on projects.client_id = clients.id\n            where time_entries.project_id = projects.id\n        ');\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('time_entries', function (Blueprint $table): void {\n            $table->dropForeign(['client_id']);\n            $table->dropColumn('client_id');\n        });\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_05_30_175801_add_is_billable_column_to_projects_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('projects', function (Blueprint $table): void {\n            $table->boolean('is_billable')->default(false);\n        });\n        DB::statement('\n            update projects\n            set is_billable = true\n            where projects.billable_rate is not null and projects.billable_rate > 0\n        ');\n        Schema::table('projects', function (Blueprint $table): void {\n            $table->boolean('is_billable')->default(null)->change();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('projects', function (Blueprint $table): void {\n            $table->dropColumn('is_billable');\n        });\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_05_30_175825_add_is_imported_column_to_time_entries_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('time_entries', function (Blueprint $table): void {\n            $table->boolean('is_imported')->default(false);\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('time_entries', function (Blueprint $table): void {\n            $table->dropColumn('is_imported');\n        });\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_06_01_000001_create_oauth_device_codes_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('oauth_device_codes', function (Blueprint $table): void {\n            $table->char('id', 80)->primary();\n            $table->foreignId('user_id')->nullable()->index();\n            $table->foreignUuid('client_id')->index();\n            $table->char('user_code', 8)->unique();\n            $table->text('scopes');\n            $table->boolean('revoked');\n            $table->dateTime('user_approved_at')->nullable();\n            $table->dateTime('last_polled_at')->nullable();\n            $table->dateTime('expires_at')->nullable();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('oauth_device_codes');\n    }\n\n    /**\n     * Get the migration connection name.\n     */\n    public function getConnection(): ?string\n    {\n        return $this->connection ?? config('passport.connection');\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_06_07_113443_change_member_id_foreign_keys_to_restrict_on_delete.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('time_entries', function (Blueprint $table): void {\n            $table->dropForeign(['member_id']);\n            $table->foreign('member_id')\n                ->references('id')\n                ->on('members')\n                ->restrictOnDelete()\n                ->cascadeOnUpdate();\n            $table->dropForeign(['client_id']);\n            $table->foreign('client_id')\n                ->references('id')\n                ->on('clients')\n                ->restrictOnDelete()\n                ->cascadeOnUpdate();\n        });\n        Schema::table('project_members', function (Blueprint $table): void {\n            $table->dropForeign(['member_id']);\n            $table->foreign('member_id')\n                ->references('id')\n                ->on('members')\n                ->restrictOnDelete()\n                ->cascadeOnUpdate();\n        });\n        Schema::table('organization_invitations', function (Blueprint $table): void {\n            $table->dropForeign(['organization_id']);\n            $table->foreign('organization_id')\n                ->references('id')\n                ->on('organizations')\n                ->restrictOnDelete()\n                ->cascadeOnUpdate();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('time_entries', function (Blueprint $table): void {\n            $table->dropForeign(['member_id']);\n            $table->foreign('member_id')\n                ->references('id')\n                ->on('members')\n                ->cascadeOnDelete()\n                ->cascadeOnUpdate();\n            $table->dropForeign(['client_id']);\n            $table->foreign('client_id')\n                ->references('id')\n                ->on('clients')\n                ->cascadeOnDelete()\n                ->cascadeOnUpdate();\n        });\n        Schema::table('project_members', function (Blueprint $table): void {\n            $table->dropForeign(['member_id']);\n            $table->foreign('member_id')\n                ->references('id')\n                ->on('members')\n                ->cascadeOnDelete()\n                ->cascadeOnUpdate();\n        });\n        Schema::table('organization_invitations', function (Blueprint $table): void {\n            $table->dropForeign(['organization_id']);\n            $table->foreign('organization_id')\n                ->references('id')\n                ->on('organizations')\n                ->cascadeOnDelete()\n                ->cascadeOnUpdate();\n        });\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_06_10_161831_reset_billable_rates_with_zero_as_value.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        DB::table('organizations')\n            ->where('billable_rate', '=', 0)\n            ->update(['billable_rate' => null]);\n        DB::table('project_members')\n            ->where('billable_rate', '=', 0)\n            ->update(['billable_rate' => null]);\n        DB::table('projects')\n            ->where('billable_rate', '=', 0)\n            ->update(['billable_rate' => null]);\n        DB::table('members')\n            ->where('billable_rate', '=', 0)\n            ->update(['billable_rate' => null]);\n        DB::table('time_entries')\n            ->where('billable_rate', '=', 0)\n            ->update(['billable_rate' => null]);\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        //\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_06_21_122754_add_is_archived_columns_to_projects_and_clients_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('projects', function (Blueprint $table): void {\n            $table->dateTime('archived_at')->nullable();\n        });\n        Schema::table('clients', function (Blueprint $table): void {\n            $table->dateTime('archived_at')->nullable();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('projects', function (Blueprint $table): void {\n            $table->dropColumn('archived_at');\n        });\n        Schema::table('clients', function (Blueprint $table): void {\n            $table->dropColumn('archived_at');\n        });\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_06_24_114433_add_done_at_to_tasks_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('tasks', function (Blueprint $table): void {\n            $table->dateTime('done_at')->nullable();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('tasks', function (Blueprint $table): void {\n            $table->dropColumn('done_at');\n        });\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_07_02_134307_add_estimated_time_to_projects_and_tasks_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('projects', function (Blueprint $table): void {\n            $table->integer('estimated_time')->unsigned()->nullable();\n        });\n        Schema::table('tasks', function (Blueprint $table): void {\n            $table->integer('estimated_time')->unsigned()->nullable();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('projects', function (Blueprint $table): void {\n            $table->dropColumn('estimated_time');\n        });\n        Schema::table('tasks', function (Blueprint $table): void {\n            $table->dropColumn('estimated_time');\n        });\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_07_03_145445_change_data_type_of_id_column_in_failed_jobs_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        DB::table('failed_jobs')->truncate();\n        Schema::table('failed_jobs', function (Blueprint $table): void {\n            $table->dropColumn('id');\n        });\n        Schema::table('failed_jobs', function (Blueprint $table): void {\n            $table->id();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        DB::table('failed_jobs')->truncate();\n        Schema::table('failed_jobs', function (Blueprint $table): void {\n            $table->dropColumn('id');\n        });\n        Schema::table('failed_jobs', function (Blueprint $table): void {\n            $table->uuid('id')->primary();\n        });\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_07_18_080906_add_still_active_email_sent_at_to_time_entries_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('time_entries', function (Blueprint $table): void {\n            $table->dateTime('still_active_email_sent_at')->nullable();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('time_entries', function (Blueprint $table): void {\n            $table->dropColumn('still_active_email_sent_at');\n        });\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_08_01_104840_create_reports_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::create('reports', function (Blueprint $table): void {\n            $table->uuid('id')->primary();\n            $table->string('name');\n            $table->text('description')->nullable();\n            $table->boolean('is_public')->default(false)->index();\n            $table->string('share_secret', 40)->nullable()->index()->unique();\n            $table->jsonb('properties');\n            $table->dateTime('public_until')->nullable();\n            $table->uuid('organization_id');\n            $table->foreign('organization_id')\n                ->references('id')\n                ->on('organizations')\n                ->restrictOnDelete()\n                ->cascadeOnUpdate();\n            $table->timestamps();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::dropIfExists('reports');\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_09_02_094105_create_audits_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nclass CreateAuditsTable extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        $connection = config('audit.drivers.database.connection', config('database.default'));\n        $table = config('audit.drivers.database.table', 'audits');\n\n        Schema::connection($connection)->create($table, function (Blueprint $table): void {\n\n            $morphPrefix = config('audit.user.morph_prefix', 'user');\n\n            $table->bigIncrements('id');\n            $table->string($morphPrefix.'_type')->nullable();\n            $table->uuid($morphPrefix.'_id')->nullable();\n            $table->string('event');\n            $table->uuidMorphs('auditable');\n            $table->json('old_values')->nullable();\n            $table->json('new_values')->nullable();\n            $table->text('url')->nullable();\n            $table->ipAddress('ip_address')->nullable();\n            $table->string('user_agent', 1023)->nullable();\n            $table->string('tags')->nullable();\n            $table->timestamps();\n\n            $table->index([$morphPrefix.'_id', $morphPrefix.'_type']);\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        $connection = config('audit.drivers.database.connection', config('database.default'));\n        $table = config('audit.drivers.database.table', 'audits');\n\n        Schema::connection($connection)->drop($table);\n    }\n}\n"
  },
  {
    "path": "database/migrations/2024_09_18_120203_add_spent_time_to_projects_and_tasks_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('projects', function (Blueprint $table): void {\n            $table->integer('spent_time')->unsigned()->default(0);\n        });\n        Schema::table('tasks', function (Blueprint $table): void {\n            $table->integer('spent_time')->unsigned()->default(0);\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('projects', function (Blueprint $table): void {\n            $table->dropColumn('spent_time');\n        });\n        Schema::table('tasks', function (Blueprint $table): void {\n            $table->dropColumn('spent_time');\n        });\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_10_01_143608_add_employees_can_see_billable_rates_to_organizations_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('organizations', function (Blueprint $table): void {\n            $table->boolean('employees_can_see_billable_rates')->default(false);\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('organizations', function (Blueprint $table): void {\n            $table->dropColumn('employees_can_see_billable_rates');\n        });\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_11_04_164807_add_foreign_key_to_organizations_and_members_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Query\\Builder;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        $foreignKeyProblems = DB::table('organizations')\n            ->select(['organizations.id', 'organizations.user_id'])\n            ->whereNotExists(function (Builder $query): void {\n                $query->select('id')\n                    ->from('users')\n                    ->whereColumn('organizations.user_id', 'users.id');\n            })\n            ->get();\n        foreach ($foreignKeyProblems as $foreignKeyProblem) {\n            Log::error('Organization with ID '.$foreignKeyProblem->id.' has non-existing owner with ID '.$foreignKeyProblem->user_id);\n        }\n        if ($foreignKeyProblems->count() > 0) {\n            throw new Exception('There are organizations with non-existing owners, check the logs for more information');\n        }\n        $foreignKeyProblems = DB::table('members')\n            ->select(['members.id', 'members.organization_id'])\n            ->whereNotExists(function (Builder $query): void {\n                $query->select('id')\n                    ->from('organizations')\n                    ->whereColumn('members.organization_id', 'organizations.id');\n            })\n            ->get();\n        foreach ($foreignKeyProblems as $foreignKeyProblem) {\n            Log::error('Member with ID '.$foreignKeyProblem->id.' has non-existing organization with ID '.$foreignKeyProblem->organization_id);\n        }\n        if ($foreignKeyProblems->count() > 0) {\n            throw new Exception('There are members with non-existing organizations, check the logs for more information');\n        }\n        $foreignKeyProblems = DB::table('members')\n            ->select(['members.id', 'members.user_id'])\n            ->whereNotExists(function (Builder $query): void {\n                $query->select('id')\n                    ->from('users')\n                    ->whereColumn('members.user_id', 'users.id');\n            })\n            ->get();\n        foreach ($foreignKeyProblems as $foreignKeyProblem) {\n            Log::error('Member with ID '.$foreignKeyProblem->id.' has non-existing user with ID '.$foreignKeyProblem->user_id);\n        }\n        if ($foreignKeyProblems->count() > 0) {\n            throw new Exception('There are members with non-existing users, check the logs for more information');\n        }\n        Schema::table('organizations', function (Blueprint $table): void {\n            $table->foreign('user_id')\n                ->references('id')\n                ->on('users')\n                ->onDelete('restrict')\n                ->onUpdate('cascade');\n        });\n        Schema::table('members', function (Blueprint $table): void {\n            $table->foreign('organization_id')\n                ->references('id')\n                ->on('organizations')\n                ->onDelete('restrict')\n                ->onUpdate('cascade');\n            $table->foreign('user_id')\n                ->references('id')\n                ->on('users')\n                ->onDelete('restrict')\n                ->onUpdate('cascade');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('organizations', function (Blueprint $table): void {\n            $table->dropForeign(['user_id']);\n        });\n        Schema::table('members', function (Blueprint $table): void {\n            $table->dropForeign(['organization_id']);\n            $table->dropForeign(['user_id']);\n        });\n    }\n};\n"
  },
  {
    "path": "database/migrations/2024_11_04_170614_add_foreign_keys_to_oauth_tables.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Query\\Builder;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        DB::table('oauth_access_tokens')\n            ->whereNotNull('user_id')\n            ->whereNotExists(function (Builder $query): void {\n                $query->select('id')\n                    ->from('users')\n                    ->whereColumn('oauth_access_tokens.user_id', 'users.id');\n            })\n            ->delete();\n        DB::table('oauth_access_tokens')\n            ->whereNotExists(function (Builder $query): void {\n                $query->select('id')\n                    ->from('oauth_clients')\n                    ->whereColumn('oauth_access_tokens.client_id', 'oauth_clients.id');\n            })\n            ->delete();\n        Schema::table('oauth_access_tokens', function (Blueprint $table): void {\n            $table->foreign('user_id')\n                ->references('id')\n                ->on('users')\n                ->onDelete('restrict')\n                ->onUpdate('cascade');\n            $table->foreign('client_id')\n                ->references('id')\n                ->on('oauth_clients')\n                ->onDelete('restrict')\n                ->onUpdate('cascade');\n        });\n        DB::table('oauth_auth_codes')\n            ->whereNotExists(function (Builder $query): void {\n                $query->select('id')\n                    ->from('users')\n                    ->whereColumn('oauth_auth_codes.user_id', 'users.id');\n            })\n            ->delete();\n        DB::table('oauth_auth_codes')\n            ->whereNotExists(function (Builder $query): void {\n                $query->select('id')\n                    ->from('oauth_clients')\n                    ->whereColumn('oauth_auth_codes.client_id', 'oauth_clients.id');\n            })\n            ->delete();\n        Schema::table('oauth_auth_codes', function (Blueprint $table): void {\n            $table->foreign('user_id')\n                ->references('id')\n                ->on('users')\n                ->onDelete('restrict')\n                ->onUpdate('cascade');\n            $table->foreign('client_id')\n                ->references('id')\n                ->on('oauth_clients')\n                ->onDelete('restrict')\n                ->onUpdate('cascade');\n        });\n        DB::table('oauth_clients')\n            ->whereNotNull('user_id')\n            ->whereNotExists(function (Builder $query): void {\n                $query->select('id')\n                    ->from('users')\n                    ->whereColumn('oauth_clients.user_id', 'users.id');\n            })\n            ->delete();\n        Schema::table('oauth_clients', function (Blueprint $table): void {\n            $table->foreign('user_id')\n                ->references('id')\n                ->on('users')\n                ->onDelete('restrict')\n                ->onUpdate('cascade');\n        });\n        Schema::table('oauth_personal_access_clients', function (Blueprint $table): void {\n            $table->foreign('client_id')\n                ->references('id')\n                ->on('oauth_clients')\n                ->onDelete('restrict')\n                ->onUpdate('cascade');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('oauth_access_tokens', function (Blueprint $table): void {\n            $table->dropForeign(['user_id']);\n            $table->dropForeign(['client_id']);\n        });\n        Schema::table('oauth_auth_codes', function (Blueprint $table): void {\n            $table->dropForeign(['user_id']);\n            $table->dropForeign(['client_id']);\n        });\n        Schema::table('oauth_clients', function (Blueprint $table): void {\n            $table->dropForeign(['user_id']);\n        });\n        Schema::table('oauth_personal_access_clients', function (Blueprint $table): void {\n            $table->dropForeign(['client_id']);\n        });\n    }\n};\n"
  },
  {
    "path": "database/migrations/2025_04_03_101827_add_localization_columns_to_organizations_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('organizations', function (Blueprint $table): void {\n            $table->string('number_format')->default(config('app.localization.default_number_format'))->nullable(false);\n            $table->string('currency_format')->default(config('app.localization.default_currency_format'))->nullable(false);\n            $table->string('date_format')->default(config('app.localization.default_date_format'))->nullable(false);\n            $table->string('interval_format')->default(config('app.localization.default_interval_format'))->nullable(false);\n            $table->string('time_format')->default(config('app.localization.default_time_format'))->nullable(false);\n        });\n\n        Schema::table('organizations', function (Blueprint $table): void {\n            $table->string('number_format')->default(null)->nullable(false)->change();\n            $table->string('currency_format')->default(null)->nullable(false)->change();\n            $table->string('date_format')->default(null)->nullable(false)->change();\n            $table->string('interval_format')->default(null)->nullable(false)->change();\n            $table->string('time_format')->default(null)->nullable(false)->change();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('organizations', function (Blueprint $table): void {\n            $table->dropColumn('number_format');\n            $table->dropColumn('currency_format');\n            $table->dropColumn('date_format');\n            $table->dropColumn('interval_format');\n            $table->dropColumn('time_format');\n        });\n    }\n};\n"
  },
  {
    "path": "database/migrations/2025_04_25_202047_change_data_type_for_spent_time_columns.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('projects', function (Blueprint $table): void {\n            $table->bigInteger('spent_time')->unsigned()->default(0)->change();\n        });\n        Schema::table('tasks', function (Blueprint $table): void {\n            $table->bigInteger('spent_time')->unsigned()->default(0)->change();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('projects', function (Blueprint $table): void {\n            $table->integer('spent_time')->unsigned()->default(0)->change();\n        });\n        Schema::table('tasks', function (Blueprint $table): void {\n            $table->integer('spent_time')->unsigned()->default(0)->change();\n        });\n    }\n};\n"
  },
  {
    "path": "database/migrations/2025_05_06_152804_fix_typos_in_organizations_table_format_columns.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        // date_format\n        DB::statement(\"update organizations set date_format = 'point-separated-d-m-yyyy' where date_format = 'point-seperated-d-m-yyyy'\");\n        DB::statement(\"update organizations set date_format = 'slash-separated-mm-dd-yyyy' where date_format = 'slash-seperated-mm-dd-yyyy'\");\n        DB::statement(\"update organizations set date_format = 'slash-separated-dd-mm-yyyy' where date_format = 'slash-seperated-dd-mm-yyyy'\");\n        DB::statement(\"update organizations set date_format = 'hyphen-separated-dd-mm-yyyy'where date_format = 'hyphen-seperated-dd-mm-yyyy'\");\n        DB::statement(\"update organizations set date_format = 'hyphen-separated-mm-dd-yyyy' where date_format = 'hyphen-seperated-mm-dd-yyyy'\");\n        DB::statement(\"update organizations set date_format = 'hyphen-separated-yyyy-mm-dd' where date_format = 'hyphen-seperated-yyyy-mm-dd'\");\n\n        // interval_format\n        DB::statement(\"update organizations set interval_format = 'hours-minutes-colon-separated' where interval_format = 'hours-minutes-colon-seperated'\");\n        DB::statement(\"update organizations set interval_format = 'hours-minutes-seconds-colon-separated' where interval_format = 'hours-minutes-seconds-colon-seperated'\");\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        // date_format\n        DB::statement(\"update organizations set date_format = 'point-seperated-d-m-yyyy' where date_format = 'point-separated-d-m-yyyy'\");\n        DB::statement(\"update organizations set date_format = 'slash-seperated-mm-dd-yyyy' where date_format = 'slash-separated-mm-dd-yyyy'\");\n        DB::statement(\"update organizations set date_format = 'slash-seperated-dd-mm-yyyy' where date_format = 'slash-separated-dd-mm-yyyy'\");\n        DB::statement(\"update organizations set date_format = 'hyphen-seperated-dd-mm-yyyy'where date_format = 'hyphen-separated-dd-mm-yyyy'\");\n        DB::statement(\"update organizations set date_format = 'hyphen-seperated-mm-dd-yyyy' where date_format = 'hyphen-separated-mm-dd-yyyy'\");\n        DB::statement(\"update organizations set date_format = 'hyphen-seperated-yyyy-mm-dd' where date_format = 'hyphen-separated-yyyy-mm-dd'\");\n\n        // interval_format\n        DB::statement(\"update organizations set interval_format = 'hours-minutes-colon-seperated' where interval_format = 'hours-minutes-colon-separated'\");\n        DB::statement(\"update organizations set interval_format = 'hours-minutes-seconds-colon-seperated' where interval_format = 'hours-minutes-seconds-colon-separated'\");\n    }\n};\n"
  },
  {
    "path": "database/migrations/2025_05_16_075757_add_foreign_key_for_current_team_id_in_users_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        DB::statement('\n            update users\n            set current_team_id = null\n            where id in (\n                select users.id from users\n                left join organizations on users.current_team_id = organizations.id\n                where users.current_team_id is not null and organizations.id is null\n            )\n        ');\n        Schema::table('users', function (Blueprint $table): void {\n            $table->foreign('current_team_id', 'organizations_current_organization_id_foreign')\n                ->references('id')\n                ->on('organizations')\n                ->onDelete('restrict')\n                ->onUpdate('cascade');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('users', function (Blueprint $table): void {\n            $table->dropForeign('organizations_current_organization_id_foreign');\n        });\n    }\n};\n"
  },
  {
    "path": "database/migrations/2025_06_30_095942_remove_oauth_personal_access_clients_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::drop('oauth_personal_access_clients');\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::create('oauth_personal_access_clients', function (Blueprint $table): void {\n            $table->bigIncrements('id');\n            $table->uuid('client_id');\n            $table->foreign('client_id')\n                ->references('id')\n                ->on('oauth_clients')\n                ->onDelete('restrict')\n                ->onUpdate('cascade');\n            $table->timestamps();\n        });\n    }\n};\n"
  },
  {
    "path": "database/migrations/2025_06_30_132538_update_oauth_clients_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        DB::table('oauth_clients')->update(['provider' => 'users']); // Change default provider if necessary\n\n        Schema::table('oauth_clients', function (Blueprint $table): void {\n            $table->text('grant_types')->default('[]')->after('provider');\n            $table->text('redirect_uris')->default('[]');\n            $table->renameColumn('user_id', 'owner_id');\n            $table->string('owner_type')->after('owner_id')->nullable();\n        });\n\n        DB::table('oauth_clients')\n            ->where('redirect', '=', 'http://localhost')\n            ->where('personal_access_client', '=', true)\n            ->update(['redirect' => '']);\n\n        DB::table('oauth_clients')\n            ->whereNotNull('owner_id')\n            ->update(['owner_type' => 'user']); // Value might be class name of the owner model, depends on if you use \"enforceMorphMap\"\n\n        DB::table('oauth_clients')->eachById(function ($client): void {\n            $grantTypes = ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token'];\n            $confidential = ! empty($client->secret);\n            $noRedirect = empty($client->redirect);\n            $redirectUris = $noRedirect ? [] : [$client->redirect];\n            $firstParty = empty($client->owner_id);\n\n            if (! $noRedirect) {\n                $grantTypes[] = 'authorization_code';\n                $grantTypes[] = 'implicit';\n            }\n\n            if ($confidential && $firstParty) {\n                $grantTypes[] = 'client_credentials';\n            }\n\n            if ($client->personal_access_client && $confidential) {\n                $grantTypes[] = 'personal_access';\n            }\n\n            if ($client->password_client) {\n                $grantTypes[] = 'password';\n            }\n\n            DB::table('oauth_clients')\n                ->where('id', $client->id)\n                ->update([\n                    'redirect_uris' => $redirectUris,\n                    'grant_types' => $grantTypes,\n                ]);\n        });\n\n        Schema::table('oauth_clients', function (Blueprint $table): void {\n            $table->dropForeign(['user_id']);\n            $table->index(['owner_id', 'owner_type']);\n            $table->dropColumn('redirect');\n            $table->dropColumn('personal_access_client');\n            $table->dropColumn('password_client');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('oauth_clients', function (Blueprint $table): void {\n            $table->dropIndex(['owner_id', 'owner_type']);\n            $table->renameColumn('owner_id', 'user_id');\n            $table->foreign('user_id')\n                ->on('users')\n                ->references('id')\n                ->onDelete('cascade')\n                ->onUpdate('cascade');\n            $table->string('redirect')->nullable();\n            $table->boolean('personal_access_client')->default(false);\n            $table->boolean('password_client')->default(false);\n        });\n\n        DB::table('oauth_clients')->eachById(function ($client): void {\n            $redirectUris = json_decode($client->redirect_uris);\n            $grantTypes = json_decode($client->grant_types);\n\n            DB::table('oauth_clients')\n                ->where('id', $client->id)\n                ->update([\n                    'redirect' => $redirectUris[0] ?? '', // redirect not nullable\n                    'password_client' => in_array('password', $grantTypes, true)\n                        && in_array('refresh_token', $grantTypes, true),\n                    'personal_access_client' => in_array('personal_access', $grantTypes, true),\n                ]);\n        });\n\n        Schema::table('oauth_clients', function (Blueprint $table): void {\n            $table->dropColumn(['grant_types', 'redirect_uris', 'owner_type']);\n            $table->string('redirect')->nullable(false)->change();\n            $table->boolean('personal_access_client')->default(null)->change();\n            $table->boolean('password_client')->default(null)->change();\n        });\n\n    }\n};\n"
  },
  {
    "path": "database/migrations/2025_07_15_105949_hash_oauth_clients.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        // This could be optimized to run all the updates in the eachById\n        DB::table('oauth_clients')->whereNotNull('secret')->eachById(function ($client): void {\n            $secret = $client->secret;\n            if (Hash::isHashed($secret) && ! Hash::needsRehash($secret)) {\n                return; // Already hashed and not needing rehash\n            }\n            DB::table('oauth_clients')\n                ->where('id', $client->id)\n                ->update([\n                    'secret' => Hash::make($secret),\n                ]);\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        // This can not be reversed without a backup of the original secrets, for security reasons.\n    }\n};\n"
  },
  {
    "path": "database/migrations/2025_07_17_104903_add_reminder_sent_at_to_oauth_access_tokens_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('oauth_access_tokens', function (Blueprint $table): void {\n            $table->dateTime('reminder_sent_at')->nullable();\n            $table->dateTime('expired_info_sent_at')->nullable();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('oauth_access_tokens', function (Blueprint $table): void {\n            $table->dropColumn('reminder_sent_at');\n            $table->dropColumn('expired_info_sent_at');\n        });\n    }\n};\n"
  },
  {
    "path": "database/migrations/2025_10_02_000001_add_prevent_overlapping_time_entries_to_organizations_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('organizations', function (Blueprint $table): void {\n            $table->boolean('prevent_overlapping_time_entries')->default(false)->after('employees_can_see_billable_rates');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('organizations', function (Blueprint $table): void {\n            $table->dropColumn('prevent_overlapping_time_entries');\n        });\n    }\n};\n"
  },
  {
    "path": "database/migrations/2025_10_16_000001_extend_time_entry_description.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('time_entries', function (Blueprint $table): void {\n            $table->string('description', 5000)->change();\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('time_entries', function (Blueprint $table): void {\n            $table->string('description', 500)->change();\n        });\n    }\n};\n"
  },
  {
    "path": "database/migrations/2025_10_24_120845_add_employees_can_manage_tasks_to_organizations_table.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    /**\n     * Run the migrations.\n     */\n    public function up(): void\n    {\n        Schema::table('organizations', function (Blueprint $table): void {\n            $table->boolean('employees_can_manage_tasks')->default(false)->after('employees_can_see_billable_rates');\n        });\n    }\n\n    /**\n     * Reverse the migrations.\n     */\n    public function down(): void\n    {\n        Schema::table('organizations', function (Blueprint $table): void {\n            $table->dropColumn('employees_can_manage_tasks');\n        });\n    }\n};\n"
  },
  {
    "path": "database/schema/pgsql_test-schema.sql",
    "content": "--\n-- PostgreSQL database dump\n--\n\n-- Dumped from database version 15.6 (Debian 15.6-1.pgdg120+2)\n-- Dumped by pg_dump version 15.7 (Ubuntu 15.7-1.pgdg22.04+1)\n\nSET statement_timeout = 0;\nSET lock_timeout = 0;\nSET idle_in_transaction_session_timeout = 0;\nSET client_encoding = 'UTF8';\nSET standard_conforming_strings = on;\nSELECT pg_catalog.set_config('search_path', '', false);\nSET check_function_bodies = false;\nSET xmloption = content;\nSET client_min_messages = warning;\nSET row_security = off;\n\nSET default_tablespace = '';\n\nSET default_table_access_method = heap;\n\n--\n-- Name: cache; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.cache (\n    key character varying(255) NOT NULL,\n    value text NOT NULL,\n    expiration integer NOT NULL\n);\n\n\n--\n-- Name: cache_locks; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.cache_locks (\n    key character varying(255) NOT NULL,\n    owner character varying(255) NOT NULL,\n    expiration integer NOT NULL\n);\n\n\n--\n-- Name: clients; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.clients (\n    id uuid NOT NULL,\n    name character varying(255) NOT NULL,\n    organization_id uuid NOT NULL,\n    created_at timestamp(0) without time zone,\n    updated_at timestamp(0) without time zone\n);\n\n\n--\n-- Name: customers; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.customers (\n    id uuid NOT NULL,\n    billable_id uuid NOT NULL,\n    billable_type character varying(255) NOT NULL,\n    paddle_id character varying(255) NOT NULL,\n    name character varying(255) NOT NULL,\n    email character varying(255) NOT NULL,\n    trial_ends_at timestamp(0) without time zone,\n    pending_checkout_id character varying(255),\n    created_at timestamp(0) without time zone,\n    updated_at timestamp(0) without time zone\n);\n\n\n--\n-- Name: failed_jobs; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.failed_jobs (\n    id uuid NOT NULL,\n    uuid uuid NOT NULL,\n    connection text NOT NULL,\n    queue text NOT NULL,\n    payload text NOT NULL,\n    exception text NOT NULL,\n    failed_at timestamp(0) without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL\n);\n\n\n--\n-- Name: jobs; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.jobs (\n    id bigint NOT NULL,\n    queue character varying(255) NOT NULL,\n    payload text NOT NULL,\n    attempts smallint NOT NULL,\n    reserved_at integer,\n    available_at integer NOT NULL,\n    created_at integer NOT NULL\n);\n\n\n--\n-- Name: jobs_id_seq; Type: SEQUENCE; Schema: public; Owner: -\n--\n\nCREATE SEQUENCE public.jobs_id_seq\n    START WITH 1\n    INCREMENT BY 1\n    NO MINVALUE\n    NO MAXVALUE\n    CACHE 1;\n\n\n--\n-- Name: jobs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -\n--\n\nALTER SEQUENCE public.jobs_id_seq OWNED BY public.jobs.id;\n\n\n--\n-- Name: members; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.members (\n    id uuid NOT NULL,\n    organization_id uuid NOT NULL,\n    user_id uuid NOT NULL,\n    role character varying(255),\n    billable_rate integer,\n    created_at timestamp(0) without time zone,\n    updated_at timestamp(0) without time zone\n);\n\n\n--\n-- Name: migrations; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.migrations (\n    id integer NOT NULL,\n    migration character varying(255) NOT NULL,\n    batch integer NOT NULL\n);\n\n\n--\n-- Name: migrations_id_seq; Type: SEQUENCE; Schema: public; Owner: -\n--\n\nCREATE SEQUENCE public.migrations_id_seq\n    AS integer\n    START WITH 1\n    INCREMENT BY 1\n    NO MINVALUE\n    NO MAXVALUE\n    CACHE 1;\n\n\n--\n-- Name: migrations_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -\n--\n\nALTER SEQUENCE public.migrations_id_seq OWNED BY public.migrations.id;\n\n\n--\n-- Name: oauth_access_tokens; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.oauth_access_tokens (\n    id character varying(100) NOT NULL,\n    user_id uuid,\n    client_id uuid NOT NULL,\n    name character varying(255),\n    scopes text,\n    revoked boolean NOT NULL,\n    created_at timestamp(0) without time zone,\n    updated_at timestamp(0) without time zone,\n    expires_at timestamp(0) without time zone\n);\n\n\n--\n-- Name: oauth_auth_codes; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.oauth_auth_codes (\n    id character varying(100) NOT NULL,\n    user_id uuid NOT NULL,\n    client_id uuid NOT NULL,\n    scopes text,\n    revoked boolean NOT NULL,\n    expires_at timestamp(0) without time zone\n);\n\n\n--\n-- Name: oauth_clients; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.oauth_clients (\n    id uuid NOT NULL,\n    user_id uuid,\n    name character varying(255) NOT NULL,\n    secret character varying(100),\n    provider character varying(255),\n    redirect text NOT NULL,\n    personal_access_client boolean NOT NULL,\n    password_client boolean NOT NULL,\n    revoked boolean NOT NULL,\n    created_at timestamp(0) without time zone,\n    updated_at timestamp(0) without time zone\n);\n\n\n--\n-- Name: oauth_personal_access_clients; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.oauth_personal_access_clients (\n    id bigint NOT NULL,\n    client_id uuid NOT NULL,\n    created_at timestamp(0) without time zone,\n    updated_at timestamp(0) without time zone\n);\n\n\n--\n-- Name: oauth_personal_access_clients_id_seq; Type: SEQUENCE; Schema: public; Owner: -\n--\n\nCREATE SEQUENCE public.oauth_personal_access_clients_id_seq\n    START WITH 1\n    INCREMENT BY 1\n    NO MINVALUE\n    NO MAXVALUE\n    CACHE 1;\n\n\n--\n-- Name: oauth_personal_access_clients_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -\n--\n\nALTER SEQUENCE public.oauth_personal_access_clients_id_seq OWNED BY public.oauth_personal_access_clients.id;\n\n\n--\n-- Name: oauth_refresh_tokens; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.oauth_refresh_tokens (\n    id character varying(100) NOT NULL,\n    access_token_id character varying(100) NOT NULL,\n    revoked boolean NOT NULL,\n    expires_at timestamp(0) without time zone\n);\n\n\n--\n-- Name: organization_invitations; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.organization_invitations (\n    id uuid NOT NULL,\n    organization_id uuid NOT NULL,\n    email character varying(255) NOT NULL,\n    role character varying(255),\n    created_at timestamp(0) without time zone,\n    updated_at timestamp(0) without time zone\n);\n\n\n--\n-- Name: organizations; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.organizations (\n    id uuid NOT NULL,\n    user_id uuid NOT NULL,\n    name character varying(255) NOT NULL,\n    personal_team boolean NOT NULL,\n    billable_rate integer,\n    currency character varying(3) NOT NULL,\n    created_at timestamp(0) without time zone,\n    updated_at timestamp(0) without time zone\n);\n\n\n--\n-- Name: password_reset_tokens; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.password_reset_tokens (\n    email character varying(255) NOT NULL,\n    token character varying(255) NOT NULL,\n    created_at timestamp(0) without time zone\n);\n\n\n--\n-- Name: personal_access_tokens; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.personal_access_tokens (\n    id uuid NOT NULL,\n    tokenable_type character varying(255) NOT NULL,\n    tokenable_id bigint NOT NULL,\n    name character varying(255) NOT NULL,\n    token character varying(64) NOT NULL,\n    abilities text,\n    last_used_at timestamp(0) without time zone,\n    expires_at timestamp(0) without time zone,\n    created_at timestamp(0) without time zone,\n    updated_at timestamp(0) without time zone\n);\n\n\n--\n-- Name: project_members; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.project_members (\n    id uuid NOT NULL,\n    billable_rate integer,\n    project_id uuid NOT NULL,\n    user_id uuid NOT NULL,\n    created_at timestamp(0) without time zone,\n    updated_at timestamp(0) without time zone,\n    member_id uuid NOT NULL\n);\n\n\n--\n-- Name: projects; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.projects (\n    id uuid NOT NULL,\n    name character varying(255) NOT NULL,\n    color character varying(16) NOT NULL,\n    billable_rate integer,\n    is_public boolean DEFAULT false NOT NULL,\n    client_id uuid,\n    organization_id uuid NOT NULL,\n    created_at timestamp(0) without time zone,\n    updated_at timestamp(0) without time zone,\n    is_billable boolean NOT NULL\n);\n\n\n--\n-- Name: sessions; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.sessions (\n    id character varying(255) NOT NULL,\n    user_id uuid,\n    ip_address character varying(45),\n    user_agent text,\n    payload text NOT NULL,\n    last_activity integer NOT NULL\n);\n\n\n--\n-- Name: subscription_items; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.subscription_items (\n    id uuid NOT NULL,\n    subscription_id uuid NOT NULL,\n    product_id character varying(255) NOT NULL,\n    price_id character varying(255) NOT NULL,\n    status character varying(255) NOT NULL,\n    quantity integer NOT NULL,\n    created_at timestamp(0) without time zone,\n    updated_at timestamp(0) without time zone\n);\n\n\n--\n-- Name: subscriptions; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.subscriptions (\n    id uuid NOT NULL,\n    billable_id uuid NOT NULL,\n    billable_type character varying(255) NOT NULL,\n    type character varying(255) NOT NULL,\n    paddle_id character varying(255) NOT NULL,\n    status character varying(255) NOT NULL,\n    trial_ends_at timestamp(0) without time zone,\n    paused_at timestamp(0) without time zone,\n    ends_at timestamp(0) without time zone,\n    created_at timestamp(0) without time zone,\n    updated_at timestamp(0) without time zone\n);\n\n\n--\n-- Name: tags; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.tags (\n    id uuid NOT NULL,\n    name character varying(255) NOT NULL,\n    organization_id uuid NOT NULL,\n    created_at timestamp(0) without time zone,\n    updated_at timestamp(0) without time zone\n);\n\n\n--\n-- Name: tasks; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.tasks (\n    id uuid NOT NULL,\n    name character varying(500) NOT NULL,\n    project_id uuid NOT NULL,\n    organization_id uuid NOT NULL,\n    created_at timestamp(0) without time zone,\n    updated_at timestamp(0) without time zone\n);\n\n\n--\n-- Name: time_entries; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.time_entries (\n    id uuid NOT NULL,\n    description character varying(5000) NOT NULL,\n    start timestamp(0) without time zone NOT NULL,\n    \"end\" timestamp(0) without time zone,\n    billable_rate integer,\n    billable boolean DEFAULT false NOT NULL,\n    user_id uuid NOT NULL,\n    organization_id uuid NOT NULL,\n    project_id uuid,\n    task_id uuid,\n    tags jsonb,\n    created_at timestamp(0) without time zone,\n    updated_at timestamp(0) without time zone,\n    member_id uuid NOT NULL,\n    client_id uuid,\n    is_imported boolean DEFAULT false NOT NULL\n);\n\n\n--\n-- Name: transactions; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.transactions (\n    id uuid NOT NULL,\n    billable_id uuid NOT NULL,\n    billable_type character varying(255) NOT NULL,\n    paddle_id character varying(255) NOT NULL,\n    paddle_subscription_id character varying(255),\n    invoice_number character varying(255),\n    status character varying(255) NOT NULL,\n    total character varying(255) NOT NULL,\n    tax character varying(255) NOT NULL,\n    currency character varying(3) NOT NULL,\n    billed_at timestamp(0) without time zone NOT NULL,\n    created_at timestamp(0) without time zone,\n    updated_at timestamp(0) without time zone\n);\n\n\n--\n-- Name: users; Type: TABLE; Schema: public; Owner: -\n--\n\nCREATE TABLE public.users (\n    id uuid NOT NULL,\n    name character varying(255) NOT NULL,\n    email character varying(255) NOT NULL,\n    email_verified_at timestamp(0) without time zone,\n    password character varying(255),\n    remember_token character varying(100),\n    is_placeholder boolean DEFAULT false NOT NULL,\n    current_team_id uuid,\n    profile_photo_path character varying(2048),\n    timezone character varying(255) NOT NULL,\n    week_start character varying(255) NOT NULL,\n    created_at timestamp(0) without time zone,\n    updated_at timestamp(0) without time zone,\n    two_factor_secret text,\n    two_factor_recovery_codes text,\n    two_factor_confirmed_at timestamp(0) without time zone,\n    CONSTRAINT users_week_start_check CHECK (((week_start)::text = ANY ((ARRAY['monday'::character varying, 'tuesday'::character varying, 'wednesday'::character varying, 'thursday'::character varying, 'friday'::character varying, 'saturday'::character varying, 'sunday'::character varying])::text[])))\n);\n\n\n--\n-- Name: jobs id; Type: DEFAULT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.jobs ALTER COLUMN id SET DEFAULT nextval('public.jobs_id_seq'::regclass);\n\n\n--\n-- Name: migrations id; Type: DEFAULT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.migrations ALTER COLUMN id SET DEFAULT nextval('public.migrations_id_seq'::regclass);\n\n\n--\n-- Name: oauth_personal_access_clients id; Type: DEFAULT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.oauth_personal_access_clients ALTER COLUMN id SET DEFAULT nextval('public.oauth_personal_access_clients_id_seq'::regclass);\n\n\n--\n-- Name: cache_locks cache_locks_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.cache_locks\n    ADD CONSTRAINT cache_locks_pkey PRIMARY KEY (key);\n\n\n--\n-- Name: cache cache_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.cache\n    ADD CONSTRAINT cache_pkey PRIMARY KEY (key);\n\n\n--\n-- Name: clients clients_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.clients\n    ADD CONSTRAINT clients_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: customers customers_paddle_id_unique; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.customers\n    ADD CONSTRAINT customers_paddle_id_unique UNIQUE (paddle_id);\n\n\n--\n-- Name: customers customers_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.customers\n    ADD CONSTRAINT customers_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: failed_jobs failed_jobs_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.failed_jobs\n    ADD CONSTRAINT failed_jobs_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: failed_jobs failed_jobs_uuid_unique; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.failed_jobs\n    ADD CONSTRAINT failed_jobs_uuid_unique UNIQUE (uuid);\n\n\n--\n-- Name: jobs jobs_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.jobs\n    ADD CONSTRAINT jobs_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: migrations migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.migrations\n    ADD CONSTRAINT migrations_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: oauth_access_tokens oauth_access_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.oauth_access_tokens\n    ADD CONSTRAINT oauth_access_tokens_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: oauth_auth_codes oauth_auth_codes_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.oauth_auth_codes\n    ADD CONSTRAINT oauth_auth_codes_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: oauth_clients oauth_clients_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.oauth_clients\n    ADD CONSTRAINT oauth_clients_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: oauth_personal_access_clients oauth_personal_access_clients_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.oauth_personal_access_clients\n    ADD CONSTRAINT oauth_personal_access_clients_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: oauth_refresh_tokens oauth_refresh_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.oauth_refresh_tokens\n    ADD CONSTRAINT oauth_refresh_tokens_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: organization_invitations organization_invitations_organization_id_email_unique; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.organization_invitations\n    ADD CONSTRAINT organization_invitations_organization_id_email_unique UNIQUE (organization_id, email);\n\n\n--\n-- Name: organization_invitations organization_invitations_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.organization_invitations\n    ADD CONSTRAINT organization_invitations_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: members organization_user_organization_id_user_id_unique; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.members\n    ADD CONSTRAINT organization_user_organization_id_user_id_unique UNIQUE (organization_id, user_id);\n\n\n--\n-- Name: members organization_user_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.members\n    ADD CONSTRAINT organization_user_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: organizations organizations_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.organizations\n    ADD CONSTRAINT organizations_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: password_reset_tokens password_reset_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.password_reset_tokens\n    ADD CONSTRAINT password_reset_tokens_pkey PRIMARY KEY (email);\n\n\n--\n-- Name: personal_access_tokens personal_access_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.personal_access_tokens\n    ADD CONSTRAINT personal_access_tokens_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: personal_access_tokens personal_access_tokens_token_unique; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.personal_access_tokens\n    ADD CONSTRAINT personal_access_tokens_token_unique UNIQUE (token);\n\n\n--\n-- Name: project_members project_members_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.project_members\n    ADD CONSTRAINT project_members_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: project_members project_members_project_id_user_id_unique; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.project_members\n    ADD CONSTRAINT project_members_project_id_user_id_unique UNIQUE (project_id, user_id);\n\n\n--\n-- Name: projects projects_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.projects\n    ADD CONSTRAINT projects_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: sessions sessions_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.sessions\n    ADD CONSTRAINT sessions_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: subscription_items subscription_items_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.subscription_items\n    ADD CONSTRAINT subscription_items_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: subscription_items subscription_items_subscription_id_price_id_unique; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.subscription_items\n    ADD CONSTRAINT subscription_items_subscription_id_price_id_unique UNIQUE (subscription_id, price_id);\n\n\n--\n-- Name: subscriptions subscriptions_paddle_id_unique; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.subscriptions\n    ADD CONSTRAINT subscriptions_paddle_id_unique UNIQUE (paddle_id);\n\n\n--\n-- Name: subscriptions subscriptions_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.subscriptions\n    ADD CONSTRAINT subscriptions_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: tags tags_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.tags\n    ADD CONSTRAINT tags_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: tasks tasks_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.tasks\n    ADD CONSTRAINT tasks_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: time_entries time_entries_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.time_entries\n    ADD CONSTRAINT time_entries_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: transactions transactions_paddle_id_unique; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.transactions\n    ADD CONSTRAINT transactions_paddle_id_unique UNIQUE (paddle_id);\n\n\n--\n-- Name: transactions transactions_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.transactions\n    ADD CONSTRAINT transactions_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.users\n    ADD CONSTRAINT users_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: customers_billable_id_billable_type_index; Type: INDEX; Schema: public; Owner: -\n--\n\nCREATE INDEX customers_billable_id_billable_type_index ON public.customers USING btree (billable_id, billable_type);\n\n\n--\n-- Name: jobs_queue_index; Type: INDEX; Schema: public; Owner: -\n--\n\nCREATE INDEX jobs_queue_index ON public.jobs USING btree (queue);\n\n\n--\n-- Name: oauth_access_tokens_user_id_index; Type: INDEX; Schema: public; Owner: -\n--\n\nCREATE INDEX oauth_access_tokens_user_id_index ON public.oauth_access_tokens USING btree (user_id);\n\n\n--\n-- Name: oauth_auth_codes_user_id_index; Type: INDEX; Schema: public; Owner: -\n--\n\nCREATE INDEX oauth_auth_codes_user_id_index ON public.oauth_auth_codes USING btree (user_id);\n\n\n--\n-- Name: oauth_clients_user_id_index; Type: INDEX; Schema: public; Owner: -\n--\n\nCREATE INDEX oauth_clients_user_id_index ON public.oauth_clients USING btree (user_id);\n\n\n--\n-- Name: oauth_refresh_tokens_access_token_id_index; Type: INDEX; Schema: public; Owner: -\n--\n\nCREATE INDEX oauth_refresh_tokens_access_token_id_index ON public.oauth_refresh_tokens USING btree (access_token_id);\n\n\n--\n-- Name: organizations_user_id_index; Type: INDEX; Schema: public; Owner: -\n--\n\nCREATE INDEX organizations_user_id_index ON public.organizations USING btree (user_id);\n\n\n--\n-- Name: personal_access_tokens_tokenable_type_tokenable_id_index; Type: INDEX; Schema: public; Owner: -\n--\n\nCREATE INDEX personal_access_tokens_tokenable_type_tokenable_id_index ON public.personal_access_tokens USING btree (tokenable_type, tokenable_id);\n\n\n--\n-- Name: sessions_last_activity_index; Type: INDEX; Schema: public; Owner: -\n--\n\nCREATE INDEX sessions_last_activity_index ON public.sessions USING btree (last_activity);\n\n\n--\n-- Name: sessions_user_id_index; Type: INDEX; Schema: public; Owner: -\n--\n\nCREATE INDEX sessions_user_id_index ON public.sessions USING btree (user_id);\n\n\n--\n-- Name: subscriptions_billable_id_billable_type_index; Type: INDEX; Schema: public; Owner: -\n--\n\nCREATE INDEX subscriptions_billable_id_billable_type_index ON public.subscriptions USING btree (billable_id, billable_type);\n\n\n--\n-- Name: tags_created_at_index; Type: INDEX; Schema: public; Owner: -\n--\n\nCREATE INDEX tags_created_at_index ON public.tags USING btree (created_at);\n\n\n--\n-- Name: time_entries_billable_index; Type: INDEX; Schema: public; Owner: -\n--\n\nCREATE INDEX time_entries_billable_index ON public.time_entries USING btree (billable);\n\n\n--\n-- Name: time_entries_end_index; Type: INDEX; Schema: public; Owner: -\n--\n\nCREATE INDEX time_entries_end_index ON public.time_entries USING btree (\"end\");\n\n\n--\n-- Name: time_entries_start_index; Type: INDEX; Schema: public; Owner: -\n--\n\nCREATE INDEX time_entries_start_index ON public.time_entries USING btree (start);\n\n\n--\n-- Name: transactions_billable_id_billable_type_index; Type: INDEX; Schema: public; Owner: -\n--\n\nCREATE INDEX transactions_billable_id_billable_type_index ON public.transactions USING btree (billable_id, billable_type);\n\n\n--\n-- Name: transactions_paddle_subscription_id_index; Type: INDEX; Schema: public; Owner: -\n--\n\nCREATE INDEX transactions_paddle_subscription_id_index ON public.transactions USING btree (paddle_subscription_id);\n\n\n--\n-- Name: users_email_unique; Type: INDEX; Schema: public; Owner: -\n--\n\nCREATE UNIQUE INDEX users_email_unique ON public.users USING btree (email) WHERE (is_placeholder = false);\n\n\n--\n-- Name: clients clients_organization_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.clients\n    ADD CONSTRAINT clients_organization_id_foreign FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON UPDATE CASCADE ON DELETE RESTRICT;\n\n\n--\n-- Name: organization_invitations organization_invitations_organization_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.organization_invitations\n    ADD CONSTRAINT organization_invitations_organization_id_foreign FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON UPDATE CASCADE ON DELETE RESTRICT;\n\n\n--\n-- Name: project_members project_members_member_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.project_members\n    ADD CONSTRAINT project_members_member_id_foreign FOREIGN KEY (member_id) REFERENCES public.members(id) ON UPDATE CASCADE ON DELETE RESTRICT;\n\n\n--\n-- Name: project_members project_members_project_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.project_members\n    ADD CONSTRAINT project_members_project_id_foreign FOREIGN KEY (project_id) REFERENCES public.projects(id) ON UPDATE CASCADE ON DELETE RESTRICT;\n\n\n--\n-- Name: project_members project_members_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.project_members\n    ADD CONSTRAINT project_members_user_id_foreign FOREIGN KEY (user_id) REFERENCES public.users(id) ON UPDATE CASCADE ON DELETE RESTRICT;\n\n\n--\n-- Name: projects projects_client_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.projects\n    ADD CONSTRAINT projects_client_id_foreign FOREIGN KEY (client_id) REFERENCES public.clients(id) ON UPDATE CASCADE ON DELETE RESTRICT;\n\n\n--\n-- Name: projects projects_organization_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.projects\n    ADD CONSTRAINT projects_organization_id_foreign FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON UPDATE CASCADE ON DELETE RESTRICT;\n\n\n--\n-- Name: tags tags_organization_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.tags\n    ADD CONSTRAINT tags_organization_id_foreign FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON UPDATE CASCADE ON DELETE RESTRICT;\n\n\n--\n-- Name: tasks tasks_organization_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.tasks\n    ADD CONSTRAINT tasks_organization_id_foreign FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON UPDATE CASCADE ON DELETE RESTRICT;\n\n\n--\n-- Name: tasks tasks_project_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.tasks\n    ADD CONSTRAINT tasks_project_id_foreign FOREIGN KEY (project_id) REFERENCES public.projects(id) ON UPDATE CASCADE ON DELETE RESTRICT;\n\n\n--\n-- Name: time_entries time_entries_client_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.time_entries\n    ADD CONSTRAINT time_entries_client_id_foreign FOREIGN KEY (client_id) REFERENCES public.clients(id) ON UPDATE CASCADE ON DELETE RESTRICT;\n\n\n--\n-- Name: time_entries time_entries_member_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.time_entries\n    ADD CONSTRAINT time_entries_member_id_foreign FOREIGN KEY (member_id) REFERENCES public.members(id) ON UPDATE CASCADE ON DELETE RESTRICT;\n\n\n--\n-- Name: time_entries time_entries_organization_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.time_entries\n    ADD CONSTRAINT time_entries_organization_id_foreign FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON UPDATE CASCADE ON DELETE RESTRICT;\n\n\n--\n-- Name: time_entries time_entries_project_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.time_entries\n    ADD CONSTRAINT time_entries_project_id_foreign FOREIGN KEY (project_id) REFERENCES public.projects(id) ON UPDATE CASCADE ON DELETE RESTRICT;\n\n\n--\n-- Name: time_entries time_entries_task_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.time_entries\n    ADD CONSTRAINT time_entries_task_id_foreign FOREIGN KEY (task_id) REFERENCES public.tasks(id) ON UPDATE CASCADE ON DELETE RESTRICT;\n\n\n--\n-- Name: time_entries time_entries_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: -\n--\n\nALTER TABLE ONLY public.time_entries\n    ADD CONSTRAINT time_entries_user_id_foreign FOREIGN KEY (user_id) REFERENCES public.users(id) ON UPDATE CASCADE ON DELETE RESTRICT;\n\n\n--\n-- PostgreSQL database dump complete\n--\n\n--\n-- PostgreSQL database dump\n--\n\n-- Dumped from database version 15.6 (Debian 15.6-1.pgdg120+2)\n-- Dumped by pg_dump version 15.7 (Ubuntu 15.7-1.pgdg22.04+1)\n\nSET statement_timeout = 0;\nSET lock_timeout = 0;\nSET idle_in_transaction_session_timeout = 0;\nSET client_encoding = 'UTF8';\nSET standard_conforming_strings = on;\nSELECT pg_catalog.set_config('search_path', '', false);\nSET check_function_bodies = false;\nSET xmloption = content;\nSET client_min_messages = warning;\nSET row_security = off;\n\n--\n-- Data for Name: migrations; Type: TABLE DATA; Schema: public; Owner: -\n--\n\nCOPY public.migrations (id, migration, batch) FROM stdin;\n1\t2014_10_12_000000_create_users_table\t1\n2\t2014_10_12_100000_create_password_reset_tokens_table\t1\n3\t2014_10_12_200000_add_two_factor_columns_to_users_table\t1\n4\t2016_06_01_000001_create_oauth_auth_codes_table\t1\n5\t2016_06_01_000002_create_oauth_access_tokens_table\t1\n6\t2016_06_01_000003_create_oauth_refresh_tokens_table\t1\n7\t2016_06_01_000004_create_oauth_clients_table\t1\n8\t2016_06_01_000005_create_oauth_personal_access_clients_table\t1\n9\t2018_08_08_100000_create_telescope_entries_table\t1\n10\t2019_05_03_000001_create_customers_table\t1\n11\t2019_05_03_000002_create_subscriptions_table\t1\n12\t2019_05_03_000003_create_subscription_items_table\t1\n13\t2019_05_03_000004_create_transactions_table\t1\n14\t2019_08_19_000000_create_failed_jobs_table\t1\n15\t2019_12_14_000001_create_personal_access_tokens_table\t1\n16\t2020_05_21_100000_create_organizations_table\t1\n17\t2020_05_21_200000_create_organization_user_table\t1\n18\t2020_05_21_300000_create_organization_invitations_table\t1\n19\t2024_01_16_161030_create_sessions_table\t1\n20\t2024_01_20_110218_create_clients_table\t1\n21\t2024_01_20_110439_create_projects_table\t1\n22\t2024_01_20_110444_create_tasks_table\t1\n23\t2024_01_20_110452_create_tags_table\t1\n24\t2024_01_20_110837_create_time_entries_table\t1\n25\t2024_03_26_171253_create_project_members_table\t1\n26\t2024_04_11_150130_create_jobs_table\t1\n27\t2024_04_12_095010_create_cache_table\t1\n28\t2024_05_07_134711_move_from_user_id_to_member_id_in_project_members_table\t1\n29\t2024_05_07_141842_move_from_user_id_to_member_id_in_time_entries_table\t1\n30\t2024_05_13_171020_rename_table_organization_user_to_members\t1\n31\t2024_05_22_151226_add_client_id_to_time_entries_table\t1\n32\t2024_05_30_175801_add_is_billable_column_to_projects_table\t1\n33\t2024_05_30_175825_add_is_imported_column_to_time_entries_table\t1\n34\t2024_06_07_113443_change_member_id_foreign_keys_to_restrict_on_delete\t1\n35\t2024_06_10_161831_reset_billable_rates_with_zero_as_value\t1\n\\.\n\n\n--\n-- Name: migrations_id_seq; Type: SEQUENCE SET; Schema: public; Owner: -\n--\n\nSELECT pg_catalog.setval('public.migrations_id_seq', 35, true);\n\n\n--\n-- PostgreSQL database dump complete\n--\n\n"
  },
  {
    "path": "database/seeders/DatabaseSeeder.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Database\\Seeders;\n\nuse App\\Enums\\Role;\nuse App\\Events\\DatabaseSeederAfterSeed;\nuse App\\Events\\DatabaseSeederBeforeDelete;\nuse App\\Models\\Audit;\nuse App\\Models\\Client;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\OrganizationInvitation;\nuse App\\Models\\Project;\nuse App\\Models\\ProjectMember;\nuse App\\Models\\Report;\nuse App\\Models\\Tag;\nuse App\\Models\\Task;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse Illuminate\\Database\\Seeder;\nuse Illuminate\\Support\\Facades\\DB;\nuse Laravel\\Passport\\AuthCode;\nuse Laravel\\Passport\\Client as PassportClient;\nuse Laravel\\Passport\\ClientRepository;\nuse Laravel\\Passport\\RefreshToken;\nuse Laravel\\Passport\\Token;\n\nclass DatabaseSeeder extends Seeder\n{\n    /**\n     * Seed the application's database.\n     */\n    public function run(): void\n    {\n        $this->deleteAll();\n\n        app(ClientRepository::class)->createAuthorizationCodeGrantClient(\n            name: 'Desktop App',\n            redirectUris: ['solidtime://oauth/callback'],\n            confidential: false, // TODO: ?\n            enableDeviceFlow: false, // TODO: ?\n        );\n\n        // TODO: grant_types ? migration?\n\n        // app(ClientRepository::class)->createPersonalAccessGrantClient('API');\n\n        /*\n        app(ClientRepository::class)->create(\n            null,\n            'desktop',\n            'solidtime://oauth/callback',\n            null,\n            false,\n            false,\n            false\n        );\n        */\n\n        $personalAccessClient = new PassportClient;\n        $personalAccessClient->id = config('passport.personal_access_client.id');\n        $personalAccessClient->secret = config('passport.personal_access_client.secret');\n        $personalAccessClient->name = 'API';\n        $personalAccessClient->redirect_uris = ['http://localhost'];\n        $personalAccessClient->revoked = false;\n        $personalAccessClient->provider = 'users';\n        $personalAccessClient->grant_types = ['personal_access'];\n        $personalAccessClient->save();\n\n        $userWithMultipleOrganizations = User::factory()->withPersonalOrganization()->create([\n            'name' => 'Mister Overemployed',\n            'email' => 'overemployed@acme.test',\n        ]);\n\n        $userAcmeOwner = User::factory()->withPersonalOrganization()->create([\n            'name' => 'Acme Owner',\n            'email' => 'owner@acme.test',\n        ]);\n        $organizationAcme = Organization::factory()->withOwner($userAcmeOwner)->create([\n            'name' => 'ACME Corp',\n            'personal_team' => false,\n            'currency' => 'EUR',\n        ]);\n        OrganizationInvitation::factory()->forOrganization($organizationAcme)->create([\n            'email' => 'new.employee@example.com',\n        ]);\n        $userAcmeManager = User::factory()->withPersonalOrganization()->create([\n            'name' => 'Acme Manager',\n            'email' => 'test@example.com',\n        ]);\n        $userAcmeManager->createToken('Testing Token 1')->accessToken;\n        $userAcmeManager->createToken('Testing Token 2')->accessToken;\n        $userAcmeAdmin = User::factory()->withPersonalOrganization()->create([\n            'name' => 'Acme Admin',\n            'email' => 'admin@acme.test',\n        ]);\n        $userAcmeEmployee = User::factory()->withPersonalOrganization()->create([\n            'name' => 'Acme Employee',\n            'email' => 'max.mustermann@acme.test',\n        ]);\n        $userAcmePlaceholder = User::factory()->placeholder()->create([\n            'name' => 'Acme Placeholder',\n            'email' => 'old.employee@acme.test',\n            'password' => null,\n        ]);\n        $userAcmeOwnerMember = Member::factory()->forUser($userAcmeOwner)->forOrganization($organizationAcme)->role(Role::Owner)->create();\n        $userAcmeManagerMember = Member::factory()->forUser($userAcmeManager)->forOrganization($organizationAcme)->role(Role::Manager)->create();\n        $userAcmeAdminMember = Member::factory()->forUser($userAcmeAdmin)->forOrganization($organizationAcme)->role(Role::Admin)->create();\n        $userAcmeEmployeeMember = Member::factory()->forUser($userAcmeEmployee)->forOrganization($organizationAcme)->role(Role::Employee)->create();\n        $userAcmePlaceholderMember = Member::factory()->forUser($userAcmePlaceholder)->forOrganization($organizationAcme)->role(Role::Placeholder)->create();\n        $userWithMultipleOrganizationsAcmeMember = Member::factory()->forUser($userWithMultipleOrganizations)->forOrganization($organizationAcme)->role(Role::Employee)->create();\n        Tag::factory()->forOrganization($organizationAcme)->create([\n            'name' => 'Code Review',\n        ]);\n        Tag::factory()->forOrganization($organizationAcme)->create([\n            'name' => 'Meeting',\n        ]);\n        Tag::factory()->forOrganization($organizationAcme)->create([\n            'name' => 'Research',\n        ]);\n\n        TimeEntry::factory()\n            ->count(10)\n            ->forMember($userAcmeAdminMember)\n            ->create();\n        TimeEntry::factory()\n            ->count(10)\n            ->forMember($userAcmeManagerMember)\n            ->create();\n        TimeEntry::factory()\n            ->count(10)\n            ->forMember($userAcmePlaceholderMember)\n            ->create();\n        TimeEntry::factory()\n            ->count(10)\n            ->forMember($userAcmeEmployeeMember)\n            ->create();\n        TimeEntry::factory()\n            ->count(5)\n            ->forMember($userWithMultipleOrganizationsAcmeMember)\n            ->create();\n        $acmeClient = Client::factory()->forOrganization($organizationAcme)->create([\n            'name' => 'Big Company',\n        ]);\n        $bigCompanyProject = Project::factory()->forOrganization($organizationAcme)->forClient($acmeClient)->create([\n            'name' => 'Big Company Project',\n        ]);\n        ProjectMember::factory()->forProject($bigCompanyProject)->forMember($userAcmeEmployeeMember)->create();\n        ProjectMember::factory()->forProject($bigCompanyProject)->forMember($userAcmeAdminMember)->create();\n        ProjectMember::factory()->forProject($bigCompanyProject)->forMember($userWithMultipleOrganizationsAcmeMember)->create();\n\n        TimeEntry::factory()\n            ->count(3)\n            ->forMember($userAcmeEmployeeMember)\n            ->forProject($bigCompanyProject)\n            ->create();\n\n        Task::factory()->forOrganization($organizationAcme)->forProject($bigCompanyProject)->create();\n\n        $internalProject = Project::factory()->forOrganization($organizationAcme)->create([\n            'name' => 'Internal Project',\n        ]);\n\n        $rivalOwner = User::factory()->create([\n            'name' => 'Other Owner',\n            'email' => 'owner@rival-company.test',\n        ]);\n        $organizationRival = Organization::factory()->withOwner($rivalOwner)->create([\n            'name' => 'Rival Corp',\n            'personal_team' => true,\n            'currency' => 'USD',\n        ]);\n        Member::factory()->forUser($rivalOwner)->forOrganization($organizationRival)->role(Role::Owner)->create();\n        $userRivalManager = User::factory()->withPersonalOrganization()->create([\n            'name' => 'Other User',\n            'email' => 'test@rival-company.test',\n        ]);\n        $userRivalManagerMember = Member::factory()->forUser($userRivalManager)->forOrganization($organizationRival)->role(Role::Admin)->create();\n        $userWithMultipleOrganizationsRivalMember = Member::factory()->forUser($userWithMultipleOrganizations)->forOrganization($organizationRival)->role(Role::Employee)->create();\n        $rivalClient = Client::factory()->forOrganization($organizationRival)->create([\n            'name' => 'Scale Company',\n        ]);\n        $otherCompanyProject = Project::factory()->forOrganization($organizationRival)->forClient($rivalClient)->create([\n            'name' => 'Scale Company - Project ABC',\n        ]);\n        ProjectMember::factory()->forProject($otherCompanyProject)->forMember($userRivalManagerMember)->create();\n        ProjectMember::factory()->forProject($otherCompanyProject)->forMember($userWithMultipleOrganizationsRivalMember)->create();\n        TimeEntry::factory()\n            ->count(5)\n            ->forMember($userWithMultipleOrganizationsRivalMember)\n            ->create();\n\n        User::factory()->withPersonalOrganization()->create([\n            'email' => 'admin@example.com',\n        ]);\n\n        DatabaseSeederAfterSeed::dispatch();\n    }\n\n    private function deleteAll(): void\n    {\n        DatabaseSeederBeforeDelete::dispatch();\n\n        // Laravel Passport tables\n        DB::table((new RefreshToken)->getTable())->delete();\n        DB::table((new Token)->getTable())->delete();\n        DB::table((new AuthCode)->getTable())->delete();\n        DB::table((new PassportClient)->getTable())->delete();\n\n        // Internal tables\n        DB::table('cache')->delete();\n        DB::table('cache_locks')->delete();\n        DB::table('jobs')->delete();\n        DB::table('failed_jobs')->delete();\n        DB::table('sessions')->delete();\n\n        // Application tables\n        DB::table((new Audit)->getTable())->delete();\n        DB::table((new Report)->getTable())->delete();\n        DB::table((new TimeEntry)->getTable())->delete();\n        DB::table((new Task)->getTable())->delete();\n        DB::table((new Tag)->getTable())->delete();\n        DB::table((new ProjectMember)->getTable())->delete();\n        DB::table((new Project)->getTable())->delete();\n        DB::table((new Client)->getTable())->delete();\n        DB::table((new Member)->getTable())->delete();\n        DB::table((new OrganizationInvitation)->getTable())->delete();\n        DB::table((new User)->getTable())->update([\n            'current_team_id' => null,\n        ]);\n        DB::table((new Organization)->getTable())->delete();\n        DB::table((new User)->getTable())->delete();\n    }\n}\n"
  },
  {
    "path": "docker/local/8.3/Dockerfile",
    "content": "FROM ubuntu:22.04\n\nLABEL maintainer=\"Taylor Otwell\"\n\nARG WWWGROUP\nARG NODE_VERSION=20\nARG POSTGRES_VERSION=15\n\nWORKDIR /var/www/html\n\nENV DEBIAN_FRONTEND noninteractive\nENV TZ=UTC\nENV SUPERVISOR_PHP_COMMAND=\"/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=80\"\nENV SUPERVISOR_PHP_USER=\"sail\"\n\nRUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone\n\nRUN apt-get update \\\n    && mkdir -p /etc/apt/keyrings \\\n    && apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev python2 dnsutils librsvg2-bin fswatch ffmpeg \\\n    && curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x14aa40ec0831756756d7f66c4f4ea0aae5267a6c' | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg > /dev/null \\\n    && echo \"deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu jammy main\" > /etc/apt/sources.list.d/ppa_ondrej_php.list \\\n    && apt-get update \\\n    && apt-get install -y php8.3-cli php8.3-dev \\\n       php8.3-pgsql php8.3-sqlite3 php8.3-gd \\\n       php8.3-curl \\\n       php8.3-imap php8.3-mysql php8.3-mbstring \\\n       php8.3-xml php8.3-zip php8.3-bcmath php8.3-soap \\\n       php8.3-intl php8.3-readline \\\n       php8.3-ldap \\\n       php8.3-msgpack php8.3-igbinary php8.3-redis php8.3-swoole \\\n       php8.3-memcached php8.3-pcov php8.3-imagick php8.3-xdebug \\\n    && curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \\\n    && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \\\n    && echo \"deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main\" > /etc/apt/sources.list.d/nodesource.list \\\n    && apt-get update \\\n    && apt-get install -y nodejs \\\n    && npm install -g npm \\\n    && npm install -g pnpm \\\n    && npm install -g bun \\\n    && curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | tee /etc/apt/keyrings/yarn.gpg >/dev/null \\\n    && echo \"deb [signed-by=/etc/apt/keyrings/yarn.gpg] https://dl.yarnpkg.com/debian/ stable main\" > /etc/apt/sources.list.d/yarn.list \\\n    && curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg >/dev/null \\\n    && echo \"deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt jammy-pgdg main\" > /etc/apt/sources.list.d/pgdg.list \\\n    && apt-get update \\\n    && apt-get install -y yarn \\\n    && apt-get install -y mysql-client \\\n    && apt-get install -y postgresql-client-$POSTGRES_VERSION \\\n    && apt-get -y autoremove \\\n    && apt-get clean \\\n    && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*\n\nRUN setcap \"cap_net_bind_service=+ep\" /usr/bin/php8.3\n\nRUN groupadd --force -g $WWWGROUP sail\nRUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail\n\nCOPY start-container /usr/local/bin/start-container\nCOPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf\nCOPY php.ini /etc/php/8.3/cli/conf.d/99-sail.ini\nRUN chmod +x /usr/local/bin/start-container\n\nEXPOSE 8000\n\nENTRYPOINT [\"start-container\"]\n"
  },
  {
    "path": "docker/local/8.3/php.ini",
    "content": "[PHP]\npost_max_size = 100M\nupload_max_filesize = 100M\nvariables_order = EGPCS\npcov.directory = .\n\n[opcache]\nopcache.enable_cli=1\n"
  },
  {
    "path": "docker/local/8.3/start-container",
    "content": "#!/usr/bin/env bash\n\nif [ \"$SUPERVISOR_PHP_USER\" != \"root\" ] && [ \"$SUPERVISOR_PHP_USER\" != \"sail\" ]; then\n    echo \"You should set SUPERVISOR_PHP_USER to either 'sail' or 'root'.\"\n    exit 1\nfi\n\nif [ ! -z \"$WWWUSER\" ]; then\n    usermod -u $WWWUSER sail\nfi\n\nif [ ! -d /.composer ]; then\n    mkdir /.composer\nfi\n\nchmod -R ugo+rw /.composer\n\nif [ $# -gt 0 ]; then\n    if [ \"$SUPERVISOR_PHP_USER\" = \"root\" ]; then\n        exec \"$@\"\n    else\n        exec gosu $WWWUSER \"$@\"\n    fi\nelse\n    exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf\nfi\n"
  },
  {
    "path": "docker/local/8.3/supervisord.conf",
    "content": "[supervisord]\nnodaemon=true\nuser=root\nlogfile=/var/log/supervisor/supervisord.log\npidfile=/var/run/supervisord.pid\n\n[program:php]\ncommand=%(ENV_SUPERVISOR_PHP_COMMAND)s\nuser=%(ENV_SUPERVISOR_PHP_USER)s\nenvironment=LARAVEL_SAIL=\"1\"\nstdout_logfile=/dev/stdout\nstdout_logfile_maxbytes=0\nstderr_logfile=/dev/stderr\nstderr_logfile_maxbytes=0\n"
  },
  {
    "path": "docker/local/minio/create_bucket.sh",
    "content": "#!/bin/sh\n\n# Source: https://helgesver.re/articles/laravel-sail-create-minio-bucket-automatically\n\n/usr/bin/mc alias set local ${S3_ENDPOINT} ${S3_ACCESS_KEY_ID} ${S3_SECRET_ACCESS_KEY};\n/usr/bin/mc rm -r --force local/${S3_BUCKET};\n/usr/bin/mc mb --ignore-existing local/${S3_BUCKET};\n/usr/bin/mc anonymous set public local/${S3_BUCKET};\n\nexit 0;\n"
  },
  {
    "path": "docker/local/pgsql/create-testing-database.sql",
    "content": "SELECT 'CREATE DATABASE testing'\nWHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'testing')\\gexec\n"
  },
  {
    "path": "docker/prod/Dockerfile",
    "content": "ARG PHP_VERSION=8.3\nARG FRANKENPHP_VERSION=1.8\nARG COMPOSER_VERSION=2.8\nARG BUN_VERSION=\"latest\"\nARG APP_ENV\nARG DOCKER_FILES_BASE_PATH=\"docker/prod/\"\n\nFROM composer:${COMPOSER_VERSION} AS vendor\n\nFROM dunglas/frankenphp:${FRANKENPHP_VERSION}-builder-php${PHP_VERSION} AS upstream\n\nCOPY --from=caddy:builder /usr/bin/xcaddy /usr/bin/xcaddy\n\nRUN CGO_ENABLED=1 \\\n    XCADDY_SETCAP=1 \\\n    XCADDY_GO_BUILD_FLAGS=\"-ldflags='-w -s' -tags=nobadger,nomysql,nopgx\" \\\n    CGO_CFLAGS=$(php-config --includes) \\\n    CGO_LDFLAGS=\"$(php-config --ldflags) $(php-config --libs)\" \\\n    xcaddy build v2.10.0 \\\n        --output /usr/local/bin/frankenphp \\\n        --with github.com/dunglas/frankenphp=./ \\\n        --with github.com/dunglas/frankenphp/caddy=./caddy/ \\\n        --with github.com/dunglas/caddy-cbrotli\n\nFROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION} AS base\n\nCOPY --from=upstream /usr/local/bin/frankenphp /usr/local/bin/frankenphp\n\nLABEL maintainer=\"solidtime <hello@solidtime.io>\"\nLABEL org.opencontainers.image.title=\"solidtime\"\nLABEL org.opencontainers.image.description=\"solidtime is a modern open source timetracker for freelancers and agencies\"\nLABEL org.opencontainers.image.source=\"https://github.com/solidtime-io/solidtime\"\nLABEL org.opencontainers.image.licenses=\"AGPL\"\n\nARG WWWUSER=1000\nARG WWWGROUP=1000\nARG TZ=UTC\nARG APP_DIR=/var/www/html\nARG APP_ENV\nARG APP_HOST\nARG DOCKER_FILES_BASE_PATH\n\nENV DEBIAN_FRONTEND=noninteractive \\\n    TERM=xterm-color \\\n    OCTANE_SERVER=frankenphp \\\n    TZ=${TZ} \\\n    USER=octane \\\n    ROOT=${APP_DIR} \\\n    APP_ENV=${APP_ENV} \\\n    COMPOSER_FUND=0 \\\n    COMPOSER_MAX_PARALLEL_HTTP=24 \\\n    XDG_CONFIG_HOME=${APP_DIR}/.config \\\n    XDG_DATA_HOME=${APP_DIR}/.data \\\n    SERVER_NAME=${APP_HOST}\n\nWORKDIR ${ROOT}\n\nSHELL [\"/bin/bash\", \"-eou\", \"pipefail\", \"-c\"]\n\nRUN ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \\\n    && echo ${TZ} > /etc/timezone\n\nRUN apt-get update; \\\n    apt-get upgrade -yqq; \\\n    apt-get install -yqq --no-install-recommends --show-progress \\\n    apt-utils \\\n    curl \\\n    wget \\\n    vim \\\n    git \\\n    ncdu \\\n    procps \\\n    unzip \\\n    ca-certificates \\\n    supervisor \\\n    libsodium-dev \\\n    libbrotli-dev \\\n    # Install PHP extensions (included with dunglas/frankenphp)\n    && install-php-extensions \\\n    bz2 \\\n    pcntl \\\n    mbstring \\\n    bcmath \\\n    sockets \\\n    pgsql \\\n    pdo_pgsql \\\n    opcache \\\n    exif \\\n    pdo_mysql \\\n    zip \\\n    uv \\\n    vips \\\n    intl \\\n    gd \\\n    redis \\\n    rdkafka \\\n    memcached \\\n    igbinary \\\n    ldap \\\n    && apt-get -y autoremove \\\n    && apt-get clean \\\n    && docker-php-source delete \\\n    && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \\\n    && rm /var/log/lastlog /var/log/faillog\n\nRUN arch=\"$(uname -m)\" \\\n    && case \"$arch\" in \\\n    armhf) _cronic_fname='supercronic-linux-arm' ;; \\\n    aarch64) _cronic_fname='supercronic-linux-arm64' ;; \\\n    x86_64) _cronic_fname='supercronic-linux-amd64' ;; \\\n    x86) _cronic_fname='supercronic-linux-386' ;; \\\n    *) echo >&2 \"error: unsupported architecture: $arch\"; exit 1 ;; \\\n    esac \\\n    && wget -q \"https://github.com/aptible/supercronic/releases/download/v0.2.29/${_cronic_fname}\" \\\n    -O /usr/bin/supercronic \\\n    && chmod +x /usr/bin/supercronic \\\n    && mkdir -p /etc/supercronic \\\n    && echo \"*/1 * * * * php ${ROOT}/artisan schedule:run --no-interaction\" > /etc/supercronic/laravel\n\nRUN userdel --remove --force www-data \\\n    && groupadd --force -g ${WWWGROUP} ${USER} \\\n    && useradd -ms /bin/bash --no-log-init --no-user-group -g ${WWWGROUP} -u ${WWWUSER} ${USER} \\\n    && setcap -r /usr/local/bin/frankenphp\n\nRUN chown -R ${USER}:${USER} ${ROOT} /var/{log,run} \\\n    && chmod -R a+rw ${ROOT} /var/{log,run}\n\nRUN cp ${PHP_INI_DIR}/php.ini-production ${PHP_INI_DIR}/php.ini\n\nUSER ${USER}\n\nCOPY --link --chown=${WWWUSER}:${WWWUSER} --from=vendor /usr/bin/composer /usr/bin/composer\n\nCOPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.conf /etc/\nCOPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/octane/FrankenPHP/supervisord.frankenphp.conf /etc/supervisor/conf.d/\nCOPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.*.conf /etc/supervisor/conf.d/\nCOPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/start-container /usr/local/bin/start-container\nCOPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/healthcheck /usr/local/bin/healthcheck\nCOPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini ${PHP_INI_DIR}/conf.d/99-octane.ini\n\nRUN chmod +x /usr/local/bin/start-container /usr/local/bin/healthcheck\n\n###########################################\n\n#FROM base AS common\n#\n#USER ${USER}\n#\n#COPY --link --chown=${WWWUSER}:${WWWUSER} . .\n#\n#RUN composer install \\\n#    --no-dev \\\n#    --no-interaction \\\n#    --no-autoloader \\\n#    --no-ansi \\\n#    --no-scripts \\\n#    --audit\n\n###########################################\n# Build frontend assets with Bun\n###########################################\n\n#FROM oven/bun:${BUN_VERSION} AS build\n#\n#ARG APP_ENV\n#\n#ENV ROOT=/var/www/html \\\n#    APP_ENV=${APP_ENV} \\\n#    NODE_ENV=${APP_ENV:-production}\n#\n#WORKDIR ${ROOT}\n#\n#COPY --link package.json bun.lock* ./\n#\n#RUN bun install --frozen-lockfile\n#\n#COPY --link . .\n#COPY --link --from=common ${ROOT}/vendor vendor\n#\n#RUN bun run build\n\n###########################################\n\n#FROM common AS runner\n\nUSER ${USER}\n\nENV WITH_HORIZON=false \\\n    WITH_SCHEDULER=false \\\n    WITH_REVERB=false\n\nCOPY --link --chown=${WWWUSER}:${WWWUSER} . .\n#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public\n\nRUN mkdir -p \\\n    storage/framework/{sessions,views,cache,testing} \\\n    storage/logs \\\n    bootstrap/cache && chmod -R a+rw storage\n\n#RUN composer install \\\n#    --classmap-authoritative \\\n#    --no-interaction \\\n#    --no-ansi \\\n#    --no-dev \\\n#    && composer clear-cache\n\nRUN cat .env\n#RUN php artisan env\n\nEXPOSE 8000\n\nENTRYPOINT [\"start-container\"]\n\n#HEALTHCHECK --start-period=5s --interval=2s --timeout=5s --retries=8 CMD healthcheck || exit 1\n"
  },
  {
    "path": "docker/prod/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2021 Exa Company\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "docker/prod/deployment/healthcheck",
    "content": "#!/usr/bin/env sh\n\nset -e\n\ncontainer_mode=${CONTAINER_MODE:-\"http\"}\n\nif [ \"${container_mode}\" = \"http\" ]; then\n    php \"${ROOT}/artisan\" octane:status\nelif [ \"${container_mode}\" = \"horizon\" ]; then\n    php \"${ROOT}/artisan\" horizon:status\nelif [ \"${container_mode}\" = \"scheduler\" ]; then\n    if [ \"$(supervisorctl status scheduler:scheduler_0 | awk '{print tolower($2)}')\" = \"running\" ]; then\n        exit 0\n    else\n        echo \"Healthcheck failed.\"\n        exit 1\n    fi\nelif [ \"${container_mode}\" = \"reverb\" ]; then\n    if [ \"$(supervisorctl status reverb:reverb_0 | awk '{print tolower($2)}')\" = \"running\" ]; then\n        exit 0\n    else\n        echo \"Healthcheck failed.\"\n        exit 1\n    fi\nelif [ \"${container_mode}\" = \"worker\" ]; then\n    if [ \"$(supervisorctl status worker:worker_0 | awk '{print tolower($2)}')\" = \"running\" ]; then\n        exit 0\n    else\n        echo \"Healthcheck failed.\"\n        exit 1\n    fi\nelse\n    echo \"Container mode mismatched.\"\n    exit 1\nfi\n"
  },
  {
    "path": "docker/prod/deployment/octane/FrankenPHP/Caddyfile",
    "content": "{\n\t{$CADDY_GLOBAL_OPTIONS}\n\n\tadmin {$CADDY_SERVER_ADMIN_HOST}:{$CADDY_SERVER_ADMIN_PORT}\n\n\tfrankenphp {\n\t\tworker \"{$APP_PUBLIC_PATH}/frankenphp-worker.php\" {$CADDY_SERVER_WORKER_COUNT}\n\t}\n\n\tmetrics {\n\t\tper_host\n\t}\n\n\tservers {\n\t\tprotocols h1\n\t}\n}\n\n{$CADDY_EXTRA_CONFIG}\n\n{$CADDY_SERVER_SERVER_NAME} {\n\tlog {\n\t\tlevel WARN\n\n\t\tformat filter {\n\t\t\twrap {$CADDY_SERVER_LOGGER}\n\t\t\tfields {\n\t\t\t\turi query {\n\t\t\t\t\treplace authorization REDACTED\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\troute {\n\t\troot * \"{$APP_PUBLIC_PATH}\"\n\t\tencode zstd br gzip\n\n\t\t{$CADDY_SERVER_EXTRA_DIRECTIVES}\n\n\t\trequest_body {\n\t\t\tmax_size 500MB\n\t\t}\n\n\t\t@static {\n\t\t\tfile\n\t\t\tpath *.js *.css *.jpg *.jpeg *.webp *.weba *.webm *.gif *.png *.ico *.cur *.gz *.svg *.svgz *.mp4 *.mp3 *.ogg *.ogv *.htc *.woff2 *.woff\n\t\t}\n\n\t\t@staticshort {\n\t\t\tfile\n\t\t\tpath *.json *.xml *.rss\n\t\t}\n\n\t\theader @static Cache-Control \"public, immutable, stale-while-revalidate, max-age=31536000\"\n\n\t\theader @staticshort Cache-Control \"no-cache, max-age=3600\"\n\n\t\t@rejected `path('*.bak', '*.conf', '*.dist', '*.fla', '*.ini', '*.inc', '*.inci', '*.log', '*.orig', '*.psd', '*.sh', '*.sql', '*.swo', '*.swp', '*.swop', '*/.*') && !path('*/.well-known/*')`\n\t\terror @rejected 401\n\n\t\tphp_server {\n\t\t\tindex frankenphp-worker.php\n\t\t\ttry_files {path} frankenphp-worker.php\n\t\t\tresolve_root_symlink\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "docker/prod/deployment/octane/FrankenPHP/supervisord.frankenphp.conf",
    "content": "[program:octane]\nprocess_name = %(program_name)s_%(process_num)s\ncommand = php %(ENV_ROOT)s/artisan octane:frankenphp --host=0.0.0.0 --port=8000 --admin-port=2019 --caddyfile=%(ENV_ROOT)s/docker/prod/deployment/octane/FrankenPHP/Caddyfile\nuser = %(ENV_USER)s\npriority = 1\nautostart = true\nautorestart = true\nenvironment = LARAVEL_OCTANE = \"1\"\nstdout_logfile = /dev/stdout\nstdout_logfile_maxbytes = 0\nstderr_logfile = /dev/stderr\nstderr_logfile_maxbytes = 0\n\n[program:horizon]\nprocess_name = %(program_name)s_%(process_num)s\ncommand = php %(ENV_ROOT)s/artisan horizon\nuser = %(ENV_USER)s\npriority = 3\nautostart = %(ENV_WITH_HORIZON)s\nautorestart = true\nstdout_logfile = %(ENV_ROOT)s/storage/logs/horizon.log\nstdout_logfile_maxbytes = 200MB\nstderr_logfile = %(ENV_ROOT)s/storage/logs/horizon.log\nstderr_logfile_maxbytes = 200MB\nstopwaitsecs = 3600\n\n[program:scheduler]\nprocess_name = %(program_name)s_%(process_num)s\ncommand = supercronic -overlapping /etc/supercronic/laravel\nuser = %(ENV_USER)s\nautostart = %(ENV_WITH_SCHEDULER)s\nautorestart = true\nstdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log\nstdout_logfile_maxbytes = 200MB\nstderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log\nstderr_logfile_maxbytes = 200MB\n\n[program:clear-scheduler-cache]\nprocess_name = %(program_name)s_%(process_num)s\ncommand = php %(ENV_ROOT)s/artisan schedule:clear-cache\nuser = %(ENV_USER)s\nautostart = %(ENV_WITH_SCHEDULER)s\nautorestart = false\nstartsecs = 0\nstartretries = 1\nstdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log\nstdout_logfile_maxbytes = 200MB\nstderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log\nstderr_logfile_maxbytes = 200MB\n\n[program:reverb]\nprocess_name = %(program_name)s_%(process_num)s\ncommand = php %(ENV_ROOT)s/artisan reverb:start\nuser = %(ENV_USER)s\npriority = 2\nautostart = %(ENV_WITH_REVERB)s\nautorestart = true\nstdout_logfile = %(ENV_ROOT)s/storage/logs/reverb.log\nstdout_logfile_maxbytes = 200MB\nstderr_logfile = %(ENV_ROOT)s/storage/logs/reverb.log\nstderr_logfile_maxbytes = 200MB\nminfds = 10000\n\n[include]\nfiles = /etc/supervisord.conf\n"
  },
  {
    "path": "docker/prod/deployment/php.ini",
    "content": "[PHP]\npost_max_size = 100M\nupload_max_filesize = 100M\nexpose_php = 0\nrealpath_cache_size = 16M\nrealpath_cache_ttl = 360\nmax_input_time = 5\nregister_argc_argv = 0\ndate.timezone = ${TZ:-UTC}\n\n[Opcache]\nopcache.enable = 1\nopcache.enable_cli = 1\nopcache.memory_consumption = 256M\nopcache.use_cwd = 0\nopcache.max_file_size = 0\nopcache.max_accelerated_files = 32531\nopcache.validate_timestamps = 0\nopcache.file_update_protection = 0\nopcache.interned_strings_buffer = 16\n\n[JIT]\nopcache.jit_buffer_size = 128M\nopcache.jit = function\nopcache.jit_prof_threshold = 0.001\nopcache.jit_max_root_traces = 2048\nopcache.jit_max_side_traces = 256\n\n[zlib]\nzlib.output_compression = On\nzlib.output_compression_level = 9\n"
  },
  {
    "path": "docker/prod/deployment/start-container",
    "content": "#!/usr/bin/env sh\nset -e\n\ncontainer_mode=${CONTAINER_MODE:-\"http\"}\noctane_server=${OCTANE_SERVER}\nauto_db_migrate=${AUTO_DB_MIGRATE:-false}\n\ninitialStuff() {\n    echo \"Container mode: $container_mode\"\n\n    if [ ${auto_db_migrate} = \"true\" ]; then\n        echo \"Auto database migration enabled.\"\n        php artisan migrate --isolated --force\n    fi\n\n    php artisan storage:link; \\\n    php artisan optimize:clear; \\\n    php artisan optimize;\n}\n\nif [ \"$1\" != \"\" ]; then\n    exec \"$@\"\nelif [ \"${container_mode}\" = \"http\" ]; then\n    initialStuff\n    echo \"Octane Server: $octane_server\"\n    if [ \"${octane_server}\"  = \"frankenphp\" ]; then\n        exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.frankenphp.conf\n    elif [ \"${octane_server}\"  = \"swoole\" ]; then\n        exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.swoole.conf\n    elif [ \"${octane_server}\"  = \"roadrunner\" ]; then\n        exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.roadrunner.conf\n    else\n        echo \"Invalid Octane server supplied.\"\n        exit 1\n    fi\nelif [ \"${container_mode}\" = \"horizon\" ]; then\n    initialStuff\n    exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.horizon.conf\nelif [ \"${container_mode}\" = \"reverb\" ]; then\n    initialStuff\n    exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.reverb.conf\nelif [ \"${container_mode}\" = \"scheduler\" ]; then\n    initialStuff\n    exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.scheduler.conf\nelif [ \"${container_mode}\" = \"worker\" ]; then\n    if [ -z \"${WORKER_COMMAND}\" ]; then\n        echo \"WORKER_COMMAND is undefined.\"\n        exit 1\n    fi\n    initialStuff\n    exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.worker.conf\nelse\n    echo \"Container mode mismatched.\"\n    exit 1\nfi\n"
  },
  {
    "path": "docker/prod/deployment/supervisord.conf",
    "content": "[supervisord]\nnodaemon = true\nuser = %(ENV_USER)s\nlogfile = /var/log/supervisor/supervisord.log\npidfile = /var/run/supervisord.pid\n\n[supervisorctl]\n\n[inet_http_server]\nport = 127.0.0.1:9001\n\n[rpcinterface:supervisor]\nsupervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface\n"
  },
  {
    "path": "docker/prod/deployment/supervisord.horizon.conf",
    "content": "[program:horizon]\nprocess_name = %(program_name)s_%(process_num)s\ncommand = php %(ENV_ROOT)s/artisan horizon\nuser = %(ENV_USER)s\nautostart = true\nautorestart = true\nstdout_logfile = /dev/stdout\nstdout_logfile_maxbytes = 0\nstderr_logfile = /dev/stderr\nstderr_logfile_maxbytes = 0\nstopwaitsecs = 3600\n\n[include]\nfiles = /etc/supervisord.conf\n"
  },
  {
    "path": "docker/prod/deployment/supervisord.reverb.conf",
    "content": "[program:reverb]\nprocess_name = %(program_name)s_%(process_num)s\ncommand = php %(ENV_ROOT)s/artisan reverb:start\nuser = %(ENV_USER)s\nautostart = true\nautorestart = true\nstdout_logfile = /dev/stdout\nstdout_logfile_maxbytes = 0\nstderr_logfile = /dev/stderr\nstderr_logfile_maxbytes = 0\nminfds = 10000\n\n[include]\nfiles = /etc/supervisord.conf\n"
  },
  {
    "path": "docker/prod/deployment/supervisord.scheduler.conf",
    "content": "[program:scheduler]\nprocess_name = %(program_name)s_%(process_num)s\ncommand = supercronic -overlapping /etc/supercronic/laravel\nuser = %(ENV_USER)s\nautostart = true\nautorestart = true\nstdout_logfile = /dev/stdout\nstdout_logfile_maxbytes = 0\nstderr_logfile = /dev/stderr\nstderr_logfile_maxbytes = 0\n\n[program:clear-scheduler-cache]\nprocess_name = %(program_name)s_%(process_num)s\ncommand = php %(ENV_ROOT)s/artisan schedule:clear-cache\nuser = %(ENV_USER)s\nautostart = true\nautorestart = false\nstartsecs = 0\nstartretries = 1\nstdout_logfile = /dev/stdout\nstdout_logfile_maxbytes = 0\nstderr_logfile = /dev/stderr\nstderr_logfile_maxbytes = 0\n\n[include]\nfiles = /etc/supervisord.conf\n"
  },
  {
    "path": "docker/prod/deployment/supervisord.worker.conf",
    "content": "[program:worker]\nprocess_name = %(program_name)s_%(process_num)s\ncommand = %(ENV_WORKER_COMMAND)s\nuser = %(ENV_USER)s\nautostart = true\nautorestart = true\nstdout_logfile = /dev/stdout\nstdout_logfile_maxbytes = 0\nstderr_logfile = /dev/stderr\nstderr_logfile_maxbytes = 0\n\n[include]\nfiles = /etc/supervisord.conf\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n    laravel.test:\n        build:\n            context: ./docker/local/8.3\n            dockerfile: Dockerfile\n            args:\n                WWWGROUP: '${WWWGROUP}'\n        image: sail-8.3/app\n        labels:\n            - \"traefik.enable=true\"\n            - \"traefik.docker.network=${NETWORK_NAME}\"\n            - \"traefik.http.services.solidtime-dev.loadbalancer.server.port=80\"\n            - \"traefik.http.routers.solidtime-dev.rule=Host(`${NGINX_HOST_NAME}`)\"\n            - \"traefik.http.routers.solidtime-dev.entrypoints=web\"\n            - \"traefik.http.routers.solidtime-dev.service=solidtime-dev\"\n            - \"traefik.http.routers.solidtime-dev-https.rule=Host(`${NGINX_HOST_NAME}`)\"\n            - \"traefik.http.routers.solidtime-dev-https.service=solidtime-dev\"\n            - \"traefik.http.routers.solidtime-dev-https.entrypoints=websecure\"\n            - \"traefik.http.routers.solidtime-dev-https.tls=true\"\n            # vite\n            - \"traefik.http.services.solidtime-dev-vite.loadbalancer.server.port=5173\"\n            # http\n            - \"traefik.http.routers.solidtime-dev-vite.rule=Host(`${VITE_HOST_NAME}`)\"\n            - \"traefik.http.routers.solidtime-dev-vite.service=solidtime-dev-vite\"\n            - \"traefik.http.routers.solidtime-dev-vite.entrypoints=web\"\n        extra_hosts:\n            - \"host.docker.internal:host-gateway\"\n            - \"storage.${NGINX_HOST_NAME}:${REVERSE_PROXY_IP:-10.100.100.10}\"\n        environment:\n            XDG_CONFIG_HOME: /var/www/html/config\n            XDG_DATA_HOME: /var/www/html/data\n            WWWUSER: '${WWWUSER}'\n            LARAVEL_SAIL: 1\n            XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'\n            XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'\n            IGNITION_LOCAL_SITES_PATH: '${PWD}'\n            VITE_HOST_NAME: '${VITE_HOST_NAME}'\n        volumes:\n            - '.:/var/www/html'\n        networks:\n            - sail\n            - reverse-proxy\n        depends_on:\n            - pgsql\n    pgsql:\n        image: 'postgres:15'\n        ports:\n            - '${FORWARD_DB_PORT:-5432}:5432'\n        environment:\n            PGPASSWORD: '${DB_PASSWORD:-secret}'\n            POSTGRES_DB: '${DB_DATABASE}'\n            POSTGRES_USER: '${DB_USERNAME}'\n            POSTGRES_PASSWORD: '${DB_PASSWORD:-secret}'\n        volumes:\n            - 'sail-pgsql:/var/lib/postgresql/data'\n            - './docker/local/pgsql/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql'\n        networks:\n            - sail\n        healthcheck:\n            test:\n                - CMD\n                - pg_isready\n                - '-q'\n                - '-d'\n                - '${DB_DATABASE}'\n                - '-U'\n                - '${DB_USERNAME}'\n            retries: 3\n            timeout: 5s\n    pgsql_test:\n        image: 'postgres:15'\n        environment:\n            PGPASSWORD: '${DB_PASSWORD:-secret}'\n            POSTGRES_DB: '${DB_DATABASE}'\n            POSTGRES_USER: '${DB_USERNAME}'\n            POSTGRES_PASSWORD: '${DB_PASSWORD:-secret}'\n        volumes:\n            - 'sail-pgsql-test:/var/lib/postgresql/data'\n            - './docker/local/pgsql/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql'\n        networks:\n            - sail\n        healthcheck:\n            test:\n                - CMD\n                - pg_isready\n                - '-q'\n                - '-d'\n                - '${DB_DATABASE}'\n                - '-U'\n                - '${DB_USERNAME}'\n            retries: 3\n            timeout: 5s\n    mailpit:\n        image: 'axllent/mailpit:latest'\n        labels:\n            - \"traefik.enable=true\"\n            - \"traefik.docker.network=${NETWORK_NAME}\"\n            - \"traefik.http.services.solidtime-dev-mailpit.loadbalancer.server.port=8025\"\n            - \"traefik.http.routers.solidtime-dev-mailpit.rule=Host(`mail.${NGINX_HOST_NAME}`)\"\n            - \"traefik.http.routers.solidtime-dev-mailpit.entrypoints=web\"\n            - \"traefik.http.routers.solidtime-dev-mailpit.service=solidtime-dev-mailpit\"\n            - \"traefik.http.routers.solidtime-dev-mailpit-https.rule=Host(`mail.${NGINX_HOST_NAME}`)\"\n            - \"traefik.http.routers.solidtime-dev-mailpit-https.service=solidtime-dev-mailpit\"\n            - \"traefik.http.routers.solidtime-dev-mailpit-https.entrypoints=websecure\"\n            - \"traefik.http.routers.solidtime-dev-mailpit-https.tls=true\"\n        networks:\n            - sail\n            - reverse-proxy\n    playwright:\n        image: mcr.microsoft.com/playwright:v1.58.1-jammy\n        command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']\n        working_dir: /src\n        extra_hosts:\n            - \"${NGINX_HOST_NAME}:${REVERSE_PROXY_IP:-10.100.100.10}\"\n            - \"${VITE_HOST_NAME}:${REVERSE_PROXY_IP:-10.100.100.10}\"\n        labels:\n            - \"traefik.enable=true\"\n            - \"traefik.docker.network=${NETWORK_NAME}\"\n            - \"traefik.http.services.solidtime-dev-playwright.loadbalancer.server.port=8080\"\n            - \"traefik.http.routers.solidtime-dev-playwright.rule=Host(`playwright.${NGINX_HOST_NAME}`)\"\n            - \"traefik.http.routers.solidtime-dev-playwright.entrypoints=web\"\n            - \"traefik.http.routers.solidtime-dev-playwright-https.rule=Host(`playwright.${NGINX_HOST_NAME}`)\"\n            - \"traefik.http.routers.solidtime-dev-playwright-https.entrypoints=websecure\"\n            - \"traefik.http.routers.solidtime-dev-playwright-https.tls=true\"\n        networks:\n            - sail\n            - reverse-proxy\n        volumes:\n            - '.:/src'\n    minio:\n        image: 'minio/minio:latest'\n        environment:\n            MINIO_BROWSER_REDIRECT_URL: 'https://storage-management.${NGINX_HOST_NAME}'\n            MINIO_ROOT_USER: 'sail'\n            MINIO_ROOT_PASSWORD: 'password'\n        volumes:\n            - 'sail-minio:/data/minio'\n        networks:\n            - reverse-proxy\n            - sail\n        command: minio server /data/minio --console-address \":8900\"\n        healthcheck:\n            test: [ \"CMD\", \"mc\", \"ready\", \"local\" ]\n            interval: 5s\n            timeout: 5s\n            retries: 5\n        labels:\n            - \"traefik.enable=true\"\n            - \"traefik.docker.network=${NETWORK_NAME}\"\n            # Storage Frontend\n            - \"traefik.http.services.solidtime-dev-storage-frontend.loadbalancer.server.port=9000\"\n            # http\n            - \"traefik.http.routers.solidtime-dev-storage-frontend.rule=Host(`storage.${NGINX_HOST_NAME}`)\"\n            - \"traefik.http.routers.solidtime-dev-storage-frontend.service=solidtime-dev-storage-frontend\"\n            - \"traefik.http.routers.solidtime-dev-storage-frontend.entrypoints=web\"\n            # https\n            - \"traefik.http.routers.solidtime-dev-storage-frontend-https.rule=Host(`storage.${NGINX_HOST_NAME}`)\"\n            - \"traefik.http.routers.solidtime-dev-storage-frontend-https.service=solidtime-dev-storage-frontend\"\n            - \"traefik.http.routers.solidtime-dev-storage-frontend-https.entrypoints=websecure\"\n            - \"traefik.http.routers.solidtime-dev-storage-frontend-https.tls=true\"\n            # Storage Management\n            - \"traefik.http.services.solidtime-dev-storage-management.loadbalancer.server.port=8900\"\n            # http\n            - \"traefik.http.routers.solidtime-dev-storage-management.rule=Host(`storage-management.${NGINX_HOST_NAME}`)\"\n            - \"traefik.http.routers.solidtime-dev-storage-management.service=solidtime-dev-storage-management\"\n            - \"traefik.http.routers.solidtime-dev-storage-management.entrypoints=web\"\n            # https\n            - \"traefik.http.routers.solidtime-dev-storage-management-https.rule=Host(`storage-management.${NGINX_HOST_NAME}`)\"\n            - \"traefik.http.routers.solidtime-dev-storage-management-https.service=solidtime-dev-storage-management\"\n            - \"traefik.http.routers.solidtime-dev-storage-management-https.entrypoints=websecure\"\n            - \"traefik.http.routers.solidtime-dev-storage-management-https.tls=true\"\n\n    minio-create-bucket:\n        image: minio/mc:latest\n        depends_on:\n            - minio\n        environment:\n            S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID}\n            S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY}\n            S3_BUCKET: ${S3_BUCKET}\n            S3_ENDPOINT: ${S3_ENDPOINT}\n        volumes:\n            - './docker/local/minio:/etc/minio'\n        networks:\n            - sail\n            - reverse-proxy\n        entrypoint: /etc/minio/create_bucket.sh\n        extra_hosts:\n            - \"storage.${NGINX_HOST_NAME}:${REVERSE_PROXY_IP:-10.100.100.10}\"\n\n    gotenberg:\n        image: gotenberg/gotenberg:8\n        networks:\n            - sail\n        healthcheck:\n            test: [\"CMD\", \"curl\", \"--silent\", \"--fail\", \"http://localhost:3000/health\"]\nnetworks:\n    reverse-proxy:\n        name: \"${NETWORK_NAME}\"\n        external: true\n    sail:\n        driver: bridge\nvolumes:\n    sail-pgsql:\n        driver: local\n    sail-pgsql-test:\n        driver: local\n    sail-minio:\n        driver: local\n"
  },
  {
    "path": "e2e/auth.spec.ts",
    "content": "import { expect, test } from '@playwright/test';\nimport { PLAYWRIGHT_BASE_URL } from '../playwright/config';\nimport { getPasswordResetUrl } from './utils/mailpit';\n\nasync function registerNewUser(page, email, password) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/register');\n    await page.getByLabel('Name').fill('John Doe');\n    await page.getByLabel('Email').fill(email);\n    await page.getByLabel('Password', { exact: true }).fill(password);\n    await page.getByLabel('Confirm Password').fill(password);\n    await page.getByLabel('I agree to the Terms of').click();\n    await page.getByRole('button', { name: 'Register' }).click();\n    await expect(page.getByTestId('dashboard_view')).toBeVisible();\n}\n\ntest('can register, logout and log back in', async ({ page }) => {\n    await page.goto(PLAYWRIGHT_BASE_URL);\n    const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;\n    const password = 'suchagreatpassword123';\n    await registerNewUser(page, email, password);\n    await expect(page.getByTestId('dashboard_view')).toBeVisible();\n    await page.getByTestId('current_user_button').click();\n    await page.getByText('Log Out').click();\n    await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');\n    await page.goto(PLAYWRIGHT_BASE_URL + '/login');\n    await page.getByLabel('Email').fill(email);\n    await page.getByLabel('Password').fill(password);\n    await page.getByRole('button', { name: 'Log in' }).click();\n    await expect(page.getByTestId('dashboard_view')).toBeVisible();\n});\n\ntest('can register and delete account', async ({ page }) => {\n    await page.goto(PLAYWRIGHT_BASE_URL);\n    const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;\n    const password = 'suchagreatpassword123';\n    await registerNewUser(page, email, password);\n    await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');\n    await page.getByRole('button', { name: 'Delete Account' }).click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n    await page.getByPlaceholder('Password').fill(password);\n    await page.getByRole('dialog').getByRole('button', { name: 'Delete Account' }).click();\n    await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');\n    await page.goto(PLAYWRIGHT_BASE_URL + '/login');\n    await page.getByLabel('Email').fill(email);\n    await page.getByLabel('Password').fill(password);\n    await page.getByRole('button', { name: 'Log in' }).click();\n    await expect(page.getByRole('alert')).toContainText(\n        'These credentials do not match our records.'\n    );\n});\n\ntest('shows error for invalid email on forgot password', async ({ page }) => {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');\n\n    // Request password reset with non-existent email\n    await page.getByLabel('Email').fill('nonexistent@example.com');\n    await page.getByRole('button', { name: 'Email Password Reset Link' }).click();\n\n    // Should show error message\n    await expect(page.getByText(\"We can't find a user with that email address.\")).toBeVisible();\n});\n\ntest('shows browser validation for invalid email format on forgot password', async ({ page }) => {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');\n\n    // Request password reset with invalid email format\n    const emailInput = page.getByLabel('Email');\n    await emailInput.fill('notanemail');\n\n    // Check for browser validation - the input should be invalid\n    const isInvalid = await emailInput.evaluate((el: HTMLInputElement) => !el.validity.valid);\n    expect(isInvalid).toBe(true);\n});\n\ntest('shows browser validation for empty email on forgot password', async ({ page }) => {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');\n\n    // The email input is required, so it should be invalid when empty\n    const emailInput = page.getByLabel('Email');\n\n    // Check for browser validation - the input should be invalid because it's required and empty\n    const isInvalid = await emailInput.evaluate((el: HTMLInputElement) => el.validity.valueMissing);\n    expect(isInvalid).toBe(true);\n});\n\ntest('can reset password via email link', async ({ page, request }) => {\n    // First register a new user\n    const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;\n    const originalPassword = 'suchagreatpassword123';\n    const newPassword = 'mynewsecurepassword456';\n    await registerNewUser(page, email, originalPassword);\n\n    // Log out\n    await page.getByTestId('current_user_button').click();\n    await page.getByText('Log Out').click();\n    await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');\n\n    // Request password reset\n    await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');\n    await page.getByLabel('Email').fill(email);\n    await page.getByRole('button', { name: 'Email Password Reset Link' }).click();\n    await expect(page.getByText('We have emailed your password reset link.')).toBeVisible();\n\n    // Get password reset URL from email\n    const resetUrl = await getPasswordResetUrl(request, email);\n\n    // Navigate to reset page\n    await page.goto(resetUrl);\n\n    // Fill in new password\n    await page.getByLabel('Password', { exact: true }).fill(newPassword);\n    await page.getByLabel('Confirm Password').fill(newPassword);\n    await page.getByRole('button', { name: 'Reset Password' }).click();\n\n    // Should redirect to login page after successful reset\n    await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');\n\n    // Try logging in with new password\n    await page.getByLabel('Email').fill(email);\n    await page.getByLabel('Password').fill(newPassword);\n    await page.getByRole('button', { name: 'Log in' }).click();\n    await expect(page.getByTestId('dashboard_view')).toBeVisible();\n});\n\ntest('shows validation error for password mismatch on reset', async ({ page, request }) => {\n    // First register a new user\n    const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;\n    const originalPassword = 'suchagreatpassword123';\n    await registerNewUser(page, email, originalPassword);\n\n    // Log out\n    await page.getByTestId('current_user_button').click();\n    await page.getByText('Log Out').click();\n    await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');\n\n    // Request password reset\n    await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');\n    await page.getByLabel('Email').fill(email);\n    await page.getByRole('button', { name: 'Email Password Reset Link' }).click();\n    await expect(page.getByText('We have emailed your password reset link.')).toBeVisible();\n\n    // Get password reset URL from email\n    const resetUrl = await getPasswordResetUrl(request, email);\n\n    // Navigate to reset page\n    await page.goto(resetUrl);\n\n    // Fill in mismatched passwords\n    await page.getByLabel('Password', { exact: true }).fill('newpassword123');\n    await page.getByLabel('Confirm Password').fill('differentpassword456');\n    await page.getByRole('button', { name: 'Reset Password' }).click();\n\n    // Should show validation error\n    await expect(page.getByText('The password field confirmation does not match.')).toBeVisible();\n});\n\ntest('shows validation error for short password on reset', async ({ page, request }) => {\n    // First register a new user\n    const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;\n    const originalPassword = 'suchagreatpassword123';\n    await registerNewUser(page, email, originalPassword);\n\n    // Log out\n    await page.getByTestId('current_user_button').click();\n    await page.getByText('Log Out').click();\n    await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');\n\n    // Request password reset\n    await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');\n    await page.getByLabel('Email').fill(email);\n    await page.getByRole('button', { name: 'Email Password Reset Link' }).click();\n    await expect(page.getByText('We have emailed your password reset link.')).toBeVisible();\n\n    // Get password reset URL from email\n    const resetUrl = await getPasswordResetUrl(request, email);\n\n    // Navigate to reset page\n    await page.goto(resetUrl);\n\n    // Fill in short password\n    await page.getByLabel('Password', { exact: true }).fill('short');\n    await page.getByLabel('Confirm Password').fill('short');\n    await page.getByRole('button', { name: 'Reset Password' }).click();\n\n    // Should show validation error about minimum length\n    await expect(page.getByText('must be at least')).toBeVisible();\n});\n\ntest('shows error for invalid login credentials', async ({ page }) => {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/login');\n    await page.getByLabel('Email').fill('nonexistent@example.com');\n    await page.getByLabel('Password').fill('wrongpassword123');\n    await page.getByRole('button', { name: 'Log in' }).click();\n\n    await expect(page.getByText('These credentials do not match our records.')).toBeVisible();\n});\n\ntest('shows error when registering with existing email', async ({ page }) => {\n    const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;\n    const password = 'suchagreatpassword123';\n\n    // Register first user\n    await registerNewUser(page, email, password);\n\n    // Log out\n    await page.getByTestId('current_user_button').click();\n    await page.getByText('Log Out').click();\n    await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');\n\n    // Try to register with the same email\n    await page.goto(PLAYWRIGHT_BASE_URL + '/register');\n    await page.getByLabel('Name').fill('Another User');\n    await page.getByLabel('Email').fill(email);\n    await page.getByLabel('Password', { exact: true }).fill(password);\n    await page.getByLabel('Confirm Password').fill(password);\n    await page.getByLabel('I agree to the Terms of').click();\n    await page.getByRole('button', { name: 'Register' }).click();\n\n    // Should show error about email already taken\n    await expect(page.getByText('The resource already exists.')).toBeVisible();\n});\n\ntest('shows validation error for weak password on registration', async ({ page }) => {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/register');\n    await page.getByLabel('Name').fill('Weak Password User');\n    await page.getByLabel('Email').fill(`weak+${Math.round(Math.random() * 10000)}@test.com`);\n    await page.getByLabel('Password', { exact: true }).fill('short');\n    await page.getByLabel('Confirm Password').fill('short');\n    await page.getByLabel('I agree to the Terms of').click();\n    await page.getByRole('button', { name: 'Register' }).click();\n\n    await expect(page.getByText('must be at least')).toBeVisible();\n});\n"
  },
  {
    "path": "e2e/calendar-settings.spec.ts",
    "content": "import { PLAYWRIGHT_BASE_URL } from '../playwright/config';\nimport { test } from '../playwright/fixtures';\nimport { expect } from '@playwright/test';\nimport type { Page } from '@playwright/test';\n\nasync function goToCalendar(page: Page) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');\n    await expect(page.locator('.fc')).toBeVisible();\n}\n\nasync function openSettingsPopover(page: Page) {\n    await page.getByRole('button', { name: 'Calendar settings' }).click();\n    await expect(page.getByText('Calendar Settings')).toBeVisible();\n}\n\nasync function clearCalendarSettings(page: Page) {\n    await page.evaluate(() => localStorage.removeItem('solidtime:calendar-settings'));\n}\n\ntest.describe('Calendar Settings', () => {\n    test.beforeEach(async ({ page }) => {\n        await clearCalendarSettings(page);\n    });\n\n    test('settings popover shows all fields with correct defaults', async ({ page }) => {\n        await goToCalendar(page);\n        await openSettingsPopover(page);\n\n        await expect(page.getByLabel('Snap Interval')).toContainText('15 min');\n        await expect(page.getByLabel('Start Time')).toContainText('12:00 AM');\n        await expect(page.getByLabel('End Time')).toContainText('12:00 AM (next)');\n        await expect(page.getByLabel('Grid Scale')).toContainText('15 min');\n    });\n\n    test('snap interval can be changed and persists across reload', async ({ page }) => {\n        await goToCalendar(page);\n        await openSettingsPopover(page);\n\n        // Change snap interval to 30 min\n        await page.getByLabel('Snap Interval').click();\n        await page.getByRole('option', { name: '30 min' }).click();\n        await page.locator('.fc-toolbar-title').click();\n\n        // Verify localStorage was updated\n        const stored = await page.evaluate(() =>\n            JSON.parse(localStorage.getItem('solidtime:calendar-settings') || '{}')\n        );\n        expect(stored.snapMinutes).toBe(30);\n\n        // Reload and verify persistence\n        await page.reload();\n        await expect(page.locator('.fc')).toBeVisible();\n        await openSettingsPopover(page);\n        await expect(page.getByLabel('Snap Interval')).toContainText('30 min');\n    });\n\n    test('start time change is applied to calendar and rejects values >= end time', async ({\n        page,\n    }) => {\n        await goToCalendar(page);\n\n        // Verify 7 AM slot exists with default start (00:00)\n        await expect(page.locator('.fc-timegrid-slot[data-time=\"07:00:00\"]')).not.toHaveCount(0);\n\n        await openSettingsPopover(page);\n\n        // Set end time to 6 PM first\n        await page.getByLabel('End Time').click();\n        await page.getByRole('option', { name: '6:00 PM' }).click();\n\n        // Change start time to 8 AM (valid)\n        await page.getByLabel('Start Time').click();\n        await page.getByRole('option', { name: '8:00 AM' }).click();\n        await page.locator('.fc-toolbar-title').click();\n\n        // Calendar should no longer show hours before 8 AM\n        await expect(page.locator('.fc-timegrid-slot[data-time=\"07:00:00\"]')).toHaveCount(0);\n        await expect(page.locator('.fc-timegrid-slot[data-time=\"08:00:00\"]')).not.toHaveCount(0);\n\n        // Try to set start time to 6 PM (invalid: equals end time)\n        await openSettingsPopover(page);\n        await page.getByLabel('Start Time').click();\n        await page.getByRole('option', { name: '6:00 PM' }).click();\n\n        // Should be rejected — start time stays at 8 AM\n        await expect(page.getByLabel('Start Time')).toContainText('8:00 AM');\n    });\n\n    test('end time change is applied to calendar and rejects values <= start time', async ({\n        page,\n    }) => {\n        await goToCalendar(page);\n\n        // Verify 19:00 slot exists with default end (24:00)\n        await expect(page.locator('.fc-timegrid-slot[data-time=\"19:00:00\"]')).not.toHaveCount(0);\n\n        await openSettingsPopover(page);\n\n        // Set start time to 8 AM first\n        await page.getByLabel('Start Time').click();\n        await page.getByRole('option', { name: '8:00 AM' }).click();\n\n        // Change end time to 6 PM (valid)\n        await page.getByLabel('End Time').click();\n        await page.getByRole('option', { name: '6:00 PM' }).click();\n        await page.locator('.fc-toolbar-title').click();\n\n        // Calendar should no longer show hours at or after 6 PM\n        await expect(page.locator('.fc-timegrid-slot[data-time=\"18:00:00\"]')).toHaveCount(0);\n        await expect(page.locator('.fc-timegrid-slot[data-time=\"17:00:00\"]')).not.toHaveCount(0);\n\n        // Try to set end time to 8 AM (invalid: equals start time)\n        await openSettingsPopover(page);\n        await page.getByLabel('End Time').click();\n        await page.getByRole('option', { name: '8:00 AM' }).click();\n\n        // Should be rejected — end time stays at 6 PM\n        await expect(page.getByLabel('End Time')).toContainText('6:00 PM');\n    });\n\n    test('grid scale affects number of calendar slots', async ({ page }) => {\n        await goToCalendar(page);\n\n        // Count slots with default 15-min scale\n        const defaultSlotCount = await page.locator('.fc-timegrid-slot').count();\n\n        // Change to 30 min scale (should halve the slots)\n        await openSettingsPopover(page);\n        await page.getByLabel('Grid Scale').click();\n        await page.getByRole('option', { name: '30 min' }).click();\n        await page.locator('.fc-toolbar-title').click();\n\n        const largerSlotCount = await page.locator('.fc-timegrid-slot').count();\n        expect(largerSlotCount).toBeLessThan(defaultSlotCount);\n\n        // Change to 5 min scale (should have many more slots)\n        await openSettingsPopover(page);\n        await page.getByLabel('Grid Scale').click();\n        await page.getByRole('option', { name: '5 min', exact: true }).click();\n        await page.locator('.fc-toolbar-title').click();\n\n        const smallerSlotCount = await page.locator('.fc-timegrid-slot').count();\n        expect(smallerSlotCount).toBeGreaterThan(defaultSlotCount);\n    });\n\n    test('all settings persist across navigation', async ({ page }) => {\n        await goToCalendar(page);\n        await openSettingsPopover(page);\n\n        // Change every setting\n        await page.getByLabel('Snap Interval').click();\n        await page.getByRole('option', { name: '5 min', exact: true }).click();\n        await page.getByLabel('Start Time').click();\n        await page.getByRole('option', { name: '6:00 AM' }).click();\n        await page.getByLabel('End Time').click();\n        await page.getByRole('option', { name: '10:00 PM' }).click();\n        await page.getByLabel('Grid Scale').click();\n        await page.getByRole('option', { name: '30 min' }).click();\n        await page.locator('.fc-toolbar-title').click();\n\n        // Navigate away and back\n        await page.goto(PLAYWRIGHT_BASE_URL + '/time');\n        await goToCalendar(page);\n\n        // Verify all settings persisted\n        await openSettingsPopover(page);\n        await expect(page.getByLabel('Snap Interval')).toContainText('5 min');\n        await expect(page.getByLabel('Start Time')).toContainText('6:00 AM');\n        await expect(page.getByLabel('End Time')).toContainText('10:00 PM');\n        await expect(page.getByLabel('Grid Scale')).toContainText('30 min');\n    });\n});\n"
  },
  {
    "path": "e2e/calendar.spec.ts",
    "content": "import { PLAYWRIGHT_BASE_URL } from '../playwright/config';\nimport { test } from '../playwright/fixtures';\nimport { expect } from '@playwright/test';\nimport type { Page } from '@playwright/test';\nimport {\n    createBillableProjectViaApi,\n    createProjectViaApi,\n    createBareTimeEntryViaApi,\n    createTimeEntryViaApi,\n} from './utils/api';\n\nasync function goToCalendar(page: Page) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');\n}\n\n/**\n * These tests verify that changing the project on a time entry via the calendar\n * updates the billable status to match the new project's is_billable setting.\n *\n * Issue: https://github.com/solidtime-io/solidtime/issues/981\n */\n\ntest('test that changing project in calendar edit modal from non-billable to billable updates billable status', async ({\n    page,\n    ctx,\n}) => {\n    const billableProjectName = 'Billable Cal Project ' + Math.floor(1 + Math.random() * 10000);\n\n    await createBillableProjectViaApi(ctx, { name: billableProjectName });\n    await createBareTimeEntryViaApi(ctx, 'Test billable calendar', '1h');\n\n    await goToCalendar(page);\n\n    // Click on the time entry event in the calendar\n    await page.locator('.fc-event').filter({ hasText: 'Test billable calendar' }).first().click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // Verify initially non-billable\n    await expect(\n        page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' })\n    ).toBeVisible();\n\n    // Select the billable project\n    await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click();\n    await page.getByRole('option', { name: billableProjectName }).click();\n\n    // Verify the billable dropdown updated to Billable\n    await expect(\n        page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' })\n    ).toBeVisible();\n\n    // Save and verify\n    const [updateResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/time-entries/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n        page.getByRole('button', { name: 'Update Time Entry' }).click(),\n    ]);\n    const responseBody = await updateResponse.json();\n    expect(responseBody.data.billable).toBe(true);\n});\n\ntest('test that changing project in calendar edit modal from billable to non-billable updates billable status', async ({\n    page,\n    ctx,\n}) => {\n    const billableProjectName = 'Billable Cal Rev Project ' + Math.floor(1 + Math.random() * 10000);\n    const nonBillableProjectName =\n        'NonBillable Cal Rev Project ' + Math.floor(1 + Math.random() * 10000);\n\n    await createBillableProjectViaApi(ctx, { name: billableProjectName });\n    await createProjectViaApi(ctx, { name: nonBillableProjectName });\n    await createBareTimeEntryViaApi(ctx, 'Test billable cal reverse', '1h');\n\n    await goToCalendar(page);\n\n    // Click on the time entry event in the calendar\n    await page\n        .locator('.fc-event')\n        .filter({ hasText: 'Test billable cal reverse' })\n        .first()\n        .click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // First assign the billable project\n    await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click();\n    await page.getByRole('option', { name: billableProjectName }).click();\n\n    // Verify billable status flipped to Billable\n    await expect(\n        page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' })\n    ).toBeVisible();\n\n    // Now switch to the non-billable project\n    await page.getByRole('dialog').getByRole('button', { name: billableProjectName }).click();\n    await page.getByRole('option', { name: nonBillableProjectName }).click();\n\n    // Verify billable status reverted to Non-Billable\n    await expect(\n        page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' })\n    ).toBeVisible();\n\n    // Save and verify\n    const [updateResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/time-entries/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n        page.getByRole('button', { name: 'Update Time Entry' }).click(),\n    ]);\n    const responseBody = await updateResponse.json();\n    expect(responseBody.data.billable).toBe(false);\n});\n\ntest('test that opening calendar edit modal for a time entry with manually overridden billable status preserves that status', async ({\n    page,\n    ctx,\n}) => {\n    const billableProjectName =\n        'Billable Cal Persist Project ' + Math.floor(1 + Math.random() * 10000);\n\n    await createBillableProjectViaApi(ctx, { name: billableProjectName });\n    await createBareTimeEntryViaApi(ctx, 'Test cal persist override', '1h');\n\n    await goToCalendar(page);\n\n    // Click on the time entry event in the calendar\n    await page\n        .locator('.fc-event')\n        .filter({ hasText: 'Test cal persist override' })\n        .first()\n        .click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // Assign the billable project\n    await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click();\n    await page.getByRole('option', { name: billableProjectName }).click();\n\n    // Verify it auto-set to Billable\n    await expect(\n        page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' })\n    ).toBeVisible();\n\n    // Now manually override billable to Non-Billable via the dropdown\n    await page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }).click();\n    await page.getByRole('option', { name: 'Non Billable' }).click();\n\n    // Verify it shows Non-Billable now\n    await expect(\n        page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' })\n    ).toBeVisible();\n\n    // Save\n    const [firstSaveResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/time-entries/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n        page.getByRole('button', { name: 'Update Time Entry' }).click(),\n    ]);\n    const firstBody = await firstSaveResponse.json();\n    expect(firstBody.data.billable).toBe(false);\n\n    // Re-open the edit modal from the calendar — the project_id watcher should NOT override billable\n    await page\n        .locator('.fc-event')\n        .filter({ hasText: 'Test cal persist override' })\n        .first()\n        .click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // The billable dropdown should still show Non-Billable\n    await expect(\n        page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' })\n    ).toBeVisible();\n\n    // Save without changes and verify the response still has billable=false\n    const [updateResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/time-entries/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n        page.getByRole('button', { name: 'Update Time Entry' }).click(),\n    ]);\n    const responseBody = await updateResponse.json();\n    expect(responseBody.data.billable).toBe(false);\n});\n\ntest('test that calendar page loads and displays time entries', async ({ page, ctx }) => {\n    await createBareTimeEntryViaApi(ctx, 'Calendar display test', '1h');\n\n    await goToCalendar(page);\n\n    // Calendar container should be visible\n    await expect(page.locator('.fc')).toBeVisible();\n\n    // The time entry should appear as a calendar event\n    await expect(\n        page.locator('.fc-event').filter({ hasText: 'Calendar display test' }).first()\n    ).toBeVisible();\n});\n\ntest('test that calendar navigation buttons work', async ({ page }) => {\n    await goToCalendar(page);\n    await expect(page.locator('.fc')).toBeVisible();\n\n    // Click the \"next\" button to navigate forward\n    await page.locator('button.fc-next-button').click();\n    await expect(page.locator('.fc')).toBeVisible();\n\n    // Click the \"prev\" button to navigate back\n    await page.locator('button.fc-prev-button').click();\n    await expect(page.locator('.fc')).toBeVisible();\n\n    // Navigate forward first so \"today\" button becomes enabled, then click it\n    await page.locator('button.fc-next-button').click();\n    await page.locator('button.fc-today-button').click();\n    await expect(page.locator('.fc')).toBeVisible();\n});\n\ntest('test that editing time entry description via calendar modal works', async ({ page, ctx }) => {\n    const originalDescription = 'Edit me in calendar ' + Math.floor(1 + Math.random() * 10000);\n    const updatedDescription = 'Updated in calendar ' + Math.floor(1 + Math.random() * 10000);\n    await createBareTimeEntryViaApi(ctx, originalDescription, '1h');\n\n    await goToCalendar(page);\n\n    // Click on the time entry event\n    await page.locator('.fc-event').filter({ hasText: originalDescription }).first().click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // Update the description (edit modal uses placeholder, not data-testid)\n    const descriptionInput = page.getByRole('dialog').getByPlaceholder('What did you work on?');\n    await descriptionInput.fill(updatedDescription);\n\n    // Save and verify\n    const [editResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/time-entries/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n        page.getByRole('button', { name: 'Update Time Entry' }).click(),\n    ]);\n    const editBody = await editResponse.json();\n    expect(editBody.data.description).toBe(updatedDescription);\n\n    // Verify the updated description is shown in the calendar UI\n    await expect(\n        page.locator('.fc-event').filter({ hasText: updatedDescription }).first()\n    ).toBeVisible();\n    // Verify the old description is no longer shown\n    await expect(\n        page.locator('.fc-event').filter({ hasText: originalDescription })\n    ).not.toBeVisible();\n});\n\ntest('test that deleting time entry from calendar modal works', async ({ page, ctx }) => {\n    const description = 'Delete me from calendar ' + Math.floor(1 + Math.random() * 10000);\n    await createBareTimeEntryViaApi(ctx, description, '1h');\n\n    await goToCalendar(page);\n\n    // Click on the time entry event\n    await page.locator('.fc-event').filter({ hasText: description }).first().click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // Click the delete button\n    await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/time-entries/') &&\n                response.request().method() === 'DELETE' &&\n                response.status() === 204\n        ),\n        page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(),\n    ]);\n\n    // Verify the event is removed from the calendar\n    await expect(page.locator('.fc-event').filter({ hasText: description })).not.toBeVisible();\n});\n\n// =============================================\n// Employee Permission Tests\n// =============================================\n\ntest.describe('Employee Calendar Isolation', () => {\n    test('employee can only see their own time entries on the calendar', async ({\n        ctx,\n        employee,\n    }) => {\n        // Owner creates a time entry for today\n        const ownerDescription = 'OwnerCalEntry ' + Math.floor(Math.random() * 10000);\n        await createBareTimeEntryViaApi(ctx, ownerDescription, '1h');\n\n        // Create a time entry for the employee for today\n        const employeeDescription = 'EmpCalEntry ' + Math.floor(Math.random() * 10000);\n        await createTimeEntryViaApi(\n            { ...ctx, memberId: employee.memberId },\n            { description: employeeDescription, duration: '30min' }\n        );\n\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/calendar');\n        await expect(employee.page.locator('.fc')).toBeVisible({ timeout: 10000 });\n\n        // Employee's event IS visible\n        await expect(\n            employee.page.locator('.fc-event').filter({ hasText: employeeDescription }).first()\n        ).toBeVisible({ timeout: 10000 });\n\n        // Owner's event is NOT visible\n        await expect(\n            employee.page.locator('.fc-event').filter({ hasText: ownerDescription })\n        ).not.toBeVisible();\n    });\n});\n"
  },
  {
    "path": "e2e/clients.spec.ts",
    "content": "import { expect } from '@playwright/test';\nimport type { Page } from '@playwright/test';\nimport { PLAYWRIGHT_BASE_URL } from '../playwright/config';\nimport { test } from '../playwright/fixtures';\nimport {\n    createClientViaApi,\n    createProjectMemberViaApi,\n    createProjectViaApi,\n    createPublicProjectViaApi,\n} from './utils/api';\nimport { getTableRowNames } from './utils/table';\n\nasync function goToClientsOverview(page: Page) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/clients');\n}\n\n// Create new client via modal\ntest('test that creating and deleting a new client via the modal works', async ({ page }) => {\n    const newClientName = 'New Project ' + Math.floor(1 + Math.random() * 10000);\n    await goToClientsOverview(page);\n    await page.getByRole('button', { name: 'Create Client' }).click();\n    await page.getByPlaceholder('Client Name').fill(newClientName);\n    await Promise.all([\n        page.getByRole('button', { name: 'Create Client' }).click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/clients') &&\n                response.request().method() === 'POST' &&\n                response.status() === 201 &&\n                (await response.json()).data.id !== null &&\n                (await response.json()).data.name === newClientName\n        ),\n    ]);\n\n    await expect(page.getByTestId('client_table')).toContainText(newClientName);\n    const moreButton = page.locator(\"[aria-label='Actions for Client \" + newClientName + \"']\");\n    await moreButton.click();\n    const deleteButton = page.locator(\"[aria-label='Delete Client \" + newClientName + \"']\");\n\n    await Promise.all([\n        deleteButton.click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/clients') &&\n                response.request().method() === 'DELETE' &&\n                response.status() === 204\n        ),\n    ]);\n    await expect(page.getByTestId('client_table')).not.toContainText(newClientName);\n});\n\ntest('test that archiving and unarchiving clients works', async ({ page, ctx }) => {\n    const newClientName = 'New Client ' + Math.floor(1 + Math.random() * 10000);\n    await createClientViaApi(ctx, { name: newClientName });\n\n    await goToClientsOverview(page);\n    await expect(page.getByText(newClientName)).toBeVisible();\n\n    await page.getByRole('row').first().getByRole('button').click();\n    await Promise.all([\n        page.getByRole('menuitem').getByText('Archive').click(),\n        expect(page.getByText(newClientName)).not.toBeVisible(),\n    ]);\n    await Promise.all([\n        page.getByRole('tab', { name: 'Archived' }).click(),\n        expect(page.getByText(newClientName)).toBeVisible(),\n    ]);\n\n    await page.getByRole('row').first().getByRole('button').click();\n    await Promise.all([\n        page.getByRole('menuitem').getByText('Unarchive').click(),\n        expect(page.getByText(newClientName)).not.toBeVisible(),\n    ]);\n    await Promise.all([\n        page.getByRole('tab', { name: 'Active' }).click(),\n        expect(page.getByText(newClientName)).toBeVisible(),\n    ]);\n});\n\ntest('test that editing a client name works', async ({ page, ctx }) => {\n    const originalName = 'Original Client ' + Math.floor(1 + Math.random() * 10000);\n    const updatedName = 'Updated Client ' + Math.floor(1 + Math.random() * 10000);\n    await createClientViaApi(ctx, { name: originalName });\n\n    await goToClientsOverview(page);\n    await expect(page.getByText(originalName)).toBeVisible();\n\n    // Open edit modal via actions menu\n    const moreButton = page.locator(\"[aria-label='Actions for Client \" + originalName + \"']\");\n    await moreButton.click();\n    await page.getByTestId('client_edit').click();\n\n    // Update the client name\n    await page.getByPlaceholder('Client Name').fill(updatedName);\n    await Promise.all([\n        page.getByRole('button', { name: 'Update Client' }).click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/clients') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n    ]);\n\n    // Verify updated name is shown and old name is gone\n    await expect(page.getByTestId('client_table')).toContainText(updatedName);\n    await expect(page.getByTestId('client_table')).not.toContainText(originalName);\n});\n\ntest('test that deleting a client via actions menu works', async ({ page, ctx }) => {\n    const clientName = 'DeleteMe Client ' + Math.floor(1 + Math.random() * 10000);\n\n    await createClientViaApi(ctx, { name: clientName });\n\n    await goToClientsOverview(page);\n    await expect(page.getByTestId('client_table')).toContainText(clientName);\n\n    const moreButton = page.locator(\"[aria-label='Actions for Client \" + clientName + \"']\");\n    await moreButton.click();\n    const deleteButton = page.locator(\"[aria-label='Delete Client \" + clientName + \"']\");\n\n    await Promise.all([\n        deleteButton.click(),\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/clients') &&\n                response.request().method() === 'DELETE' &&\n                response.status() === 204\n        ),\n    ]);\n\n    await expect(page.getByTestId('client_table')).not.toContainText(clientName);\n});\n\n// =============================================\n// Sorting Tests\n// =============================================\n\nasync function clearClientTableState(page: Page) {\n    await page.evaluate(() => {\n        localStorage.removeItem('client-table-state');\n    });\n}\n\ntest('test that sorting clients by name and status works', async ({ page, ctx }) => {\n    await createClientViaApi(ctx, { name: 'AAA SortClient' });\n    await createClientViaApi(ctx, { name: 'ZZZ SortClient' });\n\n    await goToClientsOverview(page);\n    await clearClientTableState(page);\n    await page.reload();\n\n    const table = page.getByTestId('client_table');\n    await expect(table).toBeVisible();\n\n    // -- Name sorting (default is name asc) --\n    let names = await getTableRowNames(table);\n    expect(names.indexOf('AAA SortClient')).toBeLessThan(names.indexOf('ZZZ SortClient'));\n\n    const nameHeader = table.getByText('Name').first();\n    await nameHeader.click(); // toggle to desc\n    names = await getTableRowNames(table);\n    expect(names.indexOf('ZZZ SortClient')).toBeLessThan(names.indexOf('AAA SortClient'));\n\n    // -- Status sorting --\n    const statusHeader = table.getByText('Status').first();\n    await statusHeader.click(); // asc\n    await expect(statusHeader.locator('svg')).toBeVisible();\n    await statusHeader.click(); // desc\n    await expect(statusHeader.locator('svg')).toBeVisible();\n});\n\ntest('test that sorting clients by project count works', async ({ page, ctx }) => {\n    const clientWithMany = await createClientViaApi(ctx, { name: 'ManyProjects Client' });\n    const clientWithNone = await createClientViaApi(ctx, { name: 'NoProjects Client' });\n\n    // Create projects for the first client\n    await createProjectViaApi(ctx, { name: 'Proj1', client_id: clientWithMany.id });\n    await createProjectViaApi(ctx, { name: 'Proj2', client_id: clientWithMany.id });\n\n    await goToClientsOverview(page);\n    await clearClientTableState(page);\n    await page.reload();\n\n    const table = page.getByTestId('client_table');\n    await expect(table).toBeVisible();\n\n    // Click Projects header - first click should sort desc (most projects first)\n    const projectsHeader = table.getByText('Projects').first();\n    await projectsHeader.click();\n    await expect(projectsHeader.locator('svg')).toBeVisible();\n    let names = await getTableRowNames(table);\n    expect(names.indexOf('ManyProjects Client')).toBeLessThan(names.indexOf('NoProjects Client'));\n\n    // Second click toggles to asc (least projects first)\n    await projectsHeader.click();\n    names = await getTableRowNames(table);\n    expect(names.indexOf('NoProjects Client')).toBeLessThan(names.indexOf('ManyProjects Client'));\n});\n\ntest('test that client sort state persists after page reload', async ({ page }) => {\n    await goToClientsOverview(page);\n    await clearClientTableState(page);\n    await page.reload();\n\n    const table = page.getByTestId('client_table');\n    await expect(table).toBeVisible();\n\n    const nameHeader = table.getByText('Name').first();\n    await nameHeader.click(); // toggle to desc\n    await expect(nameHeader.locator('svg')).toBeVisible();\n\n    await page.reload();\n\n    await expect(page.getByTestId('client_table')).toBeVisible();\n    await expect(\n        page.getByTestId('client_table').getByText('Name').first().locator('svg')\n    ).toBeVisible();\n});\n\n// =============================================\n// Employee Permission Tests\n// =============================================\n\ntest.describe('Employee Clients Restrictions', () => {\n    test('employee can view clients but cannot create', async ({ ctx, employee }) => {\n        // Create a client with a public project so the employee can see the client\n        const clientName = 'EmpViewClient ' + Math.floor(Math.random() * 10000);\n        const client = await createClientViaApi(ctx, { name: clientName });\n        await createPublicProjectViaApi(ctx, { name: 'EmpClientProj', client_id: client.id });\n\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients');\n        await expect(employee.page.getByTestId('clients_view')).toBeVisible({\n            timeout: 10000,\n        });\n\n        // Employee can see the client\n        await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });\n\n        // Employee cannot see Create Client button\n        await expect(\n            employee.page.getByRole('button', { name: 'Create Client' })\n        ).not.toBeVisible();\n    });\n\n    test('employee cannot see edit/delete/archive actions on clients', async ({\n        ctx,\n        employee,\n    }) => {\n        const clientName = 'EmpActionsClient ' + Math.floor(Math.random() * 10000);\n        const client = await createClientViaApi(ctx, { name: clientName });\n        await createPublicProjectViaApi(ctx, { name: 'EmpClientActProj', client_id: client.id });\n\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients');\n        await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });\n\n        // Click the actions dropdown trigger to open the menu\n        const actionsButton = employee.page.locator(\n            `[aria-label='Actions for Client ${clientName}']`\n        );\n        await actionsButton.click();\n\n        // The dropdown menu items (Edit, Archive, Delete) should NOT be visible\n        await expect(\n            employee.page.locator(`[aria-label='Edit Client ${clientName}']`)\n        ).not.toBeVisible();\n        await expect(\n            employee.page.locator(`[aria-label='Archive Client ${clientName}']`)\n        ).not.toBeVisible();\n        await expect(\n            employee.page.locator(`[aria-label='Delete Client ${clientName}']`)\n        ).not.toBeVisible();\n    });\n\n    test('employee can see client when they are a member of its private project', async ({\n        ctx,\n        employee,\n    }) => {\n        const clientName = 'EmpPrivateClient ' + Math.floor(Math.random() * 10000);\n        const client = await createClientViaApi(ctx, { name: clientName });\n\n        // Create a private project under this client\n        const project = await createProjectViaApi(ctx, {\n            name: 'PrivateProj',\n            client_id: client.id,\n            is_public: false,\n        });\n\n        // Add the employee as a project member\n        await createProjectMemberViaApi(ctx, project.id, {\n            member_id: employee.memberId,\n        });\n\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients');\n        await expect(employee.page.getByTestId('clients_view')).toBeVisible({\n            timeout: 10000,\n        });\n\n        // Employee can see the client because they are a member of its private project\n        await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });\n    });\n});\n"
  },
  {
    "path": "e2e/command-palette.spec.ts",
    "content": "import { expect, test } from '../playwright/fixtures';\nimport { PLAYWRIGHT_BASE_URL } from '../playwright/config';\nimport type { Page } from '@playwright/test';\n\nconst TIMER_BUTTON_SELECTOR = '[data-testid=\"dashboard_timer\"] [data-testid=\"timer_button\"]';\n\nasync function goToDashboard(page: Page) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');\n}\n\nasync function openCommandPalette(page: Page) {\n    await page.getByTestId('command_palette_button').click();\n    await expect(page.locator('[role=\"dialog\"]')).toBeVisible({ timeout: 5000 });\n}\n\nasync function closeCommandPalette(page: Page) {\n    await page.keyboard.press('Escape');\n    await expect(page.locator('[role=\"dialog\"]')).not.toBeVisible();\n}\n\nasync function searchInCommandPalette(page: Page, query: string) {\n    await page.locator('[role=\"dialog\"] input').fill(query);\n    // Wait for search debounce to settle (command palette uses a debounced search)\n    await page.waitForTimeout(300);\n}\n\nasync function selectCommand(page: Page, name: string) {\n    const option = page.getByRole('option', { name, exact: true });\n    await option.scrollIntoViewIfNeeded();\n    await option.click();\n}\n\nasync function assertTimerIsRunning(page: Page) {\n    await expect(page.locator(TIMER_BUTTON_SELECTOR).and(page.locator(':visible'))).toHaveClass(\n        /bg-red-400\\/80/,\n        {\n            timeout: 10000,\n        }\n    );\n}\n\nasync function assertTimerIsStopped(page: Page) {\n    await expect(page.locator(TIMER_BUTTON_SELECTOR).and(page.locator(':visible'))).toHaveClass(\n        /bg-accent-300\\/70/,\n        {\n            timeout: 10000,\n        }\n    );\n}\n\ntest.describe('Command Palette', () => {\n    test.describe('Opening and Closing', () => {\n        test('opens via search button and closes with Escape', async ({ page }) => {\n            await goToDashboard(page);\n            await openCommandPalette(page);\n            await expect(\n                page.locator('[role=\"dialog\"] input[placeholder*=\"command\"]')\n            ).toBeVisible();\n\n            await closeCommandPalette(page);\n            await expect(page.locator('[role=\"dialog\"]')).not.toBeVisible();\n        });\n\n        test('opens with keyboard shortcut', async ({ page }) => {\n            await goToDashboard(page);\n            // Click on body to ensure page has focus\n            await page.locator('body').click();\n            // Use ControlOrMeta which resolves to Ctrl on Linux/Windows and Meta on macOS\n            await page.keyboard.press('ControlOrMeta+k');\n            await expect(page.locator('[role=\"dialog\"]')).toBeVisible({ timeout: 5000 });\n        });\n\n        test('clears search on close', async ({ page }) => {\n            await goToDashboard(page);\n            await openCommandPalette(page);\n            await searchInCommandPalette(page, 'dashboard');\n            await closeCommandPalette(page);\n\n            await openCommandPalette(page);\n            await expect(page.locator('[role=\"dialog\"] input')).toHaveValue('');\n        });\n    });\n\n    test.describe('Command Display', () => {\n        test('displays navigation and timer commands', async ({ page }) => {\n            await goToDashboard(page);\n            await openCommandPalette(page);\n\n            // Navigation commands\n            await expect(page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible();\n            await expect(page.getByRole('option', { name: 'Go to Time' })).toBeVisible();\n            await expect(page.getByRole('option', { name: 'Go to Calendar' })).toBeVisible();\n\n            // Timer commands\n            await expect(page.getByRole('option', { name: 'Start Timer' })).toBeVisible();\n            await expect(page.getByRole('option', { name: 'Create Time Entry' })).toBeVisible();\n        });\n\n        test('displays create commands', async ({ page }) => {\n            await goToDashboard(page);\n            await openCommandPalette(page);\n\n            await expect(page.getByRole('option', { name: 'Create Project' })).toBeVisible();\n            await expect(page.getByRole('option', { name: 'Create Client' })).toBeVisible();\n            await expect(page.getByRole('option', { name: 'Create Tag' })).toBeVisible();\n        });\n    });\n\n    test.describe('Navigation Commands', () => {\n        // Tests use element visibility assertions for consistency with codebase patterns\n        const navigationTests = [\n            ['Go to Dashboard', 'dashboard_view', '/time'],\n            ['Go to Time', 'time_view', '/dashboard'],\n            ['Go to Calendar', 'calendar_view', '/dashboard'],\n            ['Go to Projects', 'projects_view', '/dashboard'],\n            ['Go to Clients', 'clients_view', '/dashboard'],\n            ['Go to Members', 'members_view', '/dashboard'],\n            ['Go to Tags', 'tags_view', '/dashboard'],\n        ] as const;\n\n        for (const [commandName, expectedTestId, startUrl] of navigationTests) {\n            test(`${commandName}`, async ({ page }) => {\n                await page.goto(PLAYWRIGHT_BASE_URL + startUrl);\n                await openCommandPalette(page);\n                await searchInCommandPalette(page, commandName.replace('Go to ', ''));\n                await selectCommand(page, commandName);\n                await expect(page.getByTestId(expectedTestId)).toBeVisible({ timeout: 10000 });\n            });\n        }\n\n        test('Go to Profile', async ({ page }) => {\n            await goToDashboard(page);\n            await openCommandPalette(page);\n            await searchInCommandPalette(page, 'Profile');\n            await selectCommand(page, 'Go to Profile');\n            // Profile page doesn't have a testId, so check for a unique element\n            await expect(page.getByRole('heading', { name: 'Profile Information' })).toBeVisible({\n                timeout: 10000,\n            });\n        });\n\n        test('Go to Reporting Overview', async ({ page }) => {\n            await goToDashboard(page);\n            await openCommandPalette(page);\n            await searchInCommandPalette(page, 'Reporting Overview');\n            await selectCommand(page, 'Go to Reporting Overview');\n            await expect(page.getByTestId('reporting_view')).toBeVisible({ timeout: 10000 });\n        });\n\n        test('Go to Settings', async ({ page }) => {\n            await goToDashboard(page);\n            await openCommandPalette(page);\n            await searchInCommandPalette(page, 'Settings');\n            await selectCommand(page, 'Go to Settings');\n            // Settings page uses team settings which has an h3 heading\n            await expect(\n                page.getByRole('heading', { name: 'Organization Name', level: 3 })\n            ).toBeVisible({\n                timeout: 10000,\n            });\n        });\n    });\n\n    test.describe('Search and Filtering', () => {\n        test('filters commands when searching', async ({ page }) => {\n            await goToDashboard(page);\n            await openCommandPalette(page);\n\n            await searchInCommandPalette(page, 'dashboard');\n            await expect(page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible();\n\n            await searchInCommandPalette(page, 'calendar');\n            await expect(page.getByRole('option', { name: 'Go to Calendar' })).toBeVisible();\n        });\n\n        test('search is case insensitive', async ({ page }) => {\n            await goToDashboard(page);\n            await openCommandPalette(page);\n\n            await searchInCommandPalette(page, 'DASHBOARD');\n            await expect(page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible();\n        });\n\n        test('partial word search works', async ({ page }) => {\n            await goToDashboard(page);\n            await openCommandPalette(page);\n\n            await searchInCommandPalette(page, 'proj');\n            await expect(page.getByRole('option', { name: 'Go to Projects' })).toBeVisible();\n            await expect(page.getByRole('option', { name: 'Create Project' })).toBeVisible();\n        });\n\n        test('keyboard navigation and selection works', async ({ page }) => {\n            await goToDashboard(page);\n            await openCommandPalette(page);\n\n            await page.keyboard.press('ArrowDown');\n            await page.keyboard.press('ArrowDown');\n            await page.keyboard.press('Enter');\n\n            await expect(page.locator('[role=\"dialog\"]')).not.toBeVisible();\n        });\n    });\n\n    test.describe('Theme Commands', () => {\n        test('switches to dark theme', async ({ page }) => {\n            await goToDashboard(page);\n            await openCommandPalette(page);\n            await searchInCommandPalette(page, 'Dark Theme');\n            await selectCommand(page, 'Switch to Dark Theme');\n            await expect(page.locator('html')).toHaveClass(/dark/);\n        });\n\n        test('switches to light theme', async ({ page }) => {\n            await goToDashboard(page);\n            await openCommandPalette(page);\n            await searchInCommandPalette(page, 'Light Theme');\n            await selectCommand(page, 'Switch to Light Theme');\n            await expect(page.locator('html')).toHaveClass(/light/);\n        });\n    });\n\n    test.describe('Timer Commands', () => {\n        test('starts and stops timer', async ({ page }) => {\n            await goToDashboard(page);\n\n            // Start timer\n            await openCommandPalette(page);\n            await searchInCommandPalette(page, 'Start Timer');\n            await selectCommand(page, 'Start Timer');\n            await assertTimerIsRunning(page);\n\n            // Stop timer\n            await openCommandPalette(page);\n            await searchInCommandPalette(page, 'Stop Timer');\n            await selectCommand(page, 'Stop Timer');\n            await assertTimerIsStopped(page);\n        });\n\n        test('shows active timer commands when running', async ({ page }) => {\n            await goToDashboard(page);\n\n            // Start timer\n            await openCommandPalette(page);\n            await searchInCommandPalette(page, 'Start Timer');\n            await selectCommand(page, 'Start Timer');\n            await assertTimerIsRunning(page);\n\n            // Check active timer commands - search for them to ensure visibility\n            await openCommandPalette(page);\n            await searchInCommandPalette(page, 'Set Project');\n            await expect(page.getByRole('option', { name: 'Set Project' })).toBeVisible();\n        });\n    });\n\n    test.describe('Create Commands', () => {\n        test('opens create time entry modal', async ({ page }) => {\n            await goToDashboard(page);\n            await openCommandPalette(page);\n            await searchInCommandPalette(page, 'Create Time Entry');\n            await selectCommand(page, 'Create Time Entry');\n            await expect(\n                page.locator('[role=\"dialog\"]').getByText('Create manual time entry')\n            ).toBeVisible();\n        });\n\n        test('opens create project modal', async ({ page }) => {\n            await goToDashboard(page);\n            await openCommandPalette(page);\n            await searchInCommandPalette(page, 'Create Project');\n            await selectCommand(page, 'Create Project');\n            await expect(\n                page.locator('[role=\"dialog\"]').getByRole('heading', { name: 'Create Project' })\n            ).toBeVisible();\n        });\n\n        test('opens create client modal', async ({ page }) => {\n            await goToDashboard(page);\n            await openCommandPalette(page);\n            await searchInCommandPalette(page, 'Create Client');\n            await selectCommand(page, 'Create Client');\n            await expect(\n                page.locator('[role=\"dialog\"]').getByRole('heading', { name: 'Create Client' })\n            ).toBeVisible();\n        });\n\n        test('opens create tag modal', async ({ page }) => {\n            await goToDashboard(page);\n            await openCommandPalette(page);\n            await searchInCommandPalette(page, 'Create Tag');\n            await selectCommand(page, 'Create Tag');\n            await expect(page.locator('[role=\"dialog\"]').getByText('Create Tags')).toBeVisible();\n        });\n\n        test('opens invite member modal', async ({ page }) => {\n            await goToDashboard(page);\n            await openCommandPalette(page);\n            await searchInCommandPalette(page, 'Invite Member');\n            await selectCommand(page, 'Invite Member');\n            // Modal has title with \"Invite Member\" text - use first() to get the title span\n            await expect(\n                page.locator('[role=\"dialog\"]').getByText('Invite Member').first()\n            ).toBeVisible();\n        });\n    });\n\n    test.describe('Entity Search', () => {\n        test('searches for projects and navigates on selection', async ({ page }) => {\n            const projectName = 'CmdPalette' + Math.floor(Math.random() * 10000);\n\n            // Create project first\n            await page.goto(PLAYWRIGHT_BASE_URL + '/projects');\n            await page.getByRole('button', { name: 'Create Project' }).click();\n            await page.getByPlaceholder('The next big thing').fill(projectName);\n\n            await page.getByRole('button', { name: 'Create Project' }).click();\n            // Wait for project to be created and page to update\n            await expect(page.getByText(projectName)).toBeVisible({ timeout: 10000 });\n\n            // Search from the projects page where the query cache now has the new project\n            await openCommandPalette(page);\n            await searchInCommandPalette(page, projectName);\n\n            // Wait for entity search to return results\n            const projectOption = page.getByRole('option').filter({ hasText: projectName });\n            await expect(projectOption).toBeVisible({\n                timeout: 5000,\n            });\n\n            // Select the project from search results\n            await projectOption.click();\n        });\n    });\n\n    test.describe('Organization Switching', () => {\n        test('shows switch commands only when multiple organizations exist', async ({ page }) => {\n            await goToDashboard(page);\n            await openCommandPalette(page);\n\n            // With only one org, no switch commands should appear\n            await searchInCommandPalette(page, 'Switch to');\n            // Check that no organization switch commands appear (only theme switch commands)\n            const switchOptions = page.getByRole('option', { name: /^Switch to (?!.*Theme)/ });\n            await expect(switchOptions).toHaveCount(0);\n        });\n\n        test('switches organization via command palette', async ({ page }) => {\n            const newOrgName = 'TestOrg' + Math.floor(Math.random() * 10000);\n\n            // Create a new organization\n            await page.goto(PLAYWRIGHT_BASE_URL + '/teams/create');\n            await page.getByLabel('Organization Name').fill(newOrgName);\n            await page.getByRole('button', { name: 'Create' }).click();\n\n            // Wait for navigation to new org's dashboard\n            await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 });\n\n            // Use visible switcher (desktop sidebar has one, mobile header has another)\n            const orgSwitcher = page.locator('[data-testid=\"organization_switcher\"]:visible');\n\n            // Verify we're in the new org by checking the switcher\n            await expect(orgSwitcher).toContainText(newOrgName);\n\n            // Get the original org name from switcher dropdown\n            await orgSwitcher.click();\n            await expect(page.getByText('Switch Organizations')).toBeVisible();\n\n            // Find the other organization button (has ArrowRightIcon, not CheckCircleIcon)\n            // The button contains an SVG and a div with the org name\n            const otherOrgItem = page.locator('form button').filter({ hasText: /.+/ }).first();\n            await expect(otherOrgItem).toBeVisible();\n            const originalOrgName = (await otherOrgItem.innerText()).trim();\n            await page.keyboard.press('Escape'); // Close dropdown\n\n            // Now use command palette to switch back to original org\n            await openCommandPalette(page);\n            await searchInCommandPalette(page, 'Switch to');\n\n            // Should see the switch command for the original org\n            const switchCommand = page.getByRole('option', {\n                name: new RegExp(`Switch to ${originalOrgName}`),\n            });\n            await expect(switchCommand).toBeVisible();\n            await switchCommand.click();\n\n            // Wait for organization switch to complete\n            await expect(orgSwitcher).toContainText(originalOrgName, {\n                timeout: 10000,\n            });\n        });\n\n        test('organization switch commands appear in Organization group', async ({ page }) => {\n            const newOrgName = 'GroupTestOrg' + Math.floor(Math.random() * 10000);\n\n            // Create a new organization to ensure we have multiple\n            await page.goto(PLAYWRIGHT_BASE_URL + '/teams/create');\n            await page.getByLabel('Organization Name').fill(newOrgName);\n            await page.getByRole('button', { name: 'Create' }).click();\n            await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 });\n\n            // Open command palette and check for Organization group heading\n            await openCommandPalette(page);\n\n            // The Organization group should be visible when there are switch commands\n            await expect(page.getByText('Organization', { exact: true })).toBeVisible();\n        });\n    });\n});\n\n// =============================================\n// Employee Permission Tests\n// =============================================\n\ntest.describe('Employee Command Palette Restrictions', () => {\n    test('employee command palette does not show restricted navigation commands', async ({\n        employee,\n    }) => {\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');\n        await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({\n            timeout: 10000,\n        });\n\n        // Open command palette\n        await employee.page.getByTestId('command_palette_button').click();\n        await expect(employee.page.locator('[role=\"dialog\"]')).toBeVisible({ timeout: 5000 });\n\n        // Available navigation commands\n        await expect(employee.page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible();\n        await expect(employee.page.getByRole('option', { name: 'Go to Time' })).toBeVisible();\n        await expect(employee.page.getByRole('option', { name: 'Go to Calendar' })).toBeVisible();\n\n        // Restricted commands should NOT be visible\n        await expect(\n            employee.page.getByRole('option', { name: 'Go to Members' })\n        ).not.toBeVisible();\n        await expect(\n            employee.page.getByRole('option', { name: 'Go to Settings' })\n        ).not.toBeVisible();\n    });\n\n    test('employee command palette does not show create commands for restricted entities', async ({\n        employee,\n    }) => {\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');\n        await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({\n            timeout: 10000,\n        });\n\n        // Open command palette\n        await employee.page.getByTestId('command_palette_button').click();\n        await expect(employee.page.locator('[role=\"dialog\"]')).toBeVisible({ timeout: 5000 });\n\n        // Search for \"Create\" to filter\n        await employee.page.locator('[role=\"dialog\"] input').fill('Create');\n        await employee.page.waitForTimeout(300);\n\n        // Should NOT see create commands for restricted entities\n        await expect(\n            employee.page.getByRole('option', { name: 'Create Project' })\n        ).not.toBeVisible();\n        await expect(\n            employee.page.getByRole('option', { name: 'Create Client' })\n        ).not.toBeVisible();\n        await expect(employee.page.getByRole('option', { name: 'Create Tag' })).not.toBeVisible();\n        await expect(\n            employee.page.getByRole('option', { name: 'Invite Member' })\n        ).not.toBeVisible();\n\n        // Should still see Create Time Entry (employees can create time entries)\n        await expect(\n            employee.page.getByRole('option', { name: 'Create Time Entry' })\n        ).toBeVisible();\n    });\n});\n"
  },
  {
    "path": "e2e/dashboard.spec.ts",
    "content": "import { expect, test } from '../playwright/fixtures';\nimport { PLAYWRIGHT_BASE_URL } from '../playwright/config';\nimport type { Page } from '@playwright/test';\nimport {\n    assertThatTimerHasStarted,\n    assertThatTimerIsStopped,\n    newTimeEntryResponse,\n    startOrStopTimerWithButton,\n    stoppedTimeEntryResponse,\n} from './utils/currentTimeEntry';\nimport {\n    createBareTimeEntryViaApi,\n    createPublicProjectViaApi,\n    createTimeEntryViaApi,\n    updateOrganizationSettingViaApi,\n} from './utils/api';\n\nasync function goToDashboard(page: Page) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');\n}\n\ntest('test that dashboard loads with all expected sections', async ({ page }) => {\n    await goToDashboard(page);\n    await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 });\n\n    // Timer section (scoped to dashboard_timer to avoid matching sidebar timer)\n    await expect(page.getByTestId('time_entry_description')).toBeVisible();\n    await expect(\n        page\n            .getByTestId('dashboard_timer')\n            .getByTestId('timer_button')\n            .and(page.locator(':visible'))\n    ).toBeVisible();\n\n    // Dashboard cards\n    await expect(page.getByText('Recent Time Entries', { exact: true })).toBeVisible();\n    await expect(page.getByText('Last 7 Days', { exact: true })).toBeVisible();\n    await expect(page.getByText('Activity Graph', { exact: true })).toBeVisible();\n    await expect(page.getByText('Team Activity', { exact: true })).toBeVisible();\n\n    // Weekly overview section\n    await expect(page.getByText('This Week', { exact: true })).toBeVisible();\n});\n\ntest('test that dashboard shows time entry data after creating entries', async ({ page, ctx }) => {\n    await createBareTimeEntryViaApi(ctx, 'Dashboard test entry', '1h');\n\n    await goToDashboard(page);\n    await expect(page.getByTestId('dashboard_view')).toBeVisible();\n\n    // The \"Last 7 Days\" or \"This Week\" section should reflect tracked time\n    await expect(page.getByText('This Week', { exact: true })).toBeVisible();\n});\n\ntest('test that timer on dashboard can start and stop', async ({ page }) => {\n    await goToDashboard(page);\n    await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);\n    await assertThatTimerHasStarted(page);\n\n    await page.waitForTimeout(1500);\n\n    await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);\n    await assertThatTimerIsStopped(page);\n});\n\ntest('test that weekly overview section displays stat cards', async ({ page, ctx }) => {\n    await createBareTimeEntryViaApi(ctx, 'Stats test entry', '2h');\n\n    await goToDashboard(page);\n\n    // Verify stat card labels are visible\n    await expect(page.getByText('Spent Time')).toBeVisible();\n    await expect(page.getByText('Billable Time')).toBeVisible();\n    await expect(page.getByText('Billable Amount')).toBeVisible();\n});\n\ntest('test that stopping timer refreshes dashboard data', async ({ page }) => {\n    await goToDashboard(page);\n\n    // Start timer\n    await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);\n    await assertThatTimerHasStarted(page);\n    await page.waitForTimeout(1500);\n\n    // Stop timer and verify dashboard queries are refetched\n    await Promise.all([\n        stoppedTimeEntryResponse(page),\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/charts/') &&\n                response.request().method() === 'GET' &&\n                response.status() === 200\n        ),\n        startOrStopTimerWithButton(page),\n    ]);\n    await assertThatTimerIsStopped(page);\n});\n\n// =============================================\n// Employee Permission Tests\n// =============================================\n\ntest.describe('Employee Dashboard Restrictions', () => {\n    test('employee dashboard loads and timer is functional', async ({ employee }) => {\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');\n        await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({\n            timeout: 10000,\n        });\n\n        // Timer should be available\n        await expect(\n            employee.page\n                .getByTestId('dashboard_timer')\n                .getByTestId('timer_button')\n                .and(employee.page.locator(':visible'))\n        ).toBeVisible();\n        await expect(employee.page.getByTestId('time_entry_description')).toBeEditable();\n    });\n\n    test('employee cannot see Team Activity card', async ({ employee }) => {\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');\n        await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({\n            timeout: 10000,\n        });\n\n        // Other dashboard cards should be visible\n        await expect(employee.page.getByText('Recent Time Entries', { exact: true })).toBeVisible();\n\n        // Team Activity should NOT be visible for employees\n        await expect(employee.page.getByText('Team Activity', { exact: true })).not.toBeVisible();\n    });\n\n    test('employee cannot see Cost column in This Week table by default', async ({\n        ctx,\n        employee,\n    }) => {\n        const project = await createPublicProjectViaApi(ctx, {\n            name: 'EmpDashBillProj',\n            is_billable: true,\n            billable_rate: 10000,\n        });\n        await createTimeEntryViaApi(\n            { ...ctx, memberId: employee.memberId },\n            {\n                description: 'Emp dashboard cost entry',\n                duration: '1h',\n                projectId: project.id,\n                billable: true,\n            }\n        );\n\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');\n        await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({\n            timeout: 10000,\n        });\n\n        // This Week table should be visible\n        await expect(employee.page.getByText('This Week', { exact: true })).toBeVisible();\n\n        // Duration column should be visible, but Cost column should NOT\n        await expect(employee.page.getByText('Duration', { exact: true })).toBeVisible();\n        await expect(employee.page.getByText('Cost', { exact: true })).not.toBeVisible();\n    });\n\n    test('employee can see Cost column in This Week table when employees_can_see_billable_rates is enabled', async ({\n        ctx,\n        employee,\n    }) => {\n        await updateOrganizationSettingViaApi(ctx, { employees_can_see_billable_rates: true });\n\n        const project = await createPublicProjectViaApi(ctx, {\n            name: 'EmpDashBillVisProj',\n            is_billable: true,\n            billable_rate: 10000,\n        });\n        await createTimeEntryViaApi(\n            { ...ctx, memberId: employee.memberId },\n            {\n                description: 'Emp dashboard cost visible entry',\n                duration: '1h',\n                projectId: project.id,\n                billable: true,\n            }\n        );\n\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');\n        await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({\n            timeout: 10000,\n        });\n\n        // Both Duration and Cost columns should be visible\n        await expect(employee.page.getByText('Duration', { exact: true })).toBeVisible();\n        await expect(employee.page.getByText('Cost', { exact: true })).toBeVisible();\n\n        // 1h at 100.00/h = 100.00 EUR cost should be visible\n        await expect(employee.page.getByText('100,00 EUR').first()).toBeVisible();\n    });\n});\n"
  },
  {
    "path": "e2e/import-export.spec.ts",
    "content": "import { expect, test } from '../playwright/fixtures';\nimport { PLAYWRIGHT_BASE_URL } from '../playwright/config';\nimport type { Page } from '@playwright/test';\nimport path from 'path';\n\nasync function goToImportExport(page: Page) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/import');\n}\n\ntest('test that import page loads with type dropdown and file upload', async ({ page }) => {\n    await goToImportExport(page);\n    await expect(page.getByTestId('import_view')).toBeVisible({ timeout: 10000 });\n\n    // Import section\n    await expect(page.getByRole('heading', { name: 'Import Data' })).toBeVisible();\n    await expect(page.locator('#importType')).toBeVisible();\n\n    // Export section\n    await expect(page.getByRole('heading', { name: 'Export Data' })).toBeVisible();\n    await expect(page.getByRole('button', { name: 'Export Organization Data' })).toBeVisible();\n});\n\ntest('test that selecting an import type shows instructions', async ({ page }) => {\n    await goToImportExport(page);\n\n    // Select a Toggl import type\n    await page.getByLabel('Import Type').selectOption({ index: 1 });\n\n    // Instructions should appear\n    await expect(page.getByText('Instructions:')).toBeVisible();\n});\n\ntest('test that importing without selecting type shows error', async ({ page }) => {\n    await goToImportExport(page);\n\n    // Click Import Data without selecting a type\n    await page.getByRole('button', { name: 'Import Data' }).click();\n\n    // Should show an error notification\n    await expect(page.getByText('Please select the import type')).toBeVisible();\n});\n\ntest('test that importing without selecting file shows error', async ({ page }) => {\n    await goToImportExport(page);\n\n    // Select an import type first\n    await page.getByLabel('Import Type').selectOption({ index: 1 });\n\n    // Click Import Data without selecting a file\n    await page.getByRole('button', { name: 'Import Data' }).click();\n\n    // Should show an error notification\n    await expect(\n        page.getByText('Please select the CSV or ZIP file that you want to import')\n    ).toBeVisible();\n});\n\ntest('test that export button triggers export and shows success modal', async ({ page }) => {\n    await goToImportExport(page);\n    await expect(page.getByRole('button', { name: 'Export Organization Data' })).toBeVisible();\n\n    // Override window.open to prevent the page from navigating away to the\n    // download URL (the app uses window.open(url, '_self') which would navigate\n    // away before we can verify the success modal)\n    await page.evaluate(() => {\n        window.open = () => null;\n    });\n\n    // Click Export Organization Data and wait for the API response\n    await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/export') &&\n                response.request().method() === 'POST' &&\n                response.status() === 200,\n            { timeout: 60000 }\n        ),\n        page.getByRole('button', { name: 'Export Organization Data' }).click(),\n    ]);\n\n    // Success modal should appear after export completes\n    await expect(page.getByText('The export was successful!')).toBeVisible();\n});\n\ntest('test that import type dropdown has multiple options', async ({ page }) => {\n    await goToImportExport(page);\n\n    // The dropdown should load with options from the API\n    await page.waitForResponse(\n        (response) =>\n            response.url().includes('/importers') &&\n            response.request().method() === 'GET' &&\n            response.status() === 200\n    );\n\n    // Verify the select has options besides the default placeholder\n    const options = page.getByLabel('Import Type').locator('option');\n    const count = await options.count();\n    // Should have at least the placeholder + some import types\n    expect(count).toBeGreaterThan(1);\n});\n\ntest('test that importing a generic time entries CSV works', async ({ page }) => {\n    await goToImportExport(page);\n    await expect(page.getByTestId('import_view')).toBeVisible({ timeout: 10000 });\n\n    // Select \"Generic Time Entries\" import type\n    await page.getByLabel('Import Type').selectOption({ label: 'Generic Time Entries' });\n    await expect(page.getByText('Instructions:')).toBeVisible();\n\n    // Upload the test CSV file\n    const csvPath = path.resolve('resources/testfiles/generic_time_entries_import_test_1.csv');\n    await page.locator('#file-upload').setInputFiles(csvPath);\n\n    // Click Import and wait for the API response\n    await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/import') &&\n                response.request().method() === 'POST' &&\n                response.status() === 200,\n            { timeout: 30000 }\n        ),\n        page.getByRole('button', { name: 'Import Data' }).click(),\n    ]);\n\n    // Verify success modal with import results\n    await expect(page.getByRole('heading', { name: 'Import Result' })).toBeVisible();\n    await expect(page.getByText('The import was successful!')).toBeVisible();\n\n    // The CSV has 2 time entries, 1 client, 2 projects, 1 task\n    await expect(page.getByText('Time entries created:').locator('..')).toContainText('2');\n    await expect(page.getByText('Projects created:').locator('..')).toContainText('2');\n    await expect(page.getByText('Clients created:').locator('..')).toContainText('1');\n    await expect(page.getByText('Tasks created:').locator('..')).toContainText('1');\n});\n\n// =============================================\n// Employee Permission Tests\n// =============================================\n\ntest.describe('Employee Import Restrictions', () => {\n    test('employee does not see Import / Export link in the sidebar', async ({ employee }) => {\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');\n        await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({\n            timeout: 10000,\n        });\n\n        // The Import / Export link should NOT be visible in the sidebar for employees\n        await expect(\n            employee.page.getByRole('link', { name: 'Import / Export' })\n        ).not.toBeVisible();\n    });\n});\n"
  },
  {
    "path": "e2e/members.spec.ts",
    "content": "// TODO: Edit Billable Rate\n// TODO: Resend Email Invitation\n// TODO: Remove Invitation\nimport { expect, test } from '../playwright/fixtures';\nimport { PLAYWRIGHT_BASE_URL } from '../playwright/config';\nimport type { Page } from '@playwright/test';\nimport { inviteAndAcceptMember } from './utils/members';\nimport {\n    createPlaceholderMemberViaImportApi,\n    getMembersViaApi,\n    updateMemberBillableRateViaApi,\n    updateOrganizationSettingViaApi,\n} from './utils/api';\nimport { getTableRowNames } from './utils/table';\n\n// Tests that invite + accept members need more time\ntest.describe.configure({ timeout: 45000 });\n\nasync function goToMembersPage(page: Page) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/members');\n}\n\nasync function openInviteMemberModal(page: Page) {\n    await Promise.all([\n        page.getByRole('button', { name: 'Invite Member' }).click(),\n        expect(page.getByPlaceholder('Member Email')).toBeVisible(),\n    ]);\n}\n\ntest('test that new manager can be invited and accepted', async ({ page, browser }) => {\n    const memberId = Math.round(Math.random() * 100000);\n    const memberEmail = `manager+${memberId}@invite.test`;\n\n    await inviteAndAcceptMember(page, browser, 'Invited Mgr', memberEmail, 'Manager');\n\n    // Verify the member appears in the members table with the correct role\n    await goToMembersPage(page);\n    const memberRow = page.getByRole('row').filter({ hasText: 'Invited Mgr' });\n    await expect(memberRow).toBeVisible();\n    await expect(memberRow.getByText('Manager', { exact: true })).toBeVisible();\n});\n\ntest('test that new employee can be invited and accepted', async ({ page, browser }) => {\n    const memberId = Math.round(Math.random() * 100000);\n    const memberEmail = `employee+${memberId}@invite.test`;\n\n    await inviteAndAcceptMember(page, browser, 'Invited Emp', memberEmail, 'Employee');\n\n    // Verify the member appears in the members table with the correct role\n    await goToMembersPage(page);\n    const memberRow = page.getByRole('row').filter({ hasText: 'Invited Emp' });\n    await expect(memberRow).toBeVisible();\n    await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible();\n});\n\ntest('test that new admin can be invited and accepted', async ({ page, browser }) => {\n    const memberId = Math.round(Math.random() * 100000);\n    const memberEmail = `admin+${memberId}@invite.test`;\n\n    await inviteAndAcceptMember(page, browser, 'Invited Adm', memberEmail, 'Administrator');\n\n    // Verify the member appears in the members table with the correct role\n    await goToMembersPage(page);\n    const memberRow = page.getByRole('row').filter({ hasText: 'Invited Adm' });\n    await expect(memberRow).toBeVisible();\n    await expect(memberRow.getByText('Admin', { exact: true })).toBeVisible();\n});\n\ntest('test that error shows if no role is selected', async ({ page }) => {\n    await goToMembersPage(page);\n    await openInviteMemberModal(page);\n    const noRoleId = Math.round(Math.random() * 10000);\n\n    await page.getByLabel('Email').fill(`new+${noRoleId}@norole.test`);\n    await Promise.all([\n        page.getByRole('button', { name: 'Invite Member', exact: true }).click(),\n        expect(page.getByText('Please select a role')).toBeVisible(),\n    ]);\n});\n\ntest('test that organization billable rate can be updated with all existing time entries', async ({\n    page,\n}) => {\n    await goToMembersPage(page);\n    const newBillableRate = Math.round(Math.random() * 10000);\n    await page.getByRole('row').first().getByRole('button').click();\n    await page.getByRole('menuitem').getByText('Edit').click();\n    await page.getByRole('combobox').last().click();\n    await page.getByRole('option', { name: 'Custom Rate' }).click();\n    await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());\n    await page.getByRole('button', { name: 'Update Member' }).click();\n\n    await Promise.all([\n        page.getByRole('button', { name: 'Yes, update existing time' }).click(),\n        page.waitForRequest(\n            async (request) =>\n                request.url().includes('/members/') &&\n                request.method() === 'PUT' &&\n                request.postDataJSON().billable_rate === newBillableRate * 100\n        ),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/organizations/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200 &&\n                (await response.json()).data.billable_rate === newBillableRate * 100\n        ),\n    ]);\n});\n\ntest('test that switching member billable rate from custom back to default rate works', async ({\n    page,\n    ctx,\n}) => {\n    // Set a known org billable rate\n    await updateOrganizationSettingViaApi(ctx, { billable_rate: 12000 });\n\n    // Create a placeholder member with a custom billable rate\n    await createPlaceholderMemberViaImportApi(ctx, 'CustomToDefault Member');\n    const members = await getMembersViaApi(ctx);\n    const member = members.find((m) => m.name === 'CustomToDefault Member');\n    expect(member).toBeDefined();\n    await updateMemberBillableRateViaApi(ctx, member!.id, 25000);\n\n    await goToMembersPage(page);\n    const memberRow = page.getByRole('row').filter({ hasText: 'CustomToDefault Member' });\n    await expect(memberRow).toBeVisible();\n\n    // Open edit modal\n    await memberRow.getByRole('button').click();\n    await page.getByRole('menuitem').getByText('Edit').click();\n    await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();\n\n    // Verify it starts on Custom Rate\n    const billableCombobox = page.getByRole('dialog').getByRole('combobox').last();\n    await expect(billableCombobox).toContainText('Custom Rate');\n\n    // Switch to Default Rate\n    await billableCombobox.click();\n    await page.getByRole('option', { name: 'Default Rate' }).click();\n    await expect(billableCombobox).toContainText('Default Rate');\n\n    // Verify the billable rate input is disabled\n    await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled();\n\n    // Submit — billable_rate changes from 25000 to null, so confirmation dialog appears\n    await page.getByRole('button', { name: 'Update Member' }).click();\n    await expect(page.getByRole('heading', { name: 'Update Member Billable Rate' })).toBeVisible();\n    await expect(page.getByText('the default rate of the organization')).toBeVisible();\n\n    // Confirm the update\n    await Promise.all([\n        page.getByRole('button', { name: 'Yes, update existing time' }).click(),\n        page.waitForRequest(\n            (request) =>\n                request.url().includes('/members/') &&\n                request.method() === 'PUT' &&\n                request.postDataJSON().billable_rate === null\n        ),\n    ]);\n\n    // Verify both dialogs are closed\n    await expect(page.getByRole('dialog')).not.toBeVisible();\n});\n\ntest('test that default rate shows disabled input with organization billable rate', async ({\n    page,\n    ctx,\n}) => {\n    // Set a known org billable rate (150.00)\n    await updateOrganizationSettingViaApi(ctx, { billable_rate: 15000 });\n\n    await goToMembersPage(page);\n\n    // Open edit modal for the owner (who uses default rate by default)\n    await page.getByRole('row').first().getByRole('button').click();\n    await page.getByRole('menuitem').getByText('Edit').click();\n    await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();\n\n    // Verify it's on Default Rate\n    const billableCombobox = page.getByRole('dialog').getByRole('combobox').last();\n    await expect(billableCombobox).toContainText('Default Rate');\n\n    // Verify the input is disabled and shows the org rate (formatted with currency)\n    const billableInput = page.getByPlaceholder('Billable Rate');\n    await expect(billableInput).toBeDisabled();\n    await expect(billableInput).toHaveAttribute('aria-valuenow', '150');\n\n    // Close the dialog\n    await page.getByRole('button', { name: 'Cancel' }).click();\n    await expect(page.getByRole('dialog')).not.toBeVisible();\n});\n\ntest('test that cancelling the billable rate confirmation dialog does not update the member', async ({\n    page,\n    ctx,\n}) => {\n    // Create a placeholder member with a custom billable rate\n    await createPlaceholderMemberViaImportApi(ctx, 'CancelConfirm Member');\n    const members = await getMembersViaApi(ctx);\n    const member = members.find((m) => m.name === 'CancelConfirm Member');\n    expect(member).toBeDefined();\n    await updateMemberBillableRateViaApi(ctx, member!.id, 10000);\n\n    await goToMembersPage(page);\n    const memberRow = page.getByRole('row').filter({ hasText: 'CancelConfirm Member' });\n    await expect(memberRow).toBeVisible();\n\n    // Open edit modal\n    await memberRow.getByRole('button').click();\n    await page.getByRole('menuitem').getByText('Edit').click();\n    await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();\n\n    // Change the billable rate\n    await page.getByPlaceholder('Billable Rate').fill('200');\n\n    // Click Update Member — confirmation dialog should appear\n    await page.getByRole('button', { name: 'Update Member' }).click();\n    await expect(page.getByRole('heading', { name: 'Update Member Billable Rate' })).toBeVisible();\n\n    // Set up listener to verify no PUT request is sent after cancel\n    let putRequestSent = false;\n    page.on('request', (request) => {\n        if (request.url().includes('/members/') && request.method() === 'PUT') {\n            putRequestSent = true;\n        }\n    });\n\n    // Click Cancel on the confirmation dialog\n    await page.getByRole('button', { name: 'Cancel' }).click();\n\n    // Verify confirmation dialog is closed\n    await expect(\n        page.getByRole('heading', { name: 'Update Member Billable Rate' })\n    ).not.toBeVisible();\n\n    // Verify no API call was made\n    expect(putRequestSent).toBe(false);\n});\n\ntest('test that changing role of placeholder member is rejected', async ({ page, ctx }) => {\n    const placeholderName = 'RoleChange ' + Math.floor(Math.random() * 10000);\n\n    // Create a placeholder member via import\n    await createPlaceholderMemberViaImportApi(ctx, placeholderName);\n\n    // Go to members page and verify placeholder exists with role \"Placeholder\"\n    await goToMembersPage(page);\n    const memberRow = page.getByRole('row').filter({ hasText: placeholderName });\n    await expect(memberRow).toBeVisible();\n    await expect(memberRow.getByText('Placeholder', { exact: true })).toBeVisible();\n\n    // Open the edit modal for the placeholder member\n    await memberRow.getByRole('button').click();\n    await page.getByRole('menuitem').getByText('Edit').click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n    await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();\n\n    // Change role to Employee\n    const roleSelect = page.getByRole('dialog').getByRole('combobox').first();\n    await roleSelect.click();\n    await expect(page.getByRole('option', { name: 'Employee' })).toBeVisible();\n    await page.getByRole('option', { name: 'Employee' }).click();\n    await expect(roleSelect).toContainText('Employee');\n\n    // Submit the change - the API should reject it with 400\n    await Promise.all([\n        page.getByRole('button', { name: 'Update Member' }).click(),\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/members/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 400\n        ),\n    ]);\n\n    // Verify error notification is shown\n    await expect(page.getByText('Failed to update member')).toBeVisible();\n});\n\ntest('test that changing member role updates the role in the member table', async ({\n    page,\n    browser,\n}) => {\n    const memberId = Math.floor(Math.random() * 100000);\n    const memberEmail = `member+${memberId}@rolechange.test`;\n\n    // Invite and accept a new Employee member\n    await inviteAndAcceptMember(page, browser, 'Jane Smith', memberEmail, 'Employee');\n\n    // Verify the new member appears with the Employee role\n    await goToMembersPage(page);\n    const memberRow = page.getByRole('row').filter({ hasText: 'Jane Smith' });\n    await expect(memberRow).toBeVisible();\n    await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible();\n\n    // Open the edit modal\n    await memberRow.getByRole('button').click();\n    await page.getByRole('menuitem').getByText('Edit').click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n    await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();\n\n    // Change role to Manager\n    const roleSelect = page.getByRole('dialog').getByRole('combobox').first();\n    await roleSelect.click();\n    await expect(page.getByRole('option', { name: 'Manager' })).toBeVisible();\n    await page.getByRole('option', { name: 'Manager' }).click();\n    await expect(roleSelect).toContainText('Manager');\n\n    // Submit the change and verify the API call succeeds\n    await Promise.all([\n        page.getByRole('button', { name: 'Update Member' }).click(),\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/members/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n    ]);\n\n    // Verify dialog closed\n    await expect(page.getByRole('dialog')).not.toBeVisible();\n\n    // Verify the role updated in the table\n    await expect(memberRow.getByText('Manager', { exact: true })).toBeVisible();\n});\n\ntest('test that merging a placeholder member works', async ({ page, ctx }) => {\n    const placeholderName = 'Merge Target ' + Math.floor(Math.random() * 10000);\n\n    // Create a placeholder member via import\n    await createPlaceholderMemberViaImportApi(ctx, placeholderName);\n\n    // Go to members page\n    await goToMembersPage(page);\n    await expect(page.getByText(placeholderName)).toBeVisible();\n\n    // Find the placeholder member row and open actions menu\n    const placeholderRow = page.getByRole('row').filter({ hasText: placeholderName });\n    await placeholderRow.getByRole('button').click();\n\n    // Click Merge\n    await page.getByTestId('member_merge').click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n    await expect(page.getByRole('heading', { name: 'Merge Member' })).toBeVisible();\n\n    // Select the current user (the owner) as merge target via MemberCombobox\n    // The MemberCombobox renders a Button as trigger; clicking it opens the popover with the combobox input\n    await page.getByRole('dialog').getByRole('button', { name: 'Select a member...' }).click();\n\n    // Wait for dropdown options to load\n    const firstOption = page.getByRole('option').first();\n    await expect(firstOption).toBeVisible({ timeout: 10000 });\n    await firstOption.click();\n\n    // Submit merge\n    await Promise.all([\n        page.getByRole('button', { name: 'Merge Member' }).click(),\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/member/') &&\n                response.url().includes('/merge-into') &&\n                response.ok()\n        ),\n    ]);\n\n    // Wait for merge dialog to close after successful merge\n    await expect(page.getByRole('dialog').filter({ hasText: 'Merge Member' })).not.toBeVisible();\n\n    // Verify placeholder member is no longer in the members table\n    await expect(page.getByRole('main').getByText(placeholderName)).not.toBeVisible();\n});\n\ntest('test that deleting a placeholder member works', async ({ page, ctx }) => {\n    const placeholderName = 'Delete Target ' + Math.floor(Math.random() * 10000);\n\n    // Create a placeholder member via import\n    await createPlaceholderMemberViaImportApi(ctx, placeholderName);\n\n    // Go to members page\n    await goToMembersPage(page);\n    const memberRow = page.getByRole('row').filter({ hasText: placeholderName });\n    await expect(memberRow).toBeVisible();\n\n    // Open actions menu and click Delete\n    await memberRow.getByRole('button').click();\n    await page.getByRole('menuitem').getByText('Delete').click();\n\n    // Verify delete modal is shown\n    await expect(page.getByRole('dialog')).toBeVisible();\n    await expect(page.getByRole('heading', { name: 'Delete Member' })).toBeVisible();\n\n    // Try to delete without checking the confirmation checkbox\n    await page.getByRole('button', { name: 'Delete Member' }).click();\n\n    // Should show validation error\n    await expect(\n        page.getByText('You must confirm that you understand the consequences of this action')\n    ).toBeVisible();\n\n    // Check the confirmation checkbox\n    await page.getByRole('checkbox').click();\n\n    // Click Delete Member button and wait for API response\n    await Promise.all([\n        page.getByRole('button', { name: 'Delete Member' }).click(),\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/members/') &&\n                response.request().method() === 'DELETE' &&\n                response.ok()\n        ),\n    ]);\n\n    // Verify modal is closed\n    await expect(page.getByRole('dialog')).not.toBeVisible();\n\n    // Verify member is removed from the table\n    await expect(page.getByRole('main').getByText(placeholderName)).not.toBeVisible();\n});\n\ntest('test that member delete modal can be cancelled', async ({ page, ctx }) => {\n    const placeholderName = 'Delete Cancel ' + Math.floor(Math.random() * 10000);\n\n    // Create a placeholder member via import\n    await createPlaceholderMemberViaImportApi(ctx, placeholderName);\n\n    // Go to members page\n    await goToMembersPage(page);\n    const memberRow = page.getByRole('row').filter({ hasText: placeholderName });\n    await expect(memberRow).toBeVisible();\n\n    // Open actions menu and click Delete\n    await memberRow.getByRole('button').click();\n    await page.getByRole('menuitem').getByText('Delete').click();\n\n    // Verify delete modal is shown\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // Set up listener to verify no DELETE request is sent\n    let deleteRequestSent = false;\n    page.on('request', (request) => {\n        if (request.url().includes('/members/') && request.method() === 'DELETE') {\n            deleteRequestSent = true;\n        }\n    });\n\n    // Click Cancel\n    await page.getByRole('button', { name: 'Cancel' }).click();\n\n    // Verify modal is closed\n    await expect(page.getByRole('dialog')).not.toBeVisible();\n\n    // Verify member is still in the table\n    await expect(memberRow).toBeVisible();\n\n    // Verify no DELETE request was sent\n    expect(deleteRequestSent).toBe(false);\n});\n\ntest('test that organization owner cannot be deleted', async ({ page }) => {\n    await goToMembersPage(page);\n\n    // Find the owner row (John Doe with Owner role)\n    const ownerRow = page.getByRole('row').filter({ hasText: 'Owner' });\n    await expect(ownerRow).toBeVisible();\n\n    // Open the actions menu for the owner\n    await ownerRow.getByRole('button').click();\n\n    // Click Delete\n    await page.getByRole('menuitem').getByText('Delete').click();\n\n    // Verify delete modal is shown\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // Check the confirmation checkbox\n    await page.getByRole('checkbox').click();\n\n    // Try to delete - should fail with 400 error\n    const responsePromise = page.waitForResponse(\n        (response) =>\n            response.url().includes('/members/') && response.request().method() === 'DELETE'\n    );\n    await page.getByRole('button', { name: 'Delete Member' }).click();\n    const response = await responsePromise;\n\n    // Verify the API returned an error status\n    expect(response.status()).toBe(400);\n\n    // Close the modal by pressing Escape\n    await page.keyboard.press('Escape');\n\n    // Refresh and verify the owner is still there\n    await goToMembersPage(page);\n    await expect(page.getByRole('row').filter({ hasText: 'Owner' })).toBeVisible();\n});\n\n// =============================================\n// Invitations Tab Tests\n// =============================================\n\ntest('test that invitation shows in invitations tab and can be revoked', async ({ page }) => {\n    const inviteEmail = `invite+${Math.floor(Math.random() * 100000)}@pending.test`;\n\n    await goToMembersPage(page);\n    await openInviteMemberModal(page);\n\n    await page.getByPlaceholder('Member Email').fill(inviteEmail);\n    await page.getByRole('button', { name: 'Employee' }).click();\n    await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/invitations') &&\n                response.request().method() === 'POST' &&\n                response.status() === 204\n        ),\n        page.getByRole('button', { name: 'Invite Member', exact: true }).click(),\n    ]);\n\n    // Wait for modal to close\n    await expect(page.getByPlaceholder('Member Email')).not.toBeVisible();\n\n    // Switch to Invitations tab and verify the invitation is visible\n    await page.getByText('Invitations', { exact: true }).click();\n    await expect(page.getByText(inviteEmail)).toBeVisible();\n\n    // Find and click the actions menu for this invitation\n    const invitationRow = page.locator('tr, [role=\"row\"]').filter({ hasText: inviteEmail });\n    await invitationRow.getByRole('button').click();\n    await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/invitations/') &&\n                response.request().method() === 'DELETE' &&\n                response.status() === 204\n        ),\n        page.getByRole('menuitem').getByText('Delete').click(),\n    ]);\n\n    // Verify invitation is removed\n    await expect(page.getByText(inviteEmail)).not.toBeVisible();\n});\n\ntest('test that invitation can be resent', async ({ page }) => {\n    const inviteEmail = `resend+${Math.floor(Math.random() * 100000)}@invite.test`;\n\n    await goToMembersPage(page);\n    await openInviteMemberModal(page);\n\n    await page.getByPlaceholder('Member Email').fill(inviteEmail);\n    await page.getByRole('button', { name: 'Employee' }).click();\n    await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/invitations') &&\n                response.request().method() === 'POST' &&\n                response.status() === 204\n        ),\n        page.getByRole('button', { name: 'Invite Member', exact: true }).click(),\n    ]);\n\n    // Wait for modal to close\n    await expect(page.getByPlaceholder('Member Email')).not.toBeVisible();\n\n    // Switch to Invitations tab\n    await page.getByText('Invitations', { exact: true }).click();\n    await expect(page.getByText(inviteEmail)).toBeVisible();\n\n    // Find and click the actions menu, then resend\n    const invitationRow = page.locator('tr, [role=\"row\"]').filter({ hasText: inviteEmail });\n    await invitationRow.getByRole('button').click();\n    // Wait for dropdown menu to appear\n    await expect(page.getByRole('menuitem').getByText('Resend Invitation')).toBeVisible();\n    await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/resend') && response.request().method() === 'POST'\n        ),\n        page.getByRole('menuitem').getByText('Resend Invitation').click(),\n    ]);\n});\n\ntest('test that admin user cannot transfer ownership', async ({ page, browser }) => {\n    const memberId = Math.floor(Math.random() * 100000);\n    const memberEmail = `admin+${memberId}@perms.test`;\n\n    // Invite and accept an admin member\n    await inviteAndAcceptMember(\n        page,\n        browser,\n        'Admin User ' + memberId,\n        memberEmail,\n        'Administrator'\n    );\n\n    // Go to members page and verify the admin exists\n    await goToMembersPage(page);\n    const adminRow = page.getByRole('row').filter({ hasText: 'Admin User' });\n    await expect(adminRow).toBeVisible();\n\n    // The owner should still be the owner\n    const ownerRow = page.getByRole('row').filter({ hasText: 'Owner' });\n    await expect(ownerRow).toBeVisible();\n\n    // Open actions menu for the admin - should NOT have \"Transfer Ownership\" option\n    await adminRow.getByRole('button').click();\n    await expect(page.getByRole('menuitem').getByText('Edit')).toBeVisible();\n});\n\ntest('test that accepted invitation disappears from invitations tab', async ({ page, browser }) => {\n    const memberId = Math.round(Math.random() * 100000);\n    const memberEmail = `accepted+${memberId}@invite.test`;\n\n    // Invite and accept the member\n    await inviteAndAcceptMember(page, browser, 'Accepted Member', memberEmail, 'Employee');\n\n    // Go to members page and switch to Invitations tab\n    await goToMembersPage(page);\n    await page.getByRole('tab', { name: 'Invitations' }).click();\n\n    // The accepted invitation should not be visible\n    await expect(page.getByText(memberEmail)).not.toBeVisible();\n});\n\n// =============================================\n// Sorting Tests\n// =============================================\n\n// Helper to clear localStorage before tests that check sorting\nasync function clearMemberTableState(page: Page) {\n    await page.evaluate(() => {\n        localStorage.removeItem('member-table-state');\n    });\n}\n\ntest('test that sorting members by name, role, and status works', async ({ page, ctx }) => {\n    // Create two placeholder members with names that sort predictably around \"John Doe\"\n    await createPlaceholderMemberViaImportApi(ctx, 'AAA SortFirst');\n    await createPlaceholderMemberViaImportApi(ctx, 'ZZZ SortLast');\n\n    await goToMembersPage(page);\n    await clearMemberTableState(page);\n    await page.reload();\n\n    const table = page.getByTestId('member_table');\n    await expect(table).toBeVisible();\n\n    // -- Name sorting (default is already name asc after clearing state) --\n    const nameHeader = table.getByText('Name').first();\n    let names = await getTableRowNames(table);\n    expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));\n\n    await nameHeader.click(); // toggle to desc\n    names = await getTableRowNames(table);\n    expect(names.indexOf('ZZZ SortLast')).toBeLessThan(names.indexOf('AAA SortFirst'));\n\n    // -- Role sorting --\n    const roleHeader = table.getByText('Role').first();\n    await roleHeader.click(); // asc: Owner(0) < Placeholder(4)\n    names = await getTableRowNames(table);\n    const ownerIdx = names.indexOf('John Doe');\n    const placeholderIdx = names.indexOf('AAA SortFirst');\n    expect(ownerIdx).toBeLessThan(placeholderIdx);\n\n    await roleHeader.click(); // desc: Placeholder first\n    names = await getTableRowNames(table);\n    expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));\n\n    // -- Status sorting --\n    const statusHeader = table.getByText('Status').first();\n    await statusHeader.click(); // asc: Active(0) < Inactive(1)\n    names = await getTableRowNames(table);\n    expect(names.indexOf('John Doe')).toBeLessThan(names.indexOf('AAA SortFirst'));\n\n    await statusHeader.click(); // desc: Inactive first\n    names = await getTableRowNames(table);\n    expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));\n\n    // -- Email: just verify sort indicator appears --\n    const emailHeader = table.getByText('Email').first();\n    await emailHeader.click();\n    await expect(emailHeader.locator('svg')).toBeVisible();\n});\n\ntest('test that member sort state persists after page reload', async ({ page }) => {\n    await goToMembersPage(page);\n    await clearMemberTableState(page);\n    await page.reload();\n\n    const table = page.getByTestId('member_table');\n    await expect(table).toBeVisible();\n\n    // Click Role header twice to set descending sort\n    const roleHeader = table.getByText('Role').first();\n    await roleHeader.click();\n    await expect(roleHeader.locator('svg')).toBeVisible();\n    await roleHeader.click();\n    await expect(roleHeader.locator('svg')).toBeVisible();\n\n    // Reload the page\n    await page.reload();\n\n    // Verify the sort indicator is still visible on Role column\n    await expect(page.getByTestId('member_table')).toBeVisible();\n    await expect(\n        page.getByTestId('member_table').getByText('Role').first().locator('svg')\n    ).toBeVisible();\n});\n\ntest('test that sorting members by billable rate works', async ({ page, ctx }) => {\n    // Create two placeholder members and set different billable rates\n    await createPlaceholderMemberViaImportApi(ctx, 'HighRate Member');\n    await createPlaceholderMemberViaImportApi(ctx, 'LowRate Member');\n\n    const members = await getMembersViaApi(ctx);\n    const highRateMember = members.find((m) => m.name === 'HighRate Member');\n    const lowRateMember = members.find((m) => m.name === 'LowRate Member');\n    expect(highRateMember).toBeDefined();\n    expect(lowRateMember).toBeDefined();\n\n    await updateMemberBillableRateViaApi(ctx, highRateMember!.id, 20000);\n    await updateMemberBillableRateViaApi(ctx, lowRateMember!.id, 5000);\n\n    await goToMembersPage(page);\n    await clearMemberTableState(page);\n    await page.reload();\n\n    const table = page.getByTestId('member_table');\n    await expect(table).toBeVisible();\n\n    // First click = desc (highest first), null rates last\n    const billableHeader = table.getByText('Billable Rate').first();\n    await billableHeader.click();\n    await expect(billableHeader.locator('svg')).toBeVisible();\n    let names = await getTableRowNames(table);\n    expect(names.indexOf('HighRate Member')).toBeLessThan(names.indexOf('LowRate Member'));\n\n    // Second click = asc (lowest first), null rates still last\n    await billableHeader.click();\n    names = await getTableRowNames(table);\n    expect(names.indexOf('LowRate Member')).toBeLessThan(names.indexOf('HighRate Member'));\n});\n\n// =============================================\n// Employee Permission Tests\n// =============================================\n\ntest.describe('Employee Sidebar Navigation', () => {\n    test('employee sidebar shows correct navigation links', async ({ employee }) => {\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');\n        await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({\n            timeout: 10000,\n        });\n\n        // Visible links\n        await expect(employee.page.getByRole('link', { name: 'Dashboard' })).toBeVisible();\n        await expect(employee.page.getByRole('link', { name: 'Time' })).toBeVisible();\n        await expect(employee.page.getByRole('link', { name: 'Calendar' })).toBeVisible();\n        await expect(employee.page.getByRole('link', { name: 'Projects' })).toBeVisible();\n        await expect(employee.page.getByRole('link', { name: 'Clients' })).toBeVisible();\n        await expect(employee.page.getByRole('link', { name: 'Tags' })).toBeVisible();\n\n        // Hidden links\n        await expect(employee.page.getByRole('link', { name: 'Members' })).not.toBeVisible();\n        await expect(\n            employee.page.getByRole('link', { name: 'Settings', exact: true })\n        ).not.toBeVisible();\n    });\n\n    test('employee cannot see members list or invite members', async ({ employee }) => {\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/members');\n\n        // Page loads but the members API returns 403 (no members:view permission)\n        await expect(employee.page.getByRole('heading', { name: 'Members' })).toBeVisible({\n            timeout: 10000,\n        });\n\n        // Member table is empty — no rows rendered (only headers)\n        await expect(employee.page.getByTestId('member_table').locator('[role=\"row\"]')).toHaveCount(\n            0\n        );\n\n        // Employee should NOT see the Invite Member button\n        await expect(\n            employee.page.getByRole('button', { name: 'Invite member' })\n        ).not.toBeVisible();\n    });\n});\n"
  },
  {
    "path": "e2e/organization.spec.ts",
    "content": "import { expect, test } from '../playwright/fixtures';\nimport { PLAYWRIGHT_BASE_URL } from '../playwright/config';\n\nasync function goToOrganizationSettings(page) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');\n    await page.locator('[data-testid=\"organization_switcher\"]:visible').click();\n    await page.getByText('Organization Settings').click();\n}\n\nasync function createTimeEntry(page, duration: string) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/time');\n\n    // Open the dropdown menu and click \"Manual time entry\"\n    await page.getByRole('button', { name: 'Time entry actions' }).click();\n    await page.getByRole('menuitem', { name: 'Manual time entry' }).click();\n\n    // Fill in the time entry details\n    await page.getByTestId('time_entry_description').fill('Test time entry');\n\n    // Set duration\n    await page.locator('[role=\"dialog\"] input[name=\"Duration\"]').fill(duration);\n    await page.locator('[role=\"dialog\"] input[name=\"Duration\"]').press('Tab');\n\n    // Submit the time entry\n    await Promise.all([\n        page.getByRole('button', { name: 'Create Time Entry' }).click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/time-entries') &&\n                response.request().method() === 'POST' &&\n                response.status() === 201\n        ),\n    ]);\n}\n\ntest('test that organization name can be updated', async ({ page }) => {\n    await goToOrganizationSettings(page);\n    await page.getByLabel('Organization Name').fill('NEW ORG NAME');\n    await page.getByLabel('Organization Name').press('Enter');\n    await page.getByLabel('Organization Name').press('Meta+r');\n    await expect(page.locator('[data-testid=\"organization_switcher\"]:visible')).toContainText(\n        'NEW ORG NAME'\n    );\n});\n\ntest('test that organization billable rate can be updated with all existing time entries', async ({\n    page,\n}) => {\n    await goToOrganizationSettings(page);\n    const newBillableRate = Math.round(Math.random() * 10000);\n    await page.getByLabel('Organization Billable Rate').click();\n    await page.getByLabel('Organization Billable Rate').fill(newBillableRate.toString());\n    await page\n        .locator('form')\n        .filter({ hasText: 'Organization Billable' })\n        .getByRole('button', { name: 'Save' })\n        .click();\n\n    await Promise.all([\n        page.getByRole('button', { name: 'Yes, update existing time entries' }).click(),\n        page.waitForRequest(\n            async (request) =>\n                request.url().includes('/organizations/') &&\n                request.method() === 'PUT' &&\n                request.postDataJSON().billable_rate === newBillableRate * 100\n        ),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/organizations/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200 &&\n                (await response.json()).data.billable_rate === newBillableRate * 100\n        ),\n    ]);\n});\n\ntest('test that organization format settings can be updated', async ({ page }) => {\n    await goToOrganizationSettings(page);\n\n    // Test number format\n    await page.getByLabel('Number Format').click();\n    await page.getByRole('option', { name: '1,111.11' }).click();\n    await Promise.all([\n        page\n            .locator('form')\n            .filter({ hasText: 'Number Format' })\n            .getByRole('button', { name: 'Save' })\n            .click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/organizations/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200 &&\n                (await response.json()).data.number_format === 'comma-point'\n        ),\n    ]);\n\n    // Test currency format\n    await page.getByLabel('Currency Format').click();\n    await page.getByRole('option', { name: '111 EUR' }).click();\n    await Promise.all([\n        page\n            .locator('form')\n            .filter({ hasText: 'Currency Format' })\n            .getByRole('button', { name: 'Save' })\n            .click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/organizations/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200 &&\n                (await response.json()).data.currency_format === 'iso-code-after-with-space'\n        ),\n    ]);\n\n    // Test date format\n    await page.getByLabel('Date Format').click();\n    await page.getByRole('option', { name: 'DD/MM/YYYY' }).click();\n    await Promise.all([\n        page\n            .locator('form')\n            .filter({ hasText: 'Date Format' })\n            .getByRole('button', { name: 'Save' })\n            .click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/organizations/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200 &&\n                (await response.json()).data.date_format === 'slash-separated-dd-mm-yyyy'\n        ),\n    ]);\n\n    // Test time format\n    await page.getByLabel('Time Format').click();\n    await page.getByRole('option', { name: '24-hour clock' }).click();\n    await Promise.all([\n        page\n            .locator('form')\n            .filter({ hasText: 'Time Format' })\n            .getByRole('button', { name: 'Save' })\n            .click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/organizations/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200 &&\n                (await response.json()).data.time_format === '24-hours'\n        ),\n    ]);\n\n    // Test interval format\n    await page.getByLabel('Time Duration Format').click();\n    await page.getByRole('option', { name: '12:03', exact: true }).click();\n    await Promise.all([\n        page\n            .locator('form')\n            .filter({ hasText: 'Time Duration Format' })\n            .getByRole('button', { name: 'Save' })\n            .click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/organizations/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200 &&\n                (await response.json()).data.interval_format === 'hours-minutes-colon-separated'\n        ),\n    ]);\n});\n\ntest('test that format settings are reflected in the dashboard', async ({ page }) => {\n    // check that 0h 00min is displayed\n    await expect(page.getByText('0h 00min', { exact: true }).nth(0)).toBeVisible();\n\n    // First set the format settings\n    await goToOrganizationSettings(page);\n\n    // Set number format to comma-point\n    await page.getByLabel('Number Format').click();\n    await page.getByRole('option', { name: '1,111.11' }).click();\n\n    // Set currency format to symbol-after\n    await page.getByLabel('Currency Format').click();\n    await page.getByRole('option', { name: '111€' }).click();\n\n    // Set interval format to hours-minutes-colon-separated\n    await page.getByLabel('Time Duration Format').click();\n    await page.getByRole('option', { name: '12:03', exact: true }).click();\n\n    // Set date format to DD/MM/YYYY\n    await page.getByLabel('Date Format').click();\n    await page.getByRole('option', { name: 'DD/MM/YYYY' }).click();\n\n    await Promise.all([\n        page\n            .locator('form')\n            .filter({ hasText: 'Time Duration Format' })\n            .getByRole('button', { name: 'Save' })\n            .click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/organizations/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200 &&\n                (await response.json()).data.interval_format === 'hours-minutes-colon-separated' &&\n                (await response.json()).data.currency_format === 'symbol-after' &&\n                (await response.json()).data.number_format === 'comma-point'\n        ),\n    ]);\n\n    await createTimeEntry(page, '00:00');\n\n    // Go to dashboard and check the formats\n    await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');\n\n    // Check billable amount format (number and currency)\n    await expect(page.getByText('0.00€')).toBeVisible();\n\n    // check that 00:00 is displayed\n    await expect(page.getByText('0:00', { exact: true }).nth(0)).toBeVisible();\n    // check that 0h 00min is not displayed\n    await expect(page.getByText('0h 00min', { exact: true }).nth(0)).not.toBeVisible();\n\n    // check that the current date is displayed in the dd/mm/yyyy format on the time page\n    await page.goto(PLAYWRIGHT_BASE_URL + '/time');\n    // Wait for time entries to load so organization data is available for date formatting\n    await page.waitForResponse(\n        (response) => response.url().includes('/time-entries') && response.status() === 200\n    );\n    await expect(\n        page.getByText(new Date().toLocaleDateString('en-GB'), { exact: true }).nth(0)\n    ).toBeVisible({ timeout: 10000 });\n});\n\ntest('test that organization time entry settings can be toggled', async ({ page }) => {\n    await goToOrganizationSettings(page);\n\n    const preventOverlappingCheckbox = page.getByLabel(\n        'Prevent overlapping time entries (new entries only)'\n    );\n    const manageTasksCheckbox = page.getByLabel('Allow Employees to manage tasks');\n\n    // Get current states and toggle both\n    const wasOverlappingChecked = await preventOverlappingCheckbox.isChecked();\n    const wasManageTasksChecked = await manageTasksCheckbox.isChecked();\n\n    if (wasOverlappingChecked) {\n        await preventOverlappingCheckbox.uncheck();\n    } else {\n        await preventOverlappingCheckbox.check();\n    }\n\n    if (wasManageTasksChecked) {\n        await manageTasksCheckbox.uncheck();\n    } else {\n        await manageTasksCheckbox.check();\n    }\n\n    // Save\n    const settingsForm = page.locator('form').filter({ hasText: 'Prevent overlapping' });\n    await Promise.all([\n        settingsForm.getByRole('button', { name: 'Save' }).click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/organizations/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200 &&\n                (await response.json()).data.prevent_overlapping_time_entries ===\n                    !wasOverlappingChecked\n        ),\n    ]);\n\n    // Reload and verify both settings persisted\n    await page.reload();\n    await expect(preventOverlappingCheckbox).toBeChecked({ checked: !wasOverlappingChecked });\n    await expect(manageTasksCheckbox).toBeChecked({ checked: !wasManageTasksChecked });\n\n    // Toggle both back to restore original state\n    if (!wasOverlappingChecked) {\n        await preventOverlappingCheckbox.uncheck();\n    } else {\n        await preventOverlappingCheckbox.check();\n    }\n\n    if (!wasManageTasksChecked) {\n        await manageTasksCheckbox.uncheck();\n    } else {\n        await manageTasksCheckbox.check();\n    }\n\n    await Promise.all([\n        settingsForm.getByRole('button', { name: 'Save' }).click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/organizations/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200 &&\n                (await response.json()).data.prevent_overlapping_time_entries ===\n                    wasOverlappingChecked\n        ),\n    ]);\n});\n\ntest('test that 12-hour clock format can be set', async ({ page }) => {\n    await goToOrganizationSettings(page);\n\n    await page.getByLabel('Time Format').click();\n    await page.getByRole('option', { name: '12-hour clock' }).click();\n    await Promise.all([\n        page\n            .locator('form')\n            .filter({ hasText: 'Time Format' })\n            .getByRole('button', { name: 'Save' })\n            .click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/organizations/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200 &&\n                (await response.json()).data.time_format === '12-hours'\n        ),\n    ]);\n\n    // Reload and verify it persisted\n    await page.reload();\n    await expect(page.getByLabel('Time Format')).toContainText('12-hour clock');\n\n    // Reset back to 24-hour\n    await page.getByLabel('Time Format').click();\n    await page.getByRole('option', { name: '24-hour clock' }).click();\n    await Promise.all([\n        page\n            .locator('form')\n            .filter({ hasText: 'Time Format' })\n            .getByRole('button', { name: 'Save' })\n            .click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/organizations/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200 &&\n                (await response.json()).data.time_format === '24-hours'\n        ),\n    ]);\n});\n\ntest('test that format settings persist after page reload', async ({ page }) => {\n    await goToOrganizationSettings(page);\n\n    // Set a specific date format\n    await page.getByLabel('Date Format').click();\n    await page.getByRole('option', { name: 'DD/MM/YYYY' }).click();\n    await Promise.all([\n        page\n            .locator('form')\n            .filter({ hasText: 'Date Format' })\n            .getByRole('button', { name: 'Save' })\n            .click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/organizations/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n    ]);\n\n    // Reload and verify it persisted\n    await page.reload();\n    await expect(page.getByLabel('Date Format')).toContainText('DD/MM/YYYY');\n});\n\n// =============================================\n// Admin Permission Tests\n// =============================================\n\ntest.describe('Admin Organization Settings Access', () => {\n    test('admin can see and edit organization settings', async ({ ctx, admin }) => {\n        await admin.page.goto(PLAYWRIGHT_BASE_URL + '/teams/' + ctx.orgId);\n\n        // Organization Name section is visible\n        await expect(\n            admin.page.getByRole('heading', { name: 'Organization Name', level: 3 })\n        ).toBeVisible({ timeout: 10000 });\n\n        // Editable settings sections should be visible\n        await expect(\n            admin.page.getByRole('heading', { name: 'Billable Rate', level: 3 })\n        ).toBeVisible();\n        await expect(\n            admin.page.getByRole('heading', { name: 'Format Settings', level: 3 })\n        ).toBeVisible();\n        await expect(\n            admin.page.getByRole('heading', { name: 'Organization Settings', level: 3 })\n        ).toBeVisible();\n\n        // Save buttons should be visible (admin can update)\n        await expect(admin.page.getByRole('button', { name: 'Save' }).first()).toBeVisible();\n\n        // Delete organization should NOT be visible (owner only)\n        await expect(\n            admin.page.getByRole('heading', { name: 'Delete Organization' })\n        ).not.toBeVisible();\n    });\n});\n\n// =============================================\n// Employee Permission Tests\n// =============================================\n\ntest.describe('Employee Organization Settings Restrictions', () => {\n    test('employee can see org name but not editable settings', async ({ ctx, employee }) => {\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/teams/' + ctx.orgId);\n\n        // Organization Name section is visible (but inputs are disabled)\n        await expect(\n            employee.page.getByRole('heading', { name: 'Organization Name', level: 3 })\n        ).toBeVisible({ timeout: 10000 });\n\n        // Editable settings sections should NOT be visible\n        await expect(\n            employee.page.getByRole('heading', { name: 'Billable Rate', level: 3 })\n        ).not.toBeVisible();\n        await expect(\n            employee.page.getByRole('heading', { name: 'Format Settings', level: 3 })\n        ).not.toBeVisible();\n        await expect(\n            employee.page.getByRole('heading', { name: 'Organization Settings', level: 3 })\n        ).not.toBeVisible();\n\n        // Save button should not be visible (employee cannot update)\n        await expect(employee.page.getByRole('button', { name: 'Save' })).not.toBeVisible();\n    });\n});\n"
  },
  {
    "path": "e2e/profile.spec.ts",
    "content": "import { test, expect } from '../playwright/fixtures';\nimport { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config';\nimport type { Page } from '@playwright/test';\n\nasync function goToProfilePage(page: Page) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');\n}\n\ntest('test that user name can be updated', async ({ page }) => {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');\n    await page.getByLabel('Name', { exact: true }).fill('NEW NAME');\n    await Promise.all([\n        page.getByRole('button', { name: 'Save' }).first().click(),\n        page.waitForResponse('**/user/profile-information'),\n    ]);\n    await page.reload();\n    await expect(page.getByLabel('Name', { exact: true })).toHaveValue('NEW NAME');\n});\n\ntest.skip('test that user email can be updated', async ({ page }) => {\n    // this does not work because of email verification currently\n    await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');\n    const emailId = Math.round(Math.random() * 10000);\n    await page.getByLabel('Email').fill(`newemail+${emailId}@test.com`);\n    await page.getByRole('button', { name: 'Save' }).first().click();\n    await page.reload();\n    await expect(page.getByLabel('Email')).toHaveValue(`newemail+${emailId}@test.com`);\n});\n\nasync function createNewApiToken(page) {\n    await page.getByLabel('API Key Name').fill('NEW API KEY');\n    await Promise.all([\n        page.getByRole('button', { name: 'Create API Key' }).click(),\n        page.waitForResponse('**/users/me/api-tokens'),\n    ]);\n\n    await expect(page.locator('body')).toContainText('API Token created successfully');\n    await page.getByRole('dialog').getByText('Close').click();\n    await expect(page.locator('body')).toContainText('NEW API KEY');\n}\n\ntest('test that user can create an API key', async ({ page }) => {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');\n    await createNewApiToken(page);\n});\n\ntest('test that creating an API key with empty name shows validation error', async ({ page }) => {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');\n\n    // Wait for the API Key Name input to be visible before interacting\n    const nameInput = page.getByLabel('API Key Name');\n    await expect(nameInput).toBeVisible();\n\n    // Ensure the API Key Name input is empty\n    await nameInput.fill('');\n\n    // Click the create button and wait for the 422 response\n    const [response] = await Promise.all([\n        page.waitForResponse('**/users/me/api-tokens'),\n        page.getByRole('button', { name: 'Create API Key' }).click(),\n    ]);\n\n    expect(response.status()).toBe(422);\n\n    // Verify that an error notification is shown with validation message about the name field\n    await expect(page.getByText('name field is required')).toBeVisible({ timeout: 5000 });\n});\n\ntest('test that user can delete an API key', async ({ page }) => {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');\n    await createNewApiToken(page);\n    page.getByLabel('Delete API Token NEW API KEY').click();\n    await expect(page.getByRole('dialog')).toContainText(\n        'Are you sure you would like to delete this API token?'\n    );\n    await Promise.all([\n        page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(),\n        page.waitForResponse('**/users/me/api-tokens'),\n    ]);\n    await expect(page.locator('body')).not.toContainText('NEW API KEY');\n});\n\ntest('test that user can revoke an API key', async ({ page }) => {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');\n    await createNewApiToken(page);\n    page.getByLabel('Revoke API Token NEW API KEY').click();\n    await expect(page.getByRole('dialog')).toContainText(\n        'Are you sure you would like to revoke this API token?'\n    );\n    await Promise.all([\n        page.getByRole('dialog').getByRole('button', { name: 'Revoke' }).click(),\n        page.waitForResponse('**/users/me/api-tokens'),\n    ]);\n    await expect(page.getByRole('button', { name: 'Revoke' })).toBeHidden();\n    await expect(page.locator('body')).toContainText('NEW API KEY');\n    await expect(page.locator('body')).toContainText('Revoked');\n});\n\n// =============================================\n// Update Password Form Tests\n// =============================================\n\ntest('test that password mismatch shows error', async ({ page }) => {\n    await goToProfilePage(page);\n\n    // Fill in with mismatched passwords\n    await page.getByLabel('Current Password').fill(TEST_USER_PASSWORD);\n    await page.getByLabel('New Password').fill('newSecurePassword456');\n    await page.getByLabel('Confirm Password').fill('differentPassword789');\n\n    // Find the form containing the Confirm Password field and click its Save button\n    const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form');\n    await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/user/password') && response.request().method() === 'PUT'\n        ),\n        passwordForm.getByRole('button', { name: 'Save' }).click(),\n    ]);\n\n    // Verify error message about password confirmation\n    await expect(page.getByText('confirmation does not match')).toBeVisible();\n});\n\ntest('test that short password shows validation error', async ({ page }) => {\n    await goToProfilePage(page);\n\n    // Fill in with a too short password\n    await page.getByLabel('Current Password').fill(TEST_USER_PASSWORD);\n    await page.getByLabel('New Password').fill('short');\n    await page.getByLabel('Confirm Password').fill('short');\n\n    // Find the form containing the Confirm Password field and click its Save button\n    const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form');\n    await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/user/password') && response.request().method() === 'PUT'\n        ),\n        passwordForm.getByRole('button', { name: 'Save' }).click(),\n    ]);\n\n    // Verify error message about password length\n    await expect(page.getByText('must be at least')).toBeVisible();\n});\n\ntest('test that incorrect current password shows validation error', async ({ page }) => {\n    await goToProfilePage(page);\n\n    // Fill in with wrong current password\n    await page.getByLabel('Current Password').fill('wrongCurrentPassword123');\n    await page.getByLabel('New Password').fill('newSecurePassword456');\n    await page.getByLabel('Confirm Password').fill('newSecurePassword456');\n\n    // Find the form containing the Confirm Password field and click its Save button\n    const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form');\n    await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/user/password') && response.request().method() === 'PUT'\n        ),\n        passwordForm.getByRole('button', { name: 'Save' }).click(),\n    ]);\n\n    // Verify error message about incorrect password\n    await expect(page.getByText('does not match')).toBeVisible();\n});\n\ntest('test that password can be updated successfully', async ({ page }) => {\n    await goToProfilePage(page);\n    const newPassword = 'newSecurePassword456';\n\n    // Change password to new password\n    await page.getByLabel('Current Password').fill(TEST_USER_PASSWORD);\n    await page.getByLabel('New Password').fill(newPassword);\n    await page.getByLabel('Confirm Password').fill(newPassword);\n\n    const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form');\n    const responsePromise = page.waitForResponse(\n        (response) =>\n            response.url().includes('/user/password') && response.request().method() === 'PUT'\n    );\n    await passwordForm.getByRole('button', { name: 'Save' }).click();\n    const response = await responsePromise;\n\n    // Verify successful response (303 is Inertia redirect on success, means password was updated)\n    expect(response.status()).toBe(303);\n\n    // Verify no error messages are displayed\n    await expect(page.getByText('does not match')).not.toBeVisible();\n    await expect(page.getByText('must be at least')).not.toBeVisible();\n});\n\n// =============================================\n// Theme Selection Tests\n// =============================================\n\ntest('test that theme can be changed to dark and light', async ({ page }) => {\n    await goToProfilePage(page);\n\n    // The theme select is a Reka UI combobox (button), not a native <select>\n    const themeSelect = page.locator('button[role=\"combobox\"]');\n\n    // Change theme to dark\n    await themeSelect.click();\n    await page.getByRole('option', { name: 'Dark' }).click();\n\n    // Verify the html element has 'dark' class\n    await expect(page.locator('html')).toHaveClass(/dark/);\n\n    // Change theme to light\n    await themeSelect.click();\n    await page.getByRole('option', { name: 'Light' }).click();\n\n    // Verify the html element has 'light' class and no 'dark' class\n    await expect(page.locator('html')).toHaveClass(/light/);\n    await expect(page.locator('html')).not.toHaveClass(/dark/);\n\n    // Verify localStorage persists the setting\n    const storedTheme = await page.evaluate(() => localStorage.getItem('theme'));\n    expect(storedTheme).toContain('light');\n\n    // Reload and verify the theme persists\n    await page.reload();\n    await expect(page.locator('html')).toHaveClass(/light/);\n\n    // Reset to system\n    await page.locator('button[role=\"combobox\"]').click();\n    await page.getByRole('option', { name: 'System' }).click();\n    await expect(page.getByText('System default:')).toBeVisible();\n});\n\n// =============================================\n// Two Factor Authentication Tests\n// =============================================\n\ntest('test that password confirmation modal can be cancelled without sending API request', async ({\n    page,\n}) => {\n    await goToProfilePage(page);\n\n    // Find the Enable button in the 2FA section\n    const enableButton = page\n        .getByText('You have not enabled two factor authentication.')\n        .locator('..')\n        .getByRole('button', { name: 'Enable' });\n    await enableButton.click();\n\n    // Verify password confirmation modal appears\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // Set up listener to verify no POST request is sent to confirm-password\n    let confirmPasswordRequestSent = false;\n    page.on('request', (request) => {\n        if (request.url().includes('/user/confirm-password') && request.method() === 'POST') {\n            confirmPasswordRequestSent = true;\n        }\n    });\n\n    // Click Cancel\n    await page.getByRole('dialog').getByRole('button', { name: 'Cancel' }).click();\n\n    // Verify modal is closed\n    await expect(page.getByRole('dialog')).not.toBeVisible();\n\n    // Verify no confirm-password request was sent\n    expect(confirmPasswordRequestSent).toBe(false);\n});\n\ntest('test that password confirmation modal shows error for incorrect password', async ({\n    page,\n}) => {\n    await goToProfilePage(page);\n\n    // Find the Enable button in the 2FA section\n    const enableButton = page\n        .getByText('You have not enabled two factor authentication.')\n        .locator('..')\n        .getByRole('button', { name: 'Enable' });\n    await enableButton.click();\n\n    // Verify password confirmation modal appears\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // Enter incorrect password and confirm\n    await page.getByPlaceholder('Password').fill('wrongpassword123');\n    await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click();\n\n    // Should show error message (wait longer for API response)\n    await expect(page.getByRole('dialog').getByText('incorrect')).toBeVisible({ timeout: 10000 });\n});\n\ntest('test that 2FA can be enabled with correct password', async ({ page }) => {\n    await goToProfilePage(page);\n\n    // Verify 2FA is not enabled\n    await expect(page.getByText('You have not enabled two factor authentication.')).toBeVisible();\n\n    // Find the Enable button in the 2FA section\n    const enableButton = page\n        .getByText('You have not enabled two factor authentication.')\n        .locator('..')\n        .getByRole('button', { name: 'Enable' });\n    await enableButton.click();\n\n    // Verify password confirmation modal appears\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // Enter correct password and confirm\n    await page.getByPlaceholder('Password').fill(TEST_USER_PASSWORD);\n    await Promise.all([\n        page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click(),\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/user/two-factor-authentication') &&\n                response.request().method() === 'POST'\n        ),\n    ]);\n\n    // Verify QR code is shown\n    await expect(page.getByRole('heading', { name: 'Finish enabling two factor' })).toBeVisible();\n    await expect(page.getByText('Setup Key:')).toBeVisible();\n    await expect(page.getByLabel('Code')).toBeVisible();\n});\n\n// =============================================\n// Logout Other Browser Sessions Tests\n// =============================================\n\ntest('test that logout other browser sessions works with correct password', async ({ page }) => {\n    await goToProfilePage(page);\n\n    await page.getByRole('button', { name: 'Log Out Other Browser Sessions' }).click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    await page.getByPlaceholder('Password').fill(TEST_USER_PASSWORD);\n    await Promise.all([\n        page\n            .getByRole('dialog')\n            .getByRole('button', { name: 'Log Out Other Browser Sessions' })\n            .click(),\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/user/other-browser-sessions') &&\n                response.request().method() === 'DELETE'\n        ),\n    ]);\n});\n"
  },
  {
    "path": "e2e/project-members.spec.ts",
    "content": "import { expect } from '@playwright/test';\nimport type { Page } from '@playwright/test';\nimport { PLAYWRIGHT_BASE_URL } from '../playwright/config';\nimport { test } from '../playwright/fixtures';\nimport { formatCentsWithOrganizationDefaults } from './utils/money';\nimport { createProjectViaApi, createProjectMemberViaApi, type TestContext } from './utils/api';\n\nasync function createProjectWithMemberViaApi(ctx: TestContext, page: Page, projectName: string) {\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createProjectMemberViaApi(ctx, project.id, { member_id: ctx.memberId });\n\n    // Navigate to the project detail page\n    await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + project.id);\n    await expect(page.getByTestId('project_member_table').getByRole('row').first()).toBeVisible();\n    return project;\n}\n\ntest('test that updating project member billable rate works for existing time entries', async ({\n    page,\n    ctx,\n}) => {\n    const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);\n    const newBillableRate = Math.round(Math.random() * 10000);\n    await createProjectWithMemberViaApi(ctx, page, newProjectName);\n\n    await page\n        .getByTestId('project_member_table')\n        .getByRole('row')\n        .first()\n        .getByRole('button')\n        .click();\n    await page.getByRole('menuitem', { name: 'Edit Project Member' }).click();\n    await page.getByLabel('Billable Rate').fill(newBillableRate.toString());\n    await page.getByRole('button', { name: 'Update Project Member' }).click();\n\n    await Promise.all([\n        page.getByRole('button', { name: 'Yes, update existing time' }).click(),\n        page.waitForRequest(\n            async (request) =>\n                request.url().includes('/project-members/') &&\n                request.method() === 'PUT' &&\n                request.postDataJSON().billable_rate === newBillableRate * 100\n        ),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/project-members/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200 &&\n                (await response.json()).data.billable_rate === newBillableRate * 100\n        ),\n    ]);\n    await expect(\n        page\n            .getByRole('row')\n            .first()\n            .getByText(formatCentsWithOrganizationDefaults(newBillableRate * 100))\n    ).toBeVisible();\n});\n\ntest('test that project member edit modal can be cancelled without sending API request', async ({\n    page,\n    ctx,\n}) => {\n    const projectName = 'Cancel Test ' + Math.floor(1 + Math.random() * 10000);\n\n    await createProjectWithMemberViaApi(ctx, page, projectName);\n\n    // Open the edit modal\n    await page\n        .getByTestId('project_member_table')\n        .getByRole('row')\n        .first()\n        .getByRole('button')\n        .click();\n    await page.getByRole('menuitem', { name: 'Edit Project Member' }).click();\n\n    // Verify the modal is open and shows the member name\n    await expect(page.getByRole('heading', { name: 'Edit Project Member' })).toBeVisible();\n    await expect(page.getByRole('dialog').getByText('John Doe')).toBeVisible();\n\n    // Enter a new billable rate\n    await page.getByLabel('Billable Rate').fill('999');\n\n    // Set up listener to verify no PUT request is sent\n    let putRequestSent = false;\n    page.on('request', (request) => {\n        if (request.url().includes('/project-members/') && request.method() === 'PUT') {\n            putRequestSent = true;\n        }\n    });\n\n    // Click Cancel\n    await page.getByRole('button', { name: 'Cancel' }).click();\n\n    // Verify the modal is closed\n    await expect(page.getByRole('heading', { name: 'Edit Project Member' })).not.toBeVisible();\n\n    // Verify no PUT request was sent\n    expect(putRequestSent).toBe(false);\n});\n\ntest('test that project member update without billable rate change skips confirmation and completes', async ({\n    page,\n    ctx,\n}) => {\n    const projectName = 'No Change ' + Math.floor(1 + Math.random() * 10000);\n\n    await createProjectWithMemberViaApi(ctx, page, projectName);\n\n    // Open the edit modal\n    await page\n        .getByTestId('project_member_table')\n        .getByRole('row')\n        .first()\n        .getByRole('button')\n        .click();\n    await page.getByRole('menuitem', { name: 'Edit Project Member' }).click();\n\n    // Click Update without changing anything - no confirmation modal since rate didn't change\n    await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/project-members/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n        page.getByRole('button', { name: 'Update Project Member' }).click(),\n    ]);\n\n    // Verify the edit modal is closed (confirmation modal was skipped)\n    await expect(page.getByRole('heading', { name: 'Edit Project Member' })).not.toBeVisible();\n});\n\ntest('test that billable rate confirmation modal can be cancelled without sending API request', async ({\n    page,\n    ctx,\n}) => {\n    const projectName = 'Rate Cancel ' + Math.floor(1 + Math.random() * 10000);\n    const newBillableRate = Math.round(Math.random() * 10000);\n\n    await createProjectWithMemberViaApi(ctx, page, projectName);\n\n    // Open the edit modal\n    await page\n        .getByTestId('project_member_table')\n        .getByRole('row')\n        .first()\n        .getByRole('button')\n        .click();\n    await page.getByRole('menuitem', { name: 'Edit Project Member' }).click();\n\n    // Change the billable rate\n    await page.getByLabel('Billable Rate').fill(newBillableRate.toString());\n\n    // Set up listener to verify no PUT request is sent\n    let putRequestSent = false;\n    page.on('request', (request) => {\n        if (request.url().includes('/project-members/') && request.method() === 'PUT') {\n            putRequestSent = true;\n        }\n    });\n\n    // Click Update - this should show the confirmation modal\n    await page.getByRole('button', { name: 'Update Project Member' }).click();\n\n    // Verify the confirmation modal is shown\n    await expect(page.getByText('update all existing time entries')).toBeVisible();\n\n    // Click Cancel to close the confirmation modal without updating\n    await page.getByRole('button', { name: 'Cancel' }).click();\n\n    // Verify the confirmation modal is closed but edit modal is still open\n    await expect(page.getByText('update all existing time entries')).not.toBeVisible();\n    await expect(page.getByRole('heading', { name: 'Edit Project Member' })).toBeVisible();\n\n    // Close the edit modal\n    await page.getByRole('dialog').getByRole('button', { name: 'Cancel' }).click();\n\n    // Verify the edit modal is closed\n    await expect(page.getByRole('heading', { name: 'Edit Project Member' })).not.toBeVisible();\n\n    // Verify no PUT request was sent\n    expect(putRequestSent).toBe(false);\n});\n\ntest('test that clearing billable rate reverts to project default', async ({ page, ctx }) => {\n    const projectName = 'Revert Default ' + Math.floor(1 + Math.random() * 10000);\n    const customRate = Math.round(100 + Math.random() * 10000);\n\n    await createProjectWithMemberViaApi(ctx, page, projectName);\n\n    // Verify the billable rate shows \"--\" (project default) initially\n    await expect(\n        page.getByTestId('project_member_table').getByRole('row').first().getByText('--')\n    ).toBeVisible();\n\n    // Set a custom billable rate\n    await page\n        .getByTestId('project_member_table')\n        .getByRole('row')\n        .first()\n        .getByRole('button')\n        .click();\n    await page.getByRole('menuitem', { name: 'Edit Project Member' }).click();\n    await page.getByLabel('Billable Rate').fill(customRate.toString());\n    await page.getByRole('button', { name: 'Update Project Member' }).click();\n\n    // Confirm the billable rate update\n    await Promise.all([\n        page.getByRole('button', { name: 'Yes, update existing time' }).click(),\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/project-members/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n    ]);\n\n    // Verify the custom rate is shown in the table (not \"--\")\n    await expect(\n        page.getByTestId('project_member_table').getByRole('row').first().getByText('--')\n    ).not.toBeVisible();\n\n    // Now clear the billable rate to revert to project default\n    await page\n        .getByTestId('project_member_table')\n        .getByRole('row')\n        .first()\n        .getByRole('button')\n        .click();\n    await page.getByRole('menuitem', { name: 'Edit Project Member' }).click();\n\n    // Set billable rate to 0 to revert to project default\n    await page.getByLabel('Billable Rate').fill('0');\n    await page.getByRole('button', { name: 'Update Project Member' }).click();\n\n    // Confirm the billable rate update\n    await Promise.all([\n        page.getByRole('button', { name: 'Yes, update existing time' }).click(),\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/project-members/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n    ]);\n\n    // Verify the billable rate shows \"--\" again (project default)\n    await expect(\n        page.getByTestId('project_member_table').getByRole('row').first().getByText('--')\n    ).toBeVisible();\n});\n"
  },
  {
    "path": "e2e/projects.spec.ts",
    "content": "import { expect } from '@playwright/test';\nimport type { Page } from '@playwright/test';\nimport { PLAYWRIGHT_BASE_URL } from '../playwright/config';\nimport { test } from '../playwright/fixtures';\nimport { formatCentsWithOrganizationDefaults } from './utils/money';\nimport {\n    createProjectViaApi,\n    createPublicProjectViaApi,\n    createTaskViaApi,\n    createClientViaApi,\n    createTimeEntryViaApi,\n    archiveProjectViaApi,\n    updateOrganizationSettingViaApi,\n} from './utils/api';\n\nasync function goToProjectsOverview(page: Page) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/projects');\n}\n\n// Helper to clear localStorage before tests that check persistence\nasync function clearProjectTableState(page: Page) {\n    await page.evaluate(() => {\n        localStorage.removeItem('project-table-state');\n    });\n}\n\n// Create new project via modal\ntest('test that creating and deleting a new project via the modal works', async ({ page }) => {\n    const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);\n    await goToProjectsOverview(page);\n    await page.getByRole('button', { name: 'Create Project' }).click();\n    await page.getByLabel('Project Name').fill(newProjectName);\n    await Promise.all([\n        page.getByRole('button', { name: 'Create Project' }).click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/projects') &&\n                response.request().method() === 'POST' &&\n                response.status() === 201 &&\n                (await response.json()).data.id !== null &&\n                (await response.json()).data.color !== null &&\n                (await response.json()).data.client_id === null &&\n                (await response.json()).data.name === newProjectName\n        ),\n    ]);\n\n    await expect(page.getByTestId('project_table')).toContainText(newProjectName);\n    const moreButton = page.locator(\"[aria-label='Actions for Project \" + newProjectName + \"']\");\n    await moreButton.click();\n    const deleteButton = page.locator(\"[aria-label='Delete Project \" + newProjectName + \"']\");\n\n    await Promise.all([\n        deleteButton.click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/projects') &&\n                response.request().method() === 'DELETE' &&\n                response.status() === 204\n        ),\n    ]);\n    await expect(page.getByTestId('project_table')).not.toContainText(newProjectName);\n});\n\n// Helper to select a status filter using the new dropdown UI\nasync function selectStatusFilter(page: Page, status: 'Active' | 'Archived') {\n    // Click the Filter button to open the dropdown\n    await page.getByRole('button', { name: 'Filter projects' }).click();\n    // Click on Status submenu\n    await page.getByRole('menuitem', { name: 'Status' }).click();\n    // Select the status option\n    await page.getByRole('menuitem', { name: status }).click();\n}\n\n// Helper to remove status filter by clicking the X on the badge\nasync function removeStatusFilter(page: Page) {\n    const statusBadge = page.getByTestId('status-filter-badge');\n    // Click the remove button (second button in the badge, contains XMarkIcon)\n    await statusBadge.locator('button').last().click();\n}\n\ntest('test that archiving and unarchiving projects works', async ({ page, ctx }) => {\n    const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);\n    await createProjectViaApi(ctx, { name: newProjectName });\n\n    await goToProjectsOverview(page);\n    await clearProjectTableState(page);\n    await page.reload();\n    await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });\n\n    // Archive the project\n    await page.getByRole('row').first().getByRole('button').click();\n    await page.getByRole('menuitem').getByText('Archive').first().click();\n\n    // Project should still be visible since default is \"all\" (no filter)\n    await expect(page.getByText(newProjectName)).toBeVisible();\n\n    // Apply Active filter - archived project should disappear\n    await selectStatusFilter(page, 'Active');\n    await expect(page.getByText(newProjectName)).not.toBeVisible();\n\n    // Remove Active filter and apply Archived filter\n    await removeStatusFilter(page);\n    await selectStatusFilter(page, 'Archived');\n    await expect(page.getByText(newProjectName)).toBeVisible();\n\n    // Unarchive the project\n    await page.getByRole('row').first().getByRole('button').click();\n    await page.getByRole('menuitem').getByText('Unarchive').first().click();\n\n    // Project should disappear from Archived view\n    await expect(page.getByText(newProjectName)).not.toBeVisible();\n\n    // Remove Archived filter and apply Active filter to see the project\n    await removeStatusFilter(page);\n    await selectStatusFilter(page, 'Active');\n    await expect(page.getByText(newProjectName)).toBeVisible();\n});\n\ntest('test that updating billable rate works with existing time entries', async ({ page, ctx }) => {\n    const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);\n    const newBillableRate = Math.round(Math.random() * 10000);\n    await createProjectViaApi(ctx, { name: newProjectName });\n\n    await goToProjectsOverview(page);\n    await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });\n\n    await page.getByRole('row').first().getByRole('button').click();\n    await page.getByRole('menuitem').getByText('Edit').first().click();\n\n    // Set billable default to Billable\n    await page.getByRole('dialog').locator('#billable').click();\n    await page.getByRole('option', { name: 'Billable', exact: true }).click();\n\n    // Set billable rate to Custom Rate\n    await page.getByRole('dialog').locator('#billableRateType').click();\n    await page.getByRole('option', { name: 'Custom Rate' }).click();\n\n    await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());\n    await page.getByRole('button', { name: 'Update Project' }).click();\n\n    await Promise.all([\n        page.locator('button').filter({ hasText: 'Yes, update existing time' }).click(),\n        page.waitForRequest(\n            async (request) =>\n                request.url().includes('/projects/') &&\n                request.method() === 'PUT' &&\n                request.postDataJSON().billable_rate === newBillableRate * 100\n        ),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/projects/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200 &&\n                (await response.json()).data.billable_rate === newBillableRate * 100\n        ),\n    ]);\n    await expect(\n        page\n            .getByRole('row')\n            .first()\n            .getByText(formatCentsWithOrganizationDefaults(newBillableRate * 100))\n    ).toBeVisible();\n});\n\ntest('test that creating a project with default billable rate works', async ({ page }) => {\n    const newProjectName = 'Default Rate Project ' + Math.floor(1 + Math.random() * 10000);\n    await goToProjectsOverview(page);\n    await page.getByRole('button', { name: 'Create Project' }).click();\n    await page.getByLabel('Project Name').fill(newProjectName);\n\n    // Set billable default to Billable (leaves rate type as Default Rate)\n    await page.getByRole('dialog').locator('#billable').click();\n    await page.getByRole('option', { name: 'Billable', exact: true }).click();\n\n    // Verify rate type is \"Default Rate\" and the rate input is disabled\n    await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText(\n        'Default Rate'\n    );\n    await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled();\n\n    await Promise.all([\n        page.getByRole('button', { name: 'Create Project' }).click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/projects') &&\n                response.request().method() === 'POST' &&\n                response.status() === 201 &&\n                (await response.json()).data.is_billable === true &&\n                (await response.json()).data.billable_rate === null\n        ),\n    ]);\n\n    await expect(page.getByTestId('project_table')).toContainText(newProjectName);\n});\n\ntest('test that creating a non-billable project works', async ({ page }) => {\n    const newProjectName = 'Non-Billable Project ' + Math.floor(1 + Math.random() * 10000);\n    await goToProjectsOverview(page);\n    await page.getByRole('button', { name: 'Create Project' }).click();\n    await page.getByLabel('Project Name').fill(newProjectName);\n\n    // Billable default should already be \"Non-billable\" by default\n    await expect(page.getByRole('dialog').locator('#billable')).toContainText('Non-billable');\n\n    await Promise.all([\n        page.getByRole('button', { name: 'Create Project' }).click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/projects') &&\n                response.request().method() === 'POST' &&\n                response.status() === 201 &&\n                (await response.json()).data.is_billable === false &&\n                (await response.json()).data.billable_rate === null\n        ),\n    ]);\n\n    await expect(page.getByTestId('project_table')).toContainText(newProjectName);\n});\n\ntest('test that switching from custom rate to default rate clears billable rate', async ({\n    page,\n    ctx,\n}) => {\n    const newProjectName = 'Rate Switch Project ' + Math.floor(1 + Math.random() * 10000);\n    // Create a project with an existing custom billable rate\n    await createProjectViaApi(ctx, {\n        name: newProjectName,\n        is_billable: true,\n        billable_rate: 15000,\n    });\n\n    await goToProjectsOverview(page);\n    await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });\n\n    await page.getByRole('row').first().getByRole('button').click();\n    await page.getByRole('menuitem').getByText('Edit').first().click();\n\n    // Verify it loaded as Billable with Custom Rate\n    await expect(page.getByRole('dialog').locator('#billable')).toContainText('Billable');\n    await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText(\n        'Custom Rate'\n    );\n\n    // Switch to Default Rate\n    await page.getByRole('dialog').locator('#billableRateType').click();\n    await page.getByRole('option', { name: 'Default Rate' }).click();\n\n    // Rate input should now be disabled\n    await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled();\n\n    // Submit — billable_rate changes from 15000 to null, so confirmation dialog appears\n    await page.getByRole('button', { name: 'Update Project' }).click();\n    await Promise.all([\n        page.locator('button').filter({ hasText: 'Yes, update existing time' }).click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/projects/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200 &&\n                (await response.json()).data.is_billable === true &&\n                (await response.json()).data.billable_rate === null\n        ),\n    ]);\n});\n\ntest('test that switching from billable to non-billable preserves rate settings', async ({\n    page,\n    ctx,\n}) => {\n    const newProjectName = 'Billable Reset Project ' + Math.floor(1 + Math.random() * 10000);\n    // Create a project with a custom billable rate\n    await createProjectViaApi(ctx, {\n        name: newProjectName,\n        is_billable: true,\n        billable_rate: 20000,\n    });\n\n    await goToProjectsOverview(page);\n    await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });\n\n    await page.getByRole('row').first().getByRole('button').click();\n    await page.getByRole('menuitem').getByText('Edit').first().click();\n\n    // Verify it loaded correctly as Billable with Custom Rate\n    await expect(page.getByRole('dialog').locator('#billable')).toContainText('Billable');\n    await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText(\n        'Custom Rate'\n    );\n\n    // Switch to Non-billable\n    await page.getByRole('dialog').locator('#billable').click();\n    await page.getByRole('option', { name: 'Non-billable' }).click();\n\n    // Rate type should still be Custom Rate (not reset)\n    await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText(\n        'Custom Rate'\n    );\n\n    // Submit and verify project is non-billable but keeps its custom rate\n    await Promise.all([\n        page.getByRole('button', { name: 'Update Project' }).click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/projects/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200 &&\n                (await response.json()).data.is_billable === false &&\n                (await response.json()).data.billable_rate === 20000\n        ),\n    ]);\n});\n\ntest('test that editing an existing billable project with default rate loads correctly', async ({\n    page,\n    ctx,\n}) => {\n    const newProjectName = 'Default Rate Edit Project ' + Math.floor(1 + Math.random() * 10000);\n    // Create a project that is billable but has no custom rate (= default rate)\n    await createProjectViaApi(ctx, {\n        name: newProjectName,\n        is_billable: true,\n        billable_rate: null,\n    });\n\n    await goToProjectsOverview(page);\n    await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });\n\n    await page.getByRole('row').first().getByRole('button').click();\n    await page.getByRole('menuitem').getByText('Edit').first().click();\n\n    // Verify it loaded as Billable with Default Rate\n    await expect(page.getByRole('dialog').locator('#billable')).toContainText('Billable');\n    await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText(\n        'Default Rate'\n    );\n    await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled();\n});\n\n// Sorting tests\ntest('test that sorting projects by all columns works', async ({ page, ctx }) => {\n    // Seed projects with distinct values for each sortable column\n    const clientAlpha = await createClientViaApi(ctx, { name: 'Alpha Client' });\n    const clientBeta = await createClientViaApi(ctx, { name: 'Beta Client' });\n\n    // Project A: client Alpha, low billable rate, has estimated time, active\n    const projectA = await createProjectViaApi(ctx, {\n        name: 'AAA Project',\n        client_id: clientAlpha.id,\n        is_billable: true,\n        billable_rate: 5000,\n        estimated_time: 36000, // 10h\n    });\n    // Add 1h of time entries (10% progress)\n    await createTimeEntryViaApi(ctx, {\n        duration: '1h',\n        projectId: projectA.id,\n    });\n\n    // Project B: client Beta, high billable rate, has estimated time, archived\n    const projectB = await createProjectViaApi(ctx, {\n        name: 'BBB Project',\n        client_id: clientBeta.id,\n        is_billable: true,\n        billable_rate: 15000,\n        estimated_time: 7200, // 2h\n    });\n    // Add 1h of time entries (50% progress)\n    await createTimeEntryViaApi(ctx, {\n        duration: '1h',\n        projectId: projectB.id,\n    });\n    await archiveProjectViaApi(ctx, {\n        ...projectB,\n        client_id: clientBeta.id,\n        billable_rate: 15000,\n        estimated_time: 7200,\n    });\n\n    // Project C: no client, medium billable rate, no estimated time, active\n    const projectC = await createProjectViaApi(ctx, {\n        name: 'CCC Project',\n        is_billable: true,\n        billable_rate: 10000,\n    });\n    // Add 3h of time entries\n    await createTimeEntryViaApi(ctx, {\n        duration: '3h',\n        projectId: projectC.id,\n    });\n\n    await goToProjectsOverview(page);\n    await clearProjectTableState(page);\n    await page.reload();\n    await expect(page.getByTestId('project_table')).toBeVisible();\n    await expect(page.getByText('AAA Project')).toBeVisible();\n    await expect(page.getByText('BBB Project')).toBeVisible();\n    await expect(page.getByText('CCC Project')).toBeVisible();\n\n    // Helper to get the visual order of our seeded projects by reading\n    // all row text in a single evaluate call (avoids locator timing issues)\n    const seededNames = ['AAA Project', 'BBB Project', 'CCC Project'];\n    const getOrder = async (): Promise<string[]> => {\n        const allRowTexts = await page.evaluate(() => {\n            const table = document.querySelector('[data-testid=\"project_table\"]');\n            if (!table) return [];\n            const rows = table.querySelectorAll('[role=\"row\"]');\n            return Array.from(rows).map((row) => row.textContent ?? '');\n        });\n        const order: string[] = [];\n        for (const text of allRowTexts) {\n            const match = seededNames.find((name) => text.includes(name));\n            if (match) order.push(match);\n        }\n        return order;\n    };\n\n    // Helper: click a column header and wait for sort to apply.\n    // expectedFirstAmongSeeded = which of our 3 seeded projects should appear first\n    const clickSortHeader = async (headerText: string, expectedFirstAmongSeeded: string) => {\n        const header = page\n            .locator('[data-testid=\"project_table\"] .select-none', {\n                hasText: headerText,\n            })\n            .first();\n        await header.click();\n        // Wait until the expected project appears before the others among our seeded set\n        await page.waitForFunction(\n            ({ expected, names }) => {\n                const table = document.querySelector('[data-testid=\"project_table\"]');\n                if (!table) return false;\n                const rows = table.querySelectorAll('[role=\"row\"]');\n                let firstSeededIdx = -1;\n                for (let i = 0; i < rows.length; i++) {\n                    const text = rows[i].textContent ?? '';\n                    if (names.some((n: string) => text.includes(n))) {\n                        firstSeededIdx = i;\n                        break;\n                    }\n                }\n                if (firstSeededIdx === -1) return false;\n                return (rows[firstSeededIdx].textContent ?? '').includes(expected);\n            },\n            { expected: expectedFirstAmongSeeded, names: seededNames },\n            { timeout: 5000 }\n        );\n    };\n\n    // --- Sort by Name ---\n    // Default is name asc (A-Z)\n    let order = await getOrder();\n    expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']);\n\n    // Click to toggle to Z-A\n    await clickSortHeader('Name', 'CCC Project');\n    order = await getOrder();\n    expect(order).toEqual(['CCC Project', 'BBB Project', 'AAA Project']);\n\n    // --- Sort by Client (text: first click = A-Z, no-client last) ---\n    await clickSortHeader('Client', 'AAA Project');\n    order = await getOrder();\n    expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']); // Alpha, Beta, No client\n\n    // Reverse: Z-A, no-client still last\n    await clickSortHeader('Client', 'BBB Project');\n    order = await getOrder();\n    expect(order).toEqual(['BBB Project', 'AAA Project', 'CCC Project']); // Beta, Alpha, No client\n\n    // --- Sort by Total Time (numeric: first click = highest first) ---\n    await clickSortHeader('Total Time', 'CCC Project');\n    order = await getOrder();\n    expect(order[0]).toBe('CCC Project'); // C=3h first, A and B tied at 1h\n\n    // Reverse: lowest first\n    await clickSortHeader('Total Time', 'AAA Project');\n    order = await getOrder();\n    expect(order[2]).toBe('CCC Project'); // C=3h last\n\n    // --- Sort by Billable Rate (numeric: first click = highest first) ---\n    await clickSortHeader('Billable Rate', 'BBB Project');\n    order = await getOrder();\n    expect(order).toEqual(['BBB Project', 'CCC Project', 'AAA Project']); // 15000, 10000, 5000\n\n    // Reverse: lowest first\n    await clickSortHeader('Billable Rate', 'AAA Project');\n    order = await getOrder();\n    expect(order).toEqual(['AAA Project', 'CCC Project', 'BBB Project']); // 5000, 10000, 15000\n\n    // --- Sort by Progress (numeric: first click = highest first, no-estimate last) ---\n    await clickSortHeader('Progress', 'BBB Project');\n    order = await getOrder();\n    expect(order).toEqual(['BBB Project', 'AAA Project', 'CCC Project']); // 50%, 10%, no estimate\n\n    // Reverse: lowest first, no-estimate still last\n    await clickSortHeader('Progress', 'AAA Project');\n    order = await getOrder();\n    expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']); // 10%, 50%, no estimate\n\n    // --- Sort by Status (first click = active first, archived last) ---\n    await expect(async () => {\n        await clickSortHeader('Status', 'AAA Project');\n        order = await getOrder();\n        expect(order.indexOf('BBB Project')).toBeGreaterThan(order.indexOf('AAA Project'));\n        expect(order.indexOf('BBB Project')).toBeGreaterThan(order.indexOf('CCC Project'));\n    }).toPass({ timeout: 5000 });\n\n    // Reverse: archived first\n    await expect(async () => {\n        await clickSortHeader('Status', 'BBB Project');\n        order = await getOrder();\n        expect(order.indexOf('BBB Project')).toBeLessThan(order.indexOf('AAA Project'));\n        expect(order.indexOf('BBB Project')).toBeLessThan(order.indexOf('CCC Project'));\n    }).toPass({ timeout: 5000 });\n});\n\n// Filter tests\ntest('test that filtering projects by status works', async ({ page, ctx }) => {\n    const newProjectName = 'Filter Test Project ' + Math.floor(1 + Math.random() * 10000);\n    await createProjectViaApi(ctx, { name: newProjectName });\n\n    await goToProjectsOverview(page);\n    await clearProjectTableState(page);\n    await page.reload();\n    await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });\n\n    // Archive the project\n    await page.getByRole('row').first().getByRole('button').click();\n    await page.getByRole('menuitem').getByText('Archive').first().click();\n\n    // Project should still be visible (default is \"all\" - no filter)\n    await expect(page.getByText(newProjectName)).toBeVisible();\n\n    // Apply Active filter - archived project should disappear\n    await selectStatusFilter(page, 'Active');\n    await expect(page.getByText(newProjectName)).not.toBeVisible();\n\n    // Remove Active filter - project should reappear (back to \"all\")\n    await removeStatusFilter(page);\n    await expect(page.getByText(newProjectName)).toBeVisible();\n\n    // Apply Archived filter - project should still be visible\n    await selectStatusFilter(page, 'Archived');\n    await expect(page.getByText(newProjectName)).toBeVisible();\n\n    // Remove Archived filter and apply Active filter - project should not be visible\n    await removeStatusFilter(page);\n    await selectStatusFilter(page, 'Active');\n    await expect(page.getByText(newProjectName)).not.toBeVisible();\n});\n\ntest('test that filter state persists after page reload', async ({ page }) => {\n    await goToProjectsOverview(page);\n    await clearProjectTableState(page);\n    await page.reload();\n\n    // Apply Active status filter\n    await selectStatusFilter(page, 'Active');\n\n    // Verify the filter badge is visible\n    await expect(page.getByTestId('status-filter-badge')).toBeVisible();\n\n    // Reload the page\n    await page.reload();\n\n    // Verify the filter badge is still visible after reload\n    await expect(page.getByTestId('status-filter-badge')).toBeVisible();\n});\n\ntest('test that sort state persists after page reload', async ({ page }) => {\n    await goToProjectsOverview(page);\n    await clearProjectTableState(page);\n    await page.reload();\n\n    // Click on Name header twice to sort descending\n    const nameHeader = page.getByText('Name').first();\n    await nameHeader.click();\n    await expect(nameHeader.locator('svg')).toBeVisible();\n    await nameHeader.click();\n\n    // Reload the page\n    await page.reload();\n\n    // Verify descending sort indicator is visible on Name column\n    await expect(page.getByTestId('project_table')).toBeVisible();\n});\n\ntest('test that custom billable rate is displayed correctly on project detail page', async ({\n    page,\n    ctx,\n}) => {\n    const newProjectName = 'Billable Rate Project ' + Math.floor(1 + Math.random() * 10000);\n    const newBillableRate = Math.round(10 + Math.random() * 1000);\n    await createProjectViaApi(ctx, { name: newProjectName });\n\n    await goToProjectsOverview(page);\n    await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });\n\n    // Edit the project to set a custom billable rate\n    await page.getByRole('row').first().getByRole('button').click();\n    await page.getByRole('menuitem').getByText('Edit').first().click();\n\n    // Set billable default to Billable\n    await page.getByRole('dialog').locator('#billable').click();\n    await page.getByRole('option', { name: 'Billable', exact: true }).click();\n\n    // Set billable rate to Custom Rate\n    await page.getByRole('dialog').locator('#billableRateType').click();\n    await page.getByRole('option', { name: 'Custom Rate' }).click();\n\n    await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());\n    await page.getByRole('button', { name: 'Update Project' }).click();\n\n    await Promise.all([\n        page.locator('button').filter({ hasText: 'Yes, update existing time' }).click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/projects/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n    ]);\n\n    // Navigate to the project detail page by clicking the project name\n    await page.getByText(newProjectName).first().click();\n    await page.waitForURL(/\\/projects\\/[a-f0-9-]+/);\n\n    // Verify the badge displays the correctly formatted billable rate\n    const expectedFormattedRate = formatCentsWithOrganizationDefaults(newBillableRate * 100);\n    await expect(page.locator('nav[aria-label=\"Breadcrumb\"]').locator('..')).toContainText(\n        expectedFormattedRate\n    );\n});\n\n// Tests for estimated time input (Issue #460)\ntest('test that creating a project with estimated time in human-readable format works', async ({\n    page,\n}) => {\n    const newProjectName = 'Estimated Time Project ' + Math.floor(1 + Math.random() * 10000);\n    await goToProjectsOverview(page);\n    await page.getByRole('button', { name: 'Create Project' }).click();\n    await page.getByLabel('Project Name').fill(newProjectName);\n\n    // Fill in estimated time using human-readable format\n    const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');\n    await estimatedTimeInput.fill('2h 30m');\n    await estimatedTimeInput.press('Tab');\n\n    await Promise.all([\n        page.getByRole('button', { name: 'Create Project' }).click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/projects') &&\n                response.request().method() === 'POST' &&\n                response.status() === 201 &&\n                // 2h 30m = 9000 seconds\n                (await response.json()).data.estimated_time === 9000\n        ),\n    ]);\n\n    await expect(page.getByTestId('project_table')).toContainText(newProjectName);\n});\n\ntest('test that creating a project with estimated time using decimal notation works', async ({\n    page,\n}) => {\n    const newProjectName = 'Decimal Estimated Project ' + Math.floor(1 + Math.random() * 10000);\n    await goToProjectsOverview(page);\n    await page.getByRole('button', { name: 'Create Project' }).click();\n    await page.getByLabel('Project Name').fill(newProjectName);\n\n    // Fill in estimated time using decimal notation (1.5 hours = 1h 30m)\n    const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');\n    await estimatedTimeInput.fill('1.5');\n    await estimatedTimeInput.press('Tab');\n\n    await Promise.all([\n        page.getByRole('button', { name: 'Create Project' }).click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/projects') &&\n                response.request().method() === 'POST' &&\n                response.status() === 201 &&\n                // 1.5 hours = 5400 seconds\n                (await response.json()).data.estimated_time === 5400\n        ),\n    ]);\n\n    await expect(page.getByTestId('project_table')).toContainText(newProjectName);\n});\n\ntest('test that creating a project with estimated time using comma decimal notation works', async ({\n    page,\n}) => {\n    const newProjectName = 'Comma Decimal Project ' + Math.floor(1 + Math.random() * 10000);\n    await goToProjectsOverview(page);\n    await page.getByRole('button', { name: 'Create Project' }).click();\n    await page.getByLabel('Project Name').fill(newProjectName);\n\n    // Fill in estimated time using comma decimal notation (2,5 hours = 2h 30m)\n    const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');\n    await estimatedTimeInput.fill('2,5');\n    await estimatedTimeInput.press('Tab');\n\n    await Promise.all([\n        page.getByRole('button', { name: 'Create Project' }).click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/projects') &&\n                response.request().method() === 'POST' &&\n                response.status() === 201 &&\n                // 2.5 hours = 9000 seconds\n                (await response.json()).data.estimated_time === 9000\n        ),\n    ]);\n\n    await expect(page.getByTestId('project_table')).toContainText(newProjectName);\n});\n\ntest('test that updating estimated time on existing project works', async ({ page, ctx }) => {\n    const newProjectName = 'Update Estimated Project ' + Math.floor(1 + Math.random() * 10000);\n    await createProjectViaApi(ctx, { name: newProjectName });\n\n    await goToProjectsOverview(page);\n    await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });\n\n    // Edit the project to add estimated time\n    await page.getByRole('row').first().getByRole('button').click();\n    await page.getByRole('menuitem').getByText('Edit').first().click();\n\n    // Fill in estimated time\n    const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');\n    await estimatedTimeInput.fill('4h 15m');\n    await estimatedTimeInput.press('Tab');\n\n    await Promise.all([\n        page.getByRole('button', { name: 'Update Project' }).click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/projects/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200 &&\n                // 4h 15m = 15300 seconds\n                (await response.json()).data.estimated_time === 15300\n        ),\n    ]);\n});\n\ntest('test that estimated time input displays formatted value after blur', async ({ page }) => {\n    await goToProjectsOverview(page);\n    await page.getByRole('button', { name: 'Create Project' }).click();\n\n    const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');\n\n    // Enter time in various formats and check the displayed value\n    await estimatedTimeInput.fill('90');\n    await estimatedTimeInput.press('Tab');\n    // 90 hours should be displayed as \"90h 00min\" (default format)\n    await expect(estimatedTimeInput).toHaveValue(/90h/);\n\n    await estimatedTimeInput.fill('1:30');\n    await estimatedTimeInput.press('Tab');\n    // 1:30 should be displayed as \"1h 30min\"\n    await expect(estimatedTimeInput).toHaveValue(/1h.*30/);\n});\n\ntest('test that editing a task name on the project detail page works', async ({ page, ctx }) => {\n    const projectName = 'Task Edit Project ' + Math.floor(1 + Math.random() * 10000);\n    const originalTaskName = 'Original Task ' + Math.floor(1 + Math.random() * 10000);\n    const updatedTaskName = 'Updated Task ' + Math.floor(1 + Math.random() * 10000);\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createTaskViaApi(ctx, { name: originalTaskName, project_id: project.id });\n\n    // Navigate to the project detail page\n    await goToProjectsOverview(page);\n    await expect(page.getByText(projectName)).toBeVisible({ timeout: 10000 });\n    await page.getByText(projectName).first().click();\n    await page.waitForURL(/\\/projects\\/[a-f0-9-]+/);\n\n    // Verify task is visible\n    await expect(page.getByTestId('task_table')).toContainText(originalTaskName);\n\n    // Open edit modal via actions menu\n    const moreButton = page.locator(\"[aria-label='Actions for Task \" + originalTaskName + \"']\");\n    await moreButton.click();\n    await page.getByTestId('task_edit').click();\n\n    // Update the task name\n    await page.locator('#taskName').fill(updatedTaskName);\n    await Promise.all([\n        page.getByRole('button', { name: 'Update Task' }).click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/tasks') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n    ]);\n\n    // Verify updated name is shown and old name is gone\n    await expect(page.getByTestId('task_table')).toContainText(updatedTaskName);\n    await expect(page.getByTestId('task_table')).not.toContainText(originalTaskName);\n});\n\n// =============================================\n// Employee Permission Tests\n// =============================================\n\ntest.describe('Employee Projects Restrictions', () => {\n    test('employee can view public projects but cannot create', async ({ ctx, employee }) => {\n        const projectName = 'EmpViewProj ' + Math.floor(Math.random() * 10000);\n        await createPublicProjectViaApi(ctx, { name: projectName });\n\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');\n        await expect(employee.page.getByTestId('projects_view')).toBeVisible({\n            timeout: 10000,\n        });\n\n        // Employee can see the public project\n        await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });\n\n        // Employee cannot see Create Project button\n        await expect(\n            employee.page.getByRole('button', { name: 'Create Project' })\n        ).not.toBeVisible();\n    });\n\n    test('employee cannot see edit/delete/archive actions on projects', async ({\n        ctx,\n        employee,\n    }) => {\n        const projectName = 'EmpActionsProj ' + Math.floor(Math.random() * 10000);\n        await createPublicProjectViaApi(ctx, { name: projectName });\n\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');\n        await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });\n\n        // Click the actions dropdown trigger to open the menu\n        const actionsButton = employee.page.locator(\n            `[aria-label='Actions for Project ${projectName}']`\n        );\n        await actionsButton.click();\n\n        // The dropdown menu items (Edit, Archive, Delete) should NOT be visible\n        await expect(\n            employee.page.locator(`[aria-label='Edit Project ${projectName}']`)\n        ).not.toBeVisible();\n        await expect(\n            employee.page.locator(`[aria-label='Archive Project ${projectName}']`)\n        ).not.toBeVisible();\n        await expect(\n            employee.page.locator(`[aria-label='Delete Project ${projectName}']`)\n        ).not.toBeVisible();\n    });\n});\n\ntest.describe('Employee Billable Rate Visibility', () => {\n    test('employee cannot see billable rate column by default', async ({ ctx, employee }) => {\n        const projectName = 'EmpBillableProj ' + Math.floor(Math.random() * 10000);\n        await createPublicProjectViaApi(ctx, {\n            name: projectName,\n            is_billable: true,\n            billable_rate: 15000,\n        });\n\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');\n        await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });\n\n        // Billable Rate column should not be visible to employee by default\n        await expect(employee.page.getByText('Billable Rate')).not.toBeVisible();\n    });\n\n    test('employee can see billable rate column when employees_can_see_billable_rates is enabled', async ({\n        ctx,\n        employee,\n    }) => {\n        await updateOrganizationSettingViaApi(ctx, { employees_can_see_billable_rates: true });\n\n        const projectName = 'EmpBillableVisProj ' + Math.floor(Math.random() * 10000);\n        await createPublicProjectViaApi(ctx, {\n            name: projectName,\n            is_billable: true,\n            billable_rate: 20000,\n        });\n\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');\n        await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });\n\n        // Billable Rate column header should be visible\n        await expect(employee.page.getByText('Billable Rate')).toBeVisible();\n\n        // The project row should show the formatted billable rate\n        const projectRow = employee.page.getByRole('row').filter({ hasText: projectName });\n        await expect(projectRow).toContainText('200');\n    });\n});\n"
  },
  {
    "path": "e2e/reporting-detailed.spec.ts",
    "content": "import { expect } from '@playwright/test';\nimport { test } from '../playwright/fixtures';\nimport { goToReportingDetailed, waitForDetailedReportingUpdate } from './utils/reporting';\nimport {\n    createProjectViaApi,\n    createClientViaApi,\n    createTaskViaApi,\n    createTimeEntryViaApi,\n    createTimeEntryWithTagViaApi,\n    createBareTimeEntryViaApi,\n} from './utils/api';\n\n// Each test registers a new user and creates test data via API\ntest.describe.configure({ timeout: 30000 });\n\n// ──────────────────────────────────────────────────\n// Basic Detailed View Tests\n// ──────────────────────────────────────────────────\n\ntest('test that detailed view shows time entries correctly', async ({ page, ctx }) => {\n    const projectName = 'Detailed View Project ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration: '1h',\n        projectId: project.id,\n    });\n\n    // Go to detailed reporting view\n    await goToReportingDetailed(page);\n\n    // Verify the time entry is shown with all details\n    await expect(page.getByText(projectName, { exact: true }).first()).toBeVisible();\n    await expect(page.locator('input[name=\"Duration\"]').first()).toHaveValue('1h 00min');\n    await expect(page.getByText('Entry for ' + projectName, { exact: true }).first()).toBeVisible();\n});\n\ntest('test that updating duration in detailed view works correctly', async ({ page, ctx }) => {\n    const projectName = 'Duration Update Project ' + Math.floor(Math.random() * 10000);\n    const initialDuration = '1h';\n    const updatedDuration = '2h 30min';\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration: initialDuration,\n        projectId: project.id,\n    });\n\n    // Go to detailed reporting view\n    await goToReportingDetailed(page);\n\n    // Find and update the duration\n    const durationInput = page.locator('input[name=\"Duration\"]').first();\n    await durationInput.click();\n    await durationInput.fill(updatedDuration);\n    await Promise.all([\n        durationInput.press('Enter'),\n        page.waitForResponse(\n            (response) => response.url().includes('/time-entries') && response.status() === 200\n        ),\n    ]);\n\n    // Verify the new duration is displayed\n    await expect(durationInput).toHaveValue(updatedDuration);\n});\n\n// ──────────────────────────────────────────────────\n// Project Filter Tests\n// ──────────────────────────────────────────────────\n\ntest('test that project multiselect filters work on detailed reporting page', async ({\n    page,\n    ctx,\n}) => {\n    const project1 = 'DetailProj1 ' + Math.floor(Math.random() * 10000);\n    const project2 = 'DetailProj2 ' + Math.floor(Math.random() * 10000);\n\n    const p1 = await createProjectViaApi(ctx, { name: project1 });\n    const p2 = await createProjectViaApi(ctx, { name: project2 });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${project1}`,\n        duration: '1h',\n        projectId: p1.id,\n    });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${project2}`,\n        duration: '2h',\n        projectId: p2.id,\n    });\n\n    await goToReportingDetailed(page);\n\n    // Wait for initial data load\n    await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();\n    await expect(page.getByText(`Entry for ${project2}`).first()).toBeVisible();\n\n    // Open project multiselect and select project1\n    await page.getByRole('button', { name: 'Projects' }).first().click();\n    await page.getByRole('option').filter({ hasText: project1 }).click();\n\n    await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);\n\n    // Verify only project1 entry is shown\n    await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();\n    await expect(page.getByText(`Entry for ${project2}`).first()).not.toBeVisible();\n});\n\n// ──────────────────────────────────────────────────\n// Client Filter Tests\n// ──────────────────────────────────────────────────\n\ntest('test that client multiselect filters work on detailed reporting page', async ({\n    page,\n    ctx,\n}) => {\n    const client1 = 'DetailClient1 ' + Math.floor(Math.random() * 10000);\n    const project1 = 'DetailClientProj1 ' + Math.floor(Math.random() * 10000);\n    const project2 = 'DetailClientProj2 ' + Math.floor(Math.random() * 10000);\n\n    const c1 = await createClientViaApi(ctx, { name: client1 });\n    const p1 = await createProjectViaApi(ctx, { name: project1, client_id: c1.id });\n    const p2 = await createProjectViaApi(ctx, { name: project2 });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${project1}`,\n        duration: '1h',\n        projectId: p1.id,\n    });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${project2}`,\n        duration: '2h',\n        projectId: p2.id,\n    });\n\n    await goToReportingDetailed(page);\n\n    await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();\n    await expect(page.getByText(`Entry for ${project2}`).first()).toBeVisible();\n\n    // Filter by client1\n    await page.getByRole('button', { name: 'Clients' }).first().click();\n    await page.getByRole('option').filter({ hasText: client1 }).click();\n\n    await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);\n\n    // Only entries for project1 (with client1) should be visible\n    await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();\n    await expect(page.getByText(`Entry for ${project2}`).first()).not.toBeVisible();\n});\n\n// ──────────────────────────────────────────────────\n// Task Filter Tests\n// ──────────────────────────────────────────────────\n\ntest('test that task multiselect dropdown filters reporting by task', async ({ page, ctx }) => {\n    const projectName = 'TaskFilterProj ' + Math.floor(Math.random() * 10000);\n    const task1 = 'TaskFilter1 ' + Math.floor(Math.random() * 10000);\n    const task2 = 'TaskFilter2 ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    const t1 = await createTaskViaApi(ctx, { name: task1, project_id: project.id });\n    const t2 = await createTaskViaApi(ctx, { name: task2, project_id: project.id });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName} - ${task1}`,\n        duration: '1h',\n        projectId: project.id,\n        taskId: t1.id,\n    });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName} - ${task2}`,\n        duration: '2h',\n        projectId: project.id,\n        taskId: t2.id,\n    });\n\n    // Use the detailed view to verify task filtering (shows individual entries)\n    await goToReportingDetailed(page);\n\n    await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible();\n    await expect(page.getByText(`Entry for ${projectName} - ${task2}`).first()).toBeVisible();\n\n    // Open task multiselect dropdown\n    await page.getByRole('button', { name: 'Tasks' }).first().click();\n\n    // Verify both tasks appear\n    await expect(page.getByRole('option').filter({ hasText: task1 })).toBeVisible();\n    await expect(page.getByRole('option').filter({ hasText: task2 })).toBeVisible();\n\n    // Select task1\n    await page.getByRole('option').filter({ hasText: task1 }).click();\n\n    await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);\n\n    // Verify badge shows count of 1\n    await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('1')).toBeVisible();\n\n    // Verify only task1 entry is shown\n    await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible();\n    await expect(page.getByText(`Entry for ${projectName} - ${task2}`).first()).not.toBeVisible();\n});\n\ntest('test that selecting multiple tasks shows correct badge count', async ({ page, ctx }) => {\n    const projectName = 'MultiTaskProj ' + Math.floor(Math.random() * 10000);\n    const task1 = 'MultiTask1 ' + Math.floor(Math.random() * 10000);\n    const task2 = 'MultiTask2 ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    const t1 = await createTaskViaApi(ctx, { name: task1, project_id: project.id });\n    const t2 = await createTaskViaApi(ctx, { name: task2, project_id: project.id });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName} - ${task1}`,\n        duration: '1h',\n        projectId: project.id,\n        taskId: t1.id,\n    });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName} - ${task2}`,\n        duration: '2h',\n        projectId: project.id,\n        taskId: t2.id,\n    });\n\n    // Use the detailed view to verify task filtering\n    await goToReportingDetailed(page);\n\n    await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible();\n    await expect(page.getByText(`Entry for ${projectName} - ${task2}`).first()).toBeVisible();\n\n    // Select both tasks\n    await page.getByRole('button', { name: 'Tasks' }).first().click();\n    await page.getByRole('option').filter({ hasText: task1 }).click();\n    await page.getByRole('option').filter({ hasText: task2 }).click();\n\n    await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);\n\n    // Verify badge shows count of 2\n    await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('2')).toBeVisible();\n\n    // Verify both task entries are shown\n    await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible();\n    await expect(page.getByText(`Entry for ${projectName} - ${task2}`).first()).toBeVisible();\n});\n\ntest('test that deselecting a task removes the filter', async ({ page, ctx }) => {\n    const projectName = 'TaskDeselectProj ' + Math.floor(Math.random() * 10000);\n    const task1 = 'TaskDeselect1 ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    const t1 = await createTaskViaApi(ctx, { name: task1, project_id: project.id });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName} - ${task1}`,\n        duration: '1h',\n        projectId: project.id,\n        taskId: t1.id,\n    });\n\n    await goToReportingDetailed(page);\n    await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible();\n\n    // Select task\n    await page.getByRole('button', { name: 'Tasks' }).first().click();\n    await page.getByRole('option').filter({ hasText: task1 }).click();\n    await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);\n\n    await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('1')).toBeVisible();\n\n    // Deselect task\n    await page.getByRole('button', { name: 'Tasks' }).first().click();\n    await page.getByRole('option').filter({ hasText: task1 }).click();\n    await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);\n\n    await expect(\n        page.getByRole('button', { name: 'Tasks' }).first().getByText(/^\\d+$/)\n    ).not.toBeVisible();\n});\n\n// ──────────────────────────────────────────────────\n// Member Filter Tests\n// ──────────────────────────────────────────────────\n\ntest('test that member multiselect filters work on detailed reporting page', async ({\n    page,\n    ctx,\n}) => {\n    const projectName = 'DetailMemberProj ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration: '1h',\n        projectId: project.id,\n    });\n\n    await goToReportingDetailed(page);\n    await expect(page.getByText(`Entry for ${projectName}`).first()).toBeVisible();\n\n    // Filter by the current member\n    await page.getByRole('button', { name: 'Members' }).first().click();\n    await page.getByRole('option').filter({ hasText: 'John Doe' }).click();\n\n    await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);\n\n    // Data should still be visible since all entries belong to this member\n    await expect(page.getByText(`Entry for ${projectName}`).first()).toBeVisible();\n\n    // Verify badge shows count of 1\n    await expect(\n        page.getByRole('button', { name: 'Members' }).first().getByText('1')\n    ).toBeVisible();\n});\n\n// ──────────────────────────────────────────────────\n// Tag Filter Tests\n// ──────────────────────────────────────────────────\n\ntest('test that tag filter works on detailed reporting page', async ({ page, ctx }) => {\n    const tag1 = 'DetailTag1 ' + Math.floor(Math.random() * 10000);\n    const tag2 = 'DetailTag2 ' + Math.floor(Math.random() * 10000);\n\n    await createTimeEntryWithTagViaApi(ctx, tag1, '1h');\n    await createTimeEntryWithTagViaApi(ctx, tag2, '2h');\n\n    await goToReportingDetailed(page);\n\n    await expect(page.getByText(`Entry with tag ${tag1}`).first()).toBeVisible();\n    await expect(page.getByText(`Entry with tag ${tag2}`).first()).toBeVisible();\n\n    // Filter by tag1\n    await page.getByRole('button', { name: 'Tags' }).click();\n    await page.getByRole('option').filter({ hasText: tag1 }).click();\n\n    await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);\n\n    await expect(page.getByText(`Entry with tag ${tag1}`).first()).toBeVisible();\n    await expect(page.getByText(`Entry with tag ${tag2}`).first()).not.toBeVisible();\n});\n\n// ──────────────────────────────────────────────────\n// Billable Filter Tests\n// ──────────────────────────────────────────────────\n\ntest('test that billable filter works on detailed reporting page', async ({ page, ctx }) => {\n    const projectName = 'DetailBillProj ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration: '1h',\n        projectId: project.id,\n    });\n\n    await goToReportingDetailed(page);\n    await expect(page.getByText(`Entry for ${projectName}`).first()).toBeVisible();\n\n    // Filter by billable only\n    await page.getByRole('combobox').filter({ hasText: 'Billable' }).click();\n    await Promise.all([\n        page.getByRole('option', { name: 'Billable', exact: true }).click(),\n        waitForDetailedReportingUpdate(page),\n    ]);\n\n    // Switch to Non Billable\n    await page.getByRole('combobox').filter({ hasText: 'Billable' }).click();\n    await Promise.all([\n        page.getByRole('option', { name: 'Non Billable', exact: true }).click(),\n        waitForDetailedReportingUpdate(page),\n    ]);\n\n    // Switch back to Both\n    await page.getByRole('combobox').filter({ hasText: 'Non Billable' }).click();\n    await Promise.all([\n        page.getByRole('option', { name: 'Both' }).click(),\n        waitForDetailedReportingUpdate(page),\n    ]);\n});\n\n// ──────────────────────────────────────────────────\n// Combined Filter Tests\n// ──────────────────────────────────────────────────\n\ntest('test that combining project and task filters narrows results', async ({ page, ctx }) => {\n    const projectName = 'CombinedProj ' + Math.floor(Math.random() * 10000);\n    const otherProject = 'OtherCombProj ' + Math.floor(Math.random() * 10000);\n    const task1 = 'CombinedTask1 ' + Math.floor(Math.random() * 10000);\n\n    const p1 = await createProjectViaApi(ctx, { name: projectName });\n    const p2 = await createProjectViaApi(ctx, { name: otherProject });\n    const t1 = await createTaskViaApi(ctx, { name: task1, project_id: p1.id });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName} - ${task1}`,\n        duration: '1h',\n        projectId: p1.id,\n        taskId: t1.id,\n    });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${otherProject}`,\n        duration: '2h',\n        projectId: p2.id,\n    });\n\n    await goToReportingDetailed(page);\n\n    await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible();\n    await expect(page.getByText(`Entry for ${otherProject}`).first()).toBeVisible();\n\n    // Filter by project\n    await page.getByRole('button', { name: 'Projects' }).first().click();\n    await page.getByRole('option').filter({ hasText: projectName }).click();\n    await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);\n\n    // Additionally filter by task\n    await page.getByRole('button', { name: 'Tasks' }).first().click();\n    await page.getByRole('option').filter({ hasText: task1 }).click();\n    await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);\n\n    // Verify both badges show count of 1\n    await expect(\n        page.getByRole('button', { name: 'Projects' }).first().getByText('1')\n    ).toBeVisible();\n    await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('1')).toBeVisible();\n\n    // Verify only the combined entry is shown\n    await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible();\n    await expect(page.getByText(`Entry for ${otherProject}`).first()).not.toBeVisible();\n});\n\ntest('test that combining client and member filters narrows results on detailed page', async ({\n    page,\n    ctx,\n}) => {\n    const client1 = 'CombClient ' + Math.floor(Math.random() * 10000);\n    const project1 = 'CombClientProj ' + Math.floor(Math.random() * 10000);\n    const project2 = 'CombNoClientProj ' + Math.floor(Math.random() * 10000);\n\n    const c1 = await createClientViaApi(ctx, { name: client1 });\n    const p1 = await createProjectViaApi(ctx, { name: project1, client_id: c1.id });\n    const p2 = await createProjectViaApi(ctx, { name: project2 });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${project1}`,\n        duration: '1h',\n        projectId: p1.id,\n    });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${project2}`,\n        duration: '2h',\n        projectId: p2.id,\n    });\n\n    await goToReportingDetailed(page);\n\n    await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();\n    await expect(page.getByText(`Entry for ${project2}`).first()).toBeVisible();\n\n    // Filter by client\n    await page.getByRole('button', { name: 'Clients' }).first().click();\n    await page.getByRole('option').filter({ hasText: client1 }).click();\n    await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);\n\n    // Additionally filter by member\n    await page.getByRole('button', { name: 'Members' }).first().click();\n    await page.getByRole('option').filter({ hasText: 'John Doe' }).click();\n    await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);\n\n    // Only project1 entry should be visible (filtered by client + member)\n    await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();\n    await expect(page.getByText(`Entry for ${project2}`).first()).not.toBeVisible();\n\n    // Both badges should show count of 1\n    await expect(\n        page.getByRole('button', { name: 'Clients' }).first().getByText('1')\n    ).toBeVisible();\n    await expect(\n        page.getByRole('button', { name: 'Members' }).first().getByText('1')\n    ).toBeVisible();\n});\n\ntest('test that combining tag and project filters narrows results', async ({ page, ctx }) => {\n    const tag1 = 'CombTag ' + Math.floor(Math.random() * 10000);\n    const project1 = 'CombTagProj ' + Math.floor(Math.random() * 10000);\n\n    const p1 = await createProjectViaApi(ctx, { name: project1 });\n\n    // Create a time entry with a project (no tag)\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${project1}`,\n        duration: '1h',\n        projectId: p1.id,\n    });\n\n    // Create a time entry with a tag (no specific project)\n    await createTimeEntryWithTagViaApi(ctx, tag1, '2h');\n\n    await goToReportingDetailed(page);\n\n    await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();\n    await expect(page.getByText(`Entry with tag ${tag1}`).first()).toBeVisible();\n\n    // Filter by project\n    await page.getByRole('button', { name: 'Projects' }).first().click();\n    await page.getByRole('option').filter({ hasText: project1 }).click();\n    await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);\n\n    // Only the project entry should be visible (tagged entry has no project)\n    await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();\n    await expect(page.getByText(`Entry with tag ${tag1}`).first()).not.toBeVisible();\n});\n\n// ──────────────────────────────────────────────────\n// \"No X\" Filter Tests\n// ──────────────────────────────────────────────────\n\ntest('test that \"No Project\" filter shows entries without a project', async ({ page, ctx }) => {\n    const project1 = 'NoProj1 ' + Math.floor(Math.random() * 10000);\n\n    const p1 = await createProjectViaApi(ctx, { name: project1 });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${project1}`,\n        duration: '1h',\n        projectId: p1.id,\n    });\n    await createBareTimeEntryViaApi(ctx, 'Bare entry no project', '30min');\n\n    await goToReportingDetailed(page);\n\n    await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();\n    await expect(page.getByText('Bare entry no project').first()).toBeVisible();\n\n    // Open project dropdown and select \"No Project\"\n    await page.getByRole('button', { name: 'Projects' }).first().click();\n    await page.getByRole('option').filter({ hasText: 'No Project' }).click();\n\n    await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);\n\n    // Verify badge shows 1\n    await expect(\n        page.getByRole('button', { name: 'Projects' }).first().getByText('1')\n    ).toBeVisible();\n\n    // Only the bare entry (no project) should be visible\n    await expect(page.getByText('Bare entry no project').first()).toBeVisible();\n    await expect(page.getByText(`Entry for ${project1}`).first()).not.toBeVisible();\n});\n\ntest('test that \"No Task\" filter shows entries without a task', async ({ page, ctx }) => {\n    const projectName = 'NoTaskProj ' + Math.floor(Math.random() * 10000);\n    const task1 = 'NoTaskFilter1 ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    const t1 = await createTaskViaApi(ctx, { name: task1, project_id: project.id });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName} - ${task1}`,\n        duration: '1h',\n        projectId: project.id,\n        taskId: t1.id,\n    });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration: '30min',\n        projectId: project.id,\n    });\n\n    await goToReportingDetailed(page);\n\n    await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible();\n    await expect(page.getByText(`Entry for ${projectName}`).first()).toBeVisible();\n\n    // Open task dropdown and select \"No Task\"\n    await page.getByRole('button', { name: 'Tasks' }).first().click();\n    await page.getByRole('option').filter({ hasText: 'No Task' }).click();\n\n    await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);\n\n    await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('1')).toBeVisible();\n\n    // Only the entry without a task should be visible\n    await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).not.toBeVisible();\n});\n\ntest('test that \"No Tag\" filter shows entries without tags', async ({ page, ctx }) => {\n    const tag1 = 'NoTagFilter1 ' + Math.floor(Math.random() * 10000);\n\n    await createTimeEntryWithTagViaApi(ctx, tag1, '1h');\n    await createBareTimeEntryViaApi(ctx, 'Entry without any tag', '30min');\n\n    await goToReportingDetailed(page);\n\n    await expect(page.getByText(`Entry with tag ${tag1}`).first()).toBeVisible();\n    await expect(page.getByText('Entry without any tag').first()).toBeVisible();\n\n    // Open tag dropdown and select \"No Tag\"\n    await page.getByRole('button', { name: 'Tags' }).click();\n    await page.getByRole('option').filter({ hasText: 'No Tag' }).click();\n\n    await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);\n\n    await expect(page.getByRole('button', { name: 'Tags' }).getByText('1')).toBeVisible();\n\n    await expect(page.getByText('Entry without any tag').first()).toBeVisible();\n    await expect(page.getByText(`Entry with tag ${tag1}`).first()).not.toBeVisible();\n});\n\ntest('test that \"No Client\" filter shows entries without a client', async ({ page, ctx }) => {\n    const client1 = 'NoClientFilter ' + Math.floor(Math.random() * 10000);\n    const projectWithClient = 'NoClientProj1 ' + Math.floor(Math.random() * 10000);\n    const projectNoClient = 'NoClientProj2 ' + Math.floor(Math.random() * 10000);\n\n    const c1 = await createClientViaApi(ctx, { name: client1 });\n    const pWithClient = await createProjectViaApi(ctx, {\n        name: projectWithClient,\n        client_id: c1.id,\n    });\n    const pNoClient = await createProjectViaApi(ctx, { name: projectNoClient });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectWithClient}`,\n        duration: '1h',\n        projectId: pWithClient.id,\n    });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectNoClient}`,\n        duration: '30min',\n        projectId: pNoClient.id,\n    });\n\n    await goToReportingDetailed(page);\n\n    await expect(page.getByText(`Entry for ${projectWithClient}`).first()).toBeVisible();\n    await expect(page.getByText(`Entry for ${projectNoClient}`).first()).toBeVisible();\n\n    // Open client dropdown and select \"No Client\"\n    await page.getByRole('button', { name: 'Clients' }).first().click();\n    await page.getByRole('option').filter({ hasText: 'No Client' }).click();\n\n    await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);\n\n    await expect(\n        page.getByRole('button', { name: 'Clients' }).first().getByText('1')\n    ).toBeVisible();\n\n    await expect(page.getByText(`Entry for ${projectNoClient}`).first()).toBeVisible();\n    await expect(page.getByText(`Entry for ${projectWithClient}`).first()).not.toBeVisible();\n});\n\ntest('test that combining \"No Project\" with a project ID shows both', async ({ page, ctx }) => {\n    const project1 = 'CombNoProj ' + Math.floor(Math.random() * 10000);\n\n    const p1 = await createProjectViaApi(ctx, { name: project1 });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${project1}`,\n        duration: '1h',\n        projectId: p1.id,\n    });\n    await createBareTimeEntryViaApi(ctx, 'Bare combined entry', '30min');\n\n    await goToReportingDetailed(page);\n\n    await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();\n    await expect(page.getByText('Bare combined entry').first()).toBeVisible();\n\n    // Select both \"No Project\" and the specific project\n    await page.getByRole('button', { name: 'Projects' }).first().click();\n    await page.getByRole('option').filter({ hasText: 'No Project' }).click();\n    await page.getByRole('option').filter({ hasText: project1 }).click();\n\n    await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);\n\n    // Badge should show 2\n    await expect(\n        page.getByRole('button', { name: 'Projects' }).first().getByText('2')\n    ).toBeVisible();\n\n    // Both entries should be visible\n    await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();\n    await expect(page.getByText('Bare combined entry').first()).toBeVisible();\n});\n\n// ──────────────────────────────────────────────────\n// Keyboard Navigation Tests\n// ──────────────────────────────────────────────────\n\ntest('test that keyboard navigation works in multiselect dropdown', async ({ page, ctx }) => {\n    const project1 = 'KbNavProj1 ' + Math.floor(Math.random() * 10000);\n    const project2 = 'KbNavProj2 ' + Math.floor(Math.random() * 10000);\n\n    const p1 = await createProjectViaApi(ctx, { name: project1 });\n    const p2 = await createProjectViaApi(ctx, { name: project2 });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${project1}`,\n        duration: '1h',\n        projectId: p1.id,\n    });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${project2}`,\n        duration: '2h',\n        projectId: p2.id,\n    });\n\n    await goToReportingDetailed(page);\n    await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();\n\n    // Open project dropdown\n    await page.getByRole('button', { name: 'Projects' }).first().click();\n\n    // The search input should be focused, first item (\"No Project\") highlighted\n    await expect(page.getByPlaceholder('Search for a Project...')).toBeFocused();\n\n    // Press ArrowDown to move to first project, then Enter to select it\n    await page.keyboard.press('ArrowDown');\n    await page.keyboard.press('ArrowDown');\n    await page.keyboard.press('Enter');\n\n    // Close dropdown and verify filter applied\n    await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);\n\n    // Badge should show 1\n    await expect(\n        page.getByRole('button', { name: 'Projects' }).first().getByText('1')\n    ).toBeVisible();\n});\n"
  },
  {
    "path": "e2e/reporting.spec.ts",
    "content": "import { expect } from '@playwright/test';\nimport { PLAYWRIGHT_BASE_URL } from '../playwright/config';\nimport { test } from '../playwright/fixtures';\nimport { goToReporting, waitForReportingUpdate } from './utils/reporting';\nimport {\n    createProjectViaApi,\n    createClientViaApi,\n    createTaskViaApi,\n    createTimeEntryViaApi,\n    createTimeEntryWithTagViaApi,\n    createTimeEntryWithBillableStatusViaApi,\n    createBareTimeEntryViaApi,\n    createPublicProjectViaApi,\n    updateOrganizationSettingViaApi,\n} from './utils/api';\n\n// Each test registers a new user and creates test data via API\ntest.describe.configure({ timeout: 30000 });\n\n// ──────────────────────────────────────────────────\n// Project Multiselect Dropdown Tests\n// ──────────────────────────────────────────────────\n\ntest('test that project multiselect dropdown shows projects and filters reporting', async ({\n    page,\n    ctx,\n}) => {\n    const project1Name = 'ProjFilter1 ' + Math.floor(Math.random() * 10000);\n    const project2Name = 'ProjFilter2 ' + Math.floor(Math.random() * 10000);\n\n    const project1 = await createProjectViaApi(ctx, { name: project1Name });\n    const project2 = await createProjectViaApi(ctx, { name: project2Name });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${project1Name}`,\n        duration: '1h',\n        projectId: project1.id,\n    });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${project2Name}`,\n        duration: '2h',\n        projectId: project2.id,\n    });\n\n    await goToReporting(page);\n    await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();\n\n    // Open project multiselect dropdown\n    await page.getByRole('button', { name: 'Projects' }).first().click();\n\n    // Verify both projects appear as options\n    await expect(page.getByRole('option').filter({ hasText: project1Name })).toBeVisible();\n    await expect(page.getByRole('option').filter({ hasText: project2Name })).toBeVisible();\n\n    // Select project1 and wait for report update\n    await Promise.all([\n        page.getByRole('option').filter({ hasText: project1Name }).click(),\n        waitForReportingUpdate(page),\n    ]);\n    await page.keyboard.press('Escape');\n\n    // Verify filter badge shows count of 1\n    await expect(\n        page.getByRole('button', { name: 'Projects' }).first().getByText('1')\n    ).toBeVisible();\n\n    // Verify only project1 data is shown\n    await expect(page.getByTestId('reporting_view').getByText(project1Name)).toBeVisible();\n    await expect(page.getByTestId('reporting_view').getByText(project2Name)).not.toBeVisible();\n});\n\ntest('test that project multiselect search filters the option list', async ({ page, ctx }) => {\n    const project1Name = 'SearchableAlpha ' + Math.floor(Math.random() * 10000);\n    const project2Name = 'SearchableBeta ' + Math.floor(Math.random() * 10000);\n\n    const project1 = await createProjectViaApi(ctx, { name: project1Name });\n    await createProjectViaApi(ctx, { name: project2Name });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${project1Name}`,\n        duration: '1h',\n        projectId: project1.id,\n    });\n\n    await goToReporting(page);\n    await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();\n\n    // Open project multiselect dropdown\n    await page.getByRole('button', { name: 'Projects' }).first().click();\n\n    // Type in search\n    await page.getByPlaceholder('Search for a Project...').fill('Alpha');\n\n    // Verify only matching project is visible\n    await expect(page.getByRole('option').filter({ hasText: project1Name })).toBeVisible();\n    await expect(page.getByRole('option').filter({ hasText: project2Name })).not.toBeVisible();\n\n    await page.keyboard.press('Escape');\n});\n\ntest('test that selecting multiple projects shows correct badge count', async ({ page, ctx }) => {\n    const project1Name = 'MultiProj1 ' + Math.floor(Math.random() * 10000);\n    const project2Name = 'MultiProj2 ' + Math.floor(Math.random() * 10000);\n\n    const project1 = await createProjectViaApi(ctx, { name: project1Name });\n    const project2 = await createProjectViaApi(ctx, { name: project2Name });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${project1Name}`,\n        duration: '1h',\n        projectId: project1.id,\n    });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${project2Name}`,\n        duration: '2h',\n        projectId: project2.id,\n    });\n\n    await goToReporting(page);\n    await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();\n\n    // Open project dropdown and select both\n    await page.getByRole('button', { name: 'Projects' }).first().click();\n    await page.getByRole('option').filter({ hasText: project1Name }).click();\n    await Promise.all([\n        page.getByRole('option').filter({ hasText: project2Name }).click(),\n        waitForReportingUpdate(page),\n    ]);\n    await page.keyboard.press('Escape');\n\n    // Verify filter badge shows count of 2\n    await expect(\n        page.getByRole('button', { name: 'Projects' }).first().getByText('2')\n    ).toBeVisible();\n\n    // Verify both projects are shown in the report\n    await expect(page.getByTestId('reporting_view').getByText(project1Name)).toBeVisible();\n    await expect(page.getByTestId('reporting_view').getByText(project2Name)).toBeVisible();\n});\n\ntest('test that deselecting a project removes the filter', async ({ page, ctx }) => {\n    const project1Name = 'DeselectProj ' + Math.floor(Math.random() * 10000);\n\n    const project1 = await createProjectViaApi(ctx, { name: project1Name });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${project1Name}`,\n        duration: '1h',\n        projectId: project1.id,\n    });\n\n    await goToReporting(page);\n    await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();\n\n    // Select project\n    await page.getByRole('button', { name: 'Projects' }).first().click();\n    await Promise.all([\n        page.getByRole('option').filter({ hasText: project1Name }).click(),\n        waitForReportingUpdate(page),\n    ]);\n    await page.keyboard.press('Escape');\n\n    // Verify badge count is 1\n    await expect(\n        page.getByRole('button', { name: 'Projects' }).first().getByText('1')\n    ).toBeVisible();\n\n    // Deselect project (no network request expected — TanStack Query serves cached unfiltered data)\n    await page.getByRole('button', { name: 'Projects' }).first().click();\n    await page.getByRole('option').filter({ hasText: project1Name }).click();\n    await page.keyboard.press('Escape');\n\n    // Verify badge count is gone (no count displayed when 0)\n    await expect(\n        page.getByRole('button', { name: 'Projects' }).first().getByText(/^\\d+$/)\n    ).not.toBeVisible();\n});\n\n// ──────────────────────────────────────────────────\n// Client Multiselect Dropdown Tests\n// ──────────────────────────────────────────────────\n\ntest('test that client multiselect dropdown filters reporting by client', async ({ page, ctx }) => {\n    const client1Name = 'ClientFilter1 ' + Math.floor(Math.random() * 10000);\n    const client2Name = 'ClientFilter2 ' + Math.floor(Math.random() * 10000);\n    const project1Name = 'ClientProj1 ' + Math.floor(Math.random() * 10000);\n    const project2Name = 'ClientProj2 ' + Math.floor(Math.random() * 10000);\n\n    const client1 = await createClientViaApi(ctx, { name: client1Name });\n    const client2 = await createClientViaApi(ctx, { name: client2Name });\n    const project1 = await createProjectViaApi(ctx, {\n        name: project1Name,\n        client_id: client1.id,\n    });\n    const project2 = await createProjectViaApi(ctx, {\n        name: project2Name,\n        client_id: client2.id,\n    });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${project1Name}`,\n        duration: '1h',\n        projectId: project1.id,\n    });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${project2Name}`,\n        duration: '2h',\n        projectId: project2.id,\n    });\n\n    await goToReporting(page);\n    await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();\n\n    // Open client multiselect dropdown\n    await page.getByRole('button', { name: 'Clients' }).first().click();\n\n    // Verify both clients appear\n    await expect(page.getByRole('option').filter({ hasText: client1Name })).toBeVisible();\n    await expect(page.getByRole('option').filter({ hasText: client2Name })).toBeVisible();\n\n    // Select client1\n    await Promise.all([\n        page.getByRole('option').filter({ hasText: client1Name }).click(),\n        waitForReportingUpdate(page),\n    ]);\n    await page.keyboard.press('Escape');\n\n    // Verify badge shows count of 1\n    await expect(\n        page.getByRole('button', { name: 'Clients' }).first().getByText('1')\n    ).toBeVisible();\n\n    // Verify only project1 (belonging to client1) is shown\n    await expect(page.getByTestId('reporting_view').getByText(project1Name)).toBeVisible();\n    await expect(page.getByTestId('reporting_view').getByText(project2Name)).not.toBeVisible();\n});\n\ntest('test that client multiselect search filters the option list', async ({ page, ctx }) => {\n    const client1Name = 'ClientSearchAlpha ' + Math.floor(Math.random() * 10000);\n    const client2Name = 'ClientSearchBeta ' + Math.floor(Math.random() * 10000);\n\n    await createClientViaApi(ctx, { name: client1Name });\n    await createClientViaApi(ctx, { name: client2Name });\n\n    await goToReporting(page);\n    await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();\n\n    await page.getByRole('button', { name: 'Clients' }).first().click();\n\n    // Search for \"Alpha\"\n    await page.getByPlaceholder('Search for a Client...').fill('Alpha');\n\n    await expect(page.getByRole('option').filter({ hasText: client1Name })).toBeVisible();\n    await expect(page.getByRole('option').filter({ hasText: client2Name })).not.toBeVisible();\n\n    await page.keyboard.press('Escape');\n});\n\ntest('test that deselecting a client removes the filter', async ({ page, ctx }) => {\n    const client1Name = 'ClientDeselect ' + Math.floor(Math.random() * 10000);\n    const project1Name = 'ClientDeselectProj ' + Math.floor(Math.random() * 10000);\n\n    const client1 = await createClientViaApi(ctx, { name: client1Name });\n    const project1 = await createProjectViaApi(ctx, {\n        name: project1Name,\n        client_id: client1.id,\n    });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${project1Name}`,\n        duration: '1h',\n        projectId: project1.id,\n    });\n\n    await goToReporting(page);\n    await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();\n\n    // Select client\n    await page.getByRole('button', { name: 'Clients' }).first().click();\n    await Promise.all([\n        page.getByRole('option').filter({ hasText: client1Name }).click(),\n        waitForReportingUpdate(page),\n    ]);\n    await page.keyboard.press('Escape');\n\n    await expect(\n        page.getByRole('button', { name: 'Clients' }).first().getByText('1')\n    ).toBeVisible();\n\n    // Deselect client (no network request expected — TanStack Query serves cached unfiltered data)\n    await page.getByRole('button', { name: 'Clients' }).first().click();\n    await page.getByRole('option').filter({ hasText: client1Name }).click();\n    await page.keyboard.press('Escape');\n\n    await expect(\n        page.getByRole('button', { name: 'Clients' }).first().getByText(/^\\d+$/)\n    ).not.toBeVisible();\n});\n\n// ──────────────────────────────────────────────────\n// Task Multiselect Dropdown Tests\n// ──────────────────────────────────────────────────\n\ntest('test that task filtering works in reporting', async ({ page, ctx }) => {\n    const projectName = 'Task Filter Proj ' + Math.floor(Math.random() * 10000);\n    const task1Name = 'Task Filter A ' + Math.floor(Math.random() * 10000);\n    const task2Name = 'Task Filter B ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration: '30min',\n        projectId: project.id,\n    });\n    const task1 = await createTaskViaApi(ctx, { name: task1Name, project_id: project.id });\n    const task2 = await createTaskViaApi(ctx, { name: task2Name, project_id: project.id });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName} - ${task1Name}`,\n        duration: '1h',\n        projectId: project.id,\n        taskId: task1.id,\n    });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName} - ${task2Name}`,\n        duration: '2h',\n        projectId: project.id,\n        taskId: task2.id,\n    });\n\n    // Go to reporting and group by task to see individual tasks\n    await goToReporting(page);\n\n    // Filter by task1\n    await page.getByRole('button', { name: 'Tasks' }).first().click();\n    await Promise.all([\n        page.getByRole('option').filter({ hasText: task1Name }).click(),\n        waitForReportingUpdate(page),\n    ]);\n    await page.keyboard.press('Escape');\n\n    // Verify the report only shows 1h (task1's duration)\n    await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();\n});\n\ntest('test that task multiselect search filters the option list', async ({ page, ctx }) => {\n    const projectName = 'TaskSearchProj ' + Math.floor(Math.random() * 10000);\n    const task1Name = 'TaskSearchAlpha ' + Math.floor(Math.random() * 10000);\n    const task2Name = 'TaskSearchBeta ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createTaskViaApi(ctx, { name: task1Name, project_id: project.id });\n    await createTaskViaApi(ctx, { name: task2Name, project_id: project.id });\n\n    await goToReporting(page);\n    await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();\n\n    await page.getByRole('button', { name: 'Tasks' }).first().click();\n\n    await page.getByPlaceholder('Search for a Task...').fill('Alpha');\n\n    await expect(page.getByRole('option').filter({ hasText: task1Name })).toBeVisible();\n    await expect(page.getByRole('option').filter({ hasText: task2Name })).not.toBeVisible();\n\n    await page.keyboard.press('Escape');\n});\n\n// ──────────────────────────────────────────────────\n// Member Multiselect Dropdown Tests\n// ──────────────────────────────────────────────────\n\ntest('test that member multiselect dropdown shows current member and filters reporting', async ({\n    page,\n    ctx,\n}) => {\n    const projectName = 'MemberFilterProj ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration: '1h',\n        projectId: project.id,\n    });\n\n    await goToReporting(page);\n    await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();\n\n    // Open member multiselect dropdown\n    await page.getByRole('button', { name: 'Members' }).first().click();\n\n    // Verify the current user (John Doe from fixture) appears as an option\n    await expect(page.getByRole('option').filter({ hasText: 'John Doe' })).toBeVisible();\n\n    // Select the member\n    await Promise.all([\n        page.getByRole('option').filter({ hasText: 'John Doe' }).click(),\n        waitForReportingUpdate(page),\n    ]);\n    await page.keyboard.press('Escape');\n\n    // Verify badge shows count of 1\n    await expect(\n        page.getByRole('button', { name: 'Members' }).first().getByText('1')\n    ).toBeVisible();\n\n    // Verify data is still shown (since all entries belong to this member)\n    await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();\n});\n\ntest('test that member multiselect search filters the option list', async ({ page }) => {\n    await goToReporting(page);\n    await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();\n\n    await page.getByRole('button', { name: 'Members' }).first().click();\n\n    // Search for the registered user\n    await page.getByPlaceholder('Search for a Member...').fill('John');\n    await expect(page.getByRole('option').filter({ hasText: 'John Doe' })).toBeVisible();\n\n    // Search for a non-existent member\n    await page.getByPlaceholder('Search for a Member...').fill('NonExistentMember');\n    await expect(page.getByRole('option')).not.toBeVisible();\n\n    await page.keyboard.press('Escape');\n});\n\ntest('test that deselecting a member removes the filter', async ({ page, ctx }) => {\n    const projectName = 'MemberDeselectProj ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration: '1h',\n        projectId: project.id,\n    });\n\n    await goToReporting(page);\n    await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();\n\n    // Select member\n    await page.getByRole('button', { name: 'Members' }).first().click();\n    await Promise.all([\n        page.getByRole('option').filter({ hasText: 'John Doe' }).click(),\n        waitForReportingUpdate(page),\n    ]);\n    await page.keyboard.press('Escape');\n\n    await expect(\n        page.getByRole('button', { name: 'Members' }).first().getByText('1')\n    ).toBeVisible();\n\n    // Deselect member (no network request expected — TanStack Query serves cached unfiltered data)\n    await page.getByRole('button', { name: 'Members' }).first().click();\n    await page.getByRole('option').filter({ hasText: 'John Doe' }).click();\n    await page.keyboard.press('Escape');\n\n    // Verify badge count is gone\n    await expect(\n        page.getByRole('button', { name: 'Members' }).first().getByText(/^\\d+$/)\n    ).not.toBeVisible();\n});\n\n// ──────────────────────────────────────────────────\n// Tag Dropdown Tests\n// ──────────────────────────────────────────────────\n\ntest('test that tag filtering works in reporting', async ({ page, ctx }) => {\n    const tag1Name = 'Test Tag 1 ' + Math.floor(Math.random() * 10000);\n    const tag2Name = 'Test Tag 2 ' + Math.floor(Math.random() * 10000);\n\n    // Create time entries with different tags\n    await createTimeEntryWithTagViaApi(ctx, tag1Name, '1h');\n    await createTimeEntryWithTagViaApi(ctx, tag2Name, '2h');\n\n    // Go to reporting and filter by tag1\n    await goToReporting(page);\n    await expect(page.getByRole('button', { name: 'Tags' })).toBeVisible();\n\n    await page.getByRole('button', { name: 'Tags' }).click();\n    await Promise.all([page.getByText(tag1Name).click(), waitForReportingUpdate(page)]);\n    await page.keyboard.press('Escape');\n\n    // Verify only time entries with tag1 are shown\n    await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();\n});\n\ntest('test that tag dropdown search filters the option list', async ({ page, ctx }) => {\n    const tag1Name = 'TagSearchAlpha ' + Math.floor(Math.random() * 10000);\n    const tag2Name = 'TagSearchBeta ' + Math.floor(Math.random() * 10000);\n\n    await createTimeEntryWithTagViaApi(ctx, tag1Name, '1h');\n    await createTimeEntryWithTagViaApi(ctx, tag2Name, '2h');\n\n    await goToReporting(page);\n    await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();\n\n    await page.getByRole('button', { name: 'Tags' }).click();\n\n    await page.getByPlaceholder('Search for a Tag...').fill('Alpha');\n\n    await expect(page.getByRole('option').filter({ hasText: tag1Name })).toBeVisible();\n    await expect(page.getByRole('option').filter({ hasText: tag2Name })).not.toBeVisible();\n\n    await page.keyboard.press('Escape');\n});\n\ntest('test that selecting multiple tags shows correct badge count', async ({ page, ctx }) => {\n    const tag1Name = 'MultiTag1 ' + Math.floor(Math.random() * 10000);\n    const tag2Name = 'MultiTag2 ' + Math.floor(Math.random() * 10000);\n\n    await createTimeEntryWithTagViaApi(ctx, tag1Name, '1h');\n    await createTimeEntryWithTagViaApi(ctx, tag2Name, '2h');\n\n    await goToReporting(page);\n    await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();\n\n    // Select both tags\n    await page.getByRole('button', { name: 'Tags' }).click();\n    await page.getByRole('option').filter({ hasText: tag1Name }).click();\n    await Promise.all([\n        page.getByRole('option').filter({ hasText: tag2Name }).click(),\n        waitForReportingUpdate(page),\n    ]);\n    await page.keyboard.press('Escape');\n\n    // Verify badge shows count of 2\n    await expect(page.getByRole('button', { name: 'Tags' }).getByText('2')).toBeVisible();\n});\n\ntest('test that deselecting a tag removes the filter', async ({ page, ctx }) => {\n    const tag1Name = 'TagDeselect ' + Math.floor(Math.random() * 10000);\n\n    await createTimeEntryWithTagViaApi(ctx, tag1Name, '1h');\n\n    await goToReporting(page);\n    await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();\n\n    // Select tag\n    await page.getByRole('button', { name: 'Tags' }).click();\n    await Promise.all([\n        page.getByRole('option').filter({ hasText: tag1Name }).click(),\n        waitForReportingUpdate(page),\n    ]);\n    await page.keyboard.press('Escape');\n\n    await expect(page.getByRole('button', { name: 'Tags' }).getByText('1')).toBeVisible();\n\n    // Deselect tag (no network request expected — TanStack Query serves cached unfiltered data)\n    await page.getByRole('button', { name: 'Tags' }).click();\n    await page.getByRole('option').filter({ hasText: tag1Name }).click();\n    await page.keyboard.press('Escape');\n\n    await expect(page.getByRole('button', { name: 'Tags' }).getByText(/^\\d+$/)).not.toBeVisible();\n});\n\ntest('test that creating a tag inline from the reporting filter works', async ({ page, ctx }) => {\n    const projectName = 'TagCreateProj ' + Math.floor(Math.random() * 10000);\n    const newTag = 'InlineTag ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration: '1h',\n        projectId: project.id,\n    });\n\n    await goToReporting(page);\n    await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();\n\n    // Open tag dropdown and create a new tag\n    await page.getByRole('button', { name: 'Tags' }).click();\n    await page.getByText('Create new tag').click();\n    await page.getByPlaceholder('Tag Name').fill(newTag);\n\n    await Promise.all([\n        page.getByRole('button', { name: 'Create Tag' }).click(),\n        page.waitForResponse(\n            (response) => response.url().includes('/tags') && response.status() === 201\n        ),\n    ]);\n\n    // The new tag should now be selected in the dropdown (badge should show 1)\n    await expect(page.getByRole('button', { name: 'Tags' }).getByText('1')).toBeVisible();\n});\n\n// ──────────────────────────────────────────────────\n// Billable Select Tests\n// ──────────────────────────────────────────────────\n\ntest('test that billable status filtering works in reporting', async ({ page, ctx }) => {\n    // Create billable and non-billable time entries\n    await createTimeEntryWithBillableStatusViaApi(ctx, true, '1h');\n    await createTimeEntryWithBillableStatusViaApi(ctx, false, '2h');\n\n    // Go to reporting and filter by billable\n    await goToReporting(page);\n\n    await page.getByRole('combobox').filter({ hasText: 'Billable' }).click();\n    await Promise.all([\n        page.getByRole('option', { name: 'Billable', exact: true }).click(),\n        waitForReportingUpdate(page),\n    ]);\n\n    await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();\n});\n\ntest('test that billable filter can switch between all three states', async ({ page }) => {\n    await goToReporting(page);\n    await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();\n\n    const billableSelect = page.getByRole('combobox').filter({ hasText: 'Billable' });\n\n    // Switch to Billable\n    await billableSelect.click();\n    await Promise.all([\n        page.getByRole('option', { name: 'Billable', exact: true }).click(),\n        waitForReportingUpdate(page),\n    ]);\n\n    // Switch to Non Billable\n    await billableSelect.click();\n    await Promise.all([\n        page.getByRole('option', { name: 'Non Billable', exact: true }).click(),\n        waitForReportingUpdate(page),\n    ]);\n\n    // Verify \"Non Billable\" is displayed\n    await expect(billableSelect).toContainText('Non Billable');\n\n    // Switch back to Both (cached by TanStack Query, no new API request)\n    await billableSelect.click();\n    await page.getByRole('option', { name: 'Both' }).click();\n    await expect(billableSelect).toContainText('Billable');\n});\n\n// ──────────────────────────────────────────────────\n// Rounding Controls Tests\n// ──────────────────────────────────────────────────\n\ntest('test that rounding can be enabled', async ({ page, ctx }) => {\n    const projectName = 'RoundingProj ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration: '1h 7min',\n        projectId: project.id,\n    });\n\n    await goToReporting(page);\n    await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();\n\n    // Verify rounding is off by default\n    await expect(page.getByRole('button', { name: /Rounding off/ })).toBeVisible();\n\n    // Open rounding controls and enable rounding\n    await page.getByRole('button', { name: /Rounding off/ }).click();\n\n    const reportUpdatePromise = waitForReportingUpdate(page);\n    await page.getByRole('switch', { name: 'Enable Rounding' }).click();\n    await reportUpdatePromise;\n\n    // Close the popover by clicking elsewhere\n    await page.keyboard.press('Escape');\n\n    // Verify button text changed to \"on\"\n    await expect(page.getByRole('button', { name: /Rounding on/ })).toBeVisible();\n});\n\n// ──────────────────────────────────────────────────\n// Export Tests\n// ──────────────────────────────────────────────────\n\ntest('test that export dropdown shows all format options', async ({ page, ctx }) => {\n    const projectName = 'Export Test ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration: '1h',\n        projectId: project.id,\n    });\n\n    // Go to reporting page\n    await goToReporting(page);\n    await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();\n\n    // Click the export button\n    await page.getByRole('button', { name: 'Export' }).click();\n\n    // Verify all 4 format options are visible\n    await expect(page.getByRole('menuitem', { name: /Export as PDF/i })).toBeVisible();\n    await expect(page.getByRole('menuitem', { name: /Export as Excel/i })).toBeVisible();\n    await expect(page.getByRole('menuitem', { name: /Export as CSV/i })).toBeVisible();\n    await expect(page.getByRole('menuitem', { name: /Export as ODS/i })).toBeVisible();\n});\n\ntest('test that CSV export triggers download', async ({ page, ctx }) => {\n    const projectName = 'CSV Export ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration: '1h',\n        projectId: project.id,\n    });\n\n    // Go to reporting page\n    await goToReporting(page);\n    await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();\n\n    // Click export and select CSV, wait for the export API response with a download URL\n    await page.getByRole('button', { name: 'Export' }).click();\n    const [exportResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/time-entries/aggregate/export') &&\n                response.status() === 200\n        ),\n        page.getByRole('menuitem', { name: /Export as CSV/i }).click(),\n    ]);\n\n    // Verify the API returned a download URL\n    const responseBody = await exportResponse.json();\n    expect(responseBody.download_url).toBeTruthy();\n\n    // Verify the export success modal appeared\n    await expect(page.getByText('Export Successful!')).toBeVisible();\n});\n\n// ──────────────────────────────────────────────────\n// Group By Tests\n// ──────────────────────────────────────────────────\n\ntest('test that group by select changes report grouping', async ({ page, ctx }) => {\n    const projectName = 'GroupBy Test ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration: '1h',\n        projectId: project.id,\n    });\n\n    // Go to reporting page\n    await goToReporting(page);\n    await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();\n\n    // Find the \"Group by\" selects within the reporting table\n    const groupBySelects = page.locator('[data-testid=\"reporting_view\"]').getByRole('combobox');\n\n    // Click the first group by select to change grouping\n    await groupBySelects.filter({ hasText: 'Project' }).first().click();\n\n    // Select \"Members\" option and wait for the table query to update with group=user\n    const [aggregateResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/time-entries/aggregate') &&\n                response.url().includes('group=user') &&\n                response.status() === 200\n        ),\n        page.getByRole('option', { name: 'Members' }).click(),\n    ]);\n\n    // Verify the API request contains the correct group parameter\n    const requestUrl = new URL(aggregateResponse.url());\n    expect(requestUrl.searchParams.get('group')).toBe('user');\n\n    // Verify the grouping changed (the select should now show \"Members\")\n    await expect(groupBySelects.filter({ hasText: 'Members' }).first()).toBeVisible();\n});\n\ntest('test that setting group by to current sub group triggers sub group fallback', async ({\n    page,\n    ctx,\n}) => {\n    const projectName = 'Fallback Test ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration: '1h',\n        projectId: project.id,\n    });\n\n    // Go to reporting page\n    await goToReporting(page);\n    await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();\n\n    // Find the \"Group by\" selects within the reporting table\n    const groupBySelects = page.locator('[data-testid=\"reporting_view\"]').getByRole('combobox');\n\n    // Default state: group=Project, subGroup=Tasks\n    // Change group to \"Tasks\" (which is the current sub group)\n    await groupBySelects.filter({ hasText: 'Projects' }).first().click();\n\n    const [aggregateResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/time-entries/aggregate') &&\n                response.url().includes('group=task') &&\n                response.status() === 200\n        ),\n        page.getByRole('option', { name: 'Tasks' }).click(),\n    ]);\n\n    // Verify the API request has group=task and sub_group changed away from task\n    const requestUrl = new URL(aggregateResponse.url());\n    expect(requestUrl.searchParams.get('group')).toBe('task');\n    expect(requestUrl.searchParams.get('sub_group')).not.toBe('task');\n\n    // The group should now be \"Tasks\"\n    await expect(groupBySelects.filter({ hasText: 'Tasks' }).first()).toBeVisible();\n\n    // The sub group should have fallen back to a different value (not \"Tasks\")\n    await expect(groupBySelects.filter({ hasText: 'Members' }).first()).toBeVisible();\n});\n\n// ──────────────────────────────────────────────────\n// Export Tests\n// ──────────────────────────────────────────────────\n\ntest('test that CSV export can be triggered from the reporting page', async ({ page, ctx }) => {\n    await createTimeEntryViaApi(ctx, {\n        description: 'CSV export test',\n        duration: '1h',\n    });\n\n    await goToReporting(page);\n    await waitForReportingUpdate(page);\n\n    // Open export dropdown\n    await page.getByRole('button', { name: 'Export' }).click();\n\n    // Click CSV export and wait for the API response\n    const [exportResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/time-entries/aggregate/export') &&\n                response.status() === 200\n        ),\n        page.getByRole('menuitem', { name: 'Export as CSV' }).click(),\n    ]);\n\n    // Verify the API returned a download URL\n    const responseBody = await exportResponse.json();\n    expect(responseBody.download_url).toBeTruthy();\n\n    // Verify the export success modal appeared\n    await expect(page.getByText('Export Successful!')).toBeVisible();\n});\n\ntest('test that export dropdown shows all export options', async ({ page, ctx }) => {\n    await createTimeEntryViaApi(ctx, {\n        description: 'Export options test',\n        duration: '1h',\n    });\n\n    await goToReporting(page);\n    await waitForReportingUpdate(page);\n\n    // Open export dropdown\n    await page.getByRole('button', { name: 'Export' }).click();\n\n    // Verify all export options are visible\n    await expect(page.getByRole('menuitem', { name: 'Export as PDF' })).toBeVisible();\n    await expect(page.getByRole('menuitem', { name: 'Export as Excel' })).toBeVisible();\n    await expect(page.getByRole('menuitem', { name: 'Export as CSV' })).toBeVisible();\n    await expect(page.getByRole('menuitem', { name: 'Export as ODS' })).toBeVisible();\n});\n\n// =============================================\n// Employee Permission Tests\n// =============================================\n\ntest.describe('Employee Reporting Restrictions', () => {\n    test('employee can access overview reporting and sees own data', async ({ ctx, employee }) => {\n        // Owner creates a time entry\n        await createBareTimeEntryViaApi(ctx, 'Owner report entry', '2h');\n\n        // Create employee time entry\n        await createTimeEntryViaApi(\n            { ...ctx, memberId: employee.memberId },\n            { description: 'Emp report entry', duration: '1h' }\n        );\n\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/reporting');\n        await expect(employee.page.getByTestId('reporting_view')).toBeVisible({\n            timeout: 10000,\n        });\n\n        // Employee's data should be visible (1h)\n        await expect(\n            employee.page.getByTestId('reporting_view').getByText('1h 00min').first()\n        ).toBeVisible();\n    });\n\n    test('employee can access detailed reporting and sees only own entries', async ({\n        ctx,\n        employee,\n    }) => {\n        // Owner creates time entries\n        const ownerDescription = 'OwnerDetailEntry ' + Math.floor(Math.random() * 10000);\n        await createBareTimeEntryViaApi(ctx, ownerDescription, '2h');\n\n        // Create employee time entry\n        const empDescription = 'EmpDetailEntry ' + Math.floor(Math.random() * 10000);\n        await createTimeEntryViaApi(\n            { ...ctx, memberId: employee.memberId },\n            { description: empDescription, duration: '1h' }\n        );\n\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/reporting/detailed');\n        await expect(employee.page.getByTestId('reporting_view')).toBeVisible({\n            timeout: 10000,\n        });\n\n        // Employee's entry IS visible\n        await expect(\n            employee.page.getByTestId('reporting_view').locator(`text=${empDescription}`).first()\n        ).toBeAttached({ timeout: 10000 });\n\n        // Owner's entry is NOT visible\n        await expect(\n            employee.page.getByTestId('reporting_view').locator(`text=${ownerDescription}`)\n        ).not.toBeAttached();\n    });\n\n    test('employee cannot see shared reports tab in reporting', async ({ employee }) => {\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/reporting');\n        await expect(employee.page.getByTestId('reporting_view')).toBeVisible({\n            timeout: 10000,\n        });\n\n        // Overview and Detailed tabs should be visible (scope to main to avoid sidebar matches)\n        const mainContent = employee.page.getByRole('main');\n        await expect(mainContent.getByRole('tab', { name: 'Overview' })).toBeVisible();\n        await expect(mainContent.getByRole('tab', { name: 'Detailed' })).toBeVisible();\n\n        // Shared tab should NOT be visible for employees\n        await expect(mainContent.getByRole('tab', { name: 'Shared' })).not.toBeVisible();\n    });\n\n    test('employee cannot see Cost column in reporting by default', async ({ ctx, employee }) => {\n        const project = await createPublicProjectViaApi(ctx, {\n            name: 'EmpBillProj',\n            is_billable: true,\n            billable_rate: 10000,\n        });\n        await createTimeEntryViaApi(\n            { ...ctx, memberId: employee.memberId },\n            { description: 'Emp cost entry', duration: '1h', projectId: project.id }\n        );\n\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/reporting');\n        await expect(employee.page.getByTestId('reporting_view')).toBeVisible({\n            timeout: 10000,\n        });\n\n        // Cost column header should NOT be visible\n        await expect(employee.page.getByText('Cost', { exact: true })).not.toBeVisible();\n    });\n\n    test('employee can see Cost column when employees_can_see_billable_rates is enabled', async ({\n        ctx,\n        employee,\n    }) => {\n        await updateOrganizationSettingViaApi(ctx, { employees_can_see_billable_rates: true });\n\n        const project = await createPublicProjectViaApi(ctx, {\n            name: 'EmpBillVisProj',\n            is_billable: true,\n            billable_rate: 10000,\n        });\n        await createTimeEntryViaApi(\n            { ...ctx, memberId: employee.memberId },\n            {\n                description: 'Emp cost visible entry',\n                duration: '1h',\n                projectId: project.id,\n                billable: true,\n            }\n        );\n\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/reporting');\n        await expect(employee.page.getByTestId('reporting_view')).toBeVisible({\n            timeout: 10000,\n        });\n\n        // Cost column header should be visible\n        await expect(employee.page.getByText('Cost', { exact: true })).toBeVisible();\n\n        // 1h at 100.00/h billable rate = 100.00 cost (shown in row and total)\n        await expect(employee.page.getByText('100,00 EUR').first()).toBeVisible();\n    });\n});\n"
  },
  {
    "path": "e2e/shared-reports.spec.ts",
    "content": "import { expect } from '@playwright/test';\nimport { PLAYWRIGHT_BASE_URL } from '../playwright/config';\nimport { test } from '../playwright/fixtures';\nimport {\n    createProjectViaApi,\n    createClientViaApi,\n    createTaskViaApi,\n    createTimeEntryViaApi,\n    createTimeEntryWithTagViaApi,\n    createBareTimeEntryViaApi,\n    createBillableProjectViaApi,\n    createTimeEntryWithBillableStatusViaApi,\n    createTagViaApi,\n} from './utils/api';\nimport {\n    goToReporting,\n    goToReportingShared,\n    waitForReportingUpdate,\n    saveAsSharedReport,\n} from './utils/reporting';\n\n// Each test registers a new user and creates test data via API\ntest.describe.configure({ timeout: 30000 });\n\n// Date picker button name patterns for different date formats\nconst DATE_PICKER_BUTTON_PATTERN =\n    /^Pick a date$|^\\d{4}-\\d{2}-\\d{2}$|^\\d{2}\\/\\d{2}\\/\\d{4}$|^\\d{2}\\.\\d{2}\\.\\d{4}$/;\n\n// ──────────────────────────────────────────────────\n// Shared Report Lifecycle Tests\n// ──────────────────────────────────────────────────\n\ntest('test that saving a report creates a shared report and its shareable link shows correct data', async ({\n    page,\n    ctx,\n}) => {\n    const projectName = 'SharedProject ' + Math.floor(Math.random() * 10000);\n    const reportName = 'SharedReport ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration: '1h',\n        projectId: project.id,\n    });\n\n    await goToReporting(page);\n    await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();\n\n    const { shareableLink } = await saveAsSharedReport(page, reportName);\n\n    // Verify report appears on shared tab\n    await goToReportingShared(page);\n    await expect(page.getByTestId('report_table')).toBeVisible();\n    await expect(page.getByText(reportName)).toBeVisible();\n    await expect(page.getByText('Public', { exact: true })).toBeVisible();\n    await expect(page.getByRole('button', { name: 'Copy URL' })).toBeVisible();\n\n    // Navigate to shareable link and verify report data\n    await page.goto(shareableLink);\n    await expect(page.getByText('Reporting')).toBeVisible();\n    await expect(page.getByText(projectName)).toBeVisible();\n    await expect(page.getByText('Total')).toBeVisible();\n});\n\ntest('test that shared report with invalid secret shows no data', async ({ page }) => {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/shared-report#invalid-secret-value');\n    await expect(page.getByText('No time entries found').first()).toBeVisible();\n});\n\ntest('test that a shared report can be edited to toggle public/private and then deleted', async ({\n    page,\n    ctx,\n}) => {\n    const projectName = 'EditDelProject ' + Math.floor(Math.random() * 10000);\n    const reportName = 'EditDelReport ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration: '1h',\n        projectId: project.id,\n    });\n\n    await goToReporting(page);\n    await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();\n\n    await saveAsSharedReport(page, reportName);\n\n    await goToReportingShared(page);\n    await expect(page.getByText(reportName)).toBeVisible();\n    await expect(page.getByText('Public', { exact: true })).toBeVisible();\n\n    // Click more options and edit\n    await page\n        .getByRole('button', { name: new RegExp('Actions for Project ' + reportName) })\n        .click();\n    await page.getByRole('menuitem', { name: /^Edit Report/ }).click();\n\n    // Uncheck public and save\n    await page.getByLabel('Public').click();\n    await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/reports/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n        page.getByRole('button', { name: 'Update Report' }).click(),\n    ]);\n\n    // Verify status changed to private\n    await expect(page.getByText('Private')).toBeVisible();\n    await expect(page.getByText('--')).toBeVisible();\n\n    // Delete the report\n    await page\n        .getByRole('button', { name: new RegExp('Actions for Project ' + reportName) })\n        .click();\n    await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/reports/') &&\n                response.request().method() === 'DELETE' &&\n                response.status() === 204\n        ),\n        page.getByRole('menuitem', { name: /^Delete Report/ }).click(),\n    ]);\n\n    await expect(page.getByText('No shared reports found')).toBeVisible();\n});\n\n// ──────────────────────────────────────────────────\n// Shared Report Filter Tests\n// ──────────────────────────────────────────────────\n\ntest('test that shared report respects project filter', async ({ page, ctx }) => {\n    const projectA = 'FilterProjA ' + Math.floor(Math.random() * 10000);\n    const projectB = 'FilterProjB ' + Math.floor(Math.random() * 10000);\n    const reportName = 'FilterProjReport ' + Math.floor(Math.random() * 10000);\n\n    const projA = await createProjectViaApi(ctx, { name: projectA });\n    const projB = await createProjectViaApi(ctx, { name: projectB });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectA}`,\n        duration: '1h',\n        projectId: projA.id,\n    });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectB}`,\n        duration: '2h',\n        projectId: projB.id,\n    });\n\n    await goToReporting(page);\n    await expect(page.getByTestId('reporting_view').getByText(projectA)).toBeVisible();\n\n    // Filter by project A\n    await page.getByRole('button', { name: 'Projects' }).first().click();\n    await Promise.all([\n        page.getByRole('option').filter({ hasText: projectA }).click(),\n        waitForReportingUpdate(page),\n    ]);\n    await page.keyboard.press('Escape');\n\n    const { shareableLink } = await saveAsSharedReport(page, reportName);\n\n    // View the shared report\n    await page.goto(shareableLink);\n    await expect(page.getByText('Reporting')).toBeVisible();\n    await expect(page.getByText(projectA)).toBeVisible();\n    await expect(page.getByText(projectB)).not.toBeVisible();\n});\n\ntest('test that shared report with No Project filter shows entries without a project', async ({\n    page,\n    ctx,\n}) => {\n    const projectName = 'NoProjFilter ' + Math.floor(Math.random() * 10000);\n    const reportName = 'NoProjReport ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration: '1h',\n        projectId: project.id,\n    });\n    await createBareTimeEntryViaApi(ctx, 'Bare entry no project', '2h');\n\n    await goToReporting(page);\n    await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();\n\n    // Filter by \"No Project\"\n    await page.getByRole('button', { name: 'Projects' }).first().click();\n    await Promise.all([\n        page.getByRole('option').filter({ hasText: 'No Project' }).click(),\n        waitForReportingUpdate(page),\n    ]);\n    await page.keyboard.press('Escape');\n\n    const { shareableLink } = await saveAsSharedReport(page, reportName);\n\n    // View the shared report\n    await page.goto(shareableLink);\n    await expect(page.getByText('Reporting')).toBeVisible();\n    // The \"No Project\" group should show, but the project name should not appear as a group\n    await expect(page.getByText('Total')).toBeVisible();\n    await expect(page.getByText(projectName)).not.toBeVisible();\n});\n\ntest('test that shared report with No Task filter shows entries without a task', async ({\n    page,\n    ctx,\n}) => {\n    const projectName = 'NoTaskProj ' + Math.floor(Math.random() * 10000);\n    const taskName = 'NoTaskFilter ' + Math.floor(Math.random() * 10000);\n    const reportName = 'NoTaskReport ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    const task = await createTaskViaApi(ctx, { name: taskName, project_id: project.id });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName} - ${taskName}`,\n        duration: '1h',\n        projectId: project.id,\n        taskId: task.id,\n    });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration: '2h',\n        projectId: project.id,\n    });\n\n    await goToReporting(page);\n    await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();\n\n    // Filter by \"No Task\"\n    await page.getByRole('button', { name: 'Tasks' }).first().click();\n    await Promise.all([\n        page.getByRole('option').filter({ hasText: 'No Task' }).click(),\n        waitForReportingUpdate(page),\n    ]);\n    await page.keyboard.press('Escape');\n\n    const { shareableLink } = await saveAsSharedReport(page, reportName);\n\n    // View the shared report\n    await page.goto(shareableLink);\n    await expect(page.getByText('Reporting')).toBeVisible();\n    await expect(page.getByText('Total')).toBeVisible();\n});\n\ntest('test that shared report respects task filter', async ({ page, ctx }) => {\n    const projectName = 'TaskFilterProj ' + Math.floor(Math.random() * 10000);\n    const taskA = 'TaskA ' + Math.floor(Math.random() * 10000);\n    const taskB = 'TaskB ' + Math.floor(Math.random() * 10000);\n    const reportName = 'TaskFilterReport ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    const task = await createTaskViaApi(ctx, { name: taskA, project_id: project.id });\n    await createTaskViaApi(ctx, { name: taskB, project_id: project.id });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${taskA}`,\n        duration: '1h',\n        projectId: project.id,\n        taskId: task.id,\n    });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName} no task`,\n        duration: '2h',\n        projectId: project.id,\n    });\n\n    await goToReporting(page);\n    await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();\n\n    // Filter by task A\n    await page.getByRole('button', { name: 'Tasks' }).first().click();\n    await Promise.all([\n        page.getByRole('option').filter({ hasText: taskA }).click(),\n        waitForReportingUpdate(page),\n    ]);\n    await page.keyboard.press('Escape');\n\n    const { shareableLink } = await saveAsSharedReport(page, reportName);\n\n    // View the shared report\n    await page.goto(shareableLink);\n    await expect(page.getByText('Reporting')).toBeVisible();\n    await expect(page.getByText('Total')).toBeVisible();\n    await expect(page.getByText('1h 00min').first()).toBeVisible();\n    await expect(page.getByText('3h 00min')).not.toBeVisible();\n});\n\ntest('test that shared report respects client filter', async ({ page, ctx }) => {\n    const clientA = 'ClientA ' + Math.floor(Math.random() * 10000);\n    const clientB = 'ClientB ' + Math.floor(Math.random() * 10000);\n    const projectA = 'ClientFilterProjA ' + Math.floor(Math.random() * 10000);\n    const projectB = 'ClientFilterProjB ' + Math.floor(Math.random() * 10000);\n    const reportName = 'ClientFilterReport ' + Math.floor(Math.random() * 10000);\n\n    const cliA = await createClientViaApi(ctx, { name: clientA });\n    const cliB = await createClientViaApi(ctx, { name: clientB });\n    const projA = await createProjectViaApi(ctx, { name: projectA, client_id: cliA.id });\n    const projB = await createProjectViaApi(ctx, { name: projectB, client_id: cliB.id });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${clientA}`,\n        duration: '1h',\n        projectId: projA.id,\n    });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${clientB}`,\n        duration: '2h',\n        projectId: projB.id,\n    });\n\n    await goToReporting(page);\n    await expect(page.getByTestId('reporting_view').getByText(projectA)).toBeVisible();\n\n    // Filter by client A\n    await page.getByRole('button', { name: 'Clients' }).first().click();\n    await Promise.all([\n        page.getByRole('option').filter({ hasText: clientA }).click(),\n        waitForReportingUpdate(page),\n    ]);\n    await page.keyboard.press('Escape');\n\n    const { shareableLink } = await saveAsSharedReport(page, reportName);\n\n    // View the shared report\n    await page.goto(shareableLink);\n    await expect(page.getByText('Reporting')).toBeVisible();\n    await expect(page.getByText(projectA)).toBeVisible();\n    await expect(page.getByText(projectB)).not.toBeVisible();\n});\n\ntest('test that shared report respects tag filter', async ({ page, ctx }) => {\n    const tagA = 'TagA ' + Math.floor(Math.random() * 10000);\n    const tagB = 'TagB ' + Math.floor(Math.random() * 10000);\n    const reportName = 'TagFilterReport ' + Math.floor(Math.random() * 10000);\n\n    const tagObjA = await createTagViaApi(ctx, { name: tagA });\n    await createTagViaApi(ctx, { name: tagB });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry with ${tagA}`,\n        duration: '1h',\n        tags: [tagObjA.id],\n    });\n    await createBareTimeEntryViaApi(ctx, 'Entry no tags', '2h');\n\n    await goToReporting(page);\n    await expect(page.getByTestId('reporting_view').getByText('Total')).toBeVisible();\n\n    // Filter by tag A\n    await page.getByRole('button', { name: 'Tags' }).first().click();\n    await Promise.all([\n        page.getByRole('option').filter({ hasText: tagA }).click(),\n        waitForReportingUpdate(page),\n    ]);\n    await page.keyboard.press('Escape');\n\n    const { shareableLink } = await saveAsSharedReport(page, reportName);\n\n    // View the shared report\n    await page.goto(shareableLink);\n    await expect(page.getByText('Reporting')).toBeVisible();\n    await expect(page.getByText('Total')).toBeVisible();\n    await expect(page.getByText('1h 00min').first()).toBeVisible();\n    await expect(page.getByText('3h 00min')).not.toBeVisible();\n});\n\ntest('test that shared report respects member filter', async ({ page, ctx }) => {\n    const projectName = 'MemberFilterProj ' + Math.floor(Math.random() * 10000);\n    const reportName = 'MemberFilterReport ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration: '1h',\n        projectId: project.id,\n    });\n\n    await goToReporting(page);\n    await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();\n\n    // Filter by current member (John Doe)\n    await page.getByRole('button', { name: 'Members' }).first().click();\n    await Promise.all([\n        page.getByRole('option').filter({ hasText: 'John Doe' }).click(),\n        waitForReportingUpdate(page),\n    ]);\n    await page.keyboard.press('Escape');\n\n    const { shareableLink } = await saveAsSharedReport(page, reportName);\n\n    // View the shared report — should still show data since all entries belong to this member\n    await page.goto(shareableLink);\n    await expect(page.getByText('Reporting')).toBeVisible();\n    await expect(page.getByText(projectName)).toBeVisible();\n    await expect(page.getByText('Total')).toBeVisible();\n});\n\ntest('test that shared report with billable filter only shows billable entries', async ({\n    page,\n    ctx,\n}) => {\n    const reportName = 'BillableFilterReport ' + Math.floor(Math.random() * 10000);\n\n    // Create one billable (1h) and one non-billable (2h) entry\n    await createTimeEntryWithBillableStatusViaApi(ctx, true, '1h');\n    await createTimeEntryWithBillableStatusViaApi(ctx, false, '2h');\n\n    await goToReporting(page);\n    await expect(page.getByTestId('reporting_view').getByText('Total')).toBeVisible();\n\n    // Filter by billable only\n    await page.getByRole('combobox').filter({ hasText: 'Billable' }).click();\n    await Promise.all([\n        page.getByRole('option', { name: 'Billable', exact: true }).click(),\n        waitForReportingUpdate(page),\n    ]);\n\n    // Verify only 1h shows before saving\n    await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();\n\n    const { shareableLink } = await saveAsSharedReport(page, reportName);\n\n    // Navigate to the shared report\n    await page.goto(shareableLink);\n    await expect(page.getByText('Reporting')).toBeVisible();\n    await expect(page.getByText('Total')).toBeVisible();\n\n    // Shared report should only show the 1h billable entry, not the 2h non-billable\n    await expect(page.getByText('1h 00min').first()).toBeVisible();\n    await expect(page.getByText('3h 00min')).not.toBeVisible();\n});\n\n// ──────────────────────────────────────────────────\n// Report Date Picker Tests\n// ──────────────────────────────────────────────────\n\ntest('test that creating a report with an expiration date works', async ({ page, ctx }) => {\n    const projectName = 'DatePickerProj ' + Math.floor(Math.random() * 10000);\n    const reportName = 'DatePickerReport ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration: '1h',\n        projectId: project.id,\n    });\n\n    await goToReporting(page);\n    await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();\n\n    // Open the save report modal\n    await page.getByRole('button', { name: 'Save Report' }).click();\n    await page.getByLabel('Name').fill(reportName);\n\n    // The \"Public\" checkbox should be checked by default, showing the date picker\n    const datePicker = page\n        .getByRole('dialog')\n        .getByRole('button', { name: DATE_PICKER_BUTTON_PATTERN });\n    await expect(datePicker).toBeVisible();\n    await datePicker.click();\n\n    // Select a date in the next month\n    const calendarGrid = page.getByRole('grid');\n    await expect(calendarGrid).toBeVisible({ timeout: 5000 });\n    await page.getByRole('button', { name: /Next/i }).click();\n    await page.getByRole('gridcell').filter({ hasText: /^15$/ }).first().click();\n\n    // Wait for the calendar to close\n    await expect(calendarGrid).not.toBeVisible();\n\n    // Create the report and verify it includes the public_until date\n    const [response] = await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/reports') &&\n                response.request().method() === 'POST' &&\n                response.status() === 201\n        ),\n        page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click(),\n    ]);\n    const responseBody = await response.json();\n    expect(responseBody.data.public_until).toBeTruthy();\n});\n\ntest('test that editing a report to make it public with expiration date works', async ({\n    page,\n    ctx,\n}) => {\n    const projectName = 'EditDateProj ' + Math.floor(Math.random() * 10000);\n    const reportName = 'EditDateReport ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration: '1h',\n        projectId: project.id,\n    });\n\n    await goToReporting(page);\n    await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();\n\n    // Open the save report modal and create a private report\n    await page.getByRole('button', { name: 'Save Report' }).click();\n    await page.getByLabel('Name').fill(reportName);\n\n    // Uncheck \"Public\" to create a private report\n    await page.getByLabel('Public').click();\n\n    await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/reports') &&\n                response.request().method() === 'POST' &&\n                response.status() === 201\n        ),\n        page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click(),\n    ]);\n\n    // Go to shared reports and edit\n    await goToReportingShared(page);\n    await expect(page.getByText(reportName)).toBeVisible();\n    await expect(page.getByText('Private')).toBeVisible();\n\n    // Click more options and edit\n    await page\n        .getByRole('button', { name: new RegExp('Actions for Project ' + reportName) })\n        .click();\n    await page.getByRole('menuitem', { name: /^Edit Report/ }).click();\n\n    // Check \"Public\" to make it public - this should show the date picker\n    await page.getByLabel('Public').click();\n\n    // The date picker should now be visible\n    const datePicker = page\n        .getByRole('dialog')\n        .getByRole('button', { name: DATE_PICKER_BUTTON_PATTERN });\n    await expect(datePicker).toBeVisible();\n    await datePicker.click();\n\n    // Select a date in the next month\n    const calendarGrid = page.getByRole('grid');\n    await expect(calendarGrid).toBeVisible({ timeout: 5000 });\n    await page.getByRole('button', { name: /Next/i }).click();\n    await page.getByRole('gridcell').filter({ hasText: /^20$/ }).first().click();\n\n    // Wait for the calendar to close\n    await expect(calendarGrid).not.toBeVisible();\n\n    // Update the report and verify it includes the public_until date\n    const [response] = await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/reports/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n        page.getByRole('button', { name: 'Update Report' }).click(),\n    ]);\n    const responseBody = await response.json();\n    expect(responseBody.data.public_until).toBeTruthy();\n    expect(responseBody.data.is_public).toBe(true);\n});\n\ntest('test that shared report with No Client filter shows entries without a client', async ({\n    page,\n    ctx,\n}) => {\n    const clientName = 'NoClientCli ' + Math.floor(Math.random() * 10000);\n    const projectName = 'NoClientProj ' + Math.floor(Math.random() * 10000);\n    const reportName = 'NoClientReport ' + Math.floor(Math.random() * 10000);\n\n    const client = await createClientViaApi(ctx, { name: clientName });\n    const project = await createProjectViaApi(ctx, { name: projectName, client_id: client.id });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration: '1h',\n        projectId: project.id,\n    });\n    await createBareTimeEntryViaApi(ctx, 'Entry without client', '2h');\n\n    await goToReporting(page);\n    await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();\n\n    // Filter by \"No Client\"\n    await page.getByRole('button', { name: 'Clients' }).first().click();\n    await Promise.all([\n        page.getByRole('option').filter({ hasText: 'No Client' }).click(),\n        waitForReportingUpdate(page),\n    ]);\n    await page.keyboard.press('Escape');\n\n    const { shareableLink } = await saveAsSharedReport(page, reportName);\n\n    // View the shared report\n    await page.goto(shareableLink);\n    await expect(page.getByText('Reporting')).toBeVisible();\n    await expect(page.getByText('Total')).toBeVisible();\n    await expect(page.getByText(projectName)).not.toBeVisible();\n});\n\ntest('test that shared report with No Tag filter shows entries without tags', async ({\n    page,\n    ctx,\n}) => {\n    const tagName = 'NoTagFilter ' + Math.floor(Math.random() * 10000);\n    const reportName = 'NoTagReport ' + Math.floor(Math.random() * 10000);\n\n    await createTimeEntryWithTagViaApi(ctx, tagName, '1h');\n    await createBareTimeEntryViaApi(ctx, 'Entry without tags', '2h');\n\n    await goToReporting(page);\n    await expect(page.getByText('Total')).toBeVisible();\n\n    // Filter by \"No Tag\"\n    await page.getByRole('button', { name: 'Tags' }).first().click();\n    await Promise.all([\n        page.getByRole('option').filter({ hasText: 'No Tag' }).click(),\n        waitForReportingUpdate(page),\n    ]);\n    await page.keyboard.press('Escape');\n\n    const { shareableLink } = await saveAsSharedReport(page, reportName);\n\n    // View the shared report\n    await page.goto(shareableLink);\n    await expect(page.getByText('Reporting')).toBeVisible();\n    await expect(page.getByText('Total')).toBeVisible();\n});\n\ntest('test that creating a report with empty name shows validation error', async ({\n    page,\n    ctx,\n}) => {\n    const projectName = 'EmptyNameProj ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration: '1h',\n        projectId: project.id,\n    });\n\n    await goToReporting(page);\n    await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();\n\n    // Open the save report modal\n    await page.getByRole('button', { name: 'Save Report' }).click();\n\n    // Leave name empty and try to create\n    await page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click();\n\n    // Should show validation error\n    await expect(page.getByText('The name field is required')).toBeVisible();\n});\n\ntest('test that updating report name works', async ({ page, ctx }) => {\n    const projectName = 'UpdateNameProj ' + Math.floor(Math.random() * 10000);\n    const reportName = 'OriginalName ' + Math.floor(Math.random() * 10000);\n    const newReportName = 'UpdatedName ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration: '1h',\n        projectId: project.id,\n    });\n\n    await goToReporting(page);\n    await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();\n\n    await saveAsSharedReport(page, reportName);\n\n    await goToReportingShared(page);\n    await expect(page.getByText(reportName)).toBeVisible();\n\n    // Click more options and edit\n    await page\n        .getByRole('button', { name: new RegExp('Actions for Project ' + reportName) })\n        .click();\n    await page.getByRole('menuitem', { name: /^Edit Report/ }).click();\n\n    // Update the name\n    await page.getByLabel('Name', { exact: true }).fill(newReportName);\n\n    await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/reports/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n        page.getByRole('button', { name: 'Update Report' }).click(),\n    ]);\n\n    // Verify the name was updated in the table\n    await expect(page.getByText(newReportName)).toBeVisible();\n    await expect(page.getByText(reportName)).not.toBeVisible();\n});\n\ntest('test that updating expiration date on already-public report works', async ({ page, ctx }) => {\n    const projectName = 'UpdateExpDateProj ' + Math.floor(Math.random() * 10000);\n    const reportName = 'UpdateExpDateReport ' + Math.floor(Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration: '1h',\n        projectId: project.id,\n    });\n\n    await goToReporting(page);\n    await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();\n\n    // Create a public report (already public by default)\n    await saveAsSharedReport(page, reportName);\n\n    // Go to shared reports and edit\n    await goToReportingShared(page);\n    await expect(page.getByText(reportName)).toBeVisible();\n\n    // Click more options and edit\n    await page\n        .getByRole('button', { name: new RegExp('Actions for Project ' + reportName) })\n        .click();\n    await page.getByRole('menuitem', { name: /^Edit Report/ }).click();\n\n    // The date picker should be visible (report is already public)\n    const datePicker = page\n        .getByRole('dialog')\n        .getByRole('button', { name: DATE_PICKER_BUTTON_PATTERN });\n    await expect(datePicker).toBeVisible();\n    await datePicker.click();\n\n    // Select the 25th of next month\n    const calendarGrid = page.getByRole('grid');\n    await expect(calendarGrid).toBeVisible({ timeout: 5000 });\n    await page.getByRole('button', { name: /Next/i }).click();\n    await page.getByRole('gridcell').filter({ hasText: /^25$/ }).first().click();\n\n    // Wait for the calendar to close\n    await expect(calendarGrid).not.toBeVisible();\n\n    // Update the report and verify it includes the correct public_until date\n    const [response] = await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/reports/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n        page.getByRole('button', { name: 'Update Report' }).click(),\n    ]);\n    const responseBody = await response.json();\n    expect(responseBody.data.public_until).toBeTruthy();\n\n    // Verify the date is the 25th of a future month\n    const returnedDate = new Date(responseBody.data.public_until);\n    expect(returnedDate.getUTCDate()).toBe(25);\n\n    // The returned date should be in the future\n    const now = new Date();\n    expect(returnedDate.getTime()).toBeGreaterThan(now.getTime());\n});\n\n// ──────────────────────────────────────────────────\n// Shared Report Cost Column Tests\n// ──────────────────────────────────────────────────\n\ntest('test that shared report displays cost column correctly aligned with data rows', async ({\n    page,\n    ctx,\n}) => {\n    const projectName = 'BillableProj ' + Math.floor(Math.random() * 10000);\n    const reportName = 'BillableReport ' + Math.floor(Math.random() * 10000);\n\n    const project = await createBillableProjectViaApi(ctx, {\n        name: projectName,\n        billable_rate: 10000, // 100.00 per hour\n    });\n    await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration: '1h',\n        projectId: project.id,\n        billable: true,\n    });\n\n    await goToReporting(page);\n    await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();\n\n    const { shareableLink } = await saveAsSharedReport(page, reportName);\n\n    // Navigate to the shared report\n    await page.goto(shareableLink);\n    await expect(page.getByText('Reporting')).toBeVisible();\n    await expect(page.getByText(projectName)).toBeVisible();\n\n    // Verify the table header has all three columns\n    await expect(page.getByText('Name', { exact: true })).toBeVisible();\n    await expect(page.getByText('Duration', { exact: true })).toBeVisible();\n    await expect(page.getByText('Cost', { exact: true })).toBeVisible();\n\n    // Verify the Total row displays both duration and cost\n    await expect(page.getByText('Total')).toBeVisible();\n\n    // The data rows should render cost values (not just header + duration)\n    // With 1h at 100/h the cost should be displayed somewhere in the table\n    // If showCost is not passed to ReportingRow, only the header \"Cost\" and\n    // the Total row cost will render, but individual row costs will be missing\n    const table = page.locator('[style*=\"grid-template-columns\"]');\n    // Count elements containing the cost value - header \"Cost\" + project row cost + total row cost = 3\n    // If broken (showCost not passed), the project row won't render its cost cell\n    await expect(table.getByText(/100/).first()).toBeVisible();\n\n    // Verify the cost value appears at least twice in the table\n    // (once for the data row, once for the total) beyond just the header\n    const costValues = table.getByText(/100/);\n    await expect(costValues).toHaveCount(2);\n});\n"
  },
  {
    "path": "e2e/tags.spec.ts",
    "content": "import { expect } from '@playwright/test';\nimport type { Page } from '@playwright/test';\nimport { PLAYWRIGHT_BASE_URL } from '../playwright/config';\nimport { test } from '../playwright/fixtures';\nimport { createTagViaApi } from './utils/api';\nimport { getTableRowNames } from './utils/table';\n\nasync function goToTagsOverview(page: Page) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/tags');\n}\n\ntest('test that creating and deleting a new tag via the modal works', async ({ page }) => {\n    const newTagName = 'New Tag ' + Math.floor(1 + Math.random() * 10000);\n    await goToTagsOverview(page);\n    await page.getByRole('button', { name: 'Create Tag' }).click();\n    await page.getByPlaceholder('Tag Name').fill(newTagName);\n    await Promise.all([\n        page.getByRole('button', { name: 'Create Tag' }).click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/tags') &&\n                response.request().method() === 'POST' &&\n                response.status() === 201 &&\n                (await response.json()).data.id !== null &&\n                (await response.json()).data.name === newTagName\n        ),\n    ]);\n\n    await expect(page.getByTestId('tag_table')).toContainText(newTagName);\n    const moreButton = page.locator(\"[aria-label='Actions for Tag \" + newTagName + \"']\");\n    moreButton.click();\n    const deleteButton = page.locator(\"[aria-label='Delete Tag \" + newTagName + \"']\");\n\n    await Promise.all([\n        deleteButton.click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/tags') &&\n                response.request().method() === 'DELETE' &&\n                response.status() === 204\n        ),\n    ]);\n    await expect(page.getByTestId('tag_table')).not.toContainText(newTagName);\n});\n\ntest('test that editing a tag name works', async ({ page, ctx }) => {\n    const originalTagName = 'Original Tag ' + Math.floor(1 + Math.random() * 10000);\n    const updatedTagName = 'Updated Tag ' + Math.floor(1 + Math.random() * 10000);\n\n    await createTagViaApi(ctx, { name: originalTagName });\n\n    await goToTagsOverview(page);\n    await expect(page.getByTestId('tag_table')).toContainText(originalTagName);\n\n    // Open actions menu and click Edit\n    const moreButton = page.locator(\"[aria-label='Actions for Tag \" + originalTagName + \"']\");\n    await moreButton.click();\n    await page.getByRole('menuitem').getByText('Edit').click();\n\n    // Update the tag name in the edit modal\n    await expect(page.getByRole('dialog')).toBeVisible();\n    await page.getByPlaceholder('Tag Name').fill(updatedTagName);\n    await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/tags/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n        page.getByRole('button', { name: 'Update Tag' }).click(),\n    ]);\n\n    // Verify the table shows the updated name\n    await expect(page.getByTestId('tag_table')).toContainText(updatedTagName);\n    await expect(page.getByTestId('tag_table')).not.toContainText(originalTagName);\n});\n\ntest('test that multiple tags can be created via API and displayed in the table', async ({\n    page,\n    ctx,\n}) => {\n    const tagName1 = 'TagA ' + Math.floor(1 + Math.random() * 10000);\n    const tagName2 = 'TagB ' + Math.floor(1 + Math.random() * 10000);\n\n    await createTagViaApi(ctx, { name: tagName1 });\n    await createTagViaApi(ctx, { name: tagName2 });\n\n    await goToTagsOverview(page);\n    await expect(page.getByTestId('tag_table')).toContainText(tagName1);\n    await expect(page.getByTestId('tag_table')).toContainText(tagName2);\n});\n\n// =============================================\n// Sorting Tests\n// =============================================\n\nasync function clearTagTableState(page: Page) {\n    await page.evaluate(() => {\n        localStorage.removeItem('tag-table-state');\n    });\n}\n\ntest('test that sorting tags by name works', async ({ page, ctx }) => {\n    await createTagViaApi(ctx, { name: 'AAA SortTag' });\n    await createTagViaApi(ctx, { name: 'ZZZ SortTag' });\n\n    await goToTagsOverview(page);\n    await clearTagTableState(page);\n    await page.reload();\n\n    const table = page.getByTestId('tag_table');\n    await expect(table).toBeVisible();\n\n    // Default is name asc\n    let names = await getTableRowNames(table);\n    expect(names.indexOf('AAA SortTag')).toBeLessThan(names.indexOf('ZZZ SortTag'));\n\n    const nameHeader = table.getByText('Name').first();\n    await nameHeader.click(); // toggle to desc\n    names = await getTableRowNames(table);\n    expect(names.indexOf('ZZZ SortTag')).toBeLessThan(names.indexOf('AAA SortTag'));\n});\n\ntest('test that tag sort state persists after page reload', async ({ page }) => {\n    await goToTagsOverview(page);\n    await clearTagTableState(page);\n    await page.reload();\n\n    const table = page.getByTestId('tag_table');\n    await expect(table).toBeVisible();\n\n    const nameHeader = table.getByText('Name').first();\n    await nameHeader.click(); // toggle to desc\n    await expect(nameHeader.locator('svg')).toBeVisible();\n\n    await page.reload();\n\n    await expect(page.getByTestId('tag_table')).toBeVisible();\n    await expect(\n        page.getByTestId('tag_table').getByText('Name').first().locator('svg')\n    ).toBeVisible();\n});\n\n// =============================================\n// Employee Permission Tests\n// =============================================\n\ntest.describe('Employee Tags Restrictions', () => {\n    test('employee can view tags but cannot create', async ({ ctx, employee }) => {\n        const tagName = 'EmpViewTag ' + Math.floor(Math.random() * 10000);\n        await createTagViaApi(ctx, { name: tagName });\n\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/tags');\n        await expect(employee.page.getByTestId('tags_view')).toBeVisible({ timeout: 10000 });\n\n        // Employee can see the tag (tags are visible to all members with tags:view)\n        await expect(employee.page.getByText(tagName)).toBeVisible({ timeout: 10000 });\n\n        // Employee cannot see Create Tag button\n        await expect(employee.page.getByRole('button', { name: 'Create Tag' })).not.toBeVisible();\n    });\n\n    test('employee cannot see edit/delete actions on tags', async ({ ctx, employee }) => {\n        const tagName = 'EmpActionsTag ' + Math.floor(Math.random() * 10000);\n        await createTagViaApi(ctx, { name: tagName });\n\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/tags');\n        await expect(employee.page.getByText(tagName)).toBeVisible({ timeout: 10000 });\n\n        // Actions button should not be visible for employee\n        const actionsButton = employee.page.locator(`[aria-label='Actions for Tag ${tagName}']`);\n        await expect(actionsButton).not.toBeVisible();\n    });\n});\n"
  },
  {
    "path": "e2e/tasks.spec.ts",
    "content": "import { expect } from '@playwright/test';\nimport type { Page } from '@playwright/test';\nimport { PLAYWRIGHT_BASE_URL } from '../playwright/config';\nimport { test } from '../playwright/fixtures';\nimport {\n    createProjectViaApi,\n    createPublicProjectViaApi,\n    createTaskViaApi,\n    createClientViaApi,\n    updateOrganizationSettingViaApi,\n} from './utils/api';\n\nasync function goToProjectsOverview(page: Page) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/projects');\n}\n\ntest('test that creating and deleting a new task in a new project works', async ({ page }) => {\n    const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);\n    await goToProjectsOverview(page);\n    await page.getByRole('button', { name: 'Create Project' }).click();\n    await page.getByLabel('Project Name').fill(newProjectName);\n    await Promise.all([\n        page.getByRole('button', { name: 'Create Project' }).click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/projects') &&\n                response.request().method() === 'POST' &&\n                response.status() === 201 &&\n                (await response.json()).data.id !== null &&\n                (await response.json()).data.color !== null &&\n                (await response.json()).data.client_id === null &&\n                (await response.json()).data.name === newProjectName\n        ),\n    ]);\n\n    await expect(page.getByTestId('project_table')).toContainText(newProjectName);\n    await page.getByText(newProjectName).click();\n\n    const newTaskName = 'New Task ' + Math.floor(1 + Math.random() * 10000);\n    await page.getByRole('button', { name: 'Create Task' }).click();\n    await page.getByPlaceholder('Task Name').fill(newTaskName);\n\n    await Promise.all([\n        page.getByRole('button', { name: 'Create Task' }).click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/tasks') &&\n                response.request().method() === 'POST' &&\n                response.status() === 201 &&\n                (await response.json()).data.id !== null &&\n                (await response.json()).data.project_id !== null &&\n                (await response.json()).data.name === newTaskName\n        ),\n    ]);\n\n    await expect(page.getByTestId('task_table')).toContainText(newTaskName);\n\n    const taskMoreButton = page.locator(\"[aria-label='Actions for Task \" + newTaskName + \"']\");\n    taskMoreButton.click();\n    const taskDeleteButton = page.locator(\"[aria-label='Delete Task \" + newTaskName + \"']\");\n\n    await Promise.all([\n        taskDeleteButton.click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/tasks') &&\n                response.request().method() === 'DELETE' &&\n                response.status() === 204\n        ),\n    ]);\n    await expect(page.getByTestId('task_table')).not.toContainText(newTaskName);\n\n    await goToProjectsOverview(page);\n\n    const moreButton = page.locator(\"[aria-label='Actions for Project \" + newProjectName + \"']\");\n    moreButton.click();\n    const deleteButton = page.locator(\"[aria-label='Delete Project \" + newProjectName + \"']\");\n\n    await Promise.all([\n        deleteButton.click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/projects') &&\n                response.request().method() === 'DELETE' &&\n                response.status() === 204\n        ),\n    ]);\n    await expect(page.getByTestId('project_table')).not.toContainText(newProjectName);\n});\n\ntest('test that archiving and unarchiving tasks works', async ({ page, ctx }) => {\n    const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);\n    const newTaskName = 'New Task ' + Math.floor(1 + Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: newProjectName });\n    await createTaskViaApi(ctx, { name: newTaskName, project_id: project.id });\n\n    await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + project.id);\n    await expect(page.getByRole('table')).toContainText(newTaskName);\n\n    await page.getByRole('row').first().getByRole('button').click();\n    await Promise.all([\n        page.getByRole('menuitem').getByText('Mark as done').first().click(),\n        expect(page.getByText(newTaskName)).not.toBeVisible(),\n    ]);\n    await Promise.all([\n        page.getByRole('tab', { name: 'Done' }).click(),\n        expect(page.getByText(newTaskName)).toBeVisible(),\n    ]);\n\n    await page.getByRole('row').first().getByRole('button').click();\n    await Promise.all([\n        page.getByRole('menuitem').getByText('Mark as active').first().click(),\n        expect(page.getByText(newTaskName)).not.toBeVisible(),\n    ]);\n    await Promise.all([\n        page.getByRole('tab', { name: 'Active' }).click(),\n        expect(page.getByText(newTaskName)).toBeVisible(),\n    ]);\n});\n\ntest('test that editing a task name works', async ({ page, ctx }) => {\n    const projectName = 'TaskEdit Project ' + Math.floor(1 + Math.random() * 10000);\n    const originalTaskName = 'Original Task ' + Math.floor(1 + Math.random() * 10000);\n    const updatedTaskName = 'Updated Task ' + Math.floor(1 + Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createTaskViaApi(ctx, { name: originalTaskName, project_id: project.id });\n\n    await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + project.id);\n    await expect(page.getByTestId('task_table')).toContainText(originalTaskName);\n\n    // Open actions menu and click Edit\n    const moreButton = page.locator(\"[aria-label='Actions for Task \" + originalTaskName + \"']\");\n    await moreButton.click();\n    await page.getByRole('menuitem').getByText('Edit').click();\n\n    // Update the task name\n    await expect(page.getByRole('dialog')).toBeVisible();\n    await page.getByPlaceholder('Task Name').fill(updatedTaskName);\n    await Promise.all([\n        page.getByRole('button', { name: 'Update Task' }).click(),\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/tasks') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n    ]);\n\n    await expect(page.getByTestId('task_table')).toContainText(updatedTaskName);\n    await expect(page.getByTestId('task_table')).not.toContainText(originalTaskName);\n});\n\ntest('test that creating a project with an existing client works', async ({ page, ctx }) => {\n    const clientName = 'Existing Client ' + Math.floor(1 + Math.random() * 10000);\n    const projectName = 'Project With Client ' + Math.floor(1 + Math.random() * 10000);\n\n    await createClientViaApi(ctx, { name: clientName });\n\n    await goToProjectsOverview(page);\n    await page.getByRole('button', { name: 'Create Project' }).click();\n    await page.getByLabel('Project Name').fill(projectName);\n\n    // Select the existing client\n    await page.getByRole('dialog').getByRole('button', { name: 'No Client' }).click();\n    await page.getByRole('option', { name: clientName }).click();\n\n    await Promise.all([\n        page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/projects') &&\n                response.request().method() === 'POST' &&\n                response.status() === 201 &&\n                (await response.json()).data.client_id !== null\n        ),\n    ]);\n\n    await expect(page.getByTestId('project_table')).toContainText(projectName);\n    await expect(page.getByTestId('project_table')).toContainText(clientName);\n});\n\ntest('test that multiple tasks are displayed on project detail page', async ({ page, ctx }) => {\n    const projectName = 'TaskCount Project ' + Math.floor(1 + Math.random() * 10000);\n    const taskName1 = 'CountTask A ' + Math.floor(1 + Math.random() * 10000);\n    const taskName2 = 'CountTask B ' + Math.floor(1 + Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    await createTaskViaApi(ctx, { name: taskName1, project_id: project.id });\n    await createTaskViaApi(ctx, { name: taskName2, project_id: project.id });\n\n    await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + project.id);\n    await expect(page.getByText(taskName1)).toBeVisible();\n    await expect(page.getByText(taskName2)).toBeVisible();\n});\n\ntest('test that creating a new project from the task create modal project dropdown works', async ({\n    page,\n    ctx,\n}) => {\n    const existingProjectName = 'Existing Project ' + Math.floor(1 + Math.random() * 10000);\n    const newProjectName = 'Dropdown Created Project ' + Math.floor(1 + Math.random() * 10000);\n    const newTaskName = 'Task With New Project ' + Math.floor(1 + Math.random() * 10000);\n\n    const project = await createProjectViaApi(ctx, { name: existingProjectName });\n    await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + project.id);\n\n    // Open the Create Task modal\n    await page.getByRole('button', { name: 'Create Task' }).click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n    await page.getByPlaceholder('Task Name').fill(newTaskName);\n\n    // Open the project dropdown (it should show the current project)\n    await page.getByRole('dialog').getByRole('button', { name: existingProjectName }).click();\n\n    // Click \"Create new Project\" at the bottom of the dropdown\n    await page.getByText('Create new Project').click();\n\n    // The ProjectCreateModal should appear\n    await expect(page.getByLabel('Project name')).toBeVisible();\n    await page.getByLabel('Project name').fill(newProjectName);\n\n    // Submit the project creation\n    await Promise.all([\n        page.getByRole('button', { name: 'Create Project' }).click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/projects') &&\n                response.request().method() === 'POST' &&\n                response.status() === 201 &&\n                (await response.json()).data.name === newProjectName\n        ),\n    ]);\n\n    // The project dropdown trigger should now show the new project name\n    await expect(\n        page.getByRole('dialog').getByRole('button', { name: newProjectName })\n    ).toBeVisible();\n\n    // Submit the task and capture the response to get the new project ID\n    const [taskResponse] = await Promise.all([\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/tasks') &&\n                response.request().method() === 'POST' &&\n                response.status() === 201 &&\n                (await response.json()).data.name === newTaskName\n        ),\n        page.getByRole('button', { name: 'Create Task' }).click(),\n    ]);\n\n    const taskData = await taskResponse.json();\n    const newProjectId = taskData.data.project_id;\n\n    // Navigate to the new project's page and verify the task is there\n    await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + newProjectId);\n    await expect(page.getByTestId('task_table')).toContainText(newTaskName);\n});\n\n// =============================================\n// Employee Permission Tests\n// =============================================\n\ntest.describe('Employee Tasks Restrictions', () => {\n    test('employee cannot see task management actions when employees_can_manage_tasks is disabled', async ({\n        ctx,\n        employee,\n    }) => {\n        // Create a public project with a task\n        const projectName = 'EmpTaskProj ' + Math.floor(Math.random() * 10000);\n        const taskName = 'EmpTask ' + Math.floor(Math.random() * 10000);\n        const project = await createPublicProjectViaApi(ctx, { name: projectName });\n        await createTaskViaApi(ctx, { name: taskName, project_id: project.id });\n\n        // Navigate to the project detail page\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');\n        await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });\n        await employee.page.getByText(projectName).first().click();\n        await employee.page.waitForURL(/\\/projects\\/[a-f0-9-]+/);\n\n        // Task should be visible\n        await expect(employee.page.getByText(taskName)).toBeVisible({ timeout: 10000 });\n\n        // Create Task button should not be visible\n        await expect(employee.page.getByRole('button', { name: 'Create Task' })).not.toBeVisible();\n\n        // Task actions button should not be visible\n        const actionsButton = employee.page.locator(`[aria-label='Actions for Task ${taskName}']`);\n        await expect(actionsButton).not.toBeVisible();\n    });\n\n    test('employee can manage tasks when employees_can_manage_tasks is enabled', async ({\n        ctx,\n        employee,\n    }) => {\n        // Enable the setting\n        await updateOrganizationSettingViaApi(ctx, { employees_can_manage_tasks: true });\n\n        const projectName = 'EmpTaskMgmtProj ' + Math.floor(Math.random() * 10000);\n        await createPublicProjectViaApi(ctx, { name: projectName });\n\n        // Navigate to the project detail page\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');\n        await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });\n        await employee.page.getByText(projectName).first().click();\n        await employee.page.waitForURL(/\\/projects\\/[a-f0-9-]+/);\n\n        // Create Task button SHOULD be visible\n        await expect(employee.page.getByRole('button', { name: 'Create Task' })).toBeVisible();\n    });\n});\n"
  },
  {
    "path": "e2e/time.spec.ts",
    "content": "import { PLAYWRIGHT_BASE_URL } from '../playwright/config';\nimport { test } from '../playwright/fixtures';\nimport { expect } from '@playwright/test';\nimport type { Locator, Page } from '@playwright/test';\nimport {\n    assertThatTimerHasStarted,\n    assertThatTimerIsStopped,\n    newTimeEntryResponse,\n    startOrStopTimerWithButton,\n    stoppedTimeEntryResponse,\n} from './utils/currentTimeEntry';\nimport {\n    createProjectViaApi,\n    createBillableProjectViaApi,\n    createBareTimeEntryViaApi,\n    createTimeEntryViaApi,\n    updateOrganizationCurrencyViaWeb,\n} from './utils/api';\n\n// Date picker button name patterns for different date formats\n// Matches: \"Pick a date\", \"YYYY-MM-DD\", \"DD/MM/YYYY\", \"DD.MM.YYYY\", \"MM/DD/YYYY\", \"DD-MM-YYYY\", \"MM-DD-YYYY\"\nconst DATE_PICKER_BUTTON_PATTERN =\n    /^Pick a date$|^\\d{4}-\\d{2}-\\d{2}$|^\\d{2}\\/\\d{2}\\/\\d{4}$|^\\d{2}\\.\\d{2}\\.\\d{4}$/;\n// Same pattern but without \"Pick a date\" - for when we expect an actual date to be displayed\nconst DATE_DISPLAY_PATTERN = /^\\d{4}-\\d{2}-\\d{2}$|^\\d{2}\\/\\d{2}\\/\\d{4}$|^\\d{2}\\.\\d{2}\\.\\d{4}$/;\n\n/**\n * Extracts day of month from an ISO timestamp string\n */\nfunction getDayFromTimestamp(timestamp: string): number {\n    return new Date(timestamp).getUTCDate();\n}\n\n/**\n * Extracts month (1-indexed) from an ISO timestamp string\n */\nfunction getMonthFromTimestamp(timestamp: string): number {\n    return new Date(timestamp).getUTCMonth() + 1;\n}\n\nasync function goToTimeOverview(page: Page) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/time');\n}\n\nasync function goToOrganizationSettings(page: Page) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');\n    await page.locator('[data-testid=\"organization_switcher\"]:visible').click();\n    await page.getByText('Organization Settings').click();\n}\n\nasync function createEmptyTimeEntry(page: Page) {\n    await Promise.all([\n        newTimeEntryResponse(page),\n        startOrStopTimerWithButton(page),\n        assertThatTimerHasStarted(page),\n    ]);\n    // Wait for the timer to accumulate some duration so the stopped entry has duration > 0\n    await expect(page.getByTestId('time_entry_time')).not.toHaveValue('00:00:00');\n    await Promise.all([\n        stoppedTimeEntryResponse(page),\n        startOrStopTimerWithButton(page),\n        assertThatTimerIsStopped(page),\n        page.waitForResponse(\n            (response) => response.url().includes('/time-entries') && response.status() === 200\n        ),\n    ]);\n}\n\ntest('test that starting and stopping an empty time entry shows a new time entry in the overview', async ({\n    page,\n}) => {\n    await Promise.all([\n        goToTimeOverview(page),\n        page.waitForResponse(\n            (response) => response.url().includes('/time-entries') && response.status() === 200\n        ),\n    ]);\n    // check that there are not testid time_entry_row elements on the page\n    const timeEntryRows = page.locator('[data-testid=\"time_entry_row\"]');\n    const initialTimeEntryCount = await timeEntryRows.count();\n    await createEmptyTimeEntry(page);\n\n    await expect(timeEntryRows).toHaveCount(initialTimeEntryCount + 1);\n});\n\n// Test that description update works\n\nasync function assertThatTimeEntryRowIsStopped(newTimeEntry: Locator) {\n    await expect(newTimeEntry.getByTestId('timer_button').first()).toHaveClass(/bg-quaternary/);\n}\n\ntest('test that updating a description of a time entry in the overview works on blur', async ({\n    page,\n}) => {\n    await goToTimeOverview(page);\n    const timeEntryRows = page.locator('[data-testid=\"time_entry_row\"]');\n    await createEmptyTimeEntry(page);\n    const newTimeEntry = timeEntryRows.first();\n    await assertThatTimeEntryRowIsStopped(newTimeEntry);\n\n    const newDescription = Math.floor(Math.random() * 1000000).toString();\n    const descriptionElement = newTimeEntry.getByTestId('time_entry_description').first();\n    await descriptionElement.fill(newDescription);\n    await Promise.all([\n        descriptionElement.press('Tab'),\n        page.waitForResponse(async (response) => {\n            return (\n                response.status() === 200 &&\n                (await response.headerValue('Content-Type')) === 'application/json' &&\n                (await response.json()).data.id !== null &&\n                (await response.json()).data.start !== null &&\n                (await response.json()).data.end !== null &&\n                (await response.json()).data.project_id === null &&\n                (await response.json()).data.description == newDescription &&\n                (await response.json()).data.task_id === null &&\n                (await response.json()).data.duration !== null &&\n                (await response.json()).data.user_id !== null &&\n                JSON.stringify((await response.json()).data.tags) === JSON.stringify([])\n            );\n        }),\n    ]);\n});\n\ntest('test that updating a description of a time entry in the overview works on enter', async ({\n    page,\n}) => {\n    await goToTimeOverview(page);\n    const timeEntryRows = page.locator('[data-testid=\"time_entry_row\"]');\n    await createEmptyTimeEntry(page);\n\n    const newTimeEntry = timeEntryRows.first();\n    await assertThatTimeEntryRowIsStopped(newTimeEntry);\n    const newDescription = Math.floor(Math.random() * 1000000).toString();\n    const descriptionElement = newTimeEntry.getByTestId('time_entry_description').first();\n    await descriptionElement.fill(newDescription);\n    await Promise.all([\n        descriptionElement.press('Enter'),\n        page.waitForResponse(async (response) => {\n            return (\n                response.status() === 200 &&\n                (await response.headerValue('Content-Type')) === 'application/json' &&\n                (await response.json()).data.id !== null &&\n                (await response.json()).data.start !== null &&\n                (await response.json()).data.end !== null &&\n                (await response.json()).data.project_id === null &&\n                (await response.json()).data.description == newDescription &&\n                (await response.json()).data.task_id === null &&\n                (await response.json()).data.duration !== null &&\n                (await response.json()).data.user_id !== null &&\n                JSON.stringify((await response.json()).data.tags) === JSON.stringify([])\n            );\n        }),\n    ]);\n});\n\ntest('test that adding a new tag to an existing time entry works', async ({ page }) => {\n    await goToTimeOverview(page);\n    const timeEntryRows = page.locator('[data-testid=\"time_entry_row\"]');\n    await createEmptyTimeEntry(page);\n\n    const newTimeEntry = timeEntryRows.first();\n    await assertThatTimeEntryRowIsStopped(newTimeEntry);\n    const newTagName = Math.floor(Math.random() * 1000000).toString();\n\n    await newTimeEntry.getByTestId('time_entry_tag_dropdown').first().click();\n    await page.getByText('Create new tag').click();\n    await page.getByPlaceholder('Tag Name').fill(newTagName);\n\n    const [tagReponse] = await Promise.all([\n        page.waitForResponse(async (response) => {\n            return (\n                response.status() === 201 &&\n                (await response.headerValue('Content-Type')) === 'application/json' &&\n                (await response.json()).data.name === newTagName\n            );\n        }),\n        page.getByRole('button', { name: 'Create Tag' }).click(),\n    ]);\n\n    await page.waitForResponse(async (response) => {\n        return (\n            response.status() === 200 &&\n            (await response.headerValue('Content-Type')) === 'application/json' &&\n            (await response.json()).data.id !== null &&\n            (await response.json()).data.start !== null &&\n            (await response.json()).data.end !== null &&\n            JSON.stringify((await response.json()).data.tags) ===\n                JSON.stringify([(await tagReponse.json()).data.id])\n        );\n    });\n\n    await expect(newTimeEntry.getByText(newTagName).first()).toBeVisible();\n});\n\n// Test that Start / End Time Update Works\ntest('test that updating a the start of an existing time entry in the overview works on enter', async ({\n    page,\n}) => {\n    await goToTimeOverview(page);\n    const timeEntryRows = page.locator('[data-testid=\"time_entry_row\"]');\n    await createEmptyTimeEntry(page);\n\n    const newTimeEntry = timeEntryRows.first();\n    await assertThatTimeEntryRowIsStopped(newTimeEntry);\n    const timeEntryRangeElement = newTimeEntry.getByTestId('time_entry_range_selector');\n    await expect(timeEntryRangeElement).toBeVisible();\n    await timeEntryRangeElement.click();\n    await page.getByTestId('time_entry_range_start').first().fill('1');\n    await Promise.all([\n        page.waitForResponse(async (response) => {\n            return (\n                response.status() === 200 &&\n                (await response.headerValue('Content-Type')) === 'application/json' &&\n                (await response.json()).data.id !== null &&\n                // TODO! Actually check the value\n                (await response.json()).data.start !== null &&\n                (await response.json()).data.end !== null\n            );\n        }),\n        page.getByTestId('time_entry_range_end').press('Enter'),\n    ]);\n});\n\ntest('test that updating a the duration in the overview works on blur', async ({ page }) => {\n    await goToTimeOverview(page);\n    const timeEntryRows = page.locator('[data-testid=\"time_entry_row\"]');\n    await createEmptyTimeEntry(page);\n\n    const newTimeEntry = timeEntryRows.first();\n    await assertThatTimeEntryRowIsStopped(newTimeEntry);\n    const timeEntryDurationInput = newTimeEntry.locator('input[name=\"Duration\"]').first();\n    await expect(timeEntryDurationInput).toBeEditable();\n    await timeEntryDurationInput.fill('20min');\n\n    await Promise.all([\n        page.waitForResponse(async (response) => {\n            return (\n                response.status() === 200 &&\n                (await response.headerValue('Content-Type')) === 'application/json' &&\n                (await response.json()).data.id !== null &&\n                // TODO! Actually check the value\n                (await response.json()).data.start !== null &&\n                (await response.json()).data.end !== null\n            );\n        }),\n        timeEntryDurationInput.press('Tab'),\n    ]);\n\n    await expect(timeEntryDurationInput).toHaveValue('0h 20min');\n});\n\n// Test that start stop button stops running timer\ntest('test that starting a time entry from the overview works', async ({ page }) => {\n    await goToTimeOverview(page);\n    const timeEntryRows = page.locator('[data-testid=\"time_entry_row\"]');\n    await createEmptyTimeEntry(page);\n\n    const newTimeEntry = timeEntryRows.first();\n    const startButton = newTimeEntry.getByTestId('timer_button').first();\n    await expect(startButton).toHaveClass(/bg-quaternary/);\n\n    await Promise.all([\n        page.waitForResponse(async (response) => {\n            return (\n                response.status() === 200 &&\n                (await response.headerValue('Content-Type')) === 'application/json' &&\n                (await response.json()).data.id !== null &&\n                (await response.json()).data.start !== null &&\n                (await response.json()).data.end !== null\n            );\n        }),\n        startButton.click(),\n    ]);\n\n    await assertThatTimerHasStarted(page);\n\n    // Wait for the timer to accumulate some duration\n    await expect(page.getByTestId('time_entry_time')).not.toHaveValue('00:00:00');\n    await Promise.all([\n        page.waitForResponse(async (response) => {\n            return (\n                response.status() === 200 &&\n                (await response.headerValue('Content-Type')) === 'application/json' &&\n                (await response.json()).data.id !== null &&\n                (await response.json()).data.start !== null &&\n                (await response.json()).data.end !== null\n            );\n        }),\n        startOrStopTimerWithButton(page),\n    ]);\n    await assertThatTimerIsStopped(page);\n});\n\ntest('test that deleting a time entry from the overview works', async ({ page }) => {\n    await goToTimeOverview(page);\n    const timeEntryRows = page.locator('[data-testid=\"time_entry_row\"]');\n    await createEmptyTimeEntry(page);\n    await expect(timeEntryRows).toHaveCount(1);\n\n    const newTimeEntry = timeEntryRows.first();\n    const actionsDropdown = newTimeEntry\n        .getByRole('button', { name: 'Actions for the time entry' })\n        .first();\n    await actionsDropdown.click();\n    const deleteButton = page.getByText('Delete');\n    await deleteButton.click();\n    await expect(timeEntryRows).toHaveCount(0);\n});\n\ntest.skip('test that load more works when the end of page is reached', async ({ page }) => {\n    // this test is flaky when you do not need to scroll\n    await Promise.all([\n        goToTimeOverview(page),\n        page.waitForResponse(\n            (response) => response.url().includes('/time-entries') && response.status() === 200\n        ),\n    ]);\n\n    await Promise.all([\n        page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)),\n        page.waitForResponse(async (response) => {\n            return (\n                response.status() === 200 &&\n                response.url().includes('before') &&\n                (await response.headerValue('Content-Type')) === 'application/json' &&\n                JSON.stringify((await response.json()).data) === JSON.stringify([])\n            );\n        }),\n    ]);\n\n    // assert that \"All time entries are loaded!\" is visible on page\n    await expect(page.locator('body')).toHaveText(/All time entries are loaded!/);\n});\n\n// TODO: Test that updating the time entry start / end times works while it is running\n\n// TODO: Test for project update\n\n// TODO: Test for resume button click works with project / task\n\n// TODO: Test that time entries are loaded at the end of the page\n\n// TODO: Test Grouped time entries by description/project\n\n// Date Update Tests\n\ntest('test that updating the start date of a time entry via the edit modal works', async ({\n    page,\n    ctx,\n}) => {\n    await createBareTimeEntryViaApi(ctx, 'Date edit test', '1h');\n    await goToTimeOverview(page);\n\n    const timeEntryRows = page.locator('[data-testid=\"time_entry_row\"]');\n    const newTimeEntry = timeEntryRows.first();\n\n    // Open edit modal via the actions dropdown\n    const actionsDropdown = newTimeEntry\n        .getByRole('button', { name: 'Actions for the time entry' })\n        .first();\n    await actionsDropdown.click();\n    await page.getByTestId('time_entry_edit').click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // Click the start date picker (first date picker button in the Start section)\n    const startDatePicker = page\n        .getByRole('dialog')\n        .getByRole('button', { name: DATE_PICKER_BUTTON_PATTERN })\n        .first();\n    await startDatePicker.click();\n\n    // Navigate to the previous month and select the 15th\n    await page.getByRole('button', { name: /Previous/i }).click();\n    await page.getByRole('gridcell').filter({ hasText: /^15$/ }).first().click();\n\n    // Get current month to calculate expected month after going to previous\n    const now = new Date();\n    const expectedMonth = now.getMonth() === 0 ? 12 : now.getMonth(); // Previous month (1-indexed)\n\n    // Submit the update and verify the response has correct date\n    const [updateResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/time-entries') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n        page.getByRole('button', { name: 'Update Time Entry' }).click(),\n    ]);\n    const updateBody = await updateResponse.json();\n    expect(updateBody.data.start).toBeTruthy();\n    expect(updateBody.data.end).toBeTruthy();\n    // Verify the day was changed to 15th\n    expect(getDayFromTimestamp(updateBody.data.start)).toBe(15);\n    // Verify the month is the previous month\n    expect(getMonthFromTimestamp(updateBody.data.start)).toBe(expectedMonth);\n});\n\ntest('test that setting a date in the create modal works', async ({ page }) => {\n    await goToTimeOverview(page);\n\n    // Get today's date to compare later\n    const today = new Date();\n\n    // Open create modal\n    await page.getByRole('button', { name: 'Time entry actions' }).click();\n    await page.getByRole('menuitem', { name: 'Manual time entry' }).click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // Set description\n    await page\n        .getByRole('dialog')\n        .getByRole('textbox', { name: 'Description' })\n        .fill('Date picker test entry');\n\n    // Set duration first (to ensure the form is valid)\n    await page.locator('[role=\"dialog\"] input[name=\"Duration\"]').fill('1h');\n    await page.locator('[role=\"dialog\"] input[name=\"Duration\"]').press('Tab');\n\n    // Click the start date picker\n    const startDatePicker = page\n        .getByRole('dialog')\n        .getByRole('button', { name: DATE_PICKER_BUTTON_PATTERN })\n        .first();\n    await startDatePicker.click();\n\n    // Wait for calendar to appear\n    const calendarGrid = page.getByRole('grid');\n    await expect(calendarGrid).toBeVisible({ timeout: 5000 });\n\n    // Navigate to previous month and select the 15th (a day that's always in the middle of the month)\n    await page.getByRole('button', { name: /Previous/i }).click();\n    await page.getByRole('gridcell', { name: '15' }).getByRole('button').click();\n\n    // Wait for calendar to close\n    await expect(calendarGrid).not.toBeVisible();\n\n    // Get current month to calculate expected month after going to previous\n    const expectedMonth = today.getMonth() === 0 ? 12 : today.getMonth(); // Previous month (1-indexed)\n\n    // Submit and verify creation succeeds with correct date\n    const [createResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) => response.url().includes('/time-entries') && response.status() === 201\n        ),\n        page.getByRole('button', { name: 'Create Time Entry' }).click(),\n    ]);\n    const createBody = await createResponse.json();\n    expect(createBody.data.start).toBeTruthy();\n    // Verify the day was set to 15th\n    expect(getDayFromTimestamp(createBody.data.start)).toBe(15);\n    // Verify the month is the previous month\n    expect(getMonthFromTimestamp(createBody.data.start)).toBe(expectedMonth);\n});\n\ntest('test that updating the date via the time entry row range selector works', async ({\n    page,\n    ctx,\n}) => {\n    await createBareTimeEntryViaApi(ctx, 'Date range test', '1h');\n    await goToTimeOverview(page);\n\n    const timeEntryRows = page.locator('[data-testid=\"time_entry_row\"]');\n    const newTimeEntry = timeEntryRows.first();\n    await expect(newTimeEntry).toBeVisible();\n\n    // Open the time range popover\n    const timeEntryRangeElement = newTimeEntry.getByTestId('time_entry_range_selector');\n    await timeEntryRangeElement.click();\n\n    // Verify the range selector dropdown is open\n    const rangeStart = page.getByTestId('time_entry_range_start');\n    await expect(rangeStart).toBeVisible();\n\n    // Click the start date picker button within the range selector\n    const startDatePicker = page.getByRole('button', { name: DATE_DISPLAY_PATTERN }).first();\n    await expect(startDatePicker).toBeVisible();\n    await startDatePicker.click();\n\n    // Wait for the calendar to appear and select a day\n    const calendarGrid = page.getByRole('grid');\n    await expect(calendarGrid).toBeVisible({ timeout: 5000 });\n\n    // Navigate to previous month and select the 5th\n    await page.getByRole('button', { name: /Previous/i }).click();\n    await page.getByRole('gridcell').filter({ hasText: /^5$/ }).first().click();\n\n    // Get current month to calculate expected month after going to previous\n    const now = new Date();\n    const expectedMonth = now.getMonth() === 0 ? 12 : now.getMonth(); // Previous month (1-indexed)\n\n    // Verify the time entry update API call succeeds with correct date\n    const updateResponse = await page.waitForResponse(async (response) => {\n        return (\n            response.status() === 200 &&\n            response.request().method() === 'PUT' &&\n            (await response.headerValue('Content-Type')) === 'application/json'\n        );\n    });\n    const updateBody = await updateResponse.json();\n    expect(updateBody.data.start).toBeTruthy();\n    // Verify the day was changed to 5th\n    expect(getDayFromTimestamp(updateBody.data.start)).toBe(5);\n    // Verify the month is the previous month\n    expect(getMonthFromTimestamp(updateBody.data.start)).toBe(expectedMonth);\n});\n\ntest('test that updating the end date via the time entry row range selector works', async ({\n    page,\n    ctx,\n}) => {\n    await createBareTimeEntryViaApi(ctx, 'End date range test', '1h');\n    await goToTimeOverview(page);\n\n    const timeEntryRows = page.locator('[data-testid=\"time_entry_row\"]');\n    const newTimeEntry = timeEntryRows.first();\n    await expect(newTimeEntry).toBeVisible();\n\n    // Open the time range popover\n    const timeEntryRangeElement = newTimeEntry.getByTestId('time_entry_range_selector');\n    await timeEntryRangeElement.click();\n\n    // Verify the range selector dropdown is open\n    const rangeEnd = page.getByTestId('time_entry_range_end');\n    await expect(rangeEnd).toBeVisible();\n\n    // Click the end date picker button (second date picker)\n    const datePickers = page.getByRole('button', { name: DATE_DISPLAY_PATTERN });\n    const endDatePicker = datePickers.nth(1);\n    await expect(endDatePicker).toBeVisible();\n    await endDatePicker.click();\n\n    // Wait for the calendar to appear\n    const calendarGrid = page.getByRole('grid');\n    await expect(calendarGrid).toBeVisible({ timeout: 5000 });\n\n    // Navigate to next month and select the 20th (to ensure end > start)\n    await page.getByRole('button', { name: /Next/i }).click();\n    await page.getByRole('gridcell').filter({ hasText: /^20$/ }).first().click();\n\n    // Get current month to calculate expected month after going to next\n    const now = new Date();\n    const expectedMonth = now.getMonth() === 11 ? 1 : now.getMonth() + 2; // Next month (1-indexed)\n\n    // Verify the time entry update API call succeeds with correct date\n    const updateResponse = await page.waitForResponse(async (response) => {\n        return (\n            response.status() === 200 &&\n            response.request().method() === 'PUT' &&\n            (await response.headerValue('Content-Type')) === 'application/json'\n        );\n    });\n    const updateBody = await updateResponse.json();\n    expect(updateBody.data.end).toBeTruthy();\n    // Verify the day was changed to 20th\n    expect(getDayFromTimestamp(updateBody.data.end)).toBe(20);\n    // Verify the month is the next month\n    expect(getMonthFromTimestamp(updateBody.data.end)).toBe(expectedMonth);\n});\n\ntest('test that date picker displays date in organization date format', async ({ page, ctx }) => {\n    // First change the organization date format to DD/MM/YYYY\n    await goToOrganizationSettings(page);\n    await page.getByLabel('Date Format').click();\n    await page.getByRole('option', { name: 'DD/MM/YYYY' }).click();\n    await Promise.all([\n        page\n            .locator('form')\n            .filter({ hasText: 'Date Format' })\n            .getByRole('button', { name: 'Save' })\n            .click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/organizations/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200 &&\n                (await response.json()).data.date_format === 'slash-separated-dd-mm-yyyy'\n        ),\n    ]);\n\n    // Create a time entry and open the edit modal\n    await createBareTimeEntryViaApi(ctx, 'Date format test', '1h');\n    await goToTimeOverview(page);\n\n    const timeEntryRows = page.locator('[data-testid=\"time_entry_row\"]');\n    const newTimeEntry = timeEntryRows.first();\n    await expect(newTimeEntry).toBeVisible();\n\n    // Open edit modal\n    const actionsDropdown = newTimeEntry\n        .getByRole('button', { name: 'Actions for the time entry' })\n        .first();\n    await actionsDropdown.click();\n    await page.getByTestId('time_entry_edit').click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // Verify the date picker shows the date in DD/MM/YYYY format\n    const datePicker = page\n        .getByRole('dialog')\n        .getByRole('button', { name: /^\\d{2}\\/\\d{2}\\/\\d{4}$/ })\n        .first();\n    await expect(datePicker).toBeVisible();\n});\n\n// TODO: Test that project can be created in the time entry row\n\ntest('test that billable icon shows dollar sign for USD currency on time entry row', async ({\n    page,\n    ctx,\n}) => {\n    await updateOrganizationCurrencyViaWeb(page, ctx, 'USD');\n    await goToTimeOverview(page);\n    await createEmptyTimeEntry(page);\n    const timeEntryRow = page.locator('[data-testid=\"time_entry_row\"]').first();\n    const billableButton = timeEntryRow.getByRole('button', { name: 'Non Billable' }).first();\n    await expect(billableButton).toBeVisible();\n    await expect(billableButton.locator('svg')).toHaveAttribute('viewBox', '0 0 8 14');\n});\n\ntest('test that billable icon shows euro sign for EUR currency on time entry row', async ({\n    page,\n    ctx,\n}) => {\n    await updateOrganizationCurrencyViaWeb(page, ctx, 'EUR');\n    await goToTimeOverview(page);\n    await createEmptyTimeEntry(page);\n    const timeEntryRow = page.locator('[data-testid=\"time_entry_row\"]').first();\n    const billableButton = timeEntryRow.getByRole('button', { name: 'Non Billable' }).first();\n    await expect(billableButton).toBeVisible();\n    await expect(billableButton.locator('svg')).toHaveAttribute('viewBox', '0 0 12 12');\n});\n\ntest('test that editing billable status via the edit modal works', async ({ page }) => {\n    await goToTimeOverview(page);\n    await createEmptyTimeEntry(page);\n\n    const timeEntryRows = page.locator('[data-testid=\"time_entry_row\"]');\n    const newTimeEntry = timeEntryRows.first();\n    await assertThatTimeEntryRowIsStopped(newTimeEntry);\n\n    // Open edit modal via the actions dropdown\n    const actionsDropdown = newTimeEntry\n        .getByRole('button', { name: 'Actions for the time entry' })\n        .first();\n    await actionsDropdown.click();\n    await page.getByTestId('time_entry_edit').click();\n\n    // Verify the edit dialog is visible\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // Change billable status to Billable\n    await page\n        .getByRole('dialog')\n        .getByRole('combobox')\n        .filter({ hasText: 'Non-Billable' })\n        .click();\n    await page.getByRole('option', { name: 'Billable', exact: true }).click();\n\n    // Save the time entry and verify the response has billable=true\n    const [updateResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) => response.url().includes('/time-entries') && response.status() === 200\n        ),\n        page.getByRole('button', { name: 'Update Time Entry' }).click(),\n    ]);\n    const updateBody = await updateResponse.json();\n    expect(updateBody.data.billable).toBe(true);\n\n    // Verify the dialog closed\n    await expect(page.getByRole('dialog')).not.toBeVisible();\n\n    // Re-open the edit modal and verify it now shows \"Billable\"\n    await actionsDropdown.click();\n    await page.getByTestId('time_entry_edit').click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n    await expect(\n        page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' })\n    ).toBeVisible();\n});\n\ntest('test that mass update billable status works', async ({ page }) => {\n    await goToTimeOverview(page);\n    await createEmptyTimeEntry(page);\n\n    const timeEntryRows = page.locator('[data-testid=\"time_entry_row\"]');\n    await assertThatTimeEntryRowIsStopped(timeEntryRows.first());\n\n    // Select the time entry via the \"Select All\" checkbox\n    await page.getByLabel('Select All').click();\n    await expect(page.getByText('1 selected')).toBeVisible();\n\n    // Open mass update modal via the Edit button in the mass action row\n    await page.getByRole('button', { name: 'Edit' }).click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // Change billable status to Billable\n    await page\n        .getByRole('dialog')\n        .getByRole('combobox')\n        .filter({ hasText: 'Set billable status' })\n        .click();\n    await page.getByRole('option', { name: 'Billable', exact: true }).click();\n\n    // Submit the mass update\n    const [massUpdateResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) => response.url().includes('/time-entries') && response.status() === 200\n        ),\n        page.getByRole('button', { name: 'Update Time Entries' }).click(),\n    ]);\n    const massUpdateBody = await massUpdateResponse.json();\n    expect(massUpdateBody.success.length).toBeGreaterThan(0);\n    expect(massUpdateBody.error.length).toBe(0);\n\n    // Verify dialog closes\n    await expect(page.getByRole('dialog')).not.toBeVisible();\n\n    // Verify the UI reflects the billable status by re-opening the edit modal\n    const actionsDropdown = page\n        .locator('[data-testid=\"time_entry_row\"]')\n        .first()\n        .getByRole('button', { name: 'Actions' });\n    await actionsDropdown.click();\n    await page.getByTestId('time_entry_edit').click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n    await expect(\n        page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' })\n    ).toBeVisible();\n});\n\ntest('test that resetting project selection in mass update modal does not update project', async ({\n    page,\n    ctx,\n}) => {\n    const projectName = 'Mass Update Reset Project ' + Math.floor(1 + Math.random() * 10000);\n    await createProjectViaApi(ctx, { name: projectName });\n\n    // Create a time entry with the project assigned\n    await createBareTimeEntryViaApi(ctx, 'Mass update reset test', '1h');\n    await goToTimeOverview(page);\n\n    // Assign project to the time entry\n    const timeEntryRow = page.locator('[data-testid=\"time_entry_row\"]').first();\n    await expect(timeEntryRow).toBeVisible();\n    await timeEntryRow.getByRole('button', { name: 'No Project' }).click();\n    await page.getByRole('option', { name: projectName }).click();\n    await expect(timeEntryRow.getByRole('button', { name: projectName })).toBeVisible();\n\n    // Now open mass update modal\n    await page.getByLabel('Select All').click();\n    await expect(page.getByText('1 selected')).toBeVisible();\n    await page.getByRole('button', { name: 'Edit' }).click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // The project dropdown should show \"Select project...\" (initial unset state)\n    const projectDropdown = page\n        .getByRole('dialog')\n        .getByRole('button', { name: 'Select project...' });\n    await expect(projectDropdown).toBeVisible();\n\n    // Select the project, then click the reset (X) button\n    await projectDropdown.click();\n    await page.getByRole('option', { name: projectName }).click();\n\n    // Now the dropdown shows the project name, click the X to reset\n    await expect(page.getByRole('dialog').getByRole('button', { name: projectName })).toBeVisible();\n\n    // Find and click the reset button (the X icon next to the dropdown)\n    await page.getByRole('dialog').getByTestId('project_reset_button').click();\n\n    // After reset, it should show \"Select project...\" again (not \"No Project\")\n    await expect(\n        page.getByRole('dialog').getByRole('button', { name: 'Select project...' })\n    ).toBeVisible();\n\n    // Submit the mass update - need to make at least one change for the API to accept it\n    // Change billable status to keep it unchanged by selecting the \"Keep current\" option\n    // Actually, we need to verify the reset behavior, so let's just change billable to trigger the request\n    await page\n        .getByRole('dialog')\n        .getByRole('combobox')\n        .filter({ hasText: 'Set billable status' })\n        .click();\n    await page.getByRole('option', { name: 'Billable', exact: true }).click();\n\n    await page.getByRole('button', { name: 'Update Time Entries' }).click();\n\n    // Wait for dialog to close\n    await expect(page.getByRole('dialog')).not.toBeVisible();\n\n    // Verify the time entry still has the original project (was not changed to \"No Project\")\n    await expect(\n        page\n            .locator('[data-testid=\"time_entry_row\"]')\n            .first()\n            .getByRole('button', { name: projectName })\n    ).toBeVisible();\n});\n\ntest('test that setting billable status via the create modal works', async ({ page }) => {\n    await goToTimeOverview(page);\n\n    // Open the dropdown menu and click \"Manual time entry\"\n    await page.getByRole('button', { name: 'Time entry actions' }).click();\n    await page.getByRole('menuitem', { name: 'Manual time entry' }).click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // Set description\n    await page\n        .getByRole('dialog')\n        .getByRole('textbox', { name: 'Description' })\n        .fill('Billable create test');\n\n    // Change billable status to Billable\n    await page\n        .getByRole('dialog')\n        .getByRole('combobox')\n        .filter({ hasText: 'Non-Billable' })\n        .click();\n    await page.getByRole('option', { name: 'Billable', exact: true }).click();\n\n    // Set duration\n    await page.locator('[role=\"dialog\"] input[name=\"Duration\"]').fill('1h');\n    await page.locator('[role=\"dialog\"] input[name=\"Duration\"]').press('Tab');\n\n    // Submit and verify the time entry was created with billable=true\n    await Promise.all([\n        page.getByRole('button', { name: 'Create Time Entry' }).click(),\n        page.waitForResponse(\n            async (response) =>\n                response.url().includes('/time-entries') &&\n                response.status() === 201 &&\n                (await response.json()).data.billable === true\n        ),\n    ]);\n});\n\n/**\n * The following tests verify that changing the project on a time entry\n * updates the billable status to match the new project's is_billable setting.\n *\n * Issue: https://github.com/solidtime-io/solidtime/issues/981\n */\n\ntest('test that changing project on a time entry row from non-billable to billable updates billable status', async ({\n    page,\n    ctx,\n}) => {\n    const billableProjectName = 'Billable Row Project ' + Math.floor(1 + Math.random() * 10000);\n    const nonBillableProjectName =\n        'NonBillable Row Project ' + Math.floor(1 + Math.random() * 10000);\n    await createProjectViaApi(ctx, { name: nonBillableProjectName });\n    await createBillableProjectViaApi(ctx, { name: billableProjectName });\n    await createBareTimeEntryViaApi(ctx, 'Test billable row', '1h');\n\n    await goToTimeOverview(page);\n    const timeEntryRow = page.locator('[data-testid=\"time_entry_row\"]').first();\n\n    // Assign the non-billable project first\n    await timeEntryRow.getByRole('button', { name: 'No Project' }).click();\n    await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/time-entries/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n        page.getByRole('option', { name: nonBillableProjectName }).click(),\n    ]);\n\n    // Now switch to the billable project\n    await timeEntryRow.getByRole('button', { name: nonBillableProjectName }).click();\n    const [updateResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/time-entries/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n        page.getByRole('option', { name: billableProjectName }).click(),\n    ]);\n    const responseBody = await updateResponse.json();\n    expect(responseBody.data.billable).toBe(true);\n});\n\ntest('test that changing project on a time entry row from billable to non-billable updates billable status', async ({\n    page,\n    ctx,\n}) => {\n    const billableProjectName = 'Billable Row Rev Project ' + Math.floor(1 + Math.random() * 10000);\n    const nonBillableProjectName =\n        'NonBillable Row Rev Project ' + Math.floor(1 + Math.random() * 10000);\n    await createBillableProjectViaApi(ctx, { name: billableProjectName });\n    await createProjectViaApi(ctx, { name: nonBillableProjectName });\n    await createBareTimeEntryViaApi(ctx, 'Test billable row reverse', '1h');\n\n    await goToTimeOverview(page);\n    const timeEntryRow = page.locator('[data-testid=\"time_entry_row\"]').first();\n\n    // Assign the billable project first\n    await timeEntryRow.getByRole('button', { name: 'No Project' }).click();\n    const [firstResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/time-entries/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n        page.getByRole('option', { name: billableProjectName }).click(),\n    ]);\n    const firstBody = await firstResponse.json();\n    expect(firstBody.data.billable).toBe(true);\n\n    // Now switch to the non-billable project\n    await timeEntryRow.getByRole('button', { name: billableProjectName }).click();\n    const [updateResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/time-entries/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n        page.getByRole('option', { name: nonBillableProjectName }).click(),\n    ]);\n    const responseBody = await updateResponse.json();\n    expect(responseBody.data.billable).toBe(false);\n});\n\n/**\n * Tests for TimeEntryCreateModal functionality\n */\n\ntest('test that natural language duration input works in create modal', async ({ page }) => {\n    await goToTimeOverview(page);\n\n    // Open the create modal\n    await page.getByRole('button', { name: 'Time entry actions' }).click();\n    await page.getByRole('menuitem', { name: 'Manual time entry' }).click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // Set description\n    await page\n        .getByRole('dialog')\n        .getByRole('textbox', { name: 'Description' })\n        .fill('Duration test entry');\n\n    // Test natural language duration input \"2h 30m\"\n    const durationInput = page.locator('[role=\"dialog\"] input[name=\"Duration\"]');\n    await durationInput.fill('2h 30m');\n    await durationInput.press('Tab');\n\n    // Verify the duration was parsed correctly (should show \"2h 30min\")\n    await expect(durationInput).toHaveValue('2h 30min');\n\n    // Submit and verify the duration in the response (2h 30m = 9000 seconds)\n    const [createResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) => response.url().includes('/time-entries') && response.status() === 201\n        ),\n        page.getByRole('button', { name: 'Create Time Entry' }).click(),\n    ]);\n    const createBody = await createResponse.json();\n    expect(createBody.data.duration).toBe(9000);\n});\n\ntest('test that decimal duration input works in create modal', async ({ page }) => {\n    await goToTimeOverview(page);\n\n    // Open the create modal\n    await page.getByRole('button', { name: 'Time entry actions' }).click();\n    await page.getByRole('menuitem', { name: 'Manual time entry' }).click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // Set description\n    await page\n        .getByRole('dialog')\n        .getByRole('textbox', { name: 'Description' })\n        .fill('Decimal duration test');\n\n    // Test decimal duration input \"1.5h\" (should be interpreted as 1.5 hours = 90 minutes)\n    // Note: parse-duration library requires a unit suffix for decimal values\n    const durationInput = page.locator('[role=\"dialog\"] input[name=\"Duration\"]');\n    await durationInput.fill('1.5h');\n    await durationInput.press('Tab');\n\n    // Verify the duration was parsed correctly (should show \"1h 30min\")\n    await expect(durationInput).toHaveValue('1h 30min');\n\n    // Submit and verify the duration in the response (1.5h = 5400 seconds)\n    const [createResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) => response.url().includes('/time-entries') && response.status() === 201\n        ),\n        page.getByRole('button', { name: 'Create Time Entry' }).click(),\n    ]);\n    const createBody = await createResponse.json();\n    expect(createBody.data.duration).toBe(5400);\n});\n\ntest('test that project selection works in create modal', async ({ page, ctx }) => {\n    const projectName = 'Create Modal Project ' + Math.floor(1 + Math.random() * 10000);\n    await createProjectViaApi(ctx, { name: projectName });\n\n    await goToTimeOverview(page);\n\n    // Open the create modal\n    await page.getByRole('button', { name: 'Time entry actions' }).click();\n    await page.getByRole('menuitem', { name: 'Manual time entry' }).click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // Set description\n    await page\n        .getByRole('dialog')\n        .getByRole('textbox', { name: 'Description' })\n        .fill('Project selection test');\n\n    // Select project\n    await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click();\n    await page.getByRole('option', { name: projectName }).click();\n\n    // Verify project is selected\n    await expect(page.getByRole('dialog').getByRole('button', { name: projectName })).toBeVisible();\n\n    // Set duration\n    await page.locator('[role=\"dialog\"] input[name=\"Duration\"]').fill('1h');\n    await page.locator('[role=\"dialog\"] input[name=\"Duration\"]').press('Tab');\n\n    // Submit and verify project_id is set in response\n    const [createResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) => response.url().includes('/time-entries') && response.status() === 201\n        ),\n        page.getByRole('button', { name: 'Create Time Entry' }).click(),\n    ]);\n    const createBody = await createResponse.json();\n    expect(createBody.data.project_id).not.toBeNull();\n});\n\ntest('test that tag selection works in create modal', async ({ page }) => {\n    await goToTimeOverview(page);\n\n    // Open the create modal\n    await page.getByRole('button', { name: 'Time entry actions' }).click();\n    await page.getByRole('menuitem', { name: 'Manual time entry' }).click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // Set description\n    await page\n        .getByRole('dialog')\n        .getByRole('textbox', { name: 'Description' })\n        .fill('Tag selection test');\n\n    // Open tags dropdown\n    await page.getByRole('dialog').getByRole('button', { name: 'Tags' }).click();\n\n    // Create a new tag\n    const tagName = 'TestTag' + Math.floor(1 + Math.random() * 10000);\n    await page.getByText('Create new tag').click();\n    await page.getByPlaceholder('Tag Name').fill(tagName);\n    const [tagResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) => response.url().includes('/tags') && response.status() === 201\n        ),\n        page.getByRole('button', { name: 'Create Tag' }).click(),\n    ]);\n    const tagBody = await tagResponse.json();\n    const tagId = tagBody.data.id;\n\n    // Verify tag button now shows \"1 Tag\"\n    await expect(page.getByRole('dialog').getByRole('button', { name: '1 Tag' })).toBeVisible();\n\n    // Set duration\n    await page.locator('[role=\"dialog\"] input[name=\"Duration\"]').fill('1h');\n    await page.locator('[role=\"dialog\"] input[name=\"Duration\"]').press('Tab');\n\n    // Submit and verify tags array contains the created tag\n    const [createResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) => response.url().includes('/time-entries') && response.status() === 201\n        ),\n        page.getByRole('button', { name: 'Create Time Entry' }).click(),\n    ]);\n    const createBody = await createResponse.json();\n    expect(createBody.data.tags).toContain(tagId);\n});\n\ntest('test that tags dropdown does not show No Tag option in create modal', async ({ page }) => {\n    await goToTimeOverview(page);\n\n    // Open the create modal\n    await page.getByRole('button', { name: 'Time entry actions' }).click();\n    await page.getByRole('menuitem', { name: 'Manual time entry' }).click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // Open tags dropdown\n    await page.getByRole('dialog').getByRole('button', { name: 'Tags' }).click();\n\n    // Verify \"No Tag\" option is not visible\n    await expect(page.getByText('No Tag')).not.toBeVisible();\n});\n\ntest('test that start time picker works in create modal', async ({ page }) => {\n    await goToTimeOverview(page);\n\n    // Open the create modal\n    await page.getByRole('button', { name: 'Time entry actions' }).click();\n    await page.getByRole('menuitem', { name: 'Manual time entry' }).click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // Set description\n    await page\n        .getByRole('dialog')\n        .getByRole('textbox', { name: 'Description' })\n        .fill('Time picker test');\n\n    // Set duration first (so it doesn't recalculate start time when we set it)\n    await page.locator('[role=\"dialog\"] input[name=\"Duration\"]').fill('1h');\n    await page.locator('[role=\"dialog\"] input[name=\"Duration\"]').press('Tab');\n\n    // Find the start time input (first time_picker_input in the modal)\n    const modal = page.getByRole('dialog');\n    const startTimeInput = modal.getByTestId('time_picker_input').first();\n    await startTimeInput.fill('09:30');\n    await startTimeInput.press('Tab');\n\n    // Verify the time picker input shows the correct value\n    await expect(startTimeInput).toHaveValue('09:30');\n\n    // Submit and verify the time entry was created\n    const [createResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) => response.url().includes('/time-entries') && response.status() === 201\n        ),\n        page.getByRole('button', { name: 'Create Time Entry' }).click(),\n    ]);\n    const createBody = await createResponse.json();\n    // The start time should contain 09:30 in the timestamp\n    expect(createBody.data.start).toMatch(/09:30/);\n});\n\ntest('test that end time picker works in create modal', async ({ page }) => {\n    await goToTimeOverview(page);\n\n    // Open the create modal\n    await page.getByRole('button', { name: 'Time entry actions' }).click();\n    await page.getByRole('menuitem', { name: 'Manual time entry' }).click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // Set description\n    await page\n        .getByRole('dialog')\n        .getByRole('textbox', { name: 'Description' })\n        .fill('End time picker test');\n\n    // Find the end time input (second time_picker_input in the modal)\n    const modal = page.getByRole('dialog');\n    const endTimeInput = modal.getByTestId('time_picker_input').nth(1);\n    await endTimeInput.fill('17:45');\n    await endTimeInput.press('Tab');\n\n    // Set duration (this will adjust based on the times)\n    // clear() before fill() needed because fill() appends on Firefox instead of replacing\n    await page.locator('[role=\"dialog\"] input[name=\"Duration\"]').clear();\n    await page.locator('[role=\"dialog\"] input[name=\"Duration\"]').fill('1h');\n    await page.locator('[role=\"dialog\"] input[name=\"Duration\"]').press('Tab');\n\n    // Submit and verify end time contains 17:45\n    const [createResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) => response.url().includes('/time-entries') && response.status() === 201\n        ),\n        page.getByRole('button', { name: 'Create Time Entry' }).click(),\n    ]);\n    const createBody = await createResponse.json();\n    // The end time should be set (we filled duration after, so it recalculates)\n    expect(createBody.data.end).toBeTruthy();\n});\n\ntest('test that changing project in edit modal from non-billable to billable updates billable status', async ({\n    page,\n    ctx,\n}) => {\n    const billableProjectName = 'Billable Modal Project ' + Math.floor(1 + Math.random() * 10000);\n    await createBillableProjectViaApi(ctx, { name: billableProjectName });\n    await createBareTimeEntryViaApi(ctx, 'Test billable modal', '1h');\n\n    await goToTimeOverview(page);\n    const timeEntryRow = page.locator('[data-testid=\"time_entry_row\"]').first();\n\n    // Open edit modal\n    await timeEntryRow.getByRole('button', { name: 'Actions for the time entry' }).first().click();\n    await page.getByRole('menuitem', { name: 'Edit' }).click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // Verify initially non-billable\n    await expect(\n        page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' })\n    ).toBeVisible();\n\n    // Select the billable project\n    await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click();\n    await page.getByRole('option', { name: billableProjectName }).click();\n\n    // Verify the billable dropdown updated to Billable\n    await expect(\n        page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' })\n    ).toBeVisible();\n\n    // Save and verify\n    const [updateResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/time-entries/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n        page.getByRole('button', { name: 'Update Time Entry' }).click(),\n    ]);\n    const responseBody = await updateResponse.json();\n    expect(responseBody.data.billable).toBe(true);\n});\n\ntest('test that opening edit modal for a time entry with manually overridden billable status preserves that status', async ({\n    page,\n    ctx,\n}) => {\n    const billableProjectName = 'Billable Persist Project ' + Math.floor(1 + Math.random() * 10000);\n    await createBillableProjectViaApi(ctx, { name: billableProjectName });\n    await createBareTimeEntryViaApi(ctx, 'Test persist billable override', '1h');\n\n    await goToTimeOverview(page);\n    const timeEntryRow = page.locator('[data-testid=\"time_entry_row\"]').first();\n\n    // Open edit modal and assign the billable project\n    await timeEntryRow.getByRole('button', { name: 'Actions for the time entry' }).first().click();\n    await page.getByRole('menuitem', { name: 'Edit' }).click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click();\n    await page.getByRole('option', { name: billableProjectName }).click();\n\n    // Verify it auto-set to Billable\n    await expect(\n        page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' })\n    ).toBeVisible();\n\n    // Now manually override billable to Non-Billable via the dropdown\n    await page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }).click();\n    await page.getByRole('option', { name: 'Non Billable' }).click();\n\n    // Verify it shows Non-Billable now\n    await expect(\n        page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' })\n    ).toBeVisible();\n\n    // Save\n    const [firstSaveResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/time-entries/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n        page.getByRole('button', { name: 'Update Time Entry' }).click(),\n    ]);\n    const firstBody = await firstSaveResponse.json();\n    expect(firstBody.data.billable).toBe(false);\n\n    // Re-open the edit modal — the project_id watcher should NOT override billable back to true\n    await timeEntryRow.getByRole('button', { name: 'Actions for the time entry' }).first().click();\n    await page.getByRole('menuitem', { name: 'Edit' }).click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // The billable dropdown should still show Non-Billable\n    await expect(\n        page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' })\n    ).toBeVisible();\n\n    // Save without changes and verify the response still has billable=false\n    const [updateResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/time-entries/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n        page.getByRole('button', { name: 'Update Time Entry' }).click(),\n    ]);\n    const responseBody = await updateResponse.json();\n    expect(responseBody.data.billable).toBe(false);\n});\n\ntest('test that changing project in edit modal from billable to non-billable updates billable status', async ({\n    page,\n    ctx,\n}) => {\n    const billableProjectName =\n        'Billable Modal Rev Project ' + Math.floor(1 + Math.random() * 10000);\n    const nonBillableProjectName =\n        'NonBillable Modal Rev Project ' + Math.floor(1 + Math.random() * 10000);\n    await createBillableProjectViaApi(ctx, { name: billableProjectName });\n    await createProjectViaApi(ctx, { name: nonBillableProjectName });\n    await createBareTimeEntryViaApi(ctx, 'Test billable modal reverse', '1h');\n\n    await goToTimeOverview(page);\n    const timeEntryRow = page.locator('[data-testid=\"time_entry_row\"]').first();\n\n    // Open edit modal\n    await timeEntryRow.getByRole('button', { name: 'Actions for the time entry' }).first().click();\n    await page.getByRole('menuitem', { name: 'Edit' }).click();\n    await expect(page.getByRole('dialog')).toBeVisible();\n\n    // First assign the billable project\n    await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click();\n    await page.getByRole('option', { name: billableProjectName }).click();\n\n    // Verify billable status flipped to Billable\n    await expect(\n        page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' })\n    ).toBeVisible();\n\n    // Now switch to the non-billable project\n    await page.getByRole('dialog').getByRole('button', { name: billableProjectName }).click();\n    await page.getByRole('option', { name: nonBillableProjectName }).click();\n\n    // Verify billable status reverted to Non-Billable\n    await expect(\n        page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' })\n    ).toBeVisible();\n\n    // Save and verify\n    const [updateResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/time-entries/') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n        page.getByRole('button', { name: 'Update Time Entry' }).click(),\n    ]);\n    const responseBody = await updateResponse.json();\n    expect(responseBody.data.billable).toBe(false);\n});\n\n// =============================================\n// Mass Delete Tests\n// =============================================\n\ntest('test that mass deleting time entries works', async ({ page, ctx }) => {\n    const description = 'Mass delete ' + Math.floor(1 + Math.random() * 10000);\n    await createBareTimeEntryViaApi(ctx, description, '30min');\n\n    await goToTimeOverview(page);\n\n    const timeEntryRows = page.locator('[data-testid=\"time_entry_row\"]');\n    await expect(timeEntryRows.first()).toBeVisible({ timeout: 10000 });\n\n    // Select all time entries using the checkbox\n    await page.getByLabel('Select All').click();\n    await expect(page.getByText('selected')).toBeVisible();\n\n    // Verify the time entry is visible before deleting\n    const entryRow = timeEntryRows.filter({ hasText: description });\n    await expect(entryRow).toBeVisible();\n\n    // Click delete button in mass action bar (no confirmation dialog)\n    await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/time-entries') && response.request().method() === 'DELETE'\n        ),\n        page.getByRole('button', { name: 'Delete' }).click(),\n    ]);\n\n    // Verify the time entry is no longer visible\n    await expect(entryRow).not.toBeVisible();\n});\n\n// =============================================\n// Delete Single Time Entry Test\n// =============================================\n\ntest('test that deleting a single time entry via actions menu works', async ({ page, ctx }) => {\n    const description = 'Delete single entry ' + Math.floor(1 + Math.random() * 10000);\n    await createBareTimeEntryViaApi(ctx, description, '1h');\n\n    await goToTimeOverview(page);\n\n    const timeEntryRow = page\n        .locator('[data-testid=\"time_entry_row\"]')\n        .filter({ hasText: description });\n    await expect(timeEntryRow).toBeVisible({ timeout: 10000 });\n\n    // Open actions menu and click Delete\n    await timeEntryRow.getByRole('button', { name: 'Actions for the time entry' }).first().click();\n    await expect(page.getByTestId('time_entry_delete')).toBeVisible();\n    // The dropdown delete uses the bulk delete endpoint (DELETE /time-entries?ids=...)\n    // which returns 200 with a JSON body, not the single endpoint returning 204\n    await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/time-entries') &&\n                response.request().method() === 'DELETE' &&\n                response.status() === 200\n        ),\n        page.getByTestId('time_entry_delete').click(),\n    ]);\n\n    // Verify the time entry is no longer visible\n    await expect(timeEntryRow).not.toBeVisible();\n});\n\n// =============================================\n// Multiple Time Entries Test\n// =============================================\n\ntest('test that time entries page loads multiple entries created via API', async ({\n    page,\n    ctx,\n}) => {\n    for (let i = 0; i < 5; i++) {\n        await createBareTimeEntryViaApi(ctx, `Batch entry ${i + 1}`, '30min');\n    }\n\n    await goToTimeOverview(page);\n\n    const timeEntryRows = page.locator('[data-testid=\"time_entry_row\"]');\n    await expect(timeEntryRows.first()).toBeVisible();\n    const count = await timeEntryRows.count();\n    expect(count).toBeGreaterThanOrEqual(5);\n});\n\n// =============================================\n// Employee Permission Tests\n// =============================================\n\ntest.describe('Employee Time Entry Isolation', () => {\n    test('employee can only see their own time entries on the time page', async ({\n        ctx,\n        employee,\n    }) => {\n        // Owner creates a time entry\n        const ownerDescription = 'OwnerWork ' + Math.floor(Math.random() * 10000);\n        await createBareTimeEntryViaApi(ctx, ownerDescription, '1h');\n\n        // Create a time entry for the employee using the owner's context\n        const employeeDescription = 'EmpWork ' + Math.floor(Math.random() * 10000);\n        await createTimeEntryViaApi(\n            { ...ctx, memberId: employee.memberId },\n            { description: employeeDescription, duration: '30min' }\n        );\n\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/time');\n        await expect(\n            employee.page\n                .getByTestId('dashboard_timer')\n                .getByTestId('timer_button')\n                .and(employee.page.locator(':visible'))\n        ).toBeVisible({ timeout: 10000 });\n\n        // Employee's time entry IS visible\n        const employeeRow = employee.page\n            .locator('[data-testid=\"time_entry_row\"]')\n            .filter({ hasText: employeeDescription });\n        await expect(employeeRow).toBeVisible({ timeout: 10000 });\n\n        // Owner's time entry is NOT visible\n        const ownerRow = employee.page\n            .locator('[data-testid=\"time_entry_row\"]')\n            .filter({ hasText: ownerDescription });\n        await expect(ownerRow).not.toBeVisible();\n    });\n\n    test('employee can edit their own time entry', async ({ ctx, employee }) => {\n        const description = 'EmpEditEntry ' + Math.floor(Math.random() * 10000);\n        await createTimeEntryViaApi(\n            { ...ctx, memberId: employee.memberId },\n            { description, duration: '1h' }\n        );\n\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/time');\n        const timeEntryRow = employee.page\n            .locator('[data-testid=\"time_entry_row\"]')\n            .filter({ hasText: description });\n        await expect(timeEntryRow).toBeVisible({ timeout: 10000 });\n\n        // Update description\n        const updatedDescription = 'Updated ' + description;\n        const descriptionInput = timeEntryRow.getByTestId('time_entry_description').first();\n        await descriptionInput.fill(updatedDescription);\n        await Promise.all([\n            employee.page.waitForResponse(\n                (response) =>\n                    response.url().includes('/time-entries') &&\n                    response.request().method() === 'PUT' &&\n                    response.status() === 200\n            ),\n            descriptionInput.press('Tab'),\n        ]);\n\n        // Verify updated description\n        await expect(timeEntryRow.getByTestId('time_entry_description').first()).toHaveValue(\n            updatedDescription\n        );\n    });\n\n    test('employee can delete their own time entry', async ({ ctx, employee }) => {\n        const description = 'EmpDeleteEntry ' + Math.floor(Math.random() * 10000);\n        await createTimeEntryViaApi(\n            { ...ctx, memberId: employee.memberId },\n            { description, duration: '1h' }\n        );\n\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/time');\n        const timeEntryRow = employee.page\n            .locator('[data-testid=\"time_entry_row\"]')\n            .filter({ hasText: description });\n        await expect(timeEntryRow).toBeVisible({ timeout: 10000 });\n\n        // Delete via actions menu\n        await timeEntryRow\n            .getByRole('button', { name: 'Actions for the time entry' })\n            .first()\n            .click();\n        await Promise.all([\n            employee.page.waitForResponse(\n                (response) =>\n                    response.url().includes('/time-entries') &&\n                    response.request().method() === 'DELETE'\n            ),\n            employee.page.getByTestId('time_entry_delete').click(),\n        ]);\n\n        // Verify entry is gone\n        await expect(timeEntryRow).not.toBeVisible();\n    });\n});\n"
  },
  {
    "path": "e2e/timetracker.spec.ts",
    "content": "import { expect, test } from '../playwright/fixtures';\nimport { PLAYWRIGHT_BASE_URL } from '../playwright/config';\nimport {\n    assertThatTimerHasStarted,\n    assertThatTimerIsStopped,\n    newTimeEntryResponse,\n    startOrStopTimerWithButton,\n    stoppedTimeEntryResponse,\n} from './utils/currentTimeEntry';\nimport type { Page } from '@playwright/test';\nimport { newTagResponse } from './utils/tags';\nimport { updateOrganizationCurrencyViaWeb } from './utils/api';\n\n// Date picker button name patterns for different date formats\nconst DATE_DISPLAY_PATTERN = /^\\d{4}-\\d{2}-\\d{2}$|^\\d{2}\\/\\d{2}\\/\\d{4}$|^\\d{2}\\.\\d{2}\\.\\d{4}$/;\n\nasync function goToDashboard(page: Page) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');\n}\n\ntest('test that starting and stopping a timer without description and project works', async ({\n    page,\n}) => {\n    await goToDashboard(page);\n    await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);\n    await assertThatTimerHasStarted(page);\n    await page.waitForTimeout(1500);\n    await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);\n    await assertThatTimerIsStopped(page);\n});\n\ntest('test that billable icon shows dollar sign for USD currency', async ({ page, ctx }) => {\n    await updateOrganizationCurrencyViaWeb(page, ctx, 'USD');\n    await goToDashboard(page);\n    await page.waitForLoadState('networkidle');\n    const billableButton = page.getByRole('button', { name: 'Non Billable' }).first();\n    await expect(billableButton).toBeVisible();\n    await expect(billableButton.locator('svg')).toHaveAttribute('viewBox', '0 0 8 14');\n});\n\ntest('test that billable icon shows euro sign for EUR currency', async ({ page, ctx }) => {\n    await updateOrganizationCurrencyViaWeb(page, ctx, 'EUR');\n    await goToDashboard(page);\n    await page.waitForLoadState('networkidle');\n    const billableButton = page.getByRole('button', { name: 'Non Billable' }).first();\n    await expect(billableButton).toBeVisible();\n    await expect(billableButton.locator('svg')).toHaveAttribute('viewBox', '0 0 12 12');\n});\n\ntest('test that starting and stopping a timer with a description works', async ({ page }) => {\n    await goToDashboard(page);\n    // Wait for the description input to be editable before filling\n    await expect(page.getByTestId('time_entry_description')).toBeEditable();\n    await page.getByTestId('time_entry_description').fill('New Time Entry Description');\n    await Promise.all([\n        newTimeEntryResponse(page, {\n            description: 'New Time Entry Description',\n        }),\n        startOrStopTimerWithButton(page),\n    ]);\n    await assertThatTimerHasStarted(page);\n    await page.waitForTimeout(1500);\n    await Promise.all([\n        stoppedTimeEntryResponse(page, {\n            description: 'New Time Entry Description',\n        }),\n        await startOrStopTimerWithButton(page),\n    ]);\n    await assertThatTimerIsStopped(page);\n});\n\ntest('test that starting the time entry starts the live timer and that it keeps running after reload', async ({\n    page,\n}) => {\n    await goToDashboard(page);\n\n    await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);\n    await assertThatTimerHasStarted(page);\n    const beforeTimerValue = await page.getByTestId('time_entry_time').inputValue();\n    await page.waitForTimeout(2000);\n    const afterWaitTimeValue = await page.getByTestId('time_entry_time').inputValue();\n    expect(afterWaitTimeValue).not.toEqual(beforeTimerValue);\n    await page.reload();\n    await expect(page.getByTestId('time_entry_time')).toBeVisible();\n\n    const afterReloadTimerValue = await page.getByTestId('time_entry_time').inputValue();\n    await page.waitForTimeout(2000);\n    const afterReloadAfterWaitTimerValue = await page.getByTestId('time_entry_time').inputValue();\n    expect(afterReloadTimerValue).not.toEqual(afterReloadAfterWaitTimerValue);\n});\n\ntest('test that starting and updating the description while running works', async ({ page }) => {\n    await goToDashboard(page);\n\n    await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);\n    await assertThatTimerHasStarted(page);\n    await expect(page.getByTestId('time_entry_description')).toBeEditable();\n    await page.getByTestId('time_entry_description').fill('New Time Entry Description');\n\n    await Promise.all([\n        newTimeEntryResponse(page, {\n            status: 200,\n            description: 'New Time Entry Description',\n        }),\n        page.getByTestId('time_entry_description').press('Tab'),\n    ]);\n    await Promise.all([\n        stoppedTimeEntryResponse(page, {\n            description: 'New Time Entry Description',\n        }),\n        startOrStopTimerWithButton(page),\n    ]);\n    await assertThatTimerIsStopped(page);\n});\n\ntest('test that starting and updating the time while running works', async ({ page }) => {\n    await goToDashboard(page);\n    const [createResponse] = await Promise.all([\n        newTimeEntryResponse(page),\n        await startOrStopTimerWithButton(page),\n    ]);\n    await assertThatTimerHasStarted(page);\n    await expect(page.getByTestId('time_entry_time')).toBeEditable();\n    await page.getByTestId('time_entry_time').fill('20min');\n\n    await Promise.all([\n        page.waitForResponse(async (response) => {\n            return (\n                response.url().includes('/time-entries') &&\n                response.status() === 200 &&\n                (await response.headerValue('Content-Type')) === 'application/json' &&\n                (await response.json()).data.id !== null &&\n                (await response.json()).data.start !== null &&\n                (await response.json()).data.start !== (await createResponse.json()).data.start &&\n                (await response.json()).data.end === null &&\n                (await response.json()).data.project_id === null &&\n                (await response.json()).data.description === '' &&\n                (await response.json()).data.task_id === null &&\n                (await response.json()).data.user_id !== null &&\n                JSON.stringify((await response.json()).data.tags) === JSON.stringify([])\n            );\n        }),\n        page.getByTestId('time_entry_time').press('Enter'),\n    ]);\n\n    await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20/);\n    await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);\n    await assertThatTimerIsStopped(page);\n});\n\ntest('test that entering a human readable time starts the timer on blur', async ({ page }) => {\n    await goToDashboard(page);\n    await page.getByTestId('time_entry_time').fill('20min');\n    await Promise.all([\n        newTimeEntryResponse(page),\n        page.getByTestId('time_entry_time').press('Tab'),\n    ]);\n    await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20:/);\n    await assertThatTimerHasStarted(page);\n\n    await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);\n    await assertThatTimerIsStopped(page);\n});\n\ntest('test that entering a number in the time range starts the timer on blur', async ({ page }) => {\n    await goToDashboard(page);\n    await page.getByTestId('time_entry_time').fill('5');\n    await Promise.all([\n        newTimeEntryResponse(page),\n        page.getByTestId('time_entry_time').press('Tab'),\n    ]);\n    await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:05:/);\n    await assertThatTimerHasStarted(page);\n\n    await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);\n    await assertThatTimerIsStopped(page);\n});\n\ntest('test that entering a value with the format hh:mm in the time range starts the timer on blur', async ({\n    page,\n}) => {\n    await goToDashboard(page);\n    await page.getByTestId('time_entry_time').fill('12:30');\n    await Promise.all([\n        newTimeEntryResponse(page),\n        page.getByTestId('time_entry_time').press('Tab'),\n    ]);\n    await expect(page.getByTestId('time_entry_time')).toHaveValue(/12:30:/);\n    await assertThatTimerHasStarted(page);\n\n    await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);\n    await assertThatTimerIsStopped(page);\n});\n\ntest('test that entering a random value in the time range does not start the timer on blur', async ({\n    page,\n}) => {\n    await goToDashboard(page);\n    await page.getByTestId('time_entry_time').fill('asdasdasd');\n    await page.getByTestId('time_entry_time').press('Tab');\n    await assertThatTimerIsStopped(page);\n});\n\ntest('test that entering a time starts the timer on enter', async ({ page }) => {\n    await goToDashboard(page);\n    await page.getByTestId('time_entry_time').fill('20min');\n    await Promise.all([\n        newTimeEntryResponse(page),\n        page.getByTestId('time_entry_time').press('Enter'),\n    ]);\n    await assertThatTimerHasStarted(page);\n    await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);\n    await assertThatTimerIsStopped(page);\n});\n\ntest('test that adding a new tag works', async ({ page }) => {\n    const newTagName = 'New Tag' + Math.floor(Math.random() * 10000);\n    await goToDashboard(page);\n\n    await page.getByTestId('tag_dropdown').click();\n    await page.getByText('Create new tag').click();\n    await page.getByPlaceholder('Tag Name').fill(newTagName);\n\n    await Promise.all([\n        newTagResponse(page, { name: newTagName }),\n        page.getByRole('button', { name: 'Create Tag' }).click(),\n    ]);\n\n    // Wait for tags query refetch after invalidation\n    await page.waitForResponse(\n        (response) => response.url().includes('/tags') && response.status() === 200\n    );\n\n    await page.getByTestId('tag_dropdown').click();\n    await expect(page.getByRole('option', { name: newTagName })).toBeVisible();\n});\n\ntest('test that adding a new tag when the timer is running', async ({ page }) => {\n    const newTagName = 'New Tag' + Math.floor(Math.random() * 10000);\n    await goToDashboard(page);\n    await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);\n    await assertThatTimerHasStarted(page);\n    await page.getByTestId('tag_dropdown').click();\n    await page.getByText('Create new tag').click();\n    await page.getByPlaceholder('Tag Name').fill(newTagName);\n\n    const [tagCreateResponse] = await Promise.all([\n        newTagResponse(page, { name: newTagName }),\n        page.getByRole('button', { name: 'Create Tag' }).click(),\n    ]);\n    const tagId = (await tagCreateResponse.json()).data.id;\n    await newTimeEntryResponse(page, { status: 200, tags: [tagId] });\n    await page.getByTestId('tag_dropdown').click();\n    await expect(page.getByRole('option', { name: newTagName })).toBeVisible();\n    await page.getByTestId('tag_dropdown_search').press('Escape');\n    await expect(page.getByTestId('tag_dropdown_search')).not.toBeVisible();\n\n    await Promise.all([\n        stoppedTimeEntryResponse(page, { tags: [tagId] }),\n        startOrStopTimerWithButton(page),\n    ]);\n    await assertThatTimerIsStopped(page);\n});\n\ntest('test that setting an end time with a different date via the timetracker range selector works', async ({\n    page,\n}) => {\n    await goToDashboard(page);\n\n    // Start a timer\n    await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);\n    await assertThatTimerHasStarted(page);\n\n    // Open the time range dropdown by clicking on the time display\n    await page.getByTestId('time_entry_time').click();\n    const rangeStart = page.getByTestId('time_entry_range_start');\n    await expect(rangeStart).toBeVisible();\n\n    // Click \"Set End Time\" button\n    await page.getByRole('button', { name: 'Set End Time' }).click();\n\n    // The end time picker should now be visible with a Confirm button\n    const rangeEnd = page.getByTestId('time_entry_range_end');\n    await expect(rangeEnd).toBeVisible();\n    const confirmButton = page.getByRole('button', { name: 'Confirm' });\n    await expect(confirmButton).toBeVisible();\n\n    // Click the end date picker to change the date\n    const endDatePickers = page.getByRole('button', { name: DATE_DISPLAY_PATTERN });\n    // The second date picker is the end date (first is the start date)\n    const endDatePicker = endDatePickers.nth(1);\n    await expect(endDatePicker).toBeVisible();\n    await endDatePicker.click();\n\n    // Calendar should appear\n    const calendarGrid = page.getByRole('grid');\n    await expect(calendarGrid).toBeVisible({ timeout: 5000 });\n\n    // Navigate to the next month and select a day to ensure end > start\n    await page.getByRole('button', { name: /Next/i }).click();\n    await page.getByRole('gridcell').filter({ hasText: /^15$/ }).first().click();\n\n    // The dropdown should still be open after selecting a date (not auto-closed)\n    await expect(rangeEnd).toBeVisible();\n    await expect(confirmButton).toBeVisible();\n\n    // Click Confirm to finalize and verify the API call\n    const [updateResponse] = await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/time-entries') &&\n                response.request().method() === 'PUT' &&\n                response.status() === 200\n        ),\n        confirmButton.click(),\n    ]);\n    const updateBody = await updateResponse.json();\n    expect(updateBody.data.start).toBeTruthy();\n    expect(updateBody.data.end).toBeTruthy();\n});\n\ntest('test that timer starts on enter with description', async ({ page }) => {\n    await goToDashboard(page);\n    await expect(page.getByTestId('time_entry_description')).toBeEditable();\n    await page.getByTestId('time_entry_description').fill('Start on Enter');\n\n    await Promise.all([\n        newTimeEntryResponse(page, { description: 'Start on Enter' }),\n        page.getByTestId('time_entry_description').press('Enter'),\n    ]);\n    await assertThatTimerHasStarted(page);\n\n    await Promise.all([\n        stoppedTimeEntryResponse(page, { description: 'Start on Enter' }),\n        startOrStopTimerWithButton(page),\n    ]);\n    await assertThatTimerIsStopped(page);\n});\n\ntest('test that timer started on dashboard is visible on time page', async ({ page }) => {\n    await goToDashboard(page);\n\n    // Start timer on dashboard\n    await expect(page.getByTestId('time_entry_description')).toBeEditable();\n    await page.getByTestId('time_entry_description').fill('Sync test');\n    await Promise.all([\n        newTimeEntryResponse(page, { description: 'Sync test' }),\n        startOrStopTimerWithButton(page),\n    ]);\n    await assertThatTimerHasStarted(page);\n\n    // Navigate to time page\n    await page.goto(PLAYWRIGHT_BASE_URL + '/time');\n\n    // Timer should still be running (the timer button should be red/active)\n    await expect(\n        page\n            .getByTestId('dashboard_timer')\n            .getByTestId('timer_button')\n            .and(page.locator(':visible'))\n    ).toHaveClass(/bg-red-400\\/80/);\n\n    // Stop the timer\n    await Promise.all([\n        stoppedTimeEntryResponse(page, { description: 'Sync test' }),\n        startOrStopTimerWithButton(page),\n    ]);\n    await assertThatTimerIsStopped(page);\n});\n\ntest('test that adding a project and tag before starting timer works', async ({ page }) => {\n    const newTagName = 'TimerTag ' + Math.floor(Math.random() * 10000);\n    await goToDashboard(page);\n\n    // Create and select a tag first\n    await page.getByTestId('tag_dropdown').click();\n    await page.getByText('Create new tag').click();\n    await page.getByPlaceholder('Tag Name').fill(newTagName);\n\n    const [tagCreateResponse] = await Promise.all([\n        newTagResponse(page, { name: newTagName }),\n        page.getByRole('button', { name: 'Create Tag' }).click(),\n    ]);\n    const tagId = (await tagCreateResponse.json()).data.id;\n\n    // Wait for tags query refetch (tag is auto-selected after creation)\n    await page.waitForResponse(\n        (response) => response.url().includes('/tags') && response.status() === 200\n    );\n\n    // Fill description and start\n    await page.getByTestId('time_entry_description').fill('Entry with tag');\n    await Promise.all([\n        newTimeEntryResponse(page, { description: 'Entry with tag', tags: [tagId] }),\n        startOrStopTimerWithButton(page),\n    ]);\n    await assertThatTimerHasStarted(page);\n\n    await Promise.all([\n        stoppedTimeEntryResponse(page, { description: 'Entry with tag', tags: [tagId] }),\n        startOrStopTimerWithButton(page),\n    ]);\n    await assertThatTimerIsStopped(page);\n});\n"
  },
  {
    "path": "e2e/utils/api.ts",
    "content": "import { expect } from '@playwright/test';\nimport type { APIRequestContext, Page } from '@playwright/test';\nimport { PLAYWRIGHT_BASE_URL } from '../../playwright/config';\n\n// ──────────────────────────────────────────────────\n// Types\n// ──────────────────────────────────────────────────\n\nexport interface TestContext {\n    request: APIRequestContext;\n    orgId: string;\n    memberId: string;\n}\n\n// ──────────────────────────────────────────────────\n// Auth helpers\n// ──────────────────────────────────────────────────\n\n/**\n * Create a Passport API token by calling the token endpoint from the browser.\n *\n * The browser's native fetch includes the laravel_token cookie (set by\n * CreateFreshApiToken during the dashboard page load), so authentication\n * is handled by the browser's own cookie jar. The returned Bearer token is\n * then used for all subsequent API calls, making them independent of cookie state.\n *\n * If the first attempt returns 401 (Octane hasn't fully committed the session yet),\n * we reload the page to trigger a fresh CreateFreshApiToken and retry.\n */\nasync function createApiToken(page: Page): Promise<string> {\n    for (let attempt = 0; attempt < 3; attempt++) {\n        const result = await page.evaluate(async (baseUrl) => {\n            const xsrfCookie = document.cookie.split('; ').find((c) => c.startsWith('XSRF-TOKEN='));\n            const xsrfToken = xsrfCookie\n                ? decodeURIComponent(xsrfCookie.split('=').slice(1).join('='))\n                : '';\n\n            const res = await fetch(`${baseUrl}/api/v1/users/me/api-tokens`, {\n                method: 'POST',\n                headers: {\n                    'Content-Type': 'application/json',\n                    Accept: 'application/json',\n                    'X-XSRF-TOKEN': xsrfToken,\n                },\n                body: JSON.stringify({ name: 'playwright-test' }),\n            });\n\n            if (!res.ok) {\n                return null;\n            }\n\n            const body = await res.json();\n            return body.data.access_token as string;\n        }, PLAYWRIGHT_BASE_URL);\n\n        if (result) {\n            return result;\n        }\n\n        // Reload to get a fresh laravel_token cookie and retry.\n        // networkidle gives Octane time to fully commit the session.\n        await page.reload({ waitUntil: 'networkidle' });\n    }\n\n    throw new Error('Failed to create API token after retries');\n}\n\nfunction bearerHeaders(token: string): Record<string, string> {\n    return {\n        Accept: 'application/json',\n        Authorization: `Bearer ${token}`,\n    };\n}\n\n// ──────────────────────────────────────────────────\n// Context setup\n// ──────────────────────────────────────────────────\n\nexport async function setupTestContext(page: Page): Promise<TestContext> {\n    const token = await createApiToken(page);\n    const request = page.request;\n    const headers = bearerHeaders(token);\n\n    const orgId = await getOrganizationId(request, headers);\n    const memberId = await getCurrentMemberId(request, orgId, headers);\n    return { request: createAuthenticatedRequest(request, headers), orgId, memberId };\n}\n\nfunction createAuthenticatedRequest(\n    request: APIRequestContext,\n    headers: Record<string, string>\n): APIRequestContext {\n    // Wrap the request to always include auth headers\n    return new Proxy(request, {\n        get(target, prop) {\n            if (\n                prop === 'get' ||\n                prop === 'post' ||\n                prop === 'put' ||\n                prop === 'delete' ||\n                prop === 'patch'\n            ) {\n                return (url: string, options?: Record<string, unknown>) => {\n                    return target[prop as 'get'](url, {\n                        ...options,\n                        headers: {\n                            ...headers,\n                            ...((options?.headers as Record<string, string>) || {}),\n                        },\n                    });\n                };\n            }\n            return target[prop as keyof APIRequestContext];\n        },\n    });\n}\n\nasync function getOrganizationId(\n    request: APIRequestContext,\n    headers: Record<string, string>\n): Promise<string> {\n    const response = await request.get(`${PLAYWRIGHT_BASE_URL}/api/v1/users/me/memberships`, {\n        headers,\n    });\n    expect(response.status()).toBe(200);\n    const body = await response.json();\n    return body.data[0].organization.id;\n}\n\nasync function getCurrentMemberId(\n    request: APIRequestContext,\n    orgId: string,\n    headers: Record<string, string>\n): Promise<string> {\n    const response = await request.get(\n        `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${orgId}/members`,\n        { headers }\n    );\n    expect(response.status()).toBe(200);\n    const body = await response.json();\n    return body.data[0].id;\n}\n\n// ──────────────────────────────────────────────────\n// Duration parsing\n// ──────────────────────────────────────────────────\n\nfunction parseDurationToSeconds(duration: string): number {\n    let totalSeconds = 0;\n\n    // Match patterns like \"1h\", \"30min\", \"2h 30min\", \"1h 7min\"\n    const hourMatch = duration.match(/(\\d+)\\s*h/);\n    const minMatch = duration.match(/(\\d+)\\s*min/);\n\n    if (hourMatch) {\n        totalSeconds += parseInt(hourMatch[1], 10) * 3600;\n    }\n    if (minMatch) {\n        totalSeconds += parseInt(minMatch[1], 10) * 60;\n    }\n\n    // If no h/min pattern matched, try plain number as minutes\n    if (!hourMatch && !minMatch) {\n        const plainNumber = parseInt(duration, 10);\n        if (!isNaN(plainNumber)) {\n            totalSeconds = plainNumber * 60;\n        }\n    }\n\n    return totalSeconds;\n}\n\nfunction createTimestamps(duration: string): { start: string; end: string } {\n    const durationSeconds = parseDurationToSeconds(duration);\n    const now = new Date();\n    const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 9, 0, 0);\n    const end = new Date(start.getTime() + durationSeconds * 1000);\n\n    return {\n        start: formatTimestamp(start),\n        end: formatTimestamp(end),\n    };\n}\n\nfunction formatTimestamp(date: Date): string {\n    return date.toISOString().replace(/\\.\\d{3}Z$/, 'Z');\n}\n\nfunction randomColor(): string {\n    const colors = [\n        '#ef5350',\n        '#ab47bc',\n        '#5c6bc0',\n        '#29b6f6',\n        '#26a69a',\n        '#9ccc65',\n        '#ffa726',\n        '#8d6e63',\n    ];\n    return colors[Math.floor(Math.random() * colors.length)];\n}\n\n// ──────────────────────────────────────────────────\n// Entity creation\n// ──────────────────────────────────────────────────\n\nexport async function createPublicProjectViaApi(\n    ctx: TestContext,\n    data: {\n        name: string;\n        is_billable?: boolean;\n        billable_rate?: number | null;\n        client_id?: string | null;\n    }\n) {\n    return createProjectViaApi(ctx, {\n        ...data,\n        is_public: true,\n    });\n}\n\nexport async function createProjectViaApi(\n    ctx: TestContext,\n    data: {\n        name: string;\n        color?: string;\n        is_billable?: boolean;\n        billable_rate?: number | null;\n        client_id?: string | null;\n        estimated_time?: number | null;\n        is_public?: boolean;\n    }\n) {\n    const response = await ctx.request.post(\n        `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/projects`,\n        {\n            data: {\n                name: data.name,\n                color: data.color ?? randomColor(),\n                is_billable: data.is_billable ?? false,\n                billable_rate: data.billable_rate ?? null,\n                client_id: data.client_id ?? null,\n                estimated_time: data.estimated_time ?? null,\n                is_public: data.is_public ?? false,\n            },\n        }\n    );\n    expect(response.status()).toBe(201);\n    const body = await response.json();\n    return body.data as { id: string; name: string; color: string; is_billable: boolean };\n}\n\nexport async function archiveProjectViaApi(\n    ctx: TestContext,\n    project: {\n        id: string;\n        name: string;\n        color: string;\n        is_billable: boolean;\n        client_id?: string | null;\n        billable_rate?: number | null;\n        estimated_time?: number | null;\n    }\n) {\n    const response = await ctx.request.put(\n        `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/projects/${project.id}`,\n        {\n            data: {\n                name: project.name,\n                color: project.color,\n                is_billable: project.is_billable,\n                is_archived: true,\n                client_id: project.client_id ?? null,\n                billable_rate: project.billable_rate ?? null,\n                estimated_time: project.estimated_time ?? null,\n            },\n        }\n    );\n    expect(response.status()).toBe(200);\n    const body = await response.json();\n    return body.data;\n}\n\nexport async function createBillableProjectViaApi(\n    ctx: TestContext,\n    data: { name: string; billable_rate?: number | null }\n) {\n    return createProjectViaApi(ctx, {\n        name: data.name,\n        is_billable: true,\n        billable_rate: data.billable_rate ?? null,\n    });\n}\n\nexport async function createClientViaApi(ctx: TestContext, data: { name: string }) {\n    const response = await ctx.request.post(\n        `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/clients`,\n        { data: { name: data.name } }\n    );\n    expect(response.status()).toBe(201);\n    const body = await response.json();\n    return body.data as { id: string; name: string };\n}\n\nexport async function createProjectWithClientViaApi(\n    ctx: TestContext,\n    projectName: string,\n    clientName: string\n) {\n    const client = await createClientViaApi(ctx, { name: clientName });\n    const project = await createProjectViaApi(ctx, {\n        name: projectName,\n        client_id: client.id,\n    });\n    return { project, client };\n}\n\nexport async function createTaskViaApi(\n    ctx: TestContext,\n    data: { name: string; project_id: string }\n) {\n    const response = await ctx.request.post(\n        `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/tasks`,\n        {\n            data: {\n                name: data.name,\n                project_id: data.project_id,\n            },\n        }\n    );\n    expect(response.status()).toBe(201);\n    const body = await response.json();\n    return body.data as { id: string; name: string; project_id: string };\n}\n\nexport async function createTagViaApi(ctx: TestContext, data: { name: string }) {\n    const response = await ctx.request.post(\n        `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/tags`,\n        { data: { name: data.name } }\n    );\n    expect(response.status()).toBe(201);\n    const body = await response.json();\n    return body.data as { id: string; name: string };\n}\n\nexport async function createTimeEntryViaApi(\n    ctx: TestContext,\n    data: {\n        description?: string;\n        duration: string;\n        projectId?: string | null;\n        taskId?: string | null;\n        tags?: string[];\n        billable?: boolean;\n    }\n) {\n    const { start, end } = createTimestamps(data.duration);\n    const response = await ctx.request.post(\n        `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/time-entries`,\n        {\n            data: {\n                member_id: ctx.memberId,\n                start,\n                end,\n                description: data.description ?? '',\n                project_id: data.projectId ?? null,\n                task_id: data.taskId ?? null,\n                tags: data.tags ?? [],\n                billable: data.billable ?? false,\n            },\n        }\n    );\n    expect(response.status()).toBe(201);\n    const body = await response.json();\n    return body.data as { id: string; start: string; end: string; description: string };\n}\n\nexport async function createProjectMemberViaApi(\n    ctx: TestContext,\n    projectId: string,\n    data: { member_id: string; billable_rate?: number | null }\n) {\n    const response = await ctx.request.post(\n        `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/projects/${projectId}/project-members`,\n        {\n            data: {\n                member_id: data.member_id,\n                billable_rate: data.billable_rate ?? null,\n            },\n        }\n    );\n    expect(response.status()).toBe(201);\n    const body = await response.json();\n    return body.data as { id: string; billable_rate: number | null };\n}\n\nexport async function getMembersViaApi(ctx: TestContext) {\n    const response = await ctx.request.get(\n        `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/members`\n    );\n    expect(response.status()).toBe(200);\n    const body = await response.json();\n    return body.data as Array<{\n        id: string;\n        name: string;\n        email: string;\n        role: string;\n        billable_rate: number | null;\n        is_placeholder: boolean;\n    }>;\n}\n\nexport async function updateMemberBillableRateViaApi(\n    ctx: TestContext,\n    memberId: string,\n    billableRate: number | null\n) {\n    const response = await ctx.request.put(\n        `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/members/${memberId}`,\n        { data: { billable_rate: billableRate } }\n    );\n    expect(response.status()).toBe(200);\n    const body = await response.json();\n    return body.data;\n}\n\n// ──────────────────────────────────────────────────\n// Composite helpers (matching existing UI helper signatures)\n// ──────────────────────────────────────────────────\n\nexport async function createTimeEntryWithProjectViaApi(\n    ctx: TestContext,\n    projectName: string,\n    duration: string\n) {\n    const project = await createProjectViaApi(ctx, { name: projectName });\n    const entry = await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName}`,\n        duration,\n        projectId: project.id,\n    });\n    return { project, entry };\n}\n\nexport async function createTimeEntryWithProjectAndTaskViaApi(\n    ctx: TestContext,\n    projectId: string,\n    taskName: string,\n    projectName: string,\n    duration: string\n) {\n    const task = await createTaskViaApi(ctx, { name: taskName, project_id: projectId });\n    const entry = await createTimeEntryViaApi(ctx, {\n        description: `Entry for ${projectName} - ${taskName}`,\n        duration,\n        projectId,\n        taskId: task.id,\n    });\n    return { task, entry };\n}\n\nexport async function createTimeEntryWithTagViaApi(\n    ctx: TestContext,\n    tagName: string,\n    duration: string\n) {\n    const tag = await createTagViaApi(ctx, { name: tagName });\n    const entry = await createTimeEntryViaApi(ctx, {\n        description: `Entry with tag ${tagName}`,\n        duration,\n        tags: [tag.id],\n    });\n    return { tag, entry };\n}\n\nexport async function createBareTimeEntryViaApi(\n    ctx: TestContext,\n    description: string,\n    duration: string\n) {\n    return createTimeEntryViaApi(ctx, { description, duration });\n}\n\nexport async function createTimeEntryWithBillableStatusViaApi(\n    ctx: TestContext,\n    isBillable: boolean,\n    duration: string\n) {\n    return createTimeEntryViaApi(ctx, {\n        description: `Time entry ${isBillable ? 'billable' : 'non-billable'}`,\n        duration,\n        billable: isBillable,\n    });\n}\n\n// ──────────────────────────────────────────────────\n// Import helper (for placeholder member creation)\n// ──────────────────────────────────────────────────\n\nexport async function createPlaceholderMemberViaImportApi(\n    ctx: TestContext,\n    placeholderName: string\n) {\n    const placeholderEmail = `placeholder+${Math.floor(Math.random() * 100000)}@solidtime-import.test`;\n    const csvContent = [\n        'User,Email,Client,Project,Task,Description,Billable,Start date,Start time,End date,End time,Tags',\n        `${placeholderName},${placeholderEmail},,,,Imported entry,No,2024-01-01,09:00:00,2024-01-01,10:00:00,`,\n    ].join('\\n');\n\n    const base64Data = Buffer.from(csvContent).toString('base64');\n\n    const response = await ctx.request.post(\n        `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/import`,\n        {\n            data: {\n                type: 'toggl_time_entries',\n                data: base64Data,\n            },\n        }\n    );\n    expect(response.status()).toBe(200);\n    return await response.json();\n}\n\n// ──────────────────────────────────────────────────\n// Organization settings helpers\n// ──────────────────────────────────────────────────\n\nexport async function updateOrganizationSettingViaApi(\n    ctx: TestContext,\n    settings: Record<string, unknown>\n) {\n    const response = await ctx.request.put(\n        `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}`,\n        { data: settings }\n    );\n    expect(response.status()).toBe(200);\n    const body = await response.json();\n    return body.data;\n}\n\nexport async function updateOrganizationCurrencyViaWeb(\n    page: Page,\n    ctx: TestContext,\n    currency: string,\n    name: string = 'Test Organization'\n) {\n    const cookies = await page.context().cookies();\n    const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN');\n    const xsrfToken = xsrfCookie ? decodeURIComponent(xsrfCookie.value) : '';\n\n    const response = await page.request.put(`${PLAYWRIGHT_BASE_URL}/teams/${ctx.orgId}`, {\n        headers: { 'X-XSRF-TOKEN': xsrfToken },\n        data: { name, currency },\n    });\n    expect(response.status()).toBe(200);\n}\n\n// ──────────────────────────────────────────────────\n// Bulk helpers\n// ──────────────────────────────────────────────────\n\nexport async function createMultipleTimeEntriesViaApi(\n    ctx: TestContext,\n    count: number,\n    data: { description?: string; duration?: string } = {}\n) {\n    const entries = [];\n    for (let i = 0; i < count; i++) {\n        const entry = await createTimeEntryViaApi(ctx, {\n            description: data.description ?? `Bulk entry ${i + 1}`,\n            duration: data.duration ?? '30min',\n        });\n        entries.push(entry);\n    }\n    return entries;\n}\n\n// ──────────────────────────────────────────────────\n// Invitation helpers\n// ──────────────────────────────────────────────────\n\nexport async function getInvitationsViaApi(ctx: TestContext) {\n    const response = await ctx.request.get(\n        `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/invitations`\n    );\n    expect(response.status()).toBe(200);\n    const body = await response.json();\n    return body.data as Array<{ id: string; email: string; role: string }>;\n}\n"
  },
  {
    "path": "e2e/utils/currentTimeEntry.ts",
    "content": "import { expect } from '@playwright/test';\nimport type { Page } from '@playwright/test';\n\nexport async function startOrStopTimerWithButton(page: Page) {\n    await page\n        .getByTestId('dashboard_timer')\n        .getByTestId('timer_button')\n        .and(page.locator(':visible'))\n        .click();\n}\n\nexport async function assertThatTimerHasStarted(page: Page) {\n    await expect(\n        page\n            .getByTestId('dashboard_timer')\n            .getByTestId('timer_button')\n            .and(page.locator(':visible'))\n    ).toHaveClass(/bg-red-400\\/80/);\n}\n\nexport function newTimeEntryResponse(\n    page: Page,\n    { description = '', status = 201, tags = [] } = {}\n) {\n    return page.waitForResponse(async (response) => {\n        return (\n            response.url().includes('/time-entries') &&\n            response.status() === status &&\n            (await response.headerValue('Content-Type')) === 'application/json' &&\n            (await response.json()).data.id !== null &&\n            (await response.json()).data.start !== null &&\n            (await response.json()).data.end === null &&\n            (await response.json()).data.project_id === null &&\n            (await response.json()).data.description === description &&\n            (await response.json()).data.task_id === null &&\n            (await response.json()).data.user_id !== null &&\n            JSON.stringify((await response.json()).data.tags) === JSON.stringify(tags)\n        );\n    });\n}\n\nexport async function assertThatTimerIsStopped(page: Page) {\n    await expect(\n        page\n            .getByTestId('dashboard_timer')\n            .getByTestId('timer_button')\n            .and(page.locator(':visible'))\n    ).toHaveClass(/bg-accent-300\\/70/);\n}\n\nexport async function stoppedTimeEntryResponse(page: Page, { description = '', tags = [] } = {}) {\n    return page.waitForResponse(async (response) => {\n        return (\n            response.status() === 200 &&\n            response.url().includes('/time-entries/') &&\n            (await response.headerValue('Content-Type')) === 'application/json' &&\n            (await response.json()).data.id !== null &&\n            (await response.json()).data.start !== null &&\n            (await response.json()).data.end !== null &&\n            (await response.json()).data.project_id === null &&\n            (await response.json()).data.description === description &&\n            (await response.json()).data.task_id === null &&\n            (await response.json()).data.duration !== null &&\n            (await response.json()).data.user_id !== null &&\n            JSON.stringify((await response.json()).data.tags) === JSON.stringify(tags)\n        );\n    });\n}\n"
  },
  {
    "path": "e2e/utils/mailpit.ts",
    "content": "import { expect } from '@playwright/test';\nimport type { APIRequestContext } from '@playwright/test';\nimport { MAILPIT_BASE_URL } from '../../playwright/config';\n\n/**\n * Search for emails in Mailpit matching the given query.\n */\nexport async function searchEmails(\n    request: APIRequestContext,\n    query: string\n): Promise<{ messages: Array<{ ID: string; Subject: string }> }> {\n    const response = await request.get(`${MAILPIT_BASE_URL}/api/v1/search?query=${query}`);\n    return response.json();\n}\n\n/**\n * Get the full email message from Mailpit by ID.\n */\nexport async function getMessage(\n    request: APIRequestContext,\n    messageId: string\n): Promise<{ HTML: string; Text: string }> {\n    const response = await request.get(`${MAILPIT_BASE_URL}/api/v1/message/${messageId}`);\n    return response.json();\n}\n\n/**\n * Find the invitation acceptance URL from a Mailpit email sent to the given address.\n * Retries a few times to allow for email delivery delay.\n */\nexport async function getInvitationAcceptUrl(\n    request: APIRequestContext,\n    recipientEmail: string\n): Promise<string> {\n    let searchResult: { messages: Array<{ ID: string }> } = { messages: [] };\n\n    // Retry up to 5 times with 500ms delay to allow for email delivery\n    for (let attempt = 0; attempt < 5; attempt++) {\n        searchResult = await searchEmails(\n            request,\n            `to:${encodeURIComponent(recipientEmail)} subject:\"Organization Invitation\"`\n        );\n        if (searchResult.messages.length > 0) break;\n        await new Promise((resolve) => setTimeout(resolve, 500));\n    }\n    expect(searchResult.messages.length).toBeGreaterThan(0);\n\n    const message = await getMessage(request, searchResult.messages[0].ID);\n    const acceptUrlMatch = message.HTML.match(/href=\"([^\"]*team-invitations[^\"]*)\"/);\n    expect(acceptUrlMatch).toBeTruthy();\n\n    return acceptUrlMatch![1].replace(/&amp;/g, '&');\n}\n\n/**\n * Find the password reset URL from a Mailpit email sent to the given address.\n * Retries a few times to allow for email delivery delay.\n */\nexport async function getPasswordResetUrl(\n    request: APIRequestContext,\n    recipientEmail: string\n): Promise<string> {\n    let searchResult: { messages: Array<{ ID: string }> } = { messages: [] };\n\n    // Retry up to 5 times with 500ms delay to allow for email delivery\n    for (let attempt = 0; attempt < 5; attempt++) {\n        searchResult = await searchEmails(\n            request,\n            `to:${encodeURIComponent(recipientEmail)} subject:\"Reset Password\"`\n        );\n        if (searchResult.messages.length > 0) break;\n        await new Promise((resolve) => setTimeout(resolve, 500));\n    }\n    expect(searchResult.messages.length).toBeGreaterThan(0);\n\n    const message = await getMessage(request, searchResult.messages[0].ID);\n    const resetUrlMatch = message.HTML.match(/href=\"([^\"]*reset-password[^\"]*)\"/);\n    expect(resetUrlMatch).toBeTruthy();\n\n    return resetUrlMatch![1].replace(/&amp;/g, '&');\n}\n"
  },
  {
    "path": "e2e/utils/members.ts",
    "content": "import { expect } from '@playwright/test';\nimport type { Browser, Page } from '@playwright/test';\nimport { PLAYWRIGHT_BASE_URL } from '../../playwright/config';\nimport { getInvitationAcceptUrl } from './mailpit';\nimport type { TestContext } from './api';\n\n/**\n * Register a new user in a fresh browser context and return the page + context.\n */\nexport async function registerUser(\n    browser: Browser,\n    name: string,\n    email: string\n): Promise<{ page: Page; close: () => Promise<void> }> {\n    const context = await browser.newContext();\n    const page = await context.newPage();\n\n    await page.goto(PLAYWRIGHT_BASE_URL + '/register');\n    await page.getByLabel('Name').fill(name);\n    await page.getByLabel('Email').fill(email);\n    await page.getByLabel('Password', { exact: true }).fill('amazingpassword123');\n    await page.getByLabel('Confirm Password').fill('amazingpassword123');\n    await page.getByLabel('I agree to the Terms of').click();\n    await page.getByRole('button', { name: 'Register' }).click();\n    await page.waitForURL(PLAYWRIGHT_BASE_URL + '/dashboard');\n\n    return { page, close: () => context.close() };\n}\n\n/**\n * Invite a user by email from the members page and accept the invitation\n * through a second browser session, returning the accepted member to the\n * members table as a real (non-placeholder) member.\n *\n * @param ownerPage   – The page of the organization owner who sends the invite\n * @param browser     – Browser instance used to create a second context\n * @param memberName  – Display name for the new user\n * @param memberEmail – Email address (must not be registered yet)\n * @param role        – Role button label: 'Employee' | 'Manager' | 'Administrator'\n */\nexport async function inviteAndAcceptMember(\n    ownerPage: Page,\n    browser: Browser,\n    memberName: string,\n    memberEmail: string,\n    role: 'Employee' | 'Manager' | 'Administrator'\n): Promise<void> {\n    // 1. Register the second user\n    const secondUser = await registerUser(browser, memberName, memberEmail);\n\n    // 2. Send invitation from the owner\n    await ownerPage.goto(PLAYWRIGHT_BASE_URL + '/members');\n    await ownerPage.getByRole('button', { name: 'Invite Member' }).click();\n    await expect(ownerPage.getByPlaceholder('Member Email')).toBeVisible();\n    await ownerPage.getByLabel('Email').fill(memberEmail);\n    await ownerPage.getByRole('button', { name: role }).click();\n    await Promise.all([\n        ownerPage.getByRole('button', { name: 'Invite Member', exact: true }).click(),\n        expect(ownerPage.getByRole('main')).toContainText(memberEmail),\n    ]);\n\n    // 3. Retrieve the acceptance link from Mailpit and accept\n    const acceptUrl = await getInvitationAcceptUrl(secondUser.page.request, memberEmail);\n    await secondUser.page.goto(acceptUrl);\n    await secondUser.page.waitForURL(/dashboard/);\n\n    // 4. Clean up\n    await secondUser.close();\n}\n\n/**\n * Set up an admin member in the owner's organization.\n * Returns the admin's page, their member ID, and a cleanup function.\n */\nexport async function setupAdminUser(\n    ownerPage: Page,\n    ownerCtx: TestContext,\n    browser: Browser\n): Promise<{\n    adminPage: Page;\n    adminMemberId: string;\n    closeAdmin: () => Promise<void>;\n}> {\n    const memberId = Math.floor(Math.random() * 100000);\n    const memberEmail = `admin+${memberId}@admin-perms.test`;\n    const memberName = 'Admin ' + memberId;\n\n    const admin = await registerUser(browser, memberName, memberEmail);\n\n    await ownerPage.goto(PLAYWRIGHT_BASE_URL + '/members');\n    await ownerPage.getByRole('button', { name: 'Invite Member' }).click();\n    await expect(ownerPage.getByPlaceholder('Member Email')).toBeVisible();\n    await ownerPage.getByPlaceholder('Member Email').fill(memberEmail);\n    await ownerPage.getByRole('button', { name: 'Administrator' }).click();\n    await Promise.all([\n        ownerPage.waitForResponse(\n            (response) =>\n                response.url().includes('/invitations') &&\n                response.request().method() === 'POST' &&\n                response.status() === 204\n        ),\n        ownerPage.getByRole('button', { name: 'Invite Member', exact: true }).click(),\n    ]);\n\n    const acceptUrl = await getInvitationAcceptUrl(admin.page.request, memberEmail);\n    await admin.page.goto(acceptUrl);\n    await admin.page.waitForURL(/dashboard/);\n\n    await admin.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');\n    await expect(admin.page.getByTestId('dashboard_view')).toBeVisible({ timeout: 15000 });\n\n    const orgSwitcherText = await admin.page\n        .getByTestId('organization_switcher')\n        .first()\n        .textContent();\n    if (!orgSwitcherText?.includes(\"John's Organization\")) {\n        const cookies = await admin.page.context().cookies();\n        const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN');\n        const xsrfToken = xsrfCookie ? decodeURIComponent(xsrfCookie.value) : '';\n\n        await admin.page.request.put(`${PLAYWRIGHT_BASE_URL}/current-team`, {\n            headers: {\n                'X-XSRF-TOKEN': xsrfToken,\n                Accept: 'text/html',\n            },\n            data: { team_id: ownerCtx.orgId },\n        });\n\n        await admin.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');\n        await expect(admin.page.getByTestId('dashboard_view')).toBeVisible({ timeout: 15000 });\n    }\n\n    const membersResponse = await ownerCtx.request.get(\n        `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ownerCtx.orgId}/members`\n    );\n    expect(membersResponse.status()).toBe(200);\n    const membersBody = await membersResponse.json();\n    const adminMember = membersBody.data.find(\n        (m: { role: string; name: string }) => m.role === 'admin' && m.name === memberName\n    );\n    expect(adminMember).toBeTruthy();\n\n    return {\n        adminPage: admin.page,\n        adminMemberId: adminMember.id,\n        closeAdmin: admin.close,\n    };\n}\n\n/**\n * Set up an employee member in the owner's organization.\n * Returns the employee's page, their member ID, and a cleanup function.\n *\n * The owner page (from the fixture) is used to invite the employee.\n * Test data should be created via the owner's ctx.\n *\n * IMPORTANT: Projects must be created with is_public: true for the employee to see them,\n * or the employee must be added as a project member via createProjectMemberViaApi.\n * Clients are only visible to employees if they have at least one visible project.\n * Tags are visible to all org members with tags:view permission.\n */\nexport async function setupEmployeeUser(\n    ownerPage: Page,\n    ownerCtx: TestContext,\n    browser: Browser\n): Promise<{\n    employeePage: Page;\n    employeeMemberId: string;\n    closeEmployee: () => Promise<void>;\n}> {\n    const memberId = Math.floor(Math.random() * 100000);\n    const memberEmail = `employee+${memberId}@emp-perms.test`;\n    const memberName = 'Emp ' + memberId;\n\n    // Register the employee user first\n    const employee = await registerUser(browser, memberName, memberEmail);\n\n    // Send invitation from the owner\n    await ownerPage.goto(PLAYWRIGHT_BASE_URL + '/members');\n    await ownerPage.getByRole('button', { name: 'Invite Member' }).click();\n    await expect(ownerPage.getByPlaceholder('Member Email')).toBeVisible();\n    await ownerPage.getByPlaceholder('Member Email').fill(memberEmail);\n    await ownerPage.getByRole('button', { name: 'Employee' }).click();\n    await Promise.all([\n        ownerPage.waitForResponse(\n            (response) =>\n                response.url().includes('/invitations') &&\n                response.request().method() === 'POST' &&\n                response.status() === 204\n        ),\n        ownerPage.getByRole('button', { name: 'Invite Member', exact: true }).click(),\n    ]);\n\n    // Accept the invitation\n    const acceptUrl = await getInvitationAcceptUrl(employee.page.request, memberEmail);\n    await employee.page.goto(acceptUrl);\n    await employee.page.waitForURL(/dashboard/);\n\n    // Navigate to dashboard explicitly and wait for it to load to ensure the correct org context.\n    await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');\n    await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ timeout: 15000 });\n\n    // Verify we're on the correct organization (John's Organization).\n    const orgSwitcherText = await employee.page\n        .getByTestId('organization_switcher')\n        .first()\n        .textContent();\n    if (!orgSwitcherText?.includes(\"John's Organization\")) {\n        // Switch to the owner's org using the PUT /current-team endpoint\n        const cookies = await employee.page.context().cookies();\n        const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN');\n        const xsrfToken = xsrfCookie ? decodeURIComponent(xsrfCookie.value) : '';\n\n        await employee.page.request.put(`${PLAYWRIGHT_BASE_URL}/current-team`, {\n            headers: {\n                'X-XSRF-TOKEN': xsrfToken,\n                Accept: 'text/html',\n            },\n            data: { team_id: ownerCtx.orgId },\n        });\n\n        // Reload to pick up the new org\n        await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');\n        await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ timeout: 15000 });\n    }\n\n    // Find the employee's member ID in the owner's organization\n    const membersResponse = await ownerCtx.request.get(\n        `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ownerCtx.orgId}/members`\n    );\n    expect(membersResponse.status()).toBe(200);\n    const membersBody = await membersResponse.json();\n    const employeeMember = membersBody.data.find(\n        (m: { role: string; name: string }) => m.role === 'employee' && m.name === memberName\n    );\n    expect(employeeMember).toBeTruthy();\n\n    return {\n        employeePage: employee.page,\n        employeeMemberId: employeeMember.id,\n        closeEmployee: employee.close,\n    };\n}\n"
  },
  {
    "path": "e2e/utils/money.ts",
    "content": "import { formatCents } from '../../resources/js/packages/ui/src/utils/money';\nimport type { CurrencyFormat } from '../../resources/js/packages/ui/src/utils/money';\nimport type { NumberFormat } from '../../resources/js/packages/ui/src/utils/number';\n\nexport function formatCentsWithOrganizationDefaults(\n    cents: number,\n    currencyCode: string = 'EUR',\n    currencySymbol: string = '€'\n): string {\n    return formatCents(\n        cents,\n        currencyCode,\n        'iso-code-after-with-space' as CurrencyFormat,\n        currencySymbol,\n        'point-comma' as NumberFormat\n    );\n}\n"
  },
  {
    "path": "e2e/utils/reporting.ts",
    "content": "import { expect } from '@playwright/test';\nimport type { Page } from '@playwright/test';\nimport { PLAYWRIGHT_BASE_URL } from '../../playwright/config';\n\n// ──────────────────────────────────────────────────\n// Navigation\n// ──────────────────────────────────────────────────\n\nexport async function goToReporting(page: Page) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/reporting');\n}\n\nexport async function goToReportingDetailed(page: Page) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/reporting/detailed');\n}\n\n// ──────────────────────────────────────────────────\n// Entity creation\n// ──────────────────────────────────────────────────\n\nexport async function createProject(page: Page, projectName: string) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/projects');\n    await expect(page.getByRole('button', { name: 'Create Project' })).toBeVisible();\n    await page.getByRole('button', { name: 'Create Project' }).click();\n    await page.getByLabel('Project name').fill(projectName);\n    await Promise.all([\n        page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click(),\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/projects') &&\n                response.request().method() === 'POST' &&\n                response.status() === 201\n        ),\n    ]);\n    await expect(page.getByText(projectName)).toBeVisible();\n}\n\nexport async function createBillableProject(page: Page, projectName: string) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/projects');\n    await expect(page.getByRole('button', { name: 'Create Project' })).toBeVisible();\n    await page.getByRole('button', { name: 'Create Project' }).click();\n    await page.getByLabel('Project name').fill(projectName);\n    await page.getByText('Non-Billable').click();\n    await page.getByText('Default Rate').click();\n    await Promise.all([\n        page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click(),\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/projects') &&\n                response.request().method() === 'POST' &&\n                response.status() === 201\n        ),\n    ]);\n    await expect(page.getByText(projectName)).toBeVisible();\n}\n\nexport async function createClient(page: Page, clientName: string) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/clients');\n    await expect(page.getByRole('button', { name: 'Create Client' })).toBeVisible();\n    await page.getByRole('button', { name: 'Create Client' }).click();\n    await page.getByPlaceholder('Client Name').fill(clientName);\n    await Promise.all([\n        page.getByRole('button', { name: 'Create Client' }).click(),\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/clients') &&\n                response.request().method() === 'POST' &&\n                response.status() === 201\n        ),\n    ]);\n    await expect(page.getByText(clientName)).toBeVisible();\n}\n\nexport async function createProjectWithClient(page: Page, projectName: string, clientName: string) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/projects');\n    await expect(page.getByRole('button', { name: 'Create Project' })).toBeVisible();\n    await page.getByRole('button', { name: 'Create Project' }).click();\n    await page.getByLabel('Project name').fill(projectName);\n\n    // Select client in the project create modal\n    await page.getByRole('dialog').getByRole('button', { name: 'No Client' }).click();\n    await page.getByRole('option', { name: clientName }).click();\n\n    await Promise.all([\n        page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click(),\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/projects') &&\n                response.request().method() === 'POST' &&\n                response.status() === 201\n        ),\n    ]);\n    await expect(page.getByText(projectName)).toBeVisible();\n}\n\nexport async function createTask(page: Page, projectName: string, taskName: string) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/projects');\n    await expect(page.getByText(projectName)).toBeVisible();\n    await page.getByText(projectName).click();\n    await page.getByRole('button', { name: 'Create Task' }).click();\n    await page.getByPlaceholder('Task Name').fill(taskName);\n    await Promise.all([\n        page.getByRole('button', { name: 'Create Task' }).click(),\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/tasks') &&\n                response.request().method() === 'POST' &&\n                response.status() === 201\n        ),\n    ]);\n    await expect(page.getByText(taskName)).toBeVisible();\n}\n\n// ──────────────────────────────────────────────────\n// Time entry creation\n// ──────────────────────────────────────────────────\n\nexport async function createTimeEntryWithProject(\n    page: Page,\n    projectName: string,\n    duration: string\n) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/time');\n    await expect(page.getByRole('button', { name: 'Time entry actions' })).toBeVisible();\n    await page.getByRole('button', { name: 'Time entry actions' }).click();\n    await page.getByRole('menuitem', { name: 'Manual time entry' }).click();\n\n    await page\n        .getByRole('dialog')\n        .getByRole('textbox', { name: 'Description' })\n        .fill(`Entry for ${projectName}`);\n\n    await page.getByRole('button', { name: 'No Project' }).click();\n    await page.getByRole('option').filter({ hasText: projectName }).click();\n\n    await page.locator('[role=\"dialog\"] input[name=\"Duration\"]').fill(duration);\n    await page.locator('[role=\"dialog\"] input[name=\"Duration\"]').press('Tab');\n\n    await Promise.all([\n        page.getByRole('button', { name: 'Create Time Entry' }).click(),\n        page.waitForResponse(\n            (response) => response.url().includes('/time-entries') && response.status() === 201\n        ),\n    ]);\n}\n\nexport async function createTimeEntryWithProjectAndTask(\n    page: Page,\n    projectName: string,\n    taskName: string,\n    duration: string\n) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/time');\n    await expect(page.getByRole('button', { name: 'Time entry actions' })).toBeVisible();\n    await page.getByRole('button', { name: 'Time entry actions' }).click();\n    await page.getByRole('menuitem', { name: 'Manual time entry' }).click();\n\n    await page\n        .getByRole('dialog')\n        .getByRole('textbox', { name: 'Description' })\n        .fill(`Entry for ${projectName} - ${taskName}`);\n\n    // Open the project/task dropdown\n    await page.getByRole('button', { name: 'No Project' }).click();\n\n    // Expand the project's tasks by clicking the \"Tasks\" button\n    const projectOption = page.getByRole('option').filter({ hasText: projectName });\n    await projectOption.getByText(/Tasks/).click();\n\n    // Select the task (this also selects the project and closes the dropdown)\n    await page.getByText(taskName, { exact: true }).click();\n\n    await page.locator('[role=\"dialog\"] input[name=\"Duration\"]').fill(duration);\n    await page.locator('[role=\"dialog\"] input[name=\"Duration\"]').press('Tab');\n\n    await Promise.all([\n        page.getByRole('button', { name: 'Create Time Entry' }).click(),\n        page.waitForResponse(\n            (response) => response.url().includes('/time-entries') && response.status() === 201\n        ),\n    ]);\n}\n\nexport async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/time');\n    await expect(page.getByRole('button', { name: 'Time entry actions' })).toBeVisible();\n    await page.getByRole('button', { name: 'Time entry actions' }).click();\n    await page.getByRole('menuitem', { name: 'Manual time entry' }).click();\n\n    await page\n        .getByRole('dialog')\n        .getByRole('textbox', { name: 'Description' })\n        .fill(`Entry with tag ${tagName}`);\n\n    // Add tag\n    await page.getByRole('button', { name: 'Tags' }).click();\n    await page.getByText('Create new tag').click();\n    await page.getByPlaceholder('Tag Name').fill(tagName);\n    await Promise.all([\n        page.getByRole('button', { name: 'Create Tag' }).click(),\n        page.waitForResponse(\n            (response) => response.url().includes('/tags') && response.status() === 201\n        ),\n    ]);\n\n    await page.locator('[role=\"dialog\"] input[name=\"Duration\"]').fill(duration);\n    await page.locator('[role=\"dialog\"] input[name=\"Duration\"]').press('Tab');\n\n    await Promise.all([\n        page.getByRole('button', { name: 'Create Time Entry' }).click(),\n        page.waitForResponse(\n            (response) => response.url().includes('/time-entries') && response.status() === 201\n        ),\n    ]);\n}\n\nexport async function createBareTimeEntry(page: Page, description: string, duration: string) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/time');\n    await expect(page.getByRole('button', { name: 'Time entry actions' })).toBeVisible();\n    await page.getByRole('button', { name: 'Time entry actions' }).click();\n    await page.getByRole('menuitem', { name: 'Manual time entry' }).click();\n\n    await page.getByRole('dialog').getByRole('textbox', { name: 'Description' }).fill(description);\n\n    await page.locator('[role=\"dialog\"] input[name=\"Duration\"]').fill(duration);\n    await page.locator('[role=\"dialog\"] input[name=\"Duration\"]').press('Tab');\n\n    await Promise.all([\n        page.getByRole('button', { name: 'Create Time Entry' }).click(),\n        page.waitForResponse(\n            (response) => response.url().includes('/time-entries') && response.status() === 201\n        ),\n    ]);\n}\n\nexport async function createTimeEntryWithBillableStatus(\n    page: Page,\n    isBillable: boolean,\n    duration: string\n) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/time');\n    await expect(page.getByRole('button', { name: 'Time entry actions' })).toBeVisible();\n    await page.getByRole('button', { name: 'Time entry actions' }).click();\n    await page.getByRole('menuitem', { name: 'Manual time entry' }).click();\n\n    await page\n        .getByRole('dialog')\n        .getByRole('textbox', { name: 'Description' })\n        .fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);\n\n    if (isBillable) {\n        await page\n            .getByRole('dialog')\n            .getByRole('combobox')\n            .filter({ hasText: 'Non-Billable' })\n            .click();\n        await page.getByRole('option', { name: 'Billable', exact: true }).click();\n    }\n\n    await page.locator('[role=\"dialog\"] input[name=\"Duration\"]').fill(duration);\n    await page.locator('[role=\"dialog\"] input[name=\"Duration\"]').press('Tab');\n\n    await Promise.all([\n        page.getByRole('button', { name: 'Create Time Entry' }).click(),\n        page.waitForResponse(\n            (response) => response.url().includes('/time-entries') && response.status() === 201\n        ),\n    ]);\n}\n\n// ──────────────────────────────────────────────────\n// Wait helpers\n// ──────────────────────────────────────────────────\n\nexport async function waitForReportingUpdate(page: Page) {\n    await page.waitForResponse(\n        (response) =>\n            response.url().includes('/time-entries/aggregate') && response.status() === 200\n    );\n}\n\nexport async function waitForDetailedReportingUpdate(page: Page) {\n    await page.waitForResponse(\n        (response) =>\n            response.url().includes('/time-entries') &&\n            !response.url().includes('/aggregate') &&\n            response.request().method() === 'GET' &&\n            response.status() === 200\n    );\n}\n\n// ──────────────────────────────────────────────────\n// Shared report helpers\n// ──────────────────────────────────────────────────\n\nexport async function goToReportingShared(page: Page) {\n    await page.goto(PLAYWRIGHT_BASE_URL + '/reporting/shared');\n}\n\nexport async function saveAsSharedReport(\n    page: Page,\n    reportName: string\n): Promise<{ shareableLink: string }> {\n    await page.getByRole('button', { name: 'Save Report' }).click();\n    await page.getByLabel('Name').fill(reportName);\n    // \"Public\" checkbox is checked by default\n    const [response] = await Promise.all([\n        page.waitForResponse(\n            (response) =>\n                response.url().includes('/reports') &&\n                response.request().method() === 'POST' &&\n                response.status() === 201\n        ),\n        page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click(),\n    ]);\n    const responseBody = await response.json();\n    // Wait for navigation to shared reports page\n    await page.waitForURL('**/reporting/shared');\n    return { shareableLink: responseBody.data.shareable_link };\n}\n"
  },
  {
    "path": "e2e/utils/table.ts",
    "content": "import type { Locator } from '@playwright/test';\n\n/**\n * Extract the first cell's text content from each row in a table.\n * Useful for reading the ordered names/labels from a sorted table.\n */\nexport async function getTableRowNames(table: Locator): Promise<string[]> {\n    const rows = table.getByRole('row');\n    const count = await rows.count();\n    const names: string[] = [];\n    for (let i = 0; i < count; i++) {\n        const text = await rows.nth(i).locator('div').first().textContent();\n        if (text) names.push(text.trim());\n    }\n    return names;\n}\n"
  },
  {
    "path": "e2e/utils/tags.ts",
    "content": "import type { Page } from '@playwright/test';\n\nexport function newTagResponse(page: Page, { name = '' } = {}) {\n    return page.waitForResponse(async (response) => {\n        return (\n            response.status() === 201 &&\n            (await response.headerValue('Content-Type')) === 'application/json' &&\n            (await response.json()).data.name === name\n        );\n    });\n}\n"
  },
  {
    "path": "eslint.config.mjs",
    "content": "import eslint from '@eslint/js';\nimport eslintConfigPrettier from 'eslint-config-prettier';\nimport eslintPluginVue from 'eslint-plugin-vue';\nimport globals from 'globals';\nimport typescriptEslint from 'typescript-eslint';\nimport unusedImports from 'eslint-plugin-unused-imports';\n\nexport default typescriptEslint.config(\n    { ignores: ['*.d.ts', '**/coverage', '**/dist'] },\n    {\n        extends: [\n            eslint.configs.recommended,\n            ...typescriptEslint.configs.recommended,\n            ...eslintPluginVue.configs['flat/recommended'],\n        ],\n        files: ['**/*.{ts,vue,js}'],\n        languageOptions: {\n            ecmaVersion: 'latest',\n            sourceType: 'module',\n            globals: globals.browser,\n            parserOptions: {\n                parser: typescriptEslint.parser,\n            },\n        },\n        plugins: {\n            'unused-imports': unusedImports,\n        },\n        rules: {\n            'vue/multi-word-component-names': 'off',\n            '@typescript-eslint/no-unused-vars': 'off',\n            'unused-imports/no-unused-imports': 'error',\n            'unused-imports/no-unused-vars': [\n                'error',\n                {\n                    'vars': 'all',\n                    'varsIgnorePattern': '^_',\n                    'args': 'after-used',\n                    'argsIgnorePattern': '^_',\n                },\n            ],\n        },\n    },\n    eslintConfigPrettier\n);\n"
  },
  {
    "path": "jsconfig.json",
    "content": "{\n    \"compilerOptions\": {\n        \"baseUrl\": \".\",\n        \"paths\": {\n            \"@/*\": [\"resources/js/*\"]\n        }\n    },\n    \"exclude\": [\"node_modules\", \"public\"]\n}\n"
  },
  {
    "path": "lang/en/auth.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Authentication Language Lines\n    |--------------------------------------------------------------------------\n    |\n    | The following language lines are used during authentication for various\n    | messages that we need to display to the user. You are free to modify\n    | these language lines according to your application's requirements.\n    |\n    */\n\n    'failed' => 'These credentials do not match our records.',\n    'password' => 'The provided password is incorrect.',\n    'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',\n\n];\n"
  },
  {
    "path": "lang/en/enum.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse App\\Enums\\CurrencyFormat;\nuse App\\Enums\\DateFormat;\nuse App\\Enums\\IntervalFormat;\nuse App\\Enums\\NumberFormat;\nuse App\\Enums\\TimeFormat;\nuse App\\Enums\\Weekday;\n\nreturn [\n\n    'weekday' => [\n        Weekday::Monday->value => 'Monday',\n        Weekday::Tuesday->value => 'Tuesday',\n        Weekday::Wednesday->value => 'Wednesday',\n        Weekday::Thursday->value => 'Thursday',\n        Weekday::Friday->value => 'Friday',\n        Weekday::Saturday->value => 'Saturday',\n        Weekday::Sunday->value => 'Sunday',\n    ],\n\n    'number_format' => [\n        NumberFormat::ThousandsPointDecimalComma->value => '1.111,11',\n        NumberFormat::ThousandsCommaDecimalPoint->value => '1,111.11',\n        NumberFormat::ThousandsSpaceDecimalComma->value => '1 111,11',\n        NumberFormat::ThousandsSpaceDecimalPoint->value => '1 111.11',\n        NumberFormat::ThousandsApostropheDecimalPoint->value => '1\\'111.11',\n    ],\n\n    'date_format' => [\n        DateFormat::PointSeparatedDMYYYY->value => 'D.M.YYYY',\n        DateFormat::SlashSeparatedMMDDYYYY->value => 'MM/DD/YYYY',\n        DateFormat::SlashSeparatedDDMMYYYY->value => 'DD/MM/YYYY',\n        DateFormat::HyphenSeparatedDDMMYYY->value => 'DD-MM-YYYY',\n        DateFormat::HyphenSeparatedMMDDDYYYY->value => 'MM-DD-YYYY',\n        DateFormat::HyphenSeparatedYYYYMMDD->value => 'YYYY-MM-DD',\n    ],\n\n    'time_format' => [\n        TimeFormat::TwelveHours->value => '12-hour clock',\n        TimeFormat::TwentyFourHours->value => '24-hour clock',\n    ],\n\n    'interval_format' => [\n        IntervalFormat::Decimal->value => 'Decimal',\n        IntervalFormat::HoursMinutes->value => '12h 3m',\n        IntervalFormat::HoursMinutesColonSeparated->value => '12:03',\n        IntervalFormat::HoursMinutesSecondsColonSeparated->value => '12:03:45',\n    ],\n\n    'currency_format' => [\n        CurrencyFormat::ISOCodeBeforeWithSpace->value => 'EUR 111',\n        CurrencyFormat::ISOCodeAfterWithSpace->value => '111 EUR',\n        CurrencyFormat::SymbolBefore->value => '€111',\n        CurrencyFormat::SymbolAfter->value => '111€',\n        CurrencyFormat::SymbolBeforeWithSpace->value => '€ 111',\n        CurrencyFormat::SymbolAfterWithSpace->value => '111 €',\n    ],\n\n];\n"
  },
  {
    "path": "lang/en/exceptions.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse App\\Exceptions\\Api\\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;\nuse App\\Exceptions\\Api\\CanNotRemoveOwnerFromOrganization;\nuse App\\Exceptions\\Api\\ChangingRoleOfPlaceholderIsNotAllowed;\nuse App\\Exceptions\\Api\\ChangingRoleToPlaceholderIsNotAllowed;\nuse App\\Exceptions\\Api\\EntityStillInUseApiException;\nuse App\\Exceptions\\Api\\FeatureIsNotAvailableInFreePlanApiException;\nuse App\\Exceptions\\Api\\InactiveUserCanNotBeUsedApiException;\nuse App\\Exceptions\\Api\\InvitationForTheEmailAlreadyExistsApiException;\nuse App\\Exceptions\\Api\\OnlyOwnerCanChangeOwnership;\nuse App\\Exceptions\\Api\\OnlyPlaceholdersCanBeMergedIntoAnotherMember;\nuse App\\Exceptions\\Api\\OrganizationHasNoSubscriptionButMultipleMembersException;\nuse App\\Exceptions\\Api\\OrganizationNeedsAtLeastOneOwner;\nuse App\\Exceptions\\Api\\OverlappingTimeEntryApiException;\nuse App\\Exceptions\\Api\\PdfRendererIsNotConfiguredException;\nuse App\\Exceptions\\Api\\PersonalAccessClientIsNotConfiguredException;\nuse App\\Exceptions\\Api\\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;\nuse App\\Exceptions\\Api\\TimeEntryCanNotBeRestartedApiException;\nuse App\\Exceptions\\Api\\TimeEntryStillRunningApiException;\nuse App\\Exceptions\\Api\\UserIsAlreadyMemberOfOrganizationApiException;\nuse App\\Exceptions\\Api\\UserIsAlreadyMemberOfProjectApiException;\nuse App\\Exceptions\\Api\\UserNotPlaceholderApiException;\nuse App\\Service\\Export\\ExportException;\n\nreturn [\n    'api' => [\n        TimeEntryStillRunningApiException::KEY => 'Time entry is still running',\n        UserNotPlaceholderApiException::KEY => 'The given user is not a placeholder',\n        TimeEntryCanNotBeRestartedApiException::KEY => 'Time entry is already stopped and can not be restarted',\n        InactiveUserCanNotBeUsedApiException::KEY => 'Inactive user can not be used',\n        UserIsAlreadyMemberOfOrganizationApiException::KEY => 'User is already a member of the organization',\n        UserIsAlreadyMemberOfProjectApiException::KEY => 'User is already a member of the project',\n        EntityStillInUseApiException::KEY => 'The :modelToDelete is still used by a :modelInUse and can not be deleted.',\n        CanNotRemoveOwnerFromOrganization::KEY => 'Can not remove owner from organization',\n        CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers::KEY => 'Can not delete user who is owner of organization with multiple members. Please delete the organization first.',\n        OnlyOwnerCanChangeOwnership::KEY => 'Only owner can change ownership',\n        OrganizationNeedsAtLeastOneOwner::KEY => 'Organization needs at least one owner',\n        ChangingRoleToPlaceholderIsNotAllowed::KEY => 'Changing role to placeholder is not allowed',\n        ExportException::KEY => 'Export failed, please try again later or contact support',\n        OrganizationHasNoSubscriptionButMultipleMembersException::KEY => 'Organization has no subscription but multiple members',\n        PdfRendererIsNotConfiguredException::KEY => 'PDF renderer is not configured',\n        FeatureIsNotAvailableInFreePlanApiException::KEY => 'Feature is not available in free plan',\n        PersonalAccessClientIsNotConfiguredException::KEY => 'Personal access client is not configured',\n        ChangingRoleOfPlaceholderIsNotAllowed::KEY => 'Changing role of placeholder is not allowed',\n        OnlyPlaceholdersCanBeMergedIntoAnotherMember::KEY => 'Only placeholders can be merged into another member',\n        ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',\n        InvitationForTheEmailAlreadyExistsApiException::KEY => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',\n        OverlappingTimeEntryApiException::KEY => 'Overlapping time entries are not allowed.',\n    ],\n    'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',\n];\n"
  },
  {
    "path": "lang/en/importer.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nreturn [\n    'clockify_time_entries' => [\n        'name' => 'Clockify Time Entries',\n        'description' => '1. First make sure that you set the Date format to \"MM/DD/YYYY\" and the Time format to \"12-hour\" in the user settings.<br>'.\n            '2. In the same preferences page change the language of Clockfiy to English.<br>'.\n            '3. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'.\n            '4. Now select the date range that you want to export in the right top. '.\n            'In the free Clockify plan it\\'s currently not possible to select more than one year. '.\n            'You can export each year separately and import them one after another.'.\n            '<br> 4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table left of the printer symbol. '.\n            '<br><br>Before you import make sure that the Timezone settings in Clockify are the same as in solidtime.',\n    ],\n    'generic_projects' => [\n        'name' => 'Generic Projects',\n        'description' => 'If you want to import many projects yourself this importer the right choice. Please see our docs for <a href=\"https://docs.solidtime.io/user-guide/import\">more information about the CSV structure</a>',\n    ],\n    'generic_time_entries' => [\n        'name' => 'Generic Time Entries',\n        'description' => 'If you want to import many time entries yourself this importer the right choice. Please see our docs for <a href=\"https://docs.solidtime.io/user-guide/import\">more information about the CSV structure</a>',\n    ],\n    'clockify_projects' => [\n        'name' => 'Clockify Projects',\n        'description' => '1. Make sure to set the language of Clockify to English in \"Preferences -> General\".<br>'.\n            '2. Go to PROJECTS in the navigation on the left.<br> '.\n            '3. Now click on the three dots on the right of the project that you want to export and select Export.<br> '.\n            '4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table in the top right corner.',\n    ],\n    'toggl_data_importer' => [\n        'name' => 'Toggl Data Importer',\n        'description' => '1. Go to Admin -> Settings -> Data export. <br>'.\n            '2. Under \"Data Export\" select all items for export and click on \"Export to email\". <br> '.\n            '3. You will receive an email with a download link. Download the ZIP and upload it here. '.\n            '<br><br>The \"Data Export\" exports everything except time entries. '.\n            'If you want to also import time entries use the \"Toggl Time Entries\" importer afterwards.',\n    ],\n    'toggl_time_entries' => [\n        'name' => 'Toggl Time Entries',\n        'description' => '<strong>Important:</strong> If you want to import a Toggl organization use the \"Toggl Data Importer\" before using this importer, since this export contains more details. '.\n            '<br><br>1. Go to Admin -> Settings -> Data export. <br>2. Under \"Time entries\" select the year you want to export and click on \"Export time entries\". <br><br>You can export all years one after another and import them one after another. '.\n            ' <br>Before you import make sure that the Timezone settings in Toggl are the same as in solidtime.',\n    ],\n    'solidtime_importer' => [\n        'name' => 'Solidtime',\n        'description' => '1. Choose the organization you want to export in dropdown in the left top corner<br>2. Click on \"Export\" in the left navigation under \"Admin\" (You need to be Admin or Owner of the organization to see this)<br>3. Click on \"Export\". <br>4. Save the file and upload it here.',\n    ],\n    'harvest_clients' => [\n        'name' => 'Harvest Clients',\n        'description' => '1. Go to \"Manage\" (top navigation)<br>2. Click on the \"Clients\"'.\n            '<br>3. Click on \"Import/Export\" and in the dropdown \"Export clients to CSV\" '.\n            '<br>',\n    ],\n    'harvest_projects' => [\n        'name' => 'Harvest Projects',\n        'description' => '1. Go to \"Projects\" (top navigation)<br>2. Click on the \"Export\" button'.\n            '<br>3. Select which projects you would like to export and select CSV format '.\n            '<br><br>Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.',\n    ],\n    'harvest_time_entries' => [\n        'name' => 'Harvest Time Entries',\n        'description' => '1. Go to Settings (right top corner)<br>2. Click on \"Import/Export\" in the left navigation'.\n            '<br>3. Now click on \"Export all time\" '.\n            '<br><br>Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.',\n    ],\n];\n"
  },
  {
    "path": "lang/en/pagination.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Pagination Language Lines\n    |--------------------------------------------------------------------------\n    |\n    | The following language lines are used by the paginator library to build\n    | the simple pagination links. You are free to change them to anything\n    | you want to customize your views to better match your application.\n    |\n    */\n\n    'previous' => '&laquo; Previous',\n    'next' => 'Next &raquo;',\n\n];\n"
  },
  {
    "path": "lang/en/passwords.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Password Reset Language Lines\n    |--------------------------------------------------------------------------\n    |\n    | The following language lines are the default lines which match reasons\n    | that are given by the password broker for a password update attempt\n    | has failed, such as for an invalid token or invalid new password.\n    |\n    */\n\n    'reset' => 'Your password has been reset.',\n    'sent' => 'We have emailed your password reset link.',\n    'throttled' => 'Please wait before retrying.',\n    'token' => 'This password reset token is invalid.',\n    'user' => \"We can't find a user with that email address.\",\n\n];\n"
  },
  {
    "path": "lang/en/validation.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nreturn [\n\n    /*\n    |--------------------------------------------------------------------------\n    | Validation Language Lines\n    |--------------------------------------------------------------------------\n    |\n    | The following language lines contain the default error messages used by\n    | the validator class. Some of these rules have multiple versions such\n    | as the size rules. Feel free to tweak each of these messages here.\n    |\n    */\n\n    'accepted' => 'The :attribute field must be accepted.',\n    'accepted_if' => 'The :attribute field must be accepted when :other is :value.',\n    'active_url' => 'The :attribute field must be a valid URL.',\n    'after' => 'The :attribute field must be a date after :date.',\n    'after_or_equal' => 'The :attribute field must be a date after or equal to :date.',\n    'alpha' => 'The :attribute field must only contain letters.',\n    'alpha_dash' => 'The :attribute field must only contain letters, numbers, dashes, and underscores.',\n    'alpha_num' => 'The :attribute field must only contain letters and numbers.',\n    'array' => 'The :attribute field must be an array.',\n    'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.',\n    'before' => 'The :attribute field must be a date before :date.',\n    'before_or_equal' => 'The :attribute field must be a date before or equal to :date.',\n    'between' => [\n        'array' => 'The :attribute field must have between :min and :max items.',\n        'file' => 'The :attribute field must be between :min and :max kilobytes.',\n        'numeric' => 'The :attribute field must be between :min and :max.',\n        'string' => 'The :attribute field must be between :min and :max characters.',\n    ],\n    'boolean' => 'The :attribute field must be true or false.',\n    'can' => 'The :attribute field contains an unauthorized value.',\n    'confirmed' => 'The :attribute field confirmation does not match.',\n    'current_password' => 'The password is incorrect.',\n    'date' => 'The :attribute field must be a valid date.',\n    'date_equals' => 'The :attribute field must be a date equal to :date.',\n    'date_format' => 'The :attribute field must match the format :format.',\n    'decimal' => 'The :attribute field must have :decimal decimal places.',\n    'declined' => 'The :attribute field must be declined.',\n    'declined_if' => 'The :attribute field must be declined when :other is :value.',\n    'different' => 'The :attribute field and :other must be different.',\n    'digits' => 'The :attribute field must be :digits digits.',\n    'digits_between' => 'The :attribute field must be between :min and :max digits.',\n    'dimensions' => 'The :attribute field has invalid image dimensions.',\n    'distinct' => 'The :attribute field has a duplicate value.',\n    'doesnt_end_with' => 'The :attribute field must not end with one of the following: :values.',\n    'doesnt_start_with' => 'The :attribute field must not start with one of the following: :values.',\n    'email' => 'The :attribute field must be a valid email address.',\n    'ends_with' => 'The :attribute field must end with one of the following: :values.',\n    'enum' => 'The selected :attribute is invalid.',\n    'exists' => 'The selected :attribute is invalid.',\n    'extensions' => 'The :attribute field must have one of the following extensions: :values.',\n    'file' => 'The :attribute field must be a file.',\n    'filled' => 'The :attribute field must have a value.',\n    'gt' => [\n        'array' => 'The :attribute field must have more than :value items.',\n        'file' => 'The :attribute field must be greater than :value kilobytes.',\n        'numeric' => 'The :attribute field must be greater than :value.',\n        'string' => 'The :attribute field must be greater than :value characters.',\n    ],\n    'gte' => [\n        'array' => 'The :attribute field must have :value items or more.',\n        'file' => 'The :attribute field must be greater than or equal to :value kilobytes.',\n        'numeric' => 'The :attribute field must be greater than or equal to :value.',\n        'string' => 'The :attribute field must be greater than or equal to :value characters.',\n    ],\n    'hex_color' => 'The :attribute field must be a valid hexadecimal color.',\n    'image' => 'The :attribute field must be an image.',\n    'in' => 'The selected :attribute is invalid.',\n    'in_array' => 'The :attribute field must exist in :other.',\n    'integer' => 'The :attribute field must be an integer.',\n    'ip' => 'The :attribute field must be a valid IP address.',\n    'ipv4' => 'The :attribute field must be a valid IPv4 address.',\n    'ipv6' => 'The :attribute field must be a valid IPv6 address.',\n    'json' => 'The :attribute field must be a valid JSON string.',\n    'lowercase' => 'The :attribute field must be lowercase.',\n    'lt' => [\n        'array' => 'The :attribute field must have less than :value items.',\n        'file' => 'The :attribute field must be less than :value kilobytes.',\n        'numeric' => 'The :attribute field must be less than :value.',\n        'string' => 'The :attribute field must be less than :value characters.',\n    ],\n    'lte' => [\n        'array' => 'The :attribute field must not have more than :value items.',\n        'file' => 'The :attribute field must be less than or equal to :value kilobytes.',\n        'numeric' => 'The :attribute field must be less than or equal to :value.',\n        'string' => 'The :attribute field must be less than or equal to :value characters.',\n    ],\n    'mac_address' => 'The :attribute field must be a valid MAC address.',\n    'max' => [\n        'array' => 'The :attribute field must not have more than :max items.',\n        'file' => 'The :attribute field must not be greater than :max kilobytes.',\n        'numeric' => 'The :attribute field must not be greater than :max.',\n        'string' => 'The :attribute field must not be greater than :max characters.',\n    ],\n    'max_digits' => 'The :attribute field must not have more than :max digits.',\n    'mimes' => 'The :attribute field must be a file of type: :values.',\n    'mimetypes' => 'The :attribute field must be a file of type: :values.',\n    'min' => [\n        'array' => 'The :attribute field must have at least :min items.',\n        'file' => 'The :attribute field must be at least :min kilobytes.',\n        'numeric' => 'The :attribute field must be at least :min.',\n        'string' => 'The :attribute field must be at least :min characters.',\n    ],\n    'min_digits' => 'The :attribute field must have at least :min digits.',\n    'missing' => 'The :attribute field must be missing.',\n    'missing_if' => 'The :attribute field must be missing when :other is :value.',\n    'missing_unless' => 'The :attribute field must be missing unless :other is :value.',\n    'missing_with' => 'The :attribute field must be missing when :values is present.',\n    'missing_with_all' => 'The :attribute field must be missing when :values are present.',\n    'multiple_of' => 'The :attribute field must be a multiple of :value.',\n    'not_in' => 'The selected :attribute is invalid.',\n    'not_regex' => 'The :attribute field format is invalid.',\n    'numeric' => 'The :attribute field must be a number.',\n    'password' => [\n        'letters' => 'The :attribute field must contain at least one letter.',\n        'mixed' => 'The :attribute field must contain at least one uppercase and one lowercase letter.',\n        'numbers' => 'The :attribute field must contain at least one number.',\n        'symbols' => 'The :attribute field must contain at least one symbol.',\n        'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.',\n    ],\n    'present' => 'The :attribute field must be present.',\n    'present_if' => 'The :attribute field must be present when :other is :value.',\n    'present_unless' => 'The :attribute field must be present unless :other is :value.',\n    'present_with' => 'The :attribute field must be present when :values is present.',\n    'present_with_all' => 'The :attribute field must be present when :values are present.',\n    'prohibited' => 'The :attribute field is prohibited.',\n    'prohibited_if' => 'The :attribute field is prohibited when :other is :value.',\n    'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.',\n    'prohibits' => 'The :attribute field prohibits :other from being present.',\n    'regex' => 'The :attribute field format is invalid.',\n    'required' => 'The :attribute field is required.',\n    'required_array_keys' => 'The :attribute field must contain entries for: :values.',\n    'required_if' => 'The :attribute field is required when :other is :value.',\n    'required_if_accepted' => 'The :attribute field is required when :other is accepted.',\n    'required_unless' => 'The :attribute field is required unless :other is in :values.',\n    'required_with' => 'The :attribute field is required when :values is present.',\n    'required_with_all' => 'The :attribute field is required when :values are present.',\n    'required_without' => 'The :attribute field is required when :values is not present.',\n    'required_without_all' => 'The :attribute field is required when none of :values are present.',\n    'same' => 'The :attribute field must match :other.',\n    'size' => [\n        'array' => 'The :attribute field must contain :size items.',\n        'file' => 'The :attribute field must be :size kilobytes.',\n        'numeric' => 'The :attribute field must be :size.',\n        'string' => 'The :attribute field must be :size characters.',\n    ],\n    'starts_with' => 'The :attribute field must start with one of the following: :values.',\n    'string' => 'The :attribute field must be a string.',\n    'timezone' => 'The :attribute field must be a valid timezone.',\n    'unique' => 'The :attribute has already been taken.',\n    'uploaded' => 'The :attribute failed to upload.',\n    'uppercase' => 'The :attribute field must be uppercase.',\n    'url' => 'The :attribute field must be a valid URL.',\n    'ulid' => 'The :attribute field must be a valid ULID.',\n    'uuid' => 'The :attribute field must be a valid UUID.',\n\n    /*\n    |--------------------------------------------------------------------------\n    | Custom Validation Language Lines\n    |--------------------------------------------------------------------------\n    |\n    | Here you may specify custom validation messages for attributes using the\n    | convention \"attribute.rule\" to name the lines. This makes it quick to\n    | specify a specific custom language line for a given attribute rule.\n    |\n    */\n\n    'custom' => [\n        'attribute-name' => [\n            'rule-name' => 'custom-message',\n        ],\n    ],\n\n    /*\n    |--------------------------------------------------------------------------\n    | Custom Validation Attributes\n    |--------------------------------------------------------------------------\n    |\n    | The following language lines are used to swap our attribute placeholder\n    | with something more reader friendly such as \"E-Mail Address\" instead\n    | of \"email\". This simply helps us make our message more expressive.\n    |\n    */\n\n    'attributes' => [\n        'task_id' => 'task',\n        'project_id' => 'project',\n        'organization_id' => 'organization',\n    ],\n\n    /*\n     * Custom validation rules\n     */\n\n    'color' => 'The :attribute field must be a valid color.',\n    'currency' => 'The :attribute field must be a valid currency code (ISO 4217).',\n    'organization' => 'The :attribute does not exist.',\n    'task_belongs_to_project' => 'The :attribute is not part of the given project.',\n    'project_name_already_exists' => 'A project with the same name and client already exists in the organization.',\n    'overlapping_time_entry' => 'Overlapping time entries are not allowed.',\n    'tag_name_already_exists' => 'A tag with the same name already exists in the organization.',\n    'client_name_already_exists' => 'A client with the same name already exists in the organization.',\n    'task_name_already_exists' => 'A task with the same name already exists in the project.',\n    'invitation_already_exists' => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',\n\n    'entities' => [\n        'organization' => 'organization',\n        'project' => 'project',\n        'task' => 'task',\n        'time_entry' => 'time entry',\n        'user' => 'user',\n        'client' => 'client',\n        'member' => 'member',\n        'project_member' => 'project member',\n        'tag' => 'tag',\n    ],\n];\n"
  },
  {
    "path": "openapi.json",
    "content": "{\n    \"openapi\": \"3.1.0\",\n    \"info\": {\n        \"title\": \"Laravel\",\n        \"version\": \"0.0.1\"\n    },\n    \"servers\": [\n        {\n            \"url\": \"https:\\/\\/app.solidtime.io\\/api\",\n            \"description\": \"Production\"\n        },\n        {\n            \"url\": \"https:\\/\\/app.staging.solidtime.io\\/api\",\n            \"description\": \"Staging\"\n        },\n        {\n            \"url\": \"https:\\/\\/soldtime.test\\/api\",\n            \"description\": \"Local\"\n        }\n    ],\n    \"security\": [\n        {\n            \"oauth2\": []\n        }\n    ],\n    \"paths\": {\n        \"\\/v1\\/organization\\/{organization}\\/projects\": {\n            \"get\": {\n                \"operationId\": \"getProjects\",\n                \"summary\": \"Get projects\",\n                \"tags\": [\n                    \"Project\"\n                ],\n                \"parameters\": [\n                    {\n                        \"name\": \"organization\",\n                        \"in\": \"path\",\n                        \"required\": true,\n                        \"description\": \"The organization ID\",\n                        \"schema\": {\n                            \"type\": \"string\",\n                            \"format\": \"uuid\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"`ProjectCollection`\",\n                        \"content\": {\n                            \"application\\/json\": {\n                                \"schema\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                        \"data\": {\n                                            \"$ref\": \"#\\/components\\/schemas\\/ProjectCollection\"\n                                        }\n                                    },\n                                    \"required\": [\n                                        \"data\"\n                                    ]\n                                }\n                            }\n                        }\n                    },\n                    \"403\": {\n                        \"$ref\": \"#\\/components\\/responses\\/AuthorizationException\"\n                    },\n                    \"404\": {\n                        \"$ref\": \"#\\/components\\/responses\\/ModelNotFoundException\"\n                    }\n                }\n            },\n            \"post\": {\n                \"operationId\": \"createProject\",\n                \"summary\": \"Create project\",\n                \"tags\": [\n                    \"Project\"\n                ],\n                \"parameters\": [\n                    {\n                        \"name\": \"organization\",\n                        \"in\": \"path\",\n                        \"required\": true,\n                        \"description\": \"The organization ID\",\n                        \"schema\": {\n                            \"type\": \"string\",\n                            \"format\": \"uuid\"\n                        }\n                    }\n                ],\n                \"requestBody\": {\n                    \"content\": {\n                        \"application\\/json\": {\n                            \"schema\": {\n                                \"type\": \"object\",\n                                \"properties\": {\n                                    \"name\": {\n                                        \"type\": \"string\"\n                                    },\n                                    \"color\": {\n                                        \"type\": \"string\"\n                                    }\n                                },\n                                \"required\": [\n                                    \"name\",\n                                    \"color\"\n                                ]\n                            }\n                        }\n                    }\n                },\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"`ProjectResource`\",\n                        \"content\": {\n                            \"application\\/json\": {\n                                \"schema\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                        \"data\": {\n                                            \"$ref\": \"#\\/components\\/schemas\\/ProjectResource\"\n                                        }\n                                    },\n                                    \"required\": [\n                                        \"data\"\n                                    ]\n                                }\n                            }\n                        }\n                    },\n                    \"403\": {\n                        \"$ref\": \"#\\/components\\/responses\\/AuthorizationException\"\n                    },\n                    \"404\": {\n                        \"$ref\": \"#\\/components\\/responses\\/ModelNotFoundException\"\n                    },\n                    \"422\": {\n                        \"$ref\": \"#\\/components\\/responses\\/ValidationException\"\n                    }\n                }\n            }\n        },\n        \"\\/v1\\/organization\\/{organization}\\/projects\\/{project}\": {\n            \"get\": {\n                \"operationId\": \"getProject\",\n                \"summary\": \"Get project\",\n                \"tags\": [\n                    \"Project\"\n                ],\n                \"parameters\": [\n                    {\n                        \"name\": \"organization\",\n                        \"in\": \"path\",\n                        \"required\": true,\n                        \"description\": \"The organization ID\",\n                        \"schema\": {\n                            \"type\": \"string\",\n                            \"format\": \"uuid\"\n                        }\n                    },\n                    {\n                        \"name\": \"project\",\n                        \"in\": \"path\",\n                        \"required\": true,\n                        \"description\": \"The project ID\",\n                        \"schema\": {\n                            \"type\": \"string\",\n                            \"format\": \"uuid\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"`ProjectResource`\",\n                        \"content\": {\n                            \"application\\/json\": {\n                                \"schema\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                        \"data\": {\n                                            \"$ref\": \"#\\/components\\/schemas\\/ProjectResource\"\n                                        }\n                                    },\n                                    \"required\": [\n                                        \"data\"\n                                    ]\n                                }\n                            }\n                        }\n                    },\n                    \"403\": {\n                        \"$ref\": \"#\\/components\\/responses\\/AuthorizationException\"\n                    },\n                    \"404\": {\n                        \"$ref\": \"#\\/components\\/responses\\/ModelNotFoundException\"\n                    }\n                }\n            },\n            \"put\": {\n                \"operationId\": \"updateProject\",\n                \"summary\": \"Update project\",\n                \"tags\": [\n                    \"Project\"\n                ],\n                \"parameters\": [\n                    {\n                        \"name\": \"organization\",\n                        \"in\": \"path\",\n                        \"required\": true,\n                        \"description\": \"The organization ID\",\n                        \"schema\": {\n                            \"type\": \"string\",\n                            \"format\": \"uuid\"\n                        }\n                    },\n                    {\n                        \"name\": \"project\",\n                        \"in\": \"path\",\n                        \"required\": true,\n                        \"description\": \"The project ID\",\n                        \"schema\": {\n                            \"type\": \"string\",\n                            \"format\": \"uuid\"\n                        }\n                    }\n                ],\n                \"requestBody\": {\n                    \"content\": {\n                        \"application\\/json\": {\n                            \"schema\": {\n                                \"type\": \"object\",\n                                \"properties\": {\n                                    \"name\": {\n                                        \"type\": \"string\"\n                                    },\n                                    \"color\": {\n                                        \"type\": \"string\"\n                                    }\n                                },\n                                \"required\": [\n                                    \"name\",\n                                    \"color\"\n                                ]\n                            }\n                        }\n                    }\n                },\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"`ProjectResource`\",\n                        \"content\": {\n                            \"application\\/json\": {\n                                \"schema\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                        \"data\": {\n                                            \"$ref\": \"#\\/components\\/schemas\\/ProjectResource\"\n                                        }\n                                    },\n                                    \"required\": [\n                                        \"data\"\n                                    ]\n                                }\n                            }\n                        }\n                    },\n                    \"403\": {\n                        \"$ref\": \"#\\/components\\/responses\\/AuthorizationException\"\n                    },\n                    \"404\": {\n                        \"$ref\": \"#\\/components\\/responses\\/ModelNotFoundException\"\n                    },\n                    \"422\": {\n                        \"$ref\": \"#\\/components\\/responses\\/ValidationException\"\n                    }\n                }\n            },\n            \"delete\": {\n                \"operationId\": \"v1.projects.destroy\",\n                \"summary\": \"Delete project\",\n                \"tags\": [\n                    \"Project\"\n                ],\n                \"parameters\": [\n                    {\n                        \"name\": \"organization\",\n                        \"in\": \"path\",\n                        \"required\": true,\n                        \"description\": \"The organization ID\",\n                        \"schema\": {\n                            \"type\": \"string\",\n                            \"format\": \"uuid\"\n                        }\n                    },\n                    {\n                        \"name\": \"project\",\n                        \"in\": \"path\",\n                        \"required\": true,\n                        \"description\": \"The project ID\",\n                        \"schema\": {\n                            \"type\": \"string\",\n                            \"format\": \"uuid\"\n                        }\n                    }\n                ],\n                \"requestBody\": {\n                    \"content\": {\n                        \"application\\/json\": {\n                            \"schema\": {\n                                \"type\": \"object\"\n                            }\n                        }\n                    }\n                },\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No content\",\n                        \"content\": {\n                            \"application\\/json\": {\n                                \"schema\": {\n                                    \"type\": \"null\"\n                                }\n                            }\n                        }\n                    },\n                    \"403\": {\n                        \"$ref\": \"#\\/components\\/responses\\/AuthorizationException\"\n                    },\n                    \"404\": {\n                        \"$ref\": \"#\\/components\\/responses\\/ModelNotFoundException\"\n                    }\n                }\n            }\n        },\n        \"\\/v1\\/organization\\/{organization}\\/time-entries\": {\n            \"get\": {\n                \"operationId\": \"v1.time-entries.index\",\n                \"summary\": \"Get time entries\",\n                \"tags\": [\n                    \"TimeEntry\"\n                ],\n                \"parameters\": [\n                    {\n                        \"name\": \"organization\",\n                        \"in\": \"path\",\n                        \"required\": true,\n                        \"description\": \"The organization ID\",\n                        \"schema\": {\n                            \"type\": \"string\",\n                            \"format\": \"uuid\"\n                        }\n                    },\n                    {\n                        \"name\": \"user_id\",\n                        \"in\": \"query\",\n                        \"description\": \"Filter by user ID\",\n                        \"schema\": {\n                            \"type\": \"string\",\n                            \"format\": \"uuid\"\n                        }\n                    },\n                    {\n                        \"name\": \"before\",\n                        \"in\": \"query\",\n                        \"description\": \"Filter only time entries that have a start date before (not including) the given date (example: 2021-12-31)\",\n                        \"schema\": {\n                            \"type\": [\n                                \"string\",\n                                \"null\"\n                            ]\n                        }\n                    },\n                    {\n                        \"name\": \"after\",\n                        \"in\": \"query\",\n                        \"description\": \"Filter only time entries that have a start date after (not including) the given date (example: 2021-12-31)\",\n                        \"schema\": {\n                            \"type\": [\n                                \"string\",\n                                \"null\"\n                            ]\n                        }\n                    },\n                    {\n                        \"name\": \"active\",\n                        \"in\": \"query\",\n                        \"description\": \"Filter only time entries that are active (have no end date, are still running)\",\n                        \"schema\": {\n                            \"type\": \"boolean\"\n                        }\n                    },\n                    {\n                        \"name\": \"limit\",\n                        \"in\": \"query\",\n                        \"description\": \"Limit the number of returned time entries\",\n                        \"schema\": {\n                            \"type\": \"integer\",\n                            \"minimum\": 1,\n                            \"maximum\": 500\n                        }\n                    },\n                    {\n                        \"name\": \"only_full_dates\",\n                        \"in\": \"query\",\n                        \"description\": \"Filter makes sure that only time entries of a whole date are returned\",\n                        \"schema\": {\n                            \"type\": \"boolean\"\n                        }\n                    }\n                ],\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"`TimeEntryCollection`\",\n                        \"content\": {\n                            \"application\\/json\": {\n                                \"schema\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                        \"data\": {\n                                            \"$ref\": \"#\\/components\\/schemas\\/TimeEntryCollection\"\n                                        }\n                                    },\n                                    \"required\": [\n                                        \"data\"\n                                    ]\n                                }\n                            }\n                        }\n                    },\n                    \"403\": {\n                        \"$ref\": \"#\\/components\\/responses\\/AuthorizationException\"\n                    },\n                    \"404\": {\n                        \"$ref\": \"#\\/components\\/responses\\/ModelNotFoundException\"\n                    },\n                    \"422\": {\n                        \"$ref\": \"#\\/components\\/responses\\/ValidationException\"\n                    }\n                }\n            },\n            \"post\": {\n                \"operationId\": \"v1.time-entries.store\",\n                \"summary\": \"Create time entry\",\n                \"tags\": [\n                    \"TimeEntry\"\n                ],\n                \"parameters\": [\n                    {\n                        \"name\": \"organization\",\n                        \"in\": \"path\",\n                        \"required\": true,\n                        \"description\": \"The organization ID\",\n                        \"schema\": {\n                            \"type\": \"string\",\n                            \"format\": \"uuid\"\n                        }\n                    }\n                ],\n                \"requestBody\": {\n                    \"content\": {\n                        \"application\\/json\": {\n                            \"schema\": {\n                                \"type\": \"object\",\n                                \"properties\": {\n                                    \"user_id\": {\n                                        \"type\": \"string\",\n                                        \"format\": \"uuid\",\n                                        \"description\": \"ID of the user that the time entry should belong to\"\n                                    },\n                                    \"task_id\": {\n                                        \"type\": [\n                                            \"string\",\n                                            \"null\"\n                                        ],\n                                        \"format\": \"uuid\",\n                                        \"description\": \"ID of the task that the time entry should belong to\"\n                                    },\n                                    \"start\": {\n                                        \"type\": \"string\",\n                                        \"description\": \"Start of time entry (ISO 8601 format, UTC timezone)\"\n                                    },\n                                    \"end\": {\n                                        \"type\": [\n                                            \"string\",\n                                            \"null\"\n                                        ],\n                                        \"description\": \"End of time entry (ISO 8601 format, UTC timezone)\"\n                                    },\n                                    \"description\": {\n                                        \"type\": [\n                                            \"string\",\n                                            \"null\"\n                                        ],\n                                        \"description\": \"Description of time entry\"\n                                    },\n                                    \"tags\": {\n                                        \"type\": [\n                                            \"array\",\n                                            \"null\"\n                                        ],\n                                        \"description\": \"List of tag IDs\",\n                                        \"items\": {\n                                            \"type\": \"string\",\n                                            \"format\": \"uuid\"\n                                        }\n                                    }\n                                },\n                                \"required\": [\n                                    \"user_id\",\n                                    \"start\",\n                                    \"end\"\n                                ]\n                            }\n                        }\n                    }\n                },\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"`TimeEntryResource`\",\n                        \"content\": {\n                            \"application\\/json\": {\n                                \"schema\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                        \"data\": {\n                                            \"$ref\": \"#\\/components\\/schemas\\/TimeEntryResource\"\n                                        }\n                                    },\n                                    \"required\": [\n                                        \"data\"\n                                    ]\n                                }\n                            }\n                        }\n                    },\n                    \"403\": {\n                        \"$ref\": \"#\\/components\\/responses\\/AuthorizationException\"\n                    },\n                    \"404\": {\n                        \"$ref\": \"#\\/components\\/responses\\/ModelNotFoundException\"\n                    },\n                    \"422\": {\n                        \"$ref\": \"#\\/components\\/responses\\/ValidationException\"\n                    }\n                }\n            }\n        },\n        \"\\/v1\\/organization\\/{organization}\\/time-entries\\/{timeEntry}\": {\n            \"put\": {\n                \"operationId\": \"v1.time-entries.update\",\n                \"summary\": \"Update time entry\",\n                \"tags\": [\n                    \"TimeEntry\"\n                ],\n                \"parameters\": [\n                    {\n                        \"name\": \"organization\",\n                        \"in\": \"path\",\n                        \"required\": true,\n                        \"description\": \"The organization ID\",\n                        \"schema\": {\n                            \"type\": \"string\",\n                            \"format\": \"uuid\"\n                        }\n                    },\n                    {\n                        \"name\": \"timeEntry\",\n                        \"in\": \"path\",\n                        \"required\": true,\n                        \"description\": \"The time entry ID\",\n                        \"schema\": {\n                            \"type\": \"string\",\n                            \"format\": \"uuid\"\n                        }\n                    }\n                ],\n                \"requestBody\": {\n                    \"content\": {\n                        \"application\\/json\": {\n                            \"schema\": {\n                                \"type\": \"object\",\n                                \"properties\": {\n                                    \"task_id\": {\n                                        \"type\": [\n                                            \"string\",\n                                            \"null\"\n                                        ],\n                                        \"format\": \"uuid\",\n                                        \"description\": \"ID of the task that the time entry should belong to\"\n                                    },\n                                    \"start\": {\n                                        \"type\": \"string\",\n                                        \"description\": \"Start of time entry (ISO 8601 format, UTC timezone)\"\n                                    },\n                                    \"end\": {\n                                        \"type\": [\n                                            \"string\",\n                                            \"null\"\n                                        ],\n                                        \"description\": \"End of time entry (ISO 8601 format, UTC timezone)\"\n                                    },\n                                    \"description\": {\n                                        \"type\": [\n                                            \"string\",\n                                            \"null\"\n                                        ],\n                                        \"description\": \"Description of time entry\"\n                                    },\n                                    \"tags\": {\n                                        \"type\": [\n                                            \"array\",\n                                            \"null\"\n                                        ],\n                                        \"description\": \"List of tag IDs\",\n                                        \"items\": {\n                                            \"type\": \"string\",\n                                            \"format\": \"uuid\"\n                                        }\n                                    }\n                                },\n                                \"required\": [\n                                    \"start\",\n                                    \"end\"\n                                ]\n                            }\n                        }\n                    }\n                },\n                \"responses\": {\n                    \"200\": {\n                        \"description\": \"`TimeEntryResource`\",\n                        \"content\": {\n                            \"application\\/json\": {\n                                \"schema\": {\n                                    \"type\": \"object\",\n                                    \"properties\": {\n                                        \"data\": {\n                                            \"$ref\": \"#\\/components\\/schemas\\/TimeEntryResource\"\n                                        }\n                                    },\n                                    \"required\": [\n                                        \"data\"\n                                    ]\n                                }\n                            }\n                        }\n                    },\n                    \"403\": {\n                        \"$ref\": \"#\\/components\\/responses\\/AuthorizationException\"\n                    },\n                    \"404\": {\n                        \"$ref\": \"#\\/components\\/responses\\/ModelNotFoundException\"\n                    },\n                    \"422\": {\n                        \"$ref\": \"#\\/components\\/responses\\/ValidationException\"\n                    }\n                }\n            },\n            \"delete\": {\n                \"operationId\": \"v1.time-entries.destroy\",\n                \"summary\": \"Delete time entry\",\n                \"tags\": [\n                    \"TimeEntry\"\n                ],\n                \"parameters\": [\n                    {\n                        \"name\": \"organization\",\n                        \"in\": \"path\",\n                        \"required\": true,\n                        \"description\": \"The organization ID\",\n                        \"schema\": {\n                            \"type\": \"string\",\n                            \"format\": \"uuid\"\n                        }\n                    },\n                    {\n                        \"name\": \"timeEntry\",\n                        \"in\": \"path\",\n                        \"required\": true,\n                        \"description\": \"The time entry ID\",\n                        \"schema\": {\n                            \"type\": \"string\",\n                            \"format\": \"uuid\"\n                        }\n                    }\n                ],\n                \"requestBody\": {\n                    \"content\": {\n                        \"application\\/json\": {\n                            \"schema\": {\n                                \"type\": \"object\"\n                            }\n                        }\n                    }\n                },\n                \"responses\": {\n                    \"204\": {\n                        \"description\": \"No content\",\n                        \"content\": {\n                            \"application\\/json\": {\n                                \"schema\": {\n                                    \"type\": \"null\"\n                                }\n                            }\n                        }\n                    },\n                    \"403\": {\n                        \"$ref\": \"#\\/components\\/responses\\/AuthorizationException\"\n                    },\n                    \"404\": {\n                        \"$ref\": \"#\\/components\\/responses\\/ModelNotFoundException\"\n                    }\n                }\n            }\n        }\n    },\n    \"components\": {\n        \"securitySchemes\": {\n            \"oauth2\": {\n                \"type\": \"oauth2\",\n                \"flows\": {\n                    \"authorizationCode\": {\n                        \"authorizationUrl\": \"https:\\/\\/solidtime.test\\/oauth\\/authorize\"\n                    }\n                }\n            }\n        },\n        \"schemas\": {\n            \"ProjectCollection\": {\n                \"type\": \"array\",\n                \"items\": {\n                    \"$ref\": \"#\\/components\\/schemas\\/ProjectResource\"\n                },\n                \"title\": \"ProjectCollection\"\n            },\n            \"ProjectResource\": {\n                \"type\": \"object\",\n                \"properties\": {\n                    \"id\": {\n                        \"type\": \"string\",\n                        \"description\": \"ID of project\"\n                    },\n                    \"name\": {\n                        \"type\": \"string\",\n                        \"description\": \"Name of project\"\n                    },\n                    \"color\": {\n                        \"type\": \"string\",\n                        \"description\": \"Color of project\"\n                    },\n                    \"client_id\": {\n                        \"type\": [\n                            \"string\",\n                            \"null\"\n                        ],\n                        \"description\": \"ID of client\"\n                    }\n                },\n                \"required\": [\n                    \"id\",\n                    \"name\",\n                    \"color\",\n                    \"client_id\"\n                ],\n                \"title\": \"ProjectResource\"\n            },\n            \"TimeEntryCollection\": {\n                \"type\": \"array\",\n                \"items\": {\n                    \"$ref\": \"#\\/components\\/schemas\\/TimeEntryResource\"\n                },\n                \"title\": \"TimeEntryCollection\"\n            },\n            \"TimeEntryResource\": {\n                \"type\": \"object\",\n                \"properties\": {\n                    \"id\": {\n                        \"type\": \"string\",\n                        \"description\": \"ID of time entry\"\n                    },\n                    \"start\": {\n                        \"type\": \"string\",\n                        \"description\": \"Start of time entry (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z)\"\n                    },\n                    \"end\": {\n                        \"type\": [\n                            \"string\",\n                            \"null\"\n                        ],\n                        \"description\": \"End of time entry (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z)\"\n                    },\n                    \"duration\": {\n                        \"type\": \"integer\",\n                        \"description\": \"Duration of time entry in seconds\"\n                    },\n                    \"description\": {\n                        \"type\": [\n                            \"string\",\n                            \"null\"\n                        ],\n                        \"description\": \"Description of time entry\"\n                    },\n                    \"task_id\": {\n                        \"type\": [\n                            \"string\",\n                            \"null\"\n                        ],\n                        \"description\": \"ID of task\"\n                    },\n                    \"project_id\": {\n                        \"type\": [\n                            \"string\",\n                            \"null\"\n                        ],\n                        \"description\": \"ID of project\"\n                    },\n                    \"user_id\": {\n                        \"type\": \"string\",\n                        \"description\": \"ID of user\"\n                    },\n                    \"tags\": {\n                        \"type\": \"array\",\n                        \"description\": \"List of tag IDs\",\n                        \"items\": {\n                            \"type\": \"string\"\n                        }\n                    }\n                },\n                \"required\": [\n                    \"id\",\n                    \"start\",\n                    \"end\",\n                    \"duration\",\n                    \"description\",\n                    \"task_id\",\n                    \"project_id\",\n                    \"user_id\",\n                    \"tags\"\n                ],\n                \"title\": \"TimeEntryResource\"\n            }\n        },\n        \"responses\": {\n            \"AuthorizationException\": {\n                \"description\": \"Authorization error\",\n                \"content\": {\n                    \"application\\/json\": {\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"properties\": {\n                                \"message\": {\n                                    \"type\": \"string\",\n                                    \"description\": \"Error overview.\"\n                                }\n                            },\n                            \"required\": [\n                                \"message\"\n                            ]\n                        }\n                    }\n                }\n            },\n            \"ModelNotFoundException\": {\n                \"description\": \"Not found\",\n                \"content\": {\n                    \"application\\/json\": {\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"properties\": {\n                                \"message\": {\n                                    \"type\": \"string\",\n                                    \"description\": \"Error overview.\"\n                                }\n                            },\n                            \"required\": [\n                                \"message\"\n                            ]\n                        }\n                    }\n                }\n            },\n            \"ValidationException\": {\n                \"description\": \"Validation error\",\n                \"content\": {\n                    \"application\\/json\": {\n                        \"schema\": {\n                            \"type\": \"object\",\n                            \"properties\": {\n                                \"message\": {\n                                    \"type\": \"string\",\n                                    \"description\": \"Errors overview.\"\n                                },\n                                \"errors\": {\n                                    \"type\": \"object\",\n                                    \"description\": \"A detailed description of each field that failed validation.\",\n                                    \"additionalProperties\": {\n                                        \"type\": \"array\",\n                                        \"items\": {\n                                            \"type\": \"string\"\n                                        }\n                                    }\n                                }\n                            },\n                            \"required\": [\n                                \"message\",\n                                \"errors\"\n                            ]\n                        }\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n    \"private\": true,\n    \"type\": \"module\",\n    \"workspaces\": [\n        \"resources/js/packages/ui\",\n        \"resources/js/packages/api\"\n    ],\n    \"scripts\": {\n        \"dev\": \"vite\",\n        \"build\": \"vite build\",\n        \"lint\": \"eslint resources/js\",\n        \"lint:fix\": \"eslint --fix resources/js\",\n        \"type-check\": \"vue-tsc --noEmit\",\n        \"test:e2e\": \"rm -rf test-results/.auth && npx playwright test\",\n        \"zod:generate\": \"npx openapi-zod-client http://localhost:80/docs/api.json --output resources/js/packages/api/src/openapi.json.client.ts --base-url /api\",\n        \"format\": \"prettier --write './**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,vue}'\",\n        \"format:check\": \"prettier --check './**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,vue}'\"\n    },\n    \"devDependencies\": {\n        \"@eslint/eslintrc\": \"^3.2.0\",\n        \"@eslint/js\": \"^9.19.0\",\n        \"@inertiajs/vue3\": \"^2.0.0\",\n        \"@playwright/test\": \"^1.41.1\",\n        \"@tailwindcss/forms\": \"^0.5.9\",\n        \"@tailwindcss/typography\": \"^0.5.15\",\n        \"@types/chroma-js\": \"^3.1.0\",\n        \"@types/node\": \"^22.10.10\",\n        \"@vitejs/plugin-vue\": \"^6.0.3\",\n        \"@vue/tsconfig\": \"^0.8.0\",\n        \"autoprefixer\": \"^10.4.20\",\n        \"axios\": \"^1.6.4\",\n        \"eslint-plugin-unused-imports\": \"^4.1.4\",\n        \"laravel-vite-plugin\": \"^2.1.0\",\n        \"openapi-zod-client\": \"^1.16.2\",\n        \"postcss\": \"^8.4.47\",\n        \"postcss-import\": \"^15.1.0\",\n        \"postcss-nesting\": \"^12.1.5\",\n        \"tailwindcss\": \"^3.4.13\",\n        \"typescript\": \"^5.7.3\",\n        \"vite\": \"^7.0.0\",\n        \"vite-plugin-checker\": \"^0.12.0\",\n        \"vue\": \"^3.5.0\",\n        \"vue-tsc\": \"^3.0.0\"\n    },\n    \"dependencies\": {\n        \"@floating-ui/core\": \"^1.6.0\",\n        \"@floating-ui/vue\": \"^1.0.6\",\n        \"@fullcalendar/core\": \"^6.1.18\",\n        \"@fullcalendar/daygrid\": \"^6.1.18\",\n        \"@fullcalendar/interaction\": \"^6.1.18\",\n        \"@fullcalendar/timegrid\": \"^6.1.18\",\n        \"@fullcalendar/vue3\": \"^6.1.18\",\n        \"@heroicons/vue\": \"^2.1.1\",\n        \"@rushstack/eslint-patch\": \"^1.10.5\",\n        \"@tailwindcss/container-queries\": \"^0.1.1\",\n        \"@tanstack/vue-form\": \"^1.3.1\",\n        \"@tanstack/vue-query\": \"^5.56.2\",\n        \"@tanstack/vue-query-devtools\": \"^5.58.0\",\n        \"@tanstack/vue-table\": \"^8.21.2\",\n        \"@vue/eslint-config-prettier\": \"^10.2.0\",\n        \"@vue/eslint-config-typescript\": \"^14.3.0\",\n        \"@vueuse/core\": \"^14.2.0\",\n        \"@vueuse/integrations\": \"^14.0.0\",\n        \"@zodios/core\": \"^10.9.6\",\n        \"chroma-js\": \"3.1.2\",\n        \"class-variance-authority\": \"^0.7.1\",\n        \"clsx\": \"^2.1.1\",\n        \"dayjs\": \"^1.11.11\",\n        \"echarts\": \"^6.0.0\",\n        \"focus-trap\": \"^8.0.0\",\n        \"lucide-vue-next\": \"^0.487.0\",\n        \"parse-duration\": \"^2.0.1\",\n        \"pinia\": \"^3.0.0\",\n        \"radix-vue\": \"^1.9.6\",\n        \"reka-ui\": \"^2.8.0\",\n        \"tailwind-merge\": \"^2.6.0\",\n        \"tailwindcss-animate\": \"^1.0.7\",\n        \"vue-echarts\": \"^8.0.0\",\n        \"zod\": \"^3.23.8\"\n    },\n    \"overrides\": {\n        \"vite-plugin-checker\": {\n            \"vue-tsc\": \"$vue-tsc\"\n        }\n    }\n}\n"
  },
  {
    "path": "phpstan.neon",
    "content": "includes:\n    - vendor/larastan/larastan/extension.neon\n\nparameters:\n\n    paths:\n        - app/\n\n    # Level 9 is the highest level\n    level: 7\n\n    checkOctaneCompatibility: true\n    checkModelProperties: true\n    noEnvCallsOutsideOfConfig: true\n\n    ignoreErrors:\n        - '# is not subtype of native type Illuminate\\\\Database\\\\Eloquent\\\\Builder#'\n        - '# is not subtype of native type Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation#'\n"
  },
  {
    "path": "phpunit.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<phpunit xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n         xsi:noNamespaceSchemaLocation=\"vendor/phpunit/phpunit/phpunit.xsd\"\n         bootstrap=\"vendor/autoload.php\"\n         colors=\"true\"\n>\n    <testsuites>\n        <testsuite name=\"Unit\">\n            <directory>tests/Unit</directory>\n        </testsuite>\n        <testsuite name=\"Feature\">\n            <directory>tests/Feature</directory>\n        </testsuite>\n        <testsuite name=\"Modules\">\n            <directory suffix=\"Test.php\">./extensions/*/tests/Feature</directory>\n            <directory suffix=\"Test.php\">./extensions/*/tests/Unit</directory>\n        </testsuite>\n    </testsuites>\n    <source>\n        <include>\n            <directory>app</directory>\n            <directory suffix=\".php\">./extensions</directory>\n        </include>\n        <exclude>\n            <directory suffix=\".php\">./extensions/*/database</directory>\n            <directory suffix=\".php\">./extensions/*/resources</directory>\n            <directory suffix=\".php\">./extensions/*/tests</directory>\n        </exclude>\n    </source>\n    <php>\n        <env name=\"APP_ENV\" value=\"testing\"/>\n        <env name=\"APP_FORCE_HTTPS\" value=\"false\"/>\n        <env name=\"TRUSTED_PROXIES\" value=\"0.0.0.0/0,2000:0:0:0:0:0:0:0/3\"/>\n        <env name=\"BCRYPT_ROUNDS\" value=\"4\"/>\n        <env name=\"CACHE_DRIVER\" value=\"array\"/>\n        <env name=\"DB_CONNECTION\" value=\"pgsql_test\"/>\n        <env name=\"MAIL_MAILER\" value=\"array\"/>\n        <env name=\"PULSE_ENABLED\" value=\"false\"/>\n        <env name=\"QUEUE_CONNECTION\" value=\"sync\"/>\n        <env name=\"SESSION_DRIVER\" value=\"array\"/>\n        <env name=\"TELESCOPE_ENABLED\" value=\"false\"/>\n        <env name=\"AUDITING_ENABLED\" value=\"true\"/>\n        <env name=\"NEWSLETTER_URL\" value=\"null\"/>\n        <env name=\"PASSPORT_PERSONAL_ACCESS_CLIENT_ID\" value=\"null\"/>\n        <env name=\"PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET\" value=\"null\"/>\n    </php>\n</phpunit>\n"
  },
  {
    "path": "pint.json",
    "content": "{\n    \"preset\": \"laravel\",\n    \"rules\": {\n        \"declare_strict_types\": true,\n        \"strict_comparison\": true,\n        \"strict_param\": true,\n        \"no_unused_imports\": true,\n        \"void_return\": true\n    }\n}\n"
  },
  {
    "path": "playwright/config.ts",
    "content": "export const PLAYWRIGHT_BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://solidtime.test';\nexport const MAILPIT_BASE_URL = process.env.MAILPIT_BASE_URL ?? 'http://mailpit:8025';\nexport const TEST_USER_PASSWORD = 'amazingpassword123';\n"
  },
  {
    "path": "playwright/fixtures.ts",
    "content": "import { test as baseTest } from '@playwright/test';\nimport type { Page } from '@playwright/test';\nimport { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from './config';\nimport { type TestContext, setupTestContext } from '../e2e/utils/api';\nimport { setupAdminUser, setupEmployeeUser } from '../e2e/utils/members';\n\nexport * from '@playwright/test';\nexport type { TestContext };\n\nexport interface EmployeeFixture {\n    page: Page;\n    memberId: string;\n}\n\nexport interface AdminFixture {\n    page: Page;\n    memberId: string;\n}\n\n/**\n * API-based authentication fixture - creates a new user via HTTP requests instead of UI interactions.\n * This is ~10-25x faster than UI-based authentication (~100-200ms vs ~3-5s).\n *\n * Uses page.context().request() to ensure cookies are shared between the API request and page.\n */\nexport const test = baseTest.extend<\n    { ctx: TestContext; employee: EmployeeFixture; admin: AdminFixture },\n    { workerStorageState: string }\n>({\n    page: async ({ page }, use) => {\n        // Generate unique email for this test\n        const email = `john+${Date.now()}_${Math.floor(Math.random() * 10000)}@doe.com`;\n        const password = TEST_USER_PASSWORD;\n        const name = 'John Doe';\n\n        // Use page.context().request() so cookies are automatically shared with the page\n        const request = page.context().request;\n\n        // Step 1: Visit the register page to get CSRF token and initial session\n        const csrfResponse = await request.get(`${PLAYWRIGHT_BASE_URL}/register`, {\n            maxRedirects: 0,\n        });\n\n        // Extract XSRF-TOKEN from cookies\n        const cookies = csrfResponse.headers()['set-cookie'];\n        let xsrfToken = '';\n        if (cookies) {\n            const xsrfMatch = cookies.match(/XSRF-TOKEN=([^;]+)/);\n            if (xsrfMatch) {\n                xsrfToken = decodeURIComponent(xsrfMatch[1]);\n            }\n        }\n\n        // Step 2: Register via API (Laravel Fortify web routes)\n        const registerResponse = await request.post(`${PLAYWRIGHT_BASE_URL}/register`, {\n            headers: {\n                'X-XSRF-TOKEN': xsrfToken,\n                'Content-Type': 'application/x-www-form-urlencoded',\n                'Accept': 'text/html',\n            },\n            form: {\n                name,\n                email,\n                password,\n                password_confirmation: password,\n                terms: 'on',\n            },\n            maxRedirects: 0,\n        });\n\n        // Check if registration was successful (should redirect to dashboard)\n        if (registerResponse.status() !== 302) {\n            console.error('API registration failed, falling back to UI-based registration');\n\n            // Fall back to UI-based registration\n            await page.goto(`${PLAYWRIGHT_BASE_URL}/register`);\n            await page.getByLabel('Name').fill(name);\n            await page.getByLabel('Email').fill(email);\n            await page.getByLabel('Password', { exact: true }).fill(password);\n            await page.getByLabel('Confirm Password').fill(password);\n            await page.getByLabel('I agree to the Terms of').click();\n            await page.getByRole('button', { name: 'Register' }).click();\n            await page.waitForURL(`${PLAYWRIGHT_BASE_URL}/dashboard`);\n        } else {\n            // Registration succeeded - cookies are already set in the context from the request\n            // Just navigate to dashboard to verify\n            await page.goto(`${PLAYWRIGHT_BASE_URL}/dashboard`);\n            await page.waitForLoadState('domcontentloaded');\n        }\n\n        await use(page);\n    },\n\n    ctx: async ({ page }, use) => {\n        const ctx = await setupTestContext(page);\n        await use(ctx);\n    },\n\n    employee: async ({ page, ctx, browser }, use) => {\n        const { employeePage, employeeMemberId, closeEmployee } = await setupEmployeeUser(\n            page,\n            ctx,\n            browser\n        );\n        await use({ page: employeePage, memberId: employeeMemberId });\n        await closeEmployee();\n    },\n\n    admin: async ({ page, ctx, browser }, use) => {\n        const { adminPage, adminMemberId, closeAdmin } = await setupAdminUser(page, ctx, browser);\n        await use({ page: adminPage, memberId: adminMemberId });\n        await closeAdmin();\n    },\n});\n"
  },
  {
    "path": "playwright.config.ts",
    "content": "import { defineConfig, devices } from '@playwright/test';\n\n/**\n * Read environment variables from file.\n * https://github.com/motdotla/dotenv\n */\n// require('dotenv').config();\n\n/**\n * See https://playwright.dev/docs/test-configuration.\n */\nexport default defineConfig({\n    testDir: './e2e',\n    /* Run tests in files in parallel */\n    fullyParallel: true,\n    /* Fail the build on CI if you accidentally left test.only in the source code. */\n    forbidOnly: !!process.env.CI,\n    /* Retry on CI only */\n    retries: process.env.CI ? 1 : 0,\n    /* Run tests in parallel */\n    workers: process.env.CI ? 2 : 4,\n    /* Reporter to use. See https://playwright.dev/docs/test-reporters */\n    reporter: process.env.CI ? 'blob' : 'html',\n    /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */\n    use: {\n        /* Base URL to use in actions like `await page.goto('/')`. */\n        // baseURL: 'http://127.0.0.1:3000',\n\n        /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */\n        trace: process.env.CI ? 'on-first-retry' : 'on',\n    },\n\n    timeout: 20 * 1000,\n\n    /* Configure projects for major browsers */\n    projects: [\n        {\n            name: 'chromium',\n            use: { ...devices['Desktop Chrome'] },\n        },\n\n        // Firefox only in CI to keep local runs fast\n        ...(process.env.CI\n            ? [\n                  {\n                      name: 'firefox',\n                      use: { ...devices['Desktop Firefox'] },\n                  },\n              ]\n            : []),\n    ],\n\n    /* Run your local dev server before starting the tests */\n    // webServer: {\n    //   command: 'npm run start',\n    //   url: 'http://127.0.0.1:3000',\n    //   reuseExistingServer: !process.env.CI,\n    // },\n});\n"
  },
  {
    "path": "postcss.config.js",
    "content": "export default {\n    plugins: {\n        'postcss-import': {},\n        'tailwindcss/nesting': {},\n        tailwindcss: {},\n        autoprefixer: {},\n    },\n};\n"
  },
  {
    "path": "public/.htaccess",
    "content": "<IfModule mod_rewrite.c>\n    <IfModule mod_negotiation.c>\n        Options -MultiViews -Indexes\n    </IfModule>\n\n    RewriteEngine On\n\n    # Handle Authorization Header\n    RewriteCond %{HTTP:Authorization} .\n    RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]\n\n    # Redirect Trailing Slashes If Not A Folder...\n    RewriteCond %{REQUEST_FILENAME} !-d\n    RewriteCond %{REQUEST_URI} (.+)/$\n    RewriteRule ^ %1 [L,R=301]\n\n    # Send Requests To Front Controller...\n    RewriteCond %{REQUEST_FILENAME} !-d\n    RewriteCond %{REQUEST_FILENAME} !-f\n    RewriteRule ^ index.php [L]\n</IfModule>\n"
  },
  {
    "path": "public/desktop-version/latest-linux.yml",
    "content": "version: 0.0.23\nfiles:\n  - url: https://github.com/solidtime-io/solidtime-desktop/releases/download/v0.0.23/solidtime-0.0.23.AppImage\n    sha512: O7JKg+rZOFPwaysl9KfrMdH2/KoJAaC+y+pq3JUAv0qG5Kr1eaLjIrzv5iJvWg+Ow+7n7xDGyeEd9ZojQ968OA==\n    size: 116853923\n    blockMapSize: 122039\n  - url: https://github.com/solidtime-io/solidtime-desktop/releases/download/v0.0.23/solidtime_0.0.23_amd64.deb\n    sha512: U7Rwb+F7IZZbgJmWuIqMa9D3lfOkZlWgoqnNMZO+pX9SVMuskMEkLoFB5axEPI4JKtOWCUh5H9mDdqbmBbuoFw==\n    size: 78604326\n  - url: https://github.com/solidtime-io/solidtime-desktop/releases/download/v0.0.23/solidtime-0.0.23.x86_64.rpm\n    sha512: xICjadHCip4vcpQt2mrewAZheI4gXO3csZxUo6Za8c1+eLCU5Wvdj+WaRGtz/+NIrzmiKJ7YmrSUhW2rs08qiQ==\n    size: 78917997\npath: solidtime-0.0.23.AppImage\nsha512: O7JKg+rZOFPwaysl9KfrMdH2/KoJAaC+y+pq3JUAv0qG5Kr1eaLjIrzv5iJvWg+Ow+7n7xDGyeEd9ZojQ968OA==\nreleaseDate: '2024-08-27T13:31:55.031Z'\n"
  },
  {
    "path": "public/desktop-version/latest-mac.yml",
    "content": "version: 0.0.23\nfiles:\n  - url: https://github.com/solidtime-io/solidtime-desktop/releases/download/v0.0.23/solidtime-0.0.23-mac.zip\n    sha512: /1dAPCgPV7PEE2KPQkHGme0YFPjHUqkGEvNkJj4Ncw1ZXFY5YK0+lr82XrnQiwCVUioylpWeQitdc+QEwW+2fQ==\n    size: 108473987\n  - url: https://github.com/solidtime-io/solidtime-desktop/releases/download/v0.0.23/solidtime-0.0.23-arm64-mac.zip\n    sha512: TOuyxEI8i6IMdcE4vG0U6cU0IZDs6JR67/8uSISmhqiwyTwKLC+pLuiWn/ayNLfXDtAVPvGFq6NsyhSd6CXjbA==\n    size: 100954963\n  - url: https://github.com/solidtime-io/solidtime-desktop/releases/download/v0.0.23/solidtime-0.0.23-x64.dmg\n    sha512: 9ib2nk7i149ihTB6GO3lyRM62dCzUigqLqNEGIa35Vw4CDeYIHBDfu+kTbHold0MphVAP36/Z7ZYUNXy+IDphg==\n    size: 112692794\n  - url: https://github.com/solidtime-io/solidtime-desktop/releases/download/v0.0.23/solidtime-0.0.23-arm64.dmg\n    sha512: JGekA83DFOtzmAc93dcCyYneVLFLsDl/wq/lfCr3ejCYlcNip6be9EAr5MKFlqXQ/MaqjWfpweOVYJVM/S3hDQ==\n    size: 105162031\npath: solidtime-0.0.23-mac.zip\nsha512: /1dAPCgPV7PEE2KPQkHGme0YFPjHUqkGEvNkJj4Ncw1ZXFY5YK0+lr82XrnQiwCVUioylpWeQitdc+QEwW+2fQ==\nreleaseDate: '2024-08-27T13:30:08.202Z'\n"
  },
  {
    "path": "public/desktop-version/latest.yml",
    "content": "version: 0.0.23\nfiles:\n  - url: https://github.com/solidtime-io/solidtime-desktop/releases/download/v0.0.23/solidtime-0.0.23-setup.exe\n    sha512: i3ju9ekS7scQlwOzocairegdhCKwEFKE9JppQF8SmLHXxD2aWxsW1uOCc9HcB+0KSo7/1IcnmsOMdHfIvjeW1Q==\n    size: 84101154\npath: solidtime-0.0.23-setup.exe\nsha512: i3ju9ekS7scQlwOzocairegdhCKwEFKE9JppQF8SmLHXxD2aWxsW1uOCc9HcB+0KSo7/1IcnmsOMdHfIvjeW1Q==\nreleaseDate: '2024-08-27T13:26:34.512Z'\n"
  },
  {
    "path": "public/favicons/browserconfig.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<browserconfig>\n    <msapplication>\n        <tile>\n            <square150x150logo src=\"/favicons/mstile-150x150.png\"/>\n            <TileColor>#000000</TileColor>\n        </tile>\n    </msapplication>\n</browserconfig>\n"
  },
  {
    "path": "public/favicons/site.webmanifest",
    "content": "{\n    \"name\": \"solidtime\",\n    \"short_name\": \"solidtime\",\n    \"icons\": [\n        {\n            \"src\": \"/favicons/android-chrome-192x192.png\",\n            \"sizes\": \"192x192\",\n            \"type\": \"image/png\"\n        },\n        {\n            \"src\": \"/favicons/android-chrome-512x512.png\",\n            \"sizes\": \"512x512\",\n            \"type\": \"image/png\"\n        }\n    ],\n    \"theme_color\": \"#000000\",\n    \"background_color\": \"#000000\",\n    \"display\": \"standalone\"\n}\n"
  },
  {
    "path": "public/index.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse Illuminate\\Contracts\\Http\\Kernel;\nuse Illuminate\\Http\\Request;\n\ndefine('LARAVEL_START', microtime(true));\n\n/*\n|--------------------------------------------------------------------------\n| Check If The Application Is Under Maintenance\n|--------------------------------------------------------------------------\n|\n| If the application is in maintenance / demo mode via the \"down\" command\n| we will load this file so that any pre-rendered content can be shown\n| instead of starting the framework, which could cause an exception.\n|\n*/\n\nif (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {\n    require $maintenance;\n}\n\n/*\n|--------------------------------------------------------------------------\n| Register The Auto Loader\n|--------------------------------------------------------------------------\n|\n| Composer provides a convenient, automatically generated class loader for\n| this application. We just need to utilize it! We'll simply require it\n| into the script here so we don't need to manually load our classes.\n|\n*/\n\nrequire __DIR__.'/../vendor/autoload.php';\n\n/*\n|--------------------------------------------------------------------------\n| Run The Application\n|--------------------------------------------------------------------------\n|\n| Once we have the application, we can handle the incoming request using\n| the application's HTTP kernel. Then, we will send the response back\n| to this client's browser, allowing them to enjoy our application.\n|\n*/\n\n$app = require_once __DIR__.'/../bootstrap/app.php';\n\n$kernel = $app->make(Kernel::class);\n\n$response = $kernel->handle(\n    $request = Request::capture()\n)->send();\n\n$kernel->terminate($request, $response);\n"
  },
  {
    "path": "public/robots.txt",
    "content": "User-agent: *\nDisallow:\n"
  },
  {
    "path": "public/security.txt",
    "content": "Contact: mailto:security@solidtime.io\nExpires: 2025-03-31T10:00:00.000Z\n"
  },
  {
    "path": "resources/css/app.css",
    "content": "/* Import shared solidtime styles from UI package */\n@import '../js/packages/ui/styles.css';\n\n/* Main app specific styles - Inter font */\n@font-face {\n    font-family: 'Inter';\n    src:\n        url('/fonts/InterVariable.woff2') format('woff2'),\n        url('/fonts/InterVariable.ttf') format('truetype');\n    font-weight: 100 900;\n    font-style: normal;\n    font-display: swap;\n    font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';\n}\n"
  },
  {
    "path": "resources/css/filament/admin/tailwind.config.js",
    "content": "import preset from '../../../../vendor/filament/filament/tailwind.config.preset';\n\nexport default {\n    presets: [preset],\n    content: [\n        './app/Filament/**/*.php',\n        './resources/views/filament/**/*.blade.php',\n        './vendor/filament/**/*.blade.php',\n    ],\n};\n"
  },
  {
    "path": "resources/css/filament/admin/theme.css",
    "content": "@import '/vendor/filament/filament/resources/css/theme.css';\n\n@config 'tailwind.config.js';\n"
  },
  {
    "path": "resources/js/Components/ActionMessage.vue",
    "content": "<script setup lang=\"ts\">\ndefineProps({\n    on: Boolean,\n});\n</script>\n\n<template>\n    <div>\n        <transition\n            leave-active-class=\"transition ease-in duration-1000\"\n            leave-from-class=\"opacity-100\"\n            leave-to-class=\"opacity-0\">\n            <div v-show=\"on\" class=\"text-sm text-text-secondary\">\n                <slot />\n            </div>\n        </transition>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ActionSection.vue",
    "content": "<script setup lang=\"ts\">\nimport SectionTitle from './SectionTitle.vue';\n</script>\n\n<template>\n    <div class=\"md:grid md:grid-cols-3 md:gap-6\">\n        <SectionTitle>\n            <template #title>\n                <slot name=\"title\" />\n            </template>\n            <template #description>\n                <slot name=\"description\" />\n            </template>\n        </SectionTitle>\n\n        <div class=\"mt-5 md:mt-0 md:col-span-2\">\n            <div class=\"px-4 py-5 sm:p-6 bg-card-background shadow sm:rounded-lg\">\n                <slot name=\"content\" />\n            </div>\n        </div>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ApplicationLogo.vue",
    "content": "<template>\n    <svg viewBox=\"0 0 317 48\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            d=\"M74.09 30.04V13h-4.14v21H82.1v-3.96h-8.01zM95.379 19v1.77c-1.08-1.35-2.7-2.19-4.89-2.19-3.99 0-7.29 3.45-7.29 7.92s3.3 7.92 7.29 7.92c2.19 0 3.81-.84 4.89-2.19V34h3.87V19h-3.87zm-4.17 11.73c-2.37 0-4.14-1.71-4.14-4.23 0-2.52 1.77-4.23 4.14-4.23 2.4 0 4.17 1.71 4.17 4.23 0 2.52-1.77 4.23-4.17 4.23zM106.628 21.58V19h-3.87v15h3.87v-7.17c0-3.15 2.55-4.05 4.56-3.81V18.7c-1.89 0-3.78.84-4.56 2.88zM124.295 19v1.77c-1.08-1.35-2.7-2.19-4.89-2.19-3.99 0-7.29 3.45-7.29 7.92s3.3 7.92 7.29 7.92c2.19 0 3.81-.84 4.89-2.19V34h3.87V19h-3.87zm-4.17 11.73c-2.37 0-4.14-1.71-4.14-4.23 0-2.52 1.77-4.23 4.14-4.23 2.4 0 4.17 1.71 4.17 4.23 0 2.52-1.77 4.23-4.17 4.23zM141.544 19l-3.66 10.5-3.63-10.5h-4.26l5.7 15h4.41l5.7-15h-4.26zM150.354 28.09h11.31c.09-.51.15-1.02.15-1.59 0-4.41-3.15-7.92-7.59-7.92-4.71 0-7.92 3.45-7.92 7.92s3.18 7.92 8.22 7.92c2.88 0 5.13-1.17 6.54-3.21l-3.12-1.8c-.66.87-1.86 1.5-3.36 1.5-2.04 0-3.69-.84-4.23-2.82zm-.06-3c.45-1.92 1.86-3.03 3.93-3.03 1.62 0 3.24.87 3.72 3.03h-7.65zM164.516 34h3.87V12.1h-3.87V34zM185.248 34.36c3.69 0 6.9-2.01 6.9-6.3V13h-2.1v15.06c0 3.03-2.07 4.26-4.8 4.26-2.19 0-3.93-.78-4.62-2.61l-1.77 1.05c1.05 2.43 3.57 3.6 6.39 3.6zM203.124 18.64c-4.65 0-7.83 3.45-7.83 7.86 0 4.53 3.24 7.86 7.98 7.86 3.03 0 5.34-1.41 6.6-3.45l-1.74-1.02c-.81 1.44-2.46 2.55-4.83 2.55-3.18 0-5.55-1.89-5.97-4.95h13.17c.03-.3.06-.63.06-.93 0-4.11-2.85-7.92-7.44-7.92zm0 1.92c2.58 0 4.98 1.71 5.4 5.01h-11.19c.39-2.94 2.64-5.01 5.79-5.01zM221.224 20.92V19h-4.32v-4.2l-1.98.6V19h-3.15v1.92h3.15v9.09c0 3.6 2.25 4.59 6.3 3.99v-1.74c-2.91.12-4.32.33-4.32-2.25v-9.09h4.32zM225.176 22.93c0-1.62 1.59-2.37 3.15-2.37 1.44 0 2.97.57 3.6 2.1l1.65-.96c-.87-1.86-2.79-3.06-5.25-3.06-3 0-5.13 1.89-5.13 4.29 0 5.52 8.76 3.39 8.76 7.11 0 1.77-1.68 2.4-3.45 2.4-2.01 0-3.57-.99-4.11-2.52l-1.68.99c.75 1.92 2.79 3.45 5.79 3.45 3.21 0 5.43-1.77 5.43-4.32 0-5.52-8.76-3.39-8.76-7.11zM244.603 20.92V19h-4.32v-4.2l-1.98.6V19h-3.15v1.92h3.15v9.09c0 3.6 2.25 4.59 6.3 3.99v-1.74c-2.91.12-4.32.33-4.32-2.25v-9.09h4.32zM249.883 21.49V19h-1.98v15h1.98v-8.34c0-3.72 2.34-4.98 4.74-4.98v-1.92c-1.92 0-3.69.63-4.74 2.73zM263.358 18.64c-4.65 0-7.83 3.45-7.83 7.86 0 4.53 3.24 7.86 7.98 7.86 3.03 0 5.34-1.41 6.6-3.45l-1.74-1.02c-.81 1.44-2.46 2.55-4.83 2.55-3.18 0-5.55-1.89-5.97-4.95h13.17c.03-.3.06-.63.06-.93 0-4.11-2.85-7.92-7.44-7.92zm0 1.92c2.58 0 4.98 1.71 5.4 5.01h-11.19c.39-2.94 2.64-5.01 5.79-5.01zM286.848 19v2.94c-1.26-2.01-3.39-3.3-6.06-3.3-4.23 0-7.74 3.42-7.74 7.86s3.51 7.86 7.74 7.86c2.67 0 4.8-1.29 6.06-3.3V34h1.98V19h-1.98zm-5.91 13.44c-3.33 0-5.91-2.61-5.91-5.94 0-3.33 2.58-5.94 5.91-5.94s5.91 2.61 5.91 5.94c0 3.33-2.58 5.94-5.91 5.94zM309.01 18.64c-1.92 0-3.75.87-4.86 2.73-.84-1.74-2.46-2.73-4.56-2.73-1.8 0-3.42.72-4.59 2.55V19h-1.98v15H295v-8.31c0-3.72 2.16-5.13 4.32-5.13 2.13 0 3.51 1.41 3.51 4.08V34h1.98v-8.31c0-3.72 1.86-5.13 4.17-5.13 2.13 0 3.66 1.41 3.66 4.08V34h1.98v-9.36c0-3.75-2.31-6-5.61-6z\"\n            class=\"fill-white\" />\n        <path\n            d=\"M11.395 44.428C4.557 40.198 0 32.632 0 24 0 10.745 10.745 0 24 0a23.891 23.891 0 0113.997 4.502c-.2 17.907-11.097 33.245-26.602 39.926z\"\n            fill=\"#6875F5\" />\n        <path\n            d=\"M14.134 45.885A23.914 23.914 0 0024 48c13.255 0 24-10.745 24-24 0-3.516-.756-6.856-2.115-9.866-4.659 15.143-16.608 27.092-31.75 31.751z\"\n            fill=\"#6875F5\" />\n    </svg>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ApplicationMark.vue",
    "content": "<template>\n    <svg viewBox=\"0 0 48 48\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            d=\"M11.395 44.428C4.557 40.198 0 32.632 0 24 0 10.745 10.745 0 24 0a23.891 23.891 0 0113.997 4.502c-.2 17.907-11.097 33.245-26.602 39.926z\"\n            fill=\"#6875F5\" />\n        <path\n            d=\"M14.134 45.885A23.914 23.914 0 0024 48c13.255 0 24-10.745 24-24 0-3.516-.756-6.856-2.115-9.866-4.659 15.143-16.608 27.092-31.75 31.751z\"\n            fill=\"#6875F5\" />\n    </svg>\n</template>\n"
  },
  {
    "path": "resources/js/Components/AuthenticationCard.vue",
    "content": "<script setup lang=\"ts\">\nimport { onMounted } from 'vue';\nimport { useTheme } from '@/utils/theme.js';\n\nonMounted(async () => {\n    useTheme();\n});\n</script>\n\n<template>\n    <div\n        class=\"min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-default-background\">\n        <div>\n            <slot name=\"logo\" />\n        </div>\n\n        <div\n            class=\"w-full sm:max-w-md mt-6 px-6 py-4 bg-card-background shadow-md border border-card-border overflow-hidden sm:rounded-lg\">\n            <slot />\n        </div>\n\n        <slot name=\"actions\"></slot>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/AuthenticationCardLogo.vue",
    "content": "<script setup lang=\"ts\">\nimport { Link } from '@inertiajs/vue3';\n</script>\n\n<template>\n    <Link :href=\"'/'\">\n        <svg\n            class=\"h-12 py-2 text-text-primary\"\n            viewBox=\"0 0 168 30\"\n            fill=\"none\"\n            xmlns=\"http://www.w3.org/2000/svg\">\n            <path\n                d=\"M54.4081 6.78783C55.0812 7.46093 55.9225 7.79748 56.9322 7.79748C57.9936 7.79748 58.8479 7.46093 59.4951 6.78783C60.1682 6.08885 60.5048 5.22159 60.5048 4.18606C60.5048 3.17642 60.1682 2.3221 59.4951 1.62312C58.8479 0.924138 57.9936 0.574646 56.9322 0.574646C55.9225 0.574646 55.0812 0.924138 54.4081 1.62312C53.735 2.3221 53.3984 3.17642 53.3984 4.18606C53.3984 5.22159 53.735 6.08885 54.4081 6.78783Z\"\n                fill=\"currentColor\" />\n            <path\n                d=\"M158.028 29.4272C155.905 29.4272 154.028 29.0129 152.397 28.1845C150.766 27.3302 149.485 26.1523 148.553 24.6508C147.621 23.1492 147.155 21.4277 147.155 19.4861C147.155 17.5703 147.608 15.8746 148.514 14.399C149.42 12.8975 150.65 11.7196 152.203 10.8653C153.782 9.98505 155.556 9.54495 157.523 9.54495C159.439 9.54495 161.134 9.95916 162.61 10.7876C164.112 11.5901 165.277 12.7163 166.105 14.166C166.959 15.5899 167.386 17.2208 167.386 19.0589C167.386 19.4472 167.361 19.8485 167.309 20.2627C167.283 20.651 167.205 21.1041 167.076 21.6218L150.339 21.6995V17.3503L164.396 17.2338L161.367 19.1366C161.342 18.0751 161.186 17.2079 160.901 16.5348C160.617 15.8358 160.202 15.3051 159.659 14.9427C159.115 14.5802 158.429 14.399 157.601 14.399C156.746 14.399 156.009 14.6061 155.387 15.0203C154.766 15.4345 154.287 16.017 153.95 16.7678C153.614 17.5185 153.446 18.4246 153.446 19.4861C153.446 20.5734 153.627 21.5053 153.989 22.282C154.352 23.0327 154.869 23.6023 155.543 23.9906C156.216 24.3789 157.044 24.5731 158.028 24.5731C158.96 24.5731 159.775 24.4178 160.474 24.1071C161.199 23.7964 161.846 23.3175 162.416 22.6703L165.95 26.2041C165.018 27.2655 163.879 28.068 162.532 28.6117C161.212 29.1553 159.711 29.4272 158.028 29.4272Z\"\n                fill=\"currentColor\" />\n            <path\n                d=\"M114.306 29V10.0109H121.063V29H114.306ZM126.228 29V18.0104C126.228 17.2079 125.982 16.5866 125.49 16.1465C124.998 15.6805 124.39 15.4475 123.665 15.4475C123.147 15.4475 122.694 15.551 122.306 15.7581C121.917 15.9652 121.607 16.263 121.374 16.6513C121.167 17.0137 121.063 17.4668 121.063 18.0104L118.422 16.9619C118.422 15.4345 118.759 14.1272 119.432 13.0399C120.105 11.9526 121.011 11.1112 122.15 10.5158C123.289 9.92034 124.584 9.62262 126.034 9.62262C127.328 9.62262 128.493 9.93328 129.528 10.5546C130.59 11.15 131.431 11.9914 132.053 13.0787C132.674 14.166 132.985 15.4475 132.985 16.9231V29H126.228ZM138.149 29V18.0104C138.149 17.2079 137.903 16.5866 137.411 16.1465C136.92 15.6805 136.311 15.4475 135.586 15.4475C135.094 15.4475 134.641 15.551 134.227 15.7581C133.839 15.9652 133.528 16.263 133.295 16.6513C133.088 17.0137 132.985 17.4668 132.985 18.0104L129.024 17.8163C129.075 16.1076 129.451 14.6449 130.15 13.4282C130.849 12.2114 131.807 11.2795 133.023 10.6323C134.266 9.95917 135.664 9.62262 137.217 9.62262C138.693 9.62262 140.013 9.93328 141.178 10.5546C142.343 11.1759 143.249 12.082 143.896 13.2729C144.57 14.4378 144.906 15.8358 144.906 17.4668V29H138.149Z\"\n                fill=\"currentColor\" />\n            <path d=\"M103.573 29V10.011H110.369V29H103.573Z\" fill=\"currentColor\" />\n            <path\n                d=\"M104.428 6.78783C105.101 7.46093 105.942 7.79748 106.952 7.79748C108.013 7.79748 108.867 7.46093 109.515 6.78783C110.188 6.08885 110.524 5.22159 110.524 4.18606C110.524 3.17642 110.188 2.3221 109.515 1.62312C108.867 0.924138 108.013 0.574646 106.952 0.574646C105.942 0.574646 105.101 0.924138 104.428 1.62312C103.755 2.3221 103.418 3.17642 103.418 4.18606C103.418 5.22159 103.755 6.08885 104.428 6.78783Z\"\n                fill=\"currentColor\" />\n            <path\n                d=\"M90.2867 29V2.16681H97.0435V29H90.2867ZM86.0928 15.6417V10.011H101.237V15.6417H86.0928Z\"\n                fill=\"currentColor\" />\n            <path\n                d=\"M72.4414 29.3883C70.6033 29.3883 68.9853 28.9612 67.5873 28.1068C66.1893 27.2525 65.0891 26.0876 64.2866 24.6119C63.5099 23.1104 63.1216 21.4147 63.1216 19.5249C63.1216 17.6091 63.5099 15.9005 64.2866 14.399C65.0891 12.8975 66.1764 11.7325 67.5485 10.9041C68.9464 10.0498 70.5774 9.62262 72.4414 9.62262C73.6322 9.62262 74.7454 9.84267 75.781 10.2828C76.8165 10.697 77.6837 11.2924 78.3827 12.0691C79.0817 12.8457 79.4959 13.7259 79.6254 14.7097V23.9906C79.4959 24.9744 79.0817 25.8805 78.3827 26.7089C77.6837 27.5373 76.8165 28.1975 75.781 28.6893C74.7454 29.1553 73.6322 29.3883 72.4414 29.3883ZM73.6452 23.3693C74.3959 23.3693 75.0431 23.214 75.5868 22.9033C76.1304 22.5668 76.5576 22.1137 76.8683 21.5442C77.2048 20.9487 77.3731 20.2627 77.3731 19.4861C77.3731 18.7353 77.2177 18.0751 76.9071 17.5056C76.5964 16.9361 76.1563 16.483 75.5868 16.1465C75.0431 15.8099 74.4089 15.6416 73.684 15.6416C72.9591 15.6416 72.3119 15.8099 71.7424 16.1465C71.1987 16.483 70.7586 16.949 70.4221 17.5444C70.1114 18.114 69.9561 18.7612 69.9561 19.4861C69.9561 20.2368 70.1114 20.9099 70.4221 21.5053C70.7327 22.0749 71.1728 22.5279 71.7424 22.8645C72.3119 23.201 72.9462 23.3693 73.6452 23.3693ZM83.7416 29H77.1012V23.9129L78.0721 19.2531L76.9848 14.6708V0.691162H83.7416V29Z\"\n                fill=\"currentColor\" />\n            <path d=\"M53.5537 29V10.011H60.3494V29H53.5537Z\" fill=\"currentColor\" />\n            <path d=\"M42.8608 29V0.691162H49.6177V29H42.8608Z\" fill=\"currentColor\" />\n            <path\n                d=\"M29.6176 29.4272C27.5724 29.4272 25.7473 29 24.1423 28.1457C22.5631 27.2655 21.3075 26.0746 20.3755 24.5731C19.4435 23.0457 18.9775 21.3371 18.9775 19.4472C18.9775 17.5574 19.4306 15.8746 20.3367 14.399C21.2687 12.8975 22.5372 11.7196 24.1423 10.8653C25.7473 9.98505 27.5595 9.54495 29.5788 9.54495C31.5981 9.54495 33.3973 9.98505 34.9765 10.8653C36.5816 11.7196 37.8501 12.8975 38.7821 14.399C39.714 15.8746 40.18 17.5574 40.18 19.4472C40.18 21.3371 39.714 23.0457 38.7821 24.5731C37.876 26.0746 36.6204 27.2655 35.0153 28.1457C33.4361 29 31.6369 29.4272 29.6176 29.4272ZM29.5788 23.4081C30.3295 23.4081 30.9768 23.2528 31.5204 22.9421C32.09 22.6056 32.5301 22.1396 32.8407 21.5442C33.1514 20.9487 33.3067 20.2627 33.3067 19.4861C33.3067 18.7094 33.1384 18.0363 32.8019 17.4668C32.4912 16.8713 32.0641 16.4183 31.5204 16.1076C30.9768 15.7711 30.3295 15.6028 29.5788 15.6028C28.8539 15.6028 28.2067 15.7711 27.6372 16.1076C27.0676 16.4442 26.6275 16.9102 26.3169 17.5056C26.0062 18.0751 25.8509 18.7482 25.8509 19.5249C25.8509 20.2756 26.0062 20.9487 26.3169 21.5442C26.6275 22.1396 27.0676 22.6056 27.6372 22.9421C28.2067 23.2528 28.8539 23.4081 29.5788 23.4081Z\"\n                fill=\"currentColor\" />\n            <path\n                d=\"M9.20323 29.5437C8.03825 29.5437 6.88622 29.3883 5.74714 29.0777C4.63394 28.767 3.58547 28.3528 2.60172 27.835C1.64385 27.2914 0.828369 26.6701 0.155273 25.9711L3.84435 22.2043C4.46567 22.8515 5.20349 23.3564 6.0578 23.7188C6.938 24.0812 7.86998 24.2624 8.85373 24.2624C9.42328 24.2624 9.85043 24.1848 10.1352 24.0295C10.4459 23.8741 10.6012 23.6541 10.6012 23.3693C10.6012 22.9551 10.3811 22.6444 9.94104 22.4373C9.52683 22.2043 8.97023 22.0102 8.27125 21.8548C7.59815 21.6736 6.88623 21.4665 6.13547 21.2335C5.38471 20.9746 4.65983 20.6381 3.96085 20.2239C3.26187 19.8097 2.69232 19.2272 2.25222 18.4764C1.83801 17.7257 1.63091 16.7678 1.63091 15.6028C1.63091 14.3861 1.95451 13.3247 2.60172 12.4186C3.27481 11.4866 4.20679 10.7617 5.39765 10.2439C6.58851 9.70029 7.98648 9.42847 9.59155 9.42847C11.2225 9.42847 12.7758 9.71324 14.2514 10.2828C15.7271 10.8264 16.9179 11.6549 17.824 12.7681L14.0961 16.5348C13.4748 15.8358 12.7888 15.3569 12.038 15.098C11.2872 14.8132 10.6012 14.6708 9.97987 14.6708C9.38444 14.6708 8.95729 14.7615 8.6984 14.9427C8.43952 15.098 8.31008 15.318 8.31008 15.6028C8.31008 15.9394 8.51719 16.2112 8.9314 16.4183C9.3715 16.6254 9.9281 16.8196 10.6012 17.0008C11.3002 17.1561 12.0121 17.3632 12.737 17.6221C13.4877 17.881 14.1997 18.2434 14.8728 18.7094C15.5717 19.1495 16.1283 19.7449 16.5426 20.4957C16.9827 21.2465 17.2027 22.2173 17.2027 23.4081C17.2027 25.298 16.4778 26.7995 15.0281 27.9127C13.5783 29 11.6367 29.5437 9.20323 29.5437Z\"\n                fill=\"currentColor\" />\n        </svg>\n    </Link>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Banner.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, watchEffect } from 'vue';\nimport { usePage } from '@inertiajs/vue3';\n\nconst page = usePage<{\n    jetstream: {\n        flash: {\n            banner: string;\n            bannerStyle: string;\n        };\n    };\n}>();\n\nconst show = ref(true);\nconst style = ref('success');\nconst message = ref('');\n\nwatchEffect(async () => {\n    style.value = page.props.jetstream.flash?.bannerStyle || 'success';\n    message.value = page.props.jetstream.flash?.banner || '';\n    show.value = true;\n});\n</script>\n\n<template>\n    <div>\n        <div v-if=\"show && message\" class=\"bg-secondary border-b border-border-secondary\">\n            <div class=\"mx-auto py-1 px-3 sm:px-6 lg:px-8\">\n                <div class=\"flex items-center justify-between flex-wrap\">\n                    <div class=\"w-0 flex-1 flex items-center min-w-0\">\n                        <span class=\"flex\">\n                            <svg\n                                v-if=\"style == 'success'\"\n                                class=\"h-6 w-6 text-text-secondary\"\n                                xmlns=\"http://www.w3.org/2000/svg\"\n                                fill=\"none\"\n                                viewBox=\"0 0 24 24\"\n                                stroke-width=\"1.5\"\n                                stroke=\"currentColor\">\n                                <path\n                                    stroke-linecap=\"round\"\n                                    stroke-linejoin=\"round\"\n                                    d=\"M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n                            </svg>\n\n                            <svg\n                                v-if=\"style == 'danger'\"\n                                class=\"h-5 w-5 text-text-primary\"\n                                xmlns=\"http://www.w3.org/2000/svg\"\n                                fill=\"none\"\n                                viewBox=\"0 0 24 24\"\n                                stroke-width=\"1.5\"\n                                stroke=\"currentColor\">\n                                <path\n                                    stroke-linecap=\"round\"\n                                    stroke-linejoin=\"round\"\n                                    d=\"M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z\" />\n                            </svg>\n                        </span>\n\n                        <p class=\"ms-3 font-medium text-sm text-text-primary truncate\">\n                            {{ message }}\n                        </p>\n                    </div>\n\n                    <div class=\"shrink-0 sm:ms-3\">\n                        <button\n                            type=\"button\"\n                            class=\"-me-1 flex p-2 rounded-md focus:outline-none sm:-me-2 transition hover:bg-tertiary focus:bg-tertiary\"\n                            aria-label=\"Dismiss\"\n                            @click.prevent=\"show = false\">\n                            <svg\n                                class=\"h-5 w-5 text-text-primary\"\n                                xmlns=\"http://www.w3.org/2000/svg\"\n                                fill=\"none\"\n                                viewBox=\"0 0 24 24\"\n                                stroke-width=\"1.5\"\n                                stroke=\"currentColor\">\n                                <path\n                                    stroke-linecap=\"round\"\n                                    stroke-linejoin=\"round\"\n                                    d=\"M6 18L18 6M6 6l12 12\" />\n                            </svg>\n                        </button>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Billing/BillingBanner.vue",
    "content": "<script setup lang=\"ts\">\nimport MainContainer from '@/packages/ui/src/MainContainer.vue';\nimport { CheckBadgeIcon, XMarkIcon, XCircleIcon } from '@heroicons/vue/16/solid';\nimport { Link } from '@inertiajs/vue3';\nimport { computed } from 'vue';\nimport {\n    daysLeftInTrial,\n    isBillingActivated,\n    isBlocked,\n    isFreePlan,\n    isInTrial,\n} from '@/utils/billing';\nimport { useSessionStorage } from '@vueuse/core';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport { canManageBilling } from '@/utils/permissions';\n\nconst hideTrialBanner = useSessionStorage('showTrialBanner-' + getCurrentOrganizationId(), false);\nconst showTrialBanner = computed(() => isInTrial() && !hideTrialBanner.value);\nconst hideBlockedBanner = useSessionStorage(\n    'showBlockedBanner-' + getCurrentOrganizationId(),\n    false\n);\nconst showBlockedBanner = computed(() => isBlocked() && !hideBlockedBanner.value);\nconst hideFreeUpgradeBanner = useSessionStorage(\n    'showFreeUpgradeBanner-' + getCurrentOrganizationId(),\n    false\n);\nconst showFreeUpgradeBanner = computed(\n    () =>\n        isFreePlan() && !isBlocked() && !hideFreeUpgradeBanner.value && !showBlackFridayBanner.value\n);\nconst hideBlackFridayBanner = useSessionStorage(\n    'hideBlackFridayBanner-' + getCurrentOrganizationId(),\n    false\n);\n\nconst showBlackFridayBanner = computed(() => {\n    if (hideBlackFridayBanner.value) {\n        return false;\n    }\n    const today = new Date();\n    const blackFriday = new Date(2024, 10, 30);\n    return today < blackFriday;\n});\n</script>\n\n<template>\n    <div\n        v-if=\"showBlackFridayBanner\"\n        class=\"bg-tertiary text-xs lg:text-sm pb-1 pt-2 border-b border-border-secondary\">\n        <MainContainer class=\"flex items-center justify-between\">\n            <div class=\"flex items-center space-x-1.5\">\n                <svg class=\"w-4 mr-1\" viewBox=\"0 0 256 256\" xmlns=\"http://www.w3.org/2000/svg\">\n                    <path\n                        fill=\"#FF37AD\"\n                        d=\"M22.498 68.97a11.845 11.845 0 1 0 0-23.687c-6.471.098-11.666 5.372-11.666 11.844s5.195 11.746 11.666 11.844m181.393-10.04a11.845 11.845 0 1 0-.003-23.688c-6.471.098-11.665 5.373-11.665 11.845c.001 6.472 5.197 11.745 11.668 11.842\" />\n                    <path\n                        fill=\"#FCC954\"\n                        d=\"M213.503 211.097a11.845 11.845 0 1 0-.003-23.687c-6.471.098-11.665 5.373-11.664 11.845s5.196 11.745 11.667 11.842M70.872 23.689a11.845 11.845 0 1 0 0-23.688C64.4.1 59.206 5.373 59.206 11.845S64.4 23.591 70.872 23.689\" />\n                    <path\n                        fill=\"#2890E9\"\n                        d=\"M140.945 105.94a9.25 9.25 0 0 1-8.974-11.484c.37-1.482.672-2.97.899-4.455a25.4 25.4 0 0 1-8.732 1.904c-5.379.205-10.195-.702-14.3-2.69a22.23 22.23 0 0 1-9.614-8.877c-4.415-7.652-4.034-17.718.964-25.645c4.765-7.568 12.836-11.664 21.586-10.995c6.74.527 12.647 3.051 17.378 7.382q1.293-3.647 2.473-7.803c4.833-17.058 6.429-34.187 6.442-34.36a9.24 9.24 0 0 1 10.041-8.37a9.25 9.25 0 0 1 8.37 10.044c-.067.767-1.768 19.03-7.068 37.735c-2.676 9.445-5.838 17.426-9.42 23.798q.396 2.13.631 4.372c.746 7.211.152 14.974-1.714 22.445a9.256 9.256 0 0 1-8.962 6.998m-20.123-43.827c-.956 0-2.64.28-3.996 2.43c-1.298 2.06-1.552 4.873-.588 6.544c1.282 2.223 5.054 2.417 7.19 2.336c2.424-.092 4.908-1.612 7.338-4.382a16 16 0 0 0-1.43-2.422c-2.007-2.787-4.547-4.212-7.998-4.482c-.13-.008-.305-.024-.516-.024\" />\n                    <path\n                        fill=\"#F0A420\"\n                        d=\"M114.361 131.268c-38.343-30.224-78.42-43.319-89.514-29.246a12.8 12.8 0 0 0-2.257 4.509a4 4 0 0 0-.156.61v.024q-.223.947-.333 1.917L.393 236.18c-3.477 20.412 16.73 36.755 35.967 29.093l117.721-46.908c2.076-.826 7.185-3.982 8.583-5.724q.556-.544 1.037-1.153c11.092-14.075-11-49.988-49.34-80.223z\" />\n                    <path\n                        fill=\"#FCC954\"\n                        d=\"M163.688 211.494c11.1-14.08-10.984-50-49.327-80.226c-38.343-30.227-78.425-43.316-89.524-29.236s10.983 50 49.326 80.226c38.343 30.227 78.425 43.316 89.525 29.236\" />\n                    <path\n                        fill=\"#F0A420\"\n                        d=\"M156.994 203.294c9.108-11.556-10.956-42.563-44.817-69.256c-33.861-26.695-68.697-38.966-77.804-27.413c-9.11 11.556 10.954 42.563 44.815 69.256c33.86 26.695 68.697 38.969 77.806 27.413\" />\n                    <path\n                        fill=\"#2E6AC9\"\n                        d=\"M76.059 249.456c-14.327.07-26.004-7.101-40.158-18.257C19.431 218.21 8.493 202.665 7.63 193.81l-4.668 27.327c2.16 7.798 9.523 17.683 20.202 26.101c8.883 7.004 17.844 11.813 27.135 12.48l25.76-10.266zm-14.332-49.6c-27.443-21.637-45.271-46.467-44.77-60.669l-4.549 26.63c.351 12.685 15.175 33.184 36.262 49.808c18.894 14.896 38.583 25.38 53.66 23.363l25.593-10.2c-20.62 1.425-42.376-10.147-66.196-28.931\" />\n                    <path\n                        fill=\"#2890E9\"\n                        d=\"M118.535 145.052a11.845 11.845 0 1 0 0-23.688c-6.471.098-11.666 5.372-11.666 11.844s5.195 11.746 11.666 11.844\" />\n                    <path\n                        fill=\"#FF37AD\"\n                        d=\"m182.412 122.007l.087-.097c.108-.116.308-.33.596-.621a45 45 0 0 1 2.8-2.56c3.56-2.98 7.45-5.54 11.594-7.63c10.128-5.125 25.208-9.307 44.985-4.747c5.943 1.37 11.87-2.336 13.241-8.278c1.37-5.942-2.336-11.87-8.278-13.24c-25.602-5.903-45.957-.506-59.922 6.566a82.5 82.5 0 0 0-15.857 10.449a66 66 0 0 0-4.215 3.866a45 45 0 0 0-1.53 1.615l-.12.135l-.042.048l-.02.022l-.007.008c-.003.005-.009.01 8.361 7.21l-8.37-7.2c-3.877 4.622-3.328 11.5 1.233 15.448s11.446 3.506 15.464-.994M73.03 43.248a11.75 11.75 0 0 0-16.23-3.664a11.76 11.76 0 0 0-3.665 16.227c.427.683 9.178 14.86 10.976 34.276c1.83 19.727-3.966 37.86-17.253 54.12c4.474 5.686 9.858 11.596 16.008 17.507c8.51-9.834 14.913-20.402 19.12-31.583c5.175-13.756 7.006-28.342 5.445-43.348c-2.487-23.874-12.874-41.11-14.402-43.535\" />\n                    <path\n                        fill=\"#2890E9\"\n                        d=\"M220.242 156.578c6.002 1.553 10.244 3.246 12.077 4.034a11.86 11.86 0 0 0 13.94-1.12a11.87 11.87 0 0 0 4.107-8.765a11.85 11.85 0 0 0-8.06-11.426c-5.618-2.495-26.905-10.92-55.044-9.423c-18.941 1.007-37.155 6.253-54.133 15.608c-16.076 8.86-31.004 21.412-44.556 37.425a199 199 0 0 0 20.17 12.607c22.882-26.08 49.283-40.217 78.7-42.085a105.9 105.9 0 0 1 32.8 3.145\" />\n                </svg>\n                <div class=\"flex-1 space-x-1\">\n                    <span class=\"font-medium\">\n                        <strong>BLACK FRIDAY SALE!</strong> Use the code\n                        <strong>BLACKFRIDAY</strong> at checkout and get\n                        <strong>30% off</strong> the solidtime yearly plan.\n                    </span>\n                </div>\n            </div>\n            <div class=\"flex items-center space-x-2\">\n                <Link v-if=\"canManageBilling()\" href=\"/billing\">\n                    <div\n                        class=\"text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5\">\n                        <span>Upgrade now</span>\n                    </div>\n                </Link>\n                <button class=\"p-1\" @click=\"hideBlackFridayBanner = true\">\n                    <XMarkIcon class=\"w-4 opacity-50 hover:opacity-100\"></XMarkIcon>\n                </button>\n            </div>\n        </MainContainer>\n    </div>\n    <div\n        v-if=\"showTrialBanner\"\n        class=\"bg-accent-600/50 text-xs lg:text-sm py-0.5 border-b border-border-secondary\">\n        <MainContainer class=\"flex items-center justify-between\">\n            <div class=\"flex items-center space-x-1.5\">\n                <CheckBadgeIcon class=\"w-4 text-text-primary/50\"></CheckBadgeIcon>\n                <div class=\"flex-1 space-x-1\">\n                    <span class=\"font-medium\">\n                        Your trial expires in {{ daysLeftInTrial() }} days.\n                    </span>\n                    <span class=\"hidden md:inline\">\n                        To continue using all features & support the development of solidtime,\n                        please upgrade your plan.\n                    </span>\n                </div>\n            </div>\n            <div class=\"flex items-center space-x-2\">\n                <Link v-if=\"canManageBilling()\" href=\"/billing\">\n                    <div\n                        class=\"text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5\">\n                        <span>Upgrade now</span>\n                    </div>\n                </Link>\n                <button class=\"p-1\" @click=\"hideTrialBanner = true\">\n                    <XMarkIcon class=\"w-4 opacity-50 hover:opacity-100\"></XMarkIcon>\n                </button>\n            </div>\n        </MainContainer>\n    </div>\n    <div\n        v-if=\"showBlockedBanner\"\n        class=\"bg-red-600/50 text-xs lg:text-sm py-0.5 border-b border-border-secondary\">\n        <MainContainer class=\"flex items-center justify-between\">\n            <div class=\"flex items-center space-x-1.5\">\n                <XCircleIcon class=\"w-4 text-text-primary/50\"></XCircleIcon>\n                <div class=\"flex-1 space-x-1\">\n                    <span class=\"font-medium\"> Your organization is currently blocked. </span>\n                    <span class=\"hidden md:inline\">\n                        Please upgrade to a premium plan or remove all users except the owner to\n                        unblock your organization.\n                    </span>\n                </div>\n            </div>\n            <div class=\"flex items-center space-x-2\">\n                <Link v-if=\"isBillingActivated() && canManageBilling()\" href=\"/billing\">\n                    <div\n                        class=\"text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5\">\n                        <span>Upgrade now</span>\n                    </div>\n                </Link>\n                <button class=\"p-1\" @click=\"hideBlockedBanner = true\">\n                    <XMarkIcon class=\"w-4 opacity-50 hover:opacity-100\"></XMarkIcon>\n                </button>\n            </div>\n        </MainContainer>\n    </div>\n    <div\n        v-if=\"showFreeUpgradeBanner\"\n        class=\"bg-tertiary text-xs lg:text-sm py-0.5 border-b border-border-secondary\">\n        <MainContainer class=\"flex items-center justify-between\">\n            <div class=\"flex items-center space-x-1.5\">\n                <XCircleIcon class=\"w-4 text-text-primary/50\"></XCircleIcon>\n                <div class=\"flex-1 space-x-1\">\n                    <span class=\"font-medium\"> You are currently using the Free Plan. </span>\n                    <span class=\"hidden md:inline\">\n                        To unlock all premium features & support the development of solidtime,\n                        please upgrade your plan.</span\n                    >\n                </div>\n            </div>\n            <div class=\"flex items-center space-x-2\">\n                <Link v-if=\"isBillingActivated() && canManageBilling()\" href=\"/billing\">\n                    <div\n                        class=\"text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5\">\n                        <span>Upgrade now</span>\n                    </div>\n                </Link>\n                <button class=\"p-1\" @click=\"hideFreeUpgradeBanner = true\">\n                    <XMarkIcon class=\"w-4 opacity-50 hover:opacity-100\"></XMarkIcon>\n                </button>\n            </div>\n        </MainContainer>\n    </div>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/CommandPalette/CommandPaletteProvider.vue",
    "content": "<script setup lang=\"ts\">\nimport { onMounted, onUnmounted, computed } from 'vue';\nimport { router, usePage } from '@inertiajs/vue3';\nimport { CommandPalette } from '@/packages/ui/src/CommandPalette';\nimport { useCommandPalette } from '@/utils/useCommandPalette';\nimport { useProjectsStore } from '@/utils/useProjects';\nimport { useClientsStore } from '@/utils/useClients';\nimport { useTagsStore } from '@/utils/useTags';\nimport { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';\nimport { getOrganizationCurrencyString } from '@/utils/money';\nimport { isAllowedToPerformPremiumAction } from '@/utils/billing';\nimport { canCreateProjects } from '@/utils/permissions';\nimport type {\n    CreateClientBody,\n    CreateProjectBody,\n    CreateTimeEntryBody,\n    Project,\n    Client,\n    Tag,\n} from '@/packages/api/src';\nimport type { User } from '@/types/models';\nimport type { Role } from '@/types/jetstream';\n\n// Import modals\nimport ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';\nimport ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';\nimport TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue';\nimport TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';\nimport MemberInviteModal from '@/Components/Common/Member/MemberInviteModal.vue';\nimport TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';\n\n// Import dropdowns for active timer selectors\nimport TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';\nimport TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';\n\n// Dialog components for selectors\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\n\nconst {\n    isOpen,\n    searchTerm,\n    groups,\n    entityResults,\n    togglePalette,\n    showCreateProjectModal,\n    showCreateClientModal,\n    showCreateTaskModal,\n    showCreateTagModal,\n    showInviteMemberModal,\n    showCreateTimeEntryModal,\n    showProjectSelector,\n    showTaskSelector,\n    showTagsSelector,\n    currentTimeEntry,\n    updateTimer,\n    projects,\n    clients,\n    tasks,\n    tags,\n} = useCommandPalette();\n\n// Stores for creating entities\nconst projectsStore = useProjectsStore();\nconst clientsStore = useClientsStore();\nconst tagsStore = useTagsStore();\n\n// Time entry mutations\nconst { createTimeEntry: createTimeEntryMutation } = useTimeEntriesMutations();\n\n// Get available roles from page props (for member invite modal)\nconst page = usePage<{\n    availableRoles?: Role[];\n    auth: {\n        user: User;\n    };\n}>();\n\nconst availableRoles = computed(() => page.props.availableRoles ?? []);\n\n// Active clients for dropdowns\nconst activeClients = computed(() => clients.value.filter((c) => !c.is_archived));\n\n// Keyboard shortcut handler\nfunction handleKeyDown(e: KeyboardEvent) {\n    if ((e.metaKey || e.ctrlKey) && e.key === 'k') {\n        e.preventDefault();\n        togglePalette();\n    }\n}\n\nonMounted(() => {\n    document.addEventListener('keydown', handleKeyDown);\n});\n\nonUnmounted(() => {\n    document.removeEventListener('keydown', handleKeyDown);\n});\n\n// Project creation\nasync function createProject(project: CreateProjectBody): Promise<Project | undefined> {\n    const openedFromCommandPalette = showCreateProjectModal.value;\n    const newProject = await projectsStore.createProject(project);\n    showCreateProjectModal.value = false;\n    if (newProject && openedFromCommandPalette) {\n        router.visit(route('projects.show', { project: newProject.id }));\n    }\n    return newProject;\n}\n\nasync function createClient(client: CreateClientBody): Promise<Client | undefined> {\n    const openedFromCommandPalette = showCreateClientModal.value;\n    const newClient = await clientsStore.createClient(client);\n    if (newClient && openedFromCommandPalette) {\n        showCreateClientModal.value = false;\n        router.visit(route('clients'));\n    }\n    return newClient;\n}\n\nasync function createTag(name: string): Promise<Tag | undefined> {\n    const openedFromCommandPalette = showCreateTagModal.value;\n    const newTag = await tagsStore.createTag(name);\n    if (newTag && openedFromCommandPalette) {\n        showCreateTagModal.value = false;\n        router.visit(route('tags'));\n    }\n    return newTag;\n}\n\nasync function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) {\n    await createTimeEntryMutation(timeEntry);\n    showCreateTimeEntryModal.value = false;\n}\n\nasync function handleProjectTaskSelect() {\n    showProjectSelector.value = false;\n    showTaskSelector.value = false;\n    await updateTimer();\n}\n\nasync function handleTagsSelect() {\n    showTagsSelector.value = false;\n    await updateTimer();\n}\n\nconst firstProjectId = computed(() => projects.value[0]?.id ?? '');\n</script>\n\n<template>\n    <!-- Command Palette Dialog -->\n    <CommandPalette\n        v-model:open=\"isOpen\"\n        v-model:search-term=\"searchTerm\"\n        :groups=\"groups\"\n        :entity-results=\"entityResults\" />\n\n    <!-- Project Create Modal -->\n    <ProjectCreateModal\n        v-model:show=\"showCreateProjectModal\"\n        :create-project=\"createProject\"\n        :create-client=\"createClient\"\n        :clients=\"activeClients\"\n        :currency=\"getOrganizationCurrencyString()\"\n        :enable-estimated-time=\"isAllowedToPerformPremiumAction()\" />\n\n    <!-- Client Create Modal -->\n    <ClientCreateModal v-model:show=\"showCreateClientModal\" />\n\n    <!-- Task Create Modal -->\n    <TaskCreateModal\n        v-if=\"firstProjectId\"\n        v-model:show=\"showCreateTaskModal\"\n        :project-id=\"firstProjectId\" />\n\n    <!-- Tag Create Modal -->\n    <TagCreateModal v-model:show=\"showCreateTagModal\" :create-tag=\"createTag\" />\n\n    <!-- Member Invite Modal -->\n    <MemberInviteModal v-model:show=\"showInviteMemberModal\" :available-roles=\"availableRoles\" />\n\n    <!-- Time Entry Create Modal -->\n    <TimeEntryCreateModal\n        v-model:show=\"showCreateTimeEntryModal\"\n        :create-time-entry=\"createTimeEntry\"\n        :create-project=\"createProject\"\n        :create-client=\"createClient\"\n        :create-tag=\"createTag\"\n        :projects=\"projects\"\n        :tasks=\"tasks\"\n        :tags=\"tags\"\n        :clients=\"activeClients\"\n        :currency=\"getOrganizationCurrencyString()\"\n        :enable-estimated-time=\"isAllowedToPerformPremiumAction()\"\n        :can-create-project=\"canCreateProjects()\" />\n\n    <!-- Project Selector Dialog for Active Timer -->\n    <DialogModal :show=\"showProjectSelector\" closeable @close=\"showProjectSelector = false\">\n        <template #title>Set Project</template>\n        <template #content>\n            <TimeTrackerProjectTaskDropdown\n                v-model:project=\"currentTimeEntry.project_id\"\n                v-model:task=\"currentTimeEntry.task_id\"\n                variant=\"outline\"\n                :projects=\"projects\"\n                :tasks=\"tasks\"\n                :clients=\"activeClients\"\n                :create-project=\"createProject\"\n                :create-client=\"createClient\"\n                :can-create-project=\"canCreateProjects()\"\n                :currency=\"getOrganizationCurrencyString()\"\n                :enable-estimated-time=\"isAllowedToPerformPremiumAction()\"\n                class=\"w-full\" />\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"showProjectSelector = false\"> Cancel </SecondaryButton>\n            <SecondaryButton class=\"ms-3\" @click=\"handleProjectTaskSelect\"> Save </SecondaryButton>\n        </template>\n    </DialogModal>\n\n    <!-- Task Selector Dialog for Active Timer -->\n    <DialogModal :show=\"showTaskSelector\" closeable @close=\"showTaskSelector = false\">\n        <template #title>Set Task</template>\n        <template #content>\n            <TimeTrackerProjectTaskDropdown\n                v-model:project=\"currentTimeEntry.project_id\"\n                v-model:task=\"currentTimeEntry.task_id\"\n                variant=\"outline\"\n                :projects=\"projects\"\n                :tasks=\"tasks\"\n                :clients=\"activeClients\"\n                :create-project=\"createProject\"\n                :create-client=\"createClient\"\n                :can-create-project=\"canCreateProjects()\"\n                :currency=\"getOrganizationCurrencyString()\"\n                :enable-estimated-time=\"isAllowedToPerformPremiumAction()\"\n                class=\"w-full\" />\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"showTaskSelector = false\"> Cancel </SecondaryButton>\n            <SecondaryButton class=\"ms-3\" @click=\"handleProjectTaskSelect\"> Save </SecondaryButton>\n        </template>\n    </DialogModal>\n\n    <!-- Tags Selector Dialog for Active Timer -->\n    <DialogModal :show=\"showTagsSelector\" closeable @close=\"showTagsSelector = false\">\n        <template #title>Set Tags</template>\n        <template #content>\n            <TagDropdown v-model=\"currentTimeEntry.tags\" :tags=\"tags\" :create-tag=\"createTag\">\n                <template #trigger>\n                    <div\n                        class=\"w-full p-3 border border-card-border rounded-lg cursor-pointer hover:bg-tertiary transition\">\n                        <span\n                            v-if=\"currentTimeEntry.tags.length === 0\"\n                            class=\"text-muted-foreground\">\n                            Click to select tags...\n                        </span>\n                        <span v-else> {{ currentTimeEntry.tags.length }} tag(s) selected </span>\n                    </div>\n                </template>\n            </TagDropdown>\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"showTagsSelector = false\"> Cancel </SecondaryButton>\n            <SecondaryButton class=\"ms-3\" @click=\"handleTagsSelect\"> Save </SecondaryButton>\n        </template>\n    </DialogModal>\n</template>\n"
  },
  {
    "path": "resources/js/Components/CommandPalette/index.ts",
    "content": "export { default as CommandPaletteProvider } from './CommandPaletteProvider.vue';\n"
  },
  {
    "path": "resources/js/Components/Common/Card.vue",
    "content": "<script setup lang=\"ts\"></script>\n\n<template>\n    <div\n        class=\"rounded-lg border overflow-hidden border-card-border bg-card-background shadow-card\">\n        <slot></slot>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Common/Client/ClientCreateModal.vue",
    "content": "<script setup lang=\"ts\">\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport { ref } from 'vue';\nimport type { CreateClientBody } from '@/packages/api/src';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport { useFocus } from '@vueuse/core';\nimport { useClientsStore } from '@/utils/useClients';\nimport { Field, FieldLabel } from '@/packages/ui/src/field';\n\nconst { createClient } = useClientsStore();\nconst show = defineModel('show', { default: false });\nconst saving = ref(false);\n\nconst client = ref<CreateClientBody>({\n    name: '',\n});\n\nasync function submit() {\n    await createClient(client.value);\n    client.value.name = '';\n    show.value = false;\n}\n\nconst clientNameInput = ref<HTMLInputElement | null>(null);\nuseFocus(clientNameInput, { initialValue: true });\n</script>\n\n<template>\n    <DialogModal closeable :show=\"show\" @close=\"show = false\">\n        <template #title>\n            <div class=\"flex space-x-2\">\n                <span> Create Client </span>\n            </div>\n        </template>\n\n        <template #content>\n            <div class=\"flex items-center space-x-4\">\n                <Field class=\"col-span-6 sm:col-span-4 flex-1\">\n                    <FieldLabel for=\"clientName\">Client Name</FieldLabel>\n                    <TextInput\n                        id=\"clientName\"\n                        ref=\"clientNameInput\"\n                        v-model=\"client.name\"\n                        type=\"text\"\n                        placeholder=\"Client Name\"\n                        class=\"block w-full\"\n                        required\n                        autocomplete=\"clientName\"\n                        @keydown.enter=\"submit\" />\n                </Field>\n            </div>\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"show = false\"> Cancel </SecondaryButton>\n\n            <PrimaryButton\n                class=\"ms-3\"\n                :class=\"{ 'opacity-25': saving }\"\n                :disabled=\"saving\"\n                @click=\"submit\">\n                Create Client\n            </PrimaryButton>\n        </template>\n    </DialogModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Client/ClientEditModal.vue",
    "content": "<script setup lang=\"ts\">\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport { ref } from 'vue';\nimport type { Client, UpdateClientBody } from '@/packages/api/src';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport { useFocus } from '@vueuse/core';\nimport { useClientsStore } from '@/utils/useClients';\n\nconst { updateClient } = useClientsStore();\nconst show = defineModel('show', { default: false });\nconst saving = ref(false);\n\nconst props = defineProps<{\n    client: Client;\n}>();\n\nconst clientBody = ref<UpdateClientBody>({\n    name: props.client.name,\n});\n\nasync function submit() {\n    await updateClient(props.client.id, clientBody.value);\n    show.value = false;\n}\n\nconst clientNameInput = ref<HTMLInputElement | null>(null);\nuseFocus(clientNameInput, { initialValue: true });\n</script>\n\n<template>\n    <DialogModal closeable :show=\"show\" @close=\"show = false\">\n        <template #title>\n            <div class=\"flex space-x-2\">\n                <span> Update Client </span>\n            </div>\n        </template>\n\n        <template #content>\n            <div class=\"flex items-center space-x-4\">\n                <div class=\"col-span-6 sm:col-span-4 flex-1\">\n                    <TextInput\n                        id=\"clientName\"\n                        ref=\"clientNameInput\"\n                        v-model=\"clientBody.name\"\n                        type=\"text\"\n                        placeholder=\"Client Name\"\n                        class=\"mt-1 block w-full\"\n                        required\n                        autocomplete=\"clientName\"\n                        @keydown.enter=\"submit\" />\n                </div>\n            </div>\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"show = false\"> Cancel </SecondaryButton>\n\n            <PrimaryButton\n                class=\"ms-3\"\n                :class=\"{ 'opacity-25': saving }\"\n                :disabled=\"saving\"\n                @click=\"submit\">\n                Update Client\n            </PrimaryButton>\n        </template>\n    </DialogModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Client/ClientMoreOptionsDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport { ArchiveBoxIcon, PencilSquareIcon, TrashIcon } from '@heroicons/vue/20/solid';\nimport type { Client } from '@/packages/api/src';\nimport { canDeleteClients, canUpdateClients } from '@/utils/permissions';\nimport {\n    DropdownMenu,\n    DropdownMenuContent,\n    DropdownMenuItem,\n    DropdownMenuTrigger,\n} from '@/Components/ui/dropdown-menu';\n\nconst emit = defineEmits<{\n    delete: [];\n    edit: [];\n    archive: [];\n}>();\nconst props = defineProps<{\n    client: Client;\n}>();\n</script>\n\n<template>\n    <DropdownMenu>\n        <DropdownMenuTrigger as-child>\n            <button\n                class=\"focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-2 focus-visible:ring-ring focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity text-text-secondary\"\n                :aria-label=\"'Actions for Client ' + props.client.name\">\n                <svg\n                    class=\"h-8 w-8 p-1 rounded-full\"\n                    viewBox=\"0 0 24 24\"\n                    xmlns=\"http://www.w3.org/2000/svg\">\n                    <path\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        stroke-linecap=\"round\"\n                        stroke-linejoin=\"round\"\n                        stroke-width=\"1.5\"\n                        d=\"M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92\" />\n                </svg>\n            </button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent class=\"min-w-[150px]\" align=\"end\">\n            <DropdownMenuItem\n                v-if=\"canUpdateClients()\"\n                :aria-label=\"'Edit Client ' + props.client.name\"\n                data-testid=\"client_edit\"\n                class=\"flex items-center space-x-3 cursor-pointer\"\n                @click=\"emit('edit')\">\n                <PencilSquareIcon class=\"w-5 text-icon-active\" />\n                <span>Edit</span>\n            </DropdownMenuItem>\n            <DropdownMenuItem\n                v-if=\"canUpdateClients()\"\n                :aria-label=\"'Archive Client ' + props.client.name\"\n                class=\"flex items-center space-x-3 cursor-pointer\"\n                @click.prevent=\"emit('archive')\">\n                <ArchiveBoxIcon class=\"w-5 text-icon-active\" />\n                <span>{{ client.is_archived ? 'Unarchive' : 'Archive' }}</span>\n            </DropdownMenuItem>\n            <DropdownMenuItem\n                v-if=\"canDeleteClients()\"\n                :aria-label=\"'Delete Client ' + props.client.name\"\n                data-testid=\"client_delete\"\n                class=\"flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive\"\n                @click=\"emit('delete')\">\n                <TrashIcon class=\"w-5\" />\n                <span>Delete</span>\n            </DropdownMenuItem>\n        </DropdownMenuContent>\n    </DropdownMenu>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Client/ClientMultiselectDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport MultiselectDropdown from '@/packages/ui/src/Input/MultiselectDropdown.vue';\nimport type { Client } from '@/packages/api/src';\nimport { useClientsQuery } from '@/utils/useClientsQuery';\n\nconst { clients } = useClientsQuery();\n\nfunction getKeyFromItem(item: Client) {\n    return item.id;\n}\n\nfunction getNameForItem(item: Client) {\n    return item.name;\n}\n\nconst emit = defineEmits<{\n    submit: [];\n}>();\n</script>\n\n<template>\n    <MultiselectDropdown\n        search-placeholder=\"Search for a Client...\"\n        :items=\"clients\"\n        :get-key-from-item=\"getKeyFromItem\"\n        :get-name-for-item=\"getNameForItem\"\n        no-item-label=\"No Client\"\n        @submit=\"emit('submit')\">\n        <template #trigger>\n            <slot name=\"trigger\"></slot>\n        </template>\n    </MultiselectDropdown>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Common/Client/ClientTable.vue",
    "content": "<script setup lang=\"ts\">\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport { UserCircleIcon } from '@heroicons/vue/24/solid';\nimport { PlusIcon } from '@heroicons/vue/16/solid';\nimport { type Component, computed, ref } from 'vue';\nimport { type Client } from '@/packages/api/src';\nimport ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue';\nimport ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';\nimport ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue';\nimport { canCreateClients } from '@/utils/permissions';\nimport { useProjectsQuery } from '@/utils/useProjectsQuery';\nimport {\n    useVueTable,\n    getCoreRowModel,\n    getSortedRowModel,\n    type SortingState,\n} from '@tanstack/vue-table';\n\nexport type SortColumn = 'name' | 'projects_count' | 'status';\nexport type SortDirection = 'asc' | 'desc';\n\nconst props = defineProps<{\n    clients: Client[];\n    sortColumn: SortColumn;\n    sortDirection: SortDirection;\n}>();\n\nconst emit = defineEmits<{\n    sort: [column: SortColumn, direction: SortDirection];\n}>();\n\nconst createClient = ref(false);\n\nconst { projects } = useProjectsQuery();\n\nconst projectCountMap = computed(() => {\n    const map = new Map<string, number>();\n    projects.value.forEach((project) => {\n        if (project.client_id) {\n            map.set(project.client_id, (map.get(project.client_id) ?? 0) + 1);\n        }\n    });\n    return map;\n});\n\nconst sorting = computed<SortingState>(() => [\n    {\n        id: props.sortColumn,\n        desc: props.sortDirection === 'desc',\n    },\n]);\n\nconst columns = computed(() => [\n    {\n        id: 'name',\n        accessorFn: (row: Client) => row.name.toLowerCase(),\n    },\n    {\n        id: 'projects_count',\n        sortDescFirst: true,\n        accessorFn: (row: Client) => projectCountMap.value.get(row.id) ?? 0,\n    },\n    {\n        id: 'status',\n        accessorFn: (row: Client) => (row.is_archived ? 1 : 0),\n    },\n]);\n\nconst descFirstColumns = new Set<SortColumn>(\n    columns.value\n        .filter((c) => 'sortDescFirst' in c && c.sortDescFirst)\n        .map((c) => c.id as SortColumn)\n);\n\nfunction handleSort(column: SortColumn) {\n    if (props.sortColumn === column) {\n        emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');\n    } else {\n        emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');\n    }\n}\n\nconst table = useVueTable({\n    get data() {\n        return props.clients;\n    },\n    get columns() {\n        return columns.value;\n    },\n    getCoreRowModel: getCoreRowModel(),\n    getSortedRowModel: getSortedRowModel(),\n    state: {\n        get sorting() {\n            return sorting.value;\n        },\n    },\n    manualSorting: false,\n});\n\nconst sortedClients = computed(() => {\n    return table.getRowModel().rows.map((row) => row.original);\n});\n</script>\n\n<template>\n    <ClientCreateModal v-model:show=\"createClient\"></ClientCreateModal>\n    <div class=\"flow-root max-w-[100vw] overflow-x-auto\">\n        <div class=\"inline-block min-w-full align-middle\">\n            <div\n                data-testid=\"client_table\"\n                class=\"grid min-w-full\"\n                style=\"grid-template-columns: 1fr 150px 200px 80px\">\n                <ClientTableHeading\n                    :sort-column=\"props.sortColumn\"\n                    :sort-direction=\"props.sortDirection\"\n                    :desc-first-columns=\"descFirstColumns\"\n                    @sort=\"handleSort\"></ClientTableHeading>\n                <div v-if=\"sortedClients.length === 0\" class=\"col-span-3 py-24 text-center\">\n                    <UserCircleIcon class=\"w-8 text-icon-default inline pb-2\"></UserCircleIcon>\n                    <h3 class=\"text-text-primary font-semibold\">No clients found</h3>\n                    <p v-if=\"canCreateClients()\" class=\"pb-5\">Create your first client now!</p>\n                    <SecondaryButton\n                        v-if=\"canCreateClients()\"\n                        :icon=\"PlusIcon as Component\"\n                        @click=\"createClient = true\"\n                        >Create your First Client\n                    </SecondaryButton>\n                </div>\n                <template v-for=\"client in sortedClients\" :key=\"client.id\">\n                    <ClientTableRow :client=\"client\"></ClientTableRow>\n                </template>\n            </div>\n        </div>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Common/Client/ClientTableHeading.vue",
    "content": "<script setup lang=\"ts\">\nimport TableHeading from '@/Components/Common/TableHeading.vue';\nimport { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';\nimport type { SortColumn, SortDirection } from '@/Components/Common/Client/ClientTable.vue';\n\nconst props = defineProps<{\n    sortColumn: SortColumn;\n    sortDirection: SortDirection;\n    descFirstColumns: ReadonlySet<SortColumn>;\n}>();\n\nconst emit = defineEmits<{\n    sort: [column: SortColumn];\n}>();\n\nfunction handleSort(column: SortColumn) {\n    emit('sort', column);\n}\n\nfunction isSorted(column: SortColumn): boolean {\n    return props.sortColumn === column;\n}\n\nfunction isChevronDown(column: SortColumn): boolean {\n    if (!isSorted(column)) return false;\n    return props.descFirstColumns.has(column)\n        ? props.sortDirection === 'desc'\n        : props.sortDirection === 'asc';\n}\n\nfunction isChevronUp(column: SortColumn): boolean {\n    if (!isSorted(column)) return false;\n    return !isChevronDown(column);\n}\n</script>\n\n<template>\n    <TableHeading>\n        <div\n            class=\"py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1\"\n            @click=\"handleSort('name')\">\n            Name\n            <ChevronDownIcon v-if=\"isChevronDown('name')\" class=\"w-4 h-4\" />\n            <ChevronUpIcon v-else-if=\"isChevronUp('name')\" class=\"w-4 h-4\" />\n            <span v-else class=\"w-4 h-4\"></span>\n        </div>\n        <div\n            class=\"px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1\"\n            @click=\"handleSort('projects_count')\">\n            Projects\n            <ChevronDownIcon v-if=\"isChevronDown('projects_count')\" class=\"w-4 h-4\" />\n            <ChevronUpIcon v-else-if=\"isChevronUp('projects_count')\" class=\"w-4 h-4\" />\n            <span v-else class=\"w-4 h-4\"></span>\n        </div>\n        <div\n            class=\"px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1\"\n            @click=\"handleSort('status')\">\n            Status\n            <ChevronDownIcon v-if=\"isChevronDown('status')\" class=\"w-4 h-4\" />\n            <ChevronUpIcon v-else-if=\"isChevronUp('status')\" class=\"w-4 h-4\" />\n            <span v-else class=\"w-4 h-4\"></span>\n        </div>\n        <div class=\"relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12\">\n            <span class=\"sr-only\">Edit</span>\n        </div>\n    </TableHeading>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Common/Client/ClientTableRow.vue",
    "content": "<script setup lang=\"ts\">\nimport type { Client } from '@/packages/api/src';\nimport { computed, ref } from 'vue';\nimport { CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/vue/24/outline';\nimport { useClientsStore } from '@/utils/useClients';\nimport ClientMoreOptionsDropdown from '@/Components/Common/Client/ClientMoreOptionsDropdown.vue';\nimport { useProjectsQuery } from '@/utils/useProjectsQuery';\nimport TableRow from '@/Components/TableRow.vue';\nimport ClientEditModal from '@/Components/Common/Client/ClientEditModal.vue';\n\nconst { projects } = useProjectsQuery();\n\nconst props = defineProps<{\n    client: Client;\n}>();\n\nfunction deleteClient() {\n    useClientsStore().deleteClient(props.client.id);\n}\n\nconst projectCount = computed(() => {\n    return projects.value.filter((projects) => projects.client_id === props.client.id).length;\n});\n\nfunction archiveClient() {\n    useClientsStore().updateClient(props.client.id, {\n        ...props.client,\n        is_archived: !props.client.is_archived,\n    });\n}\n\nconst showEditModal = ref(false);\n</script>\n\n<template>\n    <TableRow>\n        <ClientEditModal v-model:show=\"showEditModal\" :client=\"client\"></ClientEditModal>\n        <div\n            class=\"whitespace-nowrap flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12\">\n            <span>\n                {{ client.name }}\n            </span>\n        </div>\n        <div\n            class=\"whitespace-nowrap flex items-center px-3 py-4 text-sm font-medium text-text-primary\">\n            <span class=\"text-text-secondary\"> {{ projectCount }} Projects </span>\n        </div>\n        <div\n            class=\"whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium\">\n            <template v-if=\"client.is_archived\">\n                <ArchiveBoxIcon class=\"w-4 text-icon-default\"></ArchiveBoxIcon>\n                <span>Archived</span>\n            </template>\n            <template v-else>\n                <CheckCircleIcon class=\"w-4 text-icon-default\"></CheckCircleIcon>\n                <span>Active</span>\n            </template>\n        </div>\n        <div\n            class=\"relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12\">\n            <ClientMoreOptionsDropdown\n                :client=\"client\"\n                @edit=\"showEditModal = true\"\n                @archive=\"archiveClient\"\n                @delete=\"deleteClient\"></ClientMoreOptionsDropdown>\n        </div>\n    </TableRow>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Invitation/InvitationMoreOptionsDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport { TrashIcon, ArrowPathIcon } from '@heroicons/vue/20/solid';\nimport {\n    DropdownMenu,\n    DropdownMenuContent,\n    DropdownMenuItem,\n    DropdownMenuTrigger,\n} from '@/Components/ui/dropdown-menu';\n\nconst emit = defineEmits<{\n    delete: [];\n    resend: [];\n}>();\n</script>\n\n<template>\n    <DropdownMenu>\n        <DropdownMenuTrigger as-child>\n            <button\n                class=\"focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-2 focus-visible:ring-ring focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity text-text-secondary\"\n                aria-label=\"Actions for the invitation\">\n                <svg\n                    class=\"h-8 w-8 p-1 rounded-full\"\n                    viewBox=\"0 0 24 24\"\n                    xmlns=\"http://www.w3.org/2000/svg\">\n                    <path\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        stroke-linecap=\"round\"\n                        stroke-linejoin=\"round\"\n                        stroke-width=\"1.5\"\n                        d=\"M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92\" />\n                </svg>\n            </button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent class=\"min-w-[150px]\" align=\"end\">\n            <DropdownMenuItem\n                data-testid=\"invitation_delete\"\n                class=\"flex items-center space-x-3 cursor-pointer\"\n                @click=\"emit('resend')\">\n                <ArrowPathIcon class=\"w-5 text-icon-active\" />\n                <span>Resend Invitation</span>\n            </DropdownMenuItem>\n            <DropdownMenuItem\n                data-testid=\"invitation_delete\"\n                class=\"flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive\"\n                @click=\"emit('delete')\">\n                <TrashIcon class=\"w-5\" />\n                <span>Delete</span>\n            </DropdownMenuItem>\n        </DropdownMenuContent>\n    </DropdownMenu>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Invitation/InvitationTable.vue",
    "content": "<script setup lang=\"ts\">\nimport { onMounted } from 'vue';\nimport { storeToRefs } from 'pinia';\nimport { useInvitationsStore } from '@/utils/useInvitations';\nimport InvitationTableRow from '@/Components/Common/Invitation/InvitationTableRow.vue';\nimport InvitationTableHeading from '@/Components/Common/Invitation/InvitationTableHeading.vue';\n\nconst { invitations } = storeToRefs(useInvitationsStore());\n\nonMounted(async () => {\n    await useInvitationsStore().fetchInvitations();\n});\n</script>\n\n<template>\n    <div class=\"flow-root max-w-[100vw] overflow-x-auto\">\n        <div class=\"inline-block min-w-full align-middle\">\n            <div\n                data-testid=\"client_table\"\n                class=\"grid min-w-full\"\n                style=\"grid-template-columns: 1fr 1fr 80px\">\n                <InvitationTableHeading></InvitationTableHeading>\n                <template v-for=\"invitation in invitations\" :key=\"invitation.id\">\n                    <InvitationTableRow :invitation=\"invitation\"></InvitationTableRow>\n                </template>\n            </div>\n        </div>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Common/Invitation/InvitationTableHeading.vue",
    "content": "<script setup lang=\"ts\">\nimport TableHeading from '@/Components/Common/TableHeading.vue';\n</script>\n\n<template>\n    <TableHeading>\n        <div class=\"px-3 py-1.5 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12\">\n            Email\n        </div>\n        <div class=\"px-3 py-1.5 text-left text-text-tertiary\">Role</div>\n        <div class=\"relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background\">\n            <span class=\"sr-only\">Edit</span>\n        </div>\n    </TableHeading>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Invitation/InvitationTableRow.vue",
    "content": "<script setup lang=\"ts\">\nimport type { Invitation } from '@/packages/api/src';\nimport TableRow from '@/Components/TableRow.vue';\nimport { capitalizeFirstLetter } from '../../../utils/format';\nimport InvitationMoreOptionsDropdown from '@/Components/Common/Invitation/InvitationMoreOptionsDropdown.vue';\nimport { api } from '@/packages/api/src';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport { useNotificationsStore } from '@/utils/notification';\nimport { useInvitationsStore } from '@/utils/useInvitations';\nconst { handleApiRequestNotifications } = useNotificationsStore();\n\nconst props = defineProps<{\n    invitation: Invitation;\n}>();\n\nasync function deleteInvitation() {\n    const organizationId = getCurrentOrganizationId();\n    if (organizationId) {\n        await handleApiRequestNotifications(\n            () =>\n                api.removeInvitation(undefined, {\n                    params: {\n                        invitation: props.invitation.id,\n                        organization: organizationId,\n                    },\n                }),\n            'Invitation removed successfully',\n            'Error removing invitation',\n            () => {\n                useInvitationsStore().fetchInvitations();\n            }\n        );\n    }\n}\n\nasync function resendInvitation() {\n    const organizationId = getCurrentOrganizationId();\n    if (organizationId) {\n        await handleApiRequestNotifications(\n            () =>\n                api.resendInvitationEmail(undefined, {\n                    params: {\n                        invitation: props.invitation.id,\n                        organization: organizationId,\n                    },\n                }),\n            'Invitation mail sent successfully',\n            'Error sending invitation mail'\n        );\n    }\n}\n</script>\n\n<template>\n    <TableRow>\n        <div\n            class=\"whitespace-nowrap px-3 py-4 text-sm text-text-secondary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12\">\n            {{ invitation.email }}\n        </div>\n        <div class=\"whitespace-nowrap px-3 py-4 text-sm text-text-secondary\">\n            {{ capitalizeFirstLetter(invitation.role) }}\n        </div>\n        <div\n            class=\"relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12\">\n            <InvitationMoreOptionsDropdown @delete=\"deleteInvitation\" @resend=\"resendInvitation\" />\n        </div>\n    </TableRow>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Member/MemberBillableRateModal.vue",
    "content": "<script setup lang=\"ts\">\nimport { getOrganizationCurrencyString } from '@/utils/money';\nimport BillableRateModal from '@/packages/ui/src/BillableRateModal.vue';\nimport { formatCents } from '@/packages/ui/src/utils/money';\nimport { inject, type ComputedRef } from 'vue';\nimport type { Organization } from '@/packages/api/src';\n\nconst show = defineModel('show', { default: false });\nconst saving = defineModel('saving', { default: false });\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n\ndefineProps<{\n    newBillableRate?: number | null;\n    memberName: string;\n}>();\n\ndefineEmits<{\n    submit: [];\n}>();\n</script>\n\n<template>\n    <BillableRateModal\n        v-model:show=\"show\"\n        v-model:saving=\"saving\"\n        title=\"Update Member Billable Rate\"\n        @submit=\"$emit('submit')\">\n        <p class=\"py-1 text-center\">\n            The billable rate of {{ memberName }} will be updated to\n            <strong>{{\n                newBillableRate\n                    ? formatCents(\n                          newBillableRate,\n                          getOrganizationCurrencyString(),\n                          organization?.currency_format,\n                          organization?.currency_symbol,\n                          organization?.number_format\n                      )\n                    : ' the default rate of the organization'\n            }}</strong\n            >.\n        </p>\n        <p class=\"py-1 text-center font-semibold max-w-md mx-auto\">\n            Do you want to update all existing time entries, where the member billable rate applies\n            as well?\n        </p>\n    </BillableRateModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Member/MemberBillableSelect.vue",
    "content": "<script setup lang=\"ts\">\nimport {\n    Select,\n    SelectContent,\n    SelectItem,\n    SelectTrigger,\n    SelectValue,\n} from '@/Components/ui/select';\nimport type { BillableKey } from '@/types/projects';\n\nconst model = defineModel<BillableKey>({\n    default: 'default-rate',\n});\n\ntype Option = { key: BillableKey; name: string };\n\nconst options: Option[] = [\n    {\n        key: 'default-rate',\n        name: 'Organization Default Rate',\n    },\n    {\n        key: 'custom-rate',\n        name: 'Custom Rate',\n    },\n];\n\nfunction getNameForKey(key: BillableKey | undefined) {\n    const item = options.find((item) => item.key === key);\n    if (item) {\n        return item.name;\n    }\n    return '';\n}\n</script>\n\n<template>\n    <Select v-model=\"model\">\n        <SelectTrigger>\n            <SelectValue>{{ getNameForKey(model) }}</SelectValue>\n        </SelectTrigger>\n        <SelectContent>\n            <SelectItem v-for=\"option in options\" :key=\"option.key\" :value=\"option.key\">\n                {{ option.name }}\n            </SelectItem>\n        </SelectContent>\n    </Select>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Member/MemberCombobox.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, nextTick, ref, watch } from 'vue';\nimport { useMembersQuery } from '@/utils/useMembersQuery';\nimport { UserIcon } from '@heroicons/vue/24/solid';\nimport { ChevronDown } from 'lucide-vue-next';\nimport type { ProjectMember } from '@/packages/api/src';\nimport type { Member } from '@/packages/api/src';\nimport {\n    ComboboxAnchor,\n    ComboboxContent,\n    ComboboxInput,\n    ComboboxItem,\n    ComboboxRoot,\n    ComboboxViewport,\n} from 'radix-vue';\nimport Dropdown from '@/packages/ui/src/Input/Dropdown.vue';\nimport { Button } from '@/packages/ui/src/Buttons';\n\nconst { members } = useMembersQuery();\n\nconst model = defineModel<string>({\n    default: '',\n});\n\nconst props = withDefaults(\n    defineProps<{\n        hiddenMembers?: ProjectMember[];\n        disabled?: boolean;\n    }>(),\n    {\n        hiddenMembers: () => [] as ProjectMember[],\n        disabled: false,\n    }\n);\n\nconst open = ref(false);\nconst searchValue = ref('');\nconst searchInput = ref<HTMLElement | null>(null);\n\nwatch(open, (isOpen) => {\n    if (isOpen) {\n        searchValue.value = '';\n        nextTick(() => {\n            // @ts-expect-error We need to access the actual HTML Element to focus\n            searchInput.value?.$el?.focus();\n        });\n    }\n});\n\nconst filteredMembers = computed<Member[]>(() => {\n    return members.value.filter((member) => {\n        return (\n            member.name.toLowerCase().includes(searchValue.value.toLowerCase().trim() || '') &&\n            !props.hiddenMembers.some((hiddenMember) => hiddenMember.member_id === member.id) &&\n            member.is_placeholder === false\n        );\n    });\n});\n\nconst currentValue = computed(() => {\n    if (model.value) {\n        return members.value.find((member) => member.id === model.value)?.name;\n    }\n    return '';\n});\n\nfunction selectMember(member: Member) {\n    model.value = member.id;\n    open.value = false;\n}\n</script>\n\n<template>\n    <Dropdown v-model=\"open\" align=\"start\" :close-on-content-click=\"false\">\n        <template #trigger>\n            <Button\n                :disabled=\"disabled\"\n                type=\"button\"\n                variant=\"input\"\n                class=\"w-full justify-between text-start font-normal\">\n                <div class=\"flex items-center gap-3 truncate\">\n                    <UserIcon class=\"w-4 text-text-secondary shrink-0\" />\n                    <span v-if=\"currentValue\" class=\"truncate text-text-primary\">{{\n                        currentValue\n                    }}</span>\n                    <span v-else class=\"text-muted-foreground\">Select a member...</span>\n                </div>\n                <ChevronDown class=\"w-4 h-4 text-icon-default shrink-0\" />\n            </Button>\n        </template>\n        <template #content>\n            <ComboboxRoot\n                v-model:search-term=\"searchValue\"\n                v-model:open=\"open\"\n                class=\"relative\"\n                :filter-function=\"(val: string[]) => val\">\n                <ComboboxAnchor>\n                    <ComboboxInput\n                        ref=\"searchInput\"\n                        class=\"bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full\"\n                        placeholder=\"Search for a member...\" />\n                </ComboboxAnchor>\n                <ComboboxContent\n                    :dismiss-able=\"false\"\n                    position=\"inline\"\n                    class=\"w-60 max-h-60 overflow-y-auto\">\n                    <ComboboxViewport>\n                        <ComboboxItem\n                            v-for=\"member in filteredMembers\"\n                            :key=\"member.id\"\n                            :value=\"member.id\"\n                            class=\"flex items-center gap-3 px-3 py-2.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default\"\n                            @select.prevent=\"selectMember(member)\">\n                            <UserIcon class=\"w-4 text-text-secondary shrink-0\" />\n                            <span class=\"truncate\">{{ member.name }}</span>\n                        </ComboboxItem>\n                    </ComboboxViewport>\n                </ComboboxContent>\n            </ComboboxRoot>\n        </template>\n    </Dropdown>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Member/MemberDeleteModal.vue",
    "content": "<script setup lang=\"ts\">\nimport type { Member } from '@/packages/api/src';\nimport { api } from '@/packages/api/src';\nimport { useForm } from '@tanstack/vue-form';\nimport { useMutation, useQueryClient } from '@tanstack/vue-query';\nimport Modal from '@/packages/ui/src/Modal.vue';\nimport DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport Checkbox from '@/packages/ui/src/Input/Checkbox.vue';\nimport { useNotificationsStore } from '@/utils/notification';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';\n\nconst props = defineProps<{\n    show: boolean;\n    member: Member;\n}>();\n\nconst emit = defineEmits<{\n    'update:show': [value: boolean];\n}>();\n\nconst { handleApiRequestNotifications } = useNotificationsStore();\nconst queryClient = useQueryClient();\n\nconst deleteMutation = useMutation({\n    mutationFn: async () => {\n        const organizationId = getCurrentOrganizationId();\n        if (!organizationId) {\n            throw new Error('No organization ID found');\n        }\n\n        return api.removeMember(undefined, {\n            params: {\n                member: props.member.id,\n                organization: organizationId,\n            },\n            queries: {\n                delete_related: 'true',\n            },\n        });\n    },\n    onSuccess: () => {\n        close();\n        queryClient.invalidateQueries({ queryKey: ['members'] });\n    },\n});\n\nconst form = useForm({\n    canSubmitWhenInvalid: true,\n    defaultValues: {\n        confirmDelete: false,\n    },\n    onSubmit: async () => {\n        await handleApiRequestNotifications(\n            () => deleteMutation.mutateAsync(),\n            'Member deleted successfully',\n            'Error deleting member'\n        );\n    },\n});\n\nconst close = () => {\n    emit('update:show', false);\n    form.reset();\n};\n</script>\n\n<template>\n    <Modal :show=\"show\" max-width=\"md\" @close=\"close\">\n        <div class=\"p-6\">\n            <h2 class=\"text-lg font-medium text-text-primary\">Delete Member</h2>\n\n            <div class=\"mt-4 text-sm text-text-secondary\">\n                <p class=\"mb-4\">\n                    Are you sure you want to delete {{ member.name }}? This action cannot be undone.\n                </p>\n                <p class=\"mb-4\">This will permanently delete:</p>\n\n                <ul class=\"list-disc ml-6 mt-2\">\n                    <li>All time entries created by this member</li>\n                    <li>Their project assignments</li>\n                    <li>Their organization membership</li>\n                </ul>\n                <p class=\"pt-4\">\n                    <strong>Note:</strong> Deleting time entries will affect all reports and\n                    statistics. If you want to keep the time entries but remove the member from your\n                    organization, you can convert them to a placeholder user instead. Placeholder\n                    users are not charged and their time entries remain intact for reporting\n                    purposes.\n                </p>\n            </div>\n\n            <form\n                class=\"mt-6\"\n                @submit=\"\n                    (e) => {\n                        e.preventDefault();\n                        e.stopPropagation();\n                        form.handleSubmit();\n                    }\n                \">\n                <div class=\"flex items-start\">\n                    <form.Field\n                        name=\"confirmDelete\"\n                        :validators=\"{\n                            onSubmit: ({ value }) => {\n                                if (!value) {\n                                    return 'You must confirm that you understand the consequences of this action';\n                                }\n                                return '';\n                            },\n                        }\">\n                        <template #default=\"{ field }\">\n                            <Field orientation=\"horizontal\">\n                                <Checkbox\n                                    :id=\"field.name\"\n                                    :name=\"field.name\"\n                                    :checked=\"field.state.value\"\n                                    @update:checked=\"field.handleChange\"\n                                    @blur=\"field.handleBlur\" />\n                                <FieldLabel :for=\"field.name\" class=\"font-medium text-text-primary\">\n                                    I understand that this will permanently delete all data related\n                                    to this member\n                                </FieldLabel>\n                                <FieldError v-if=\"field.state.meta.errors[0]\" class=\"pl-7 pt-2\">\n                                    {{ field.state.meta.errors[0] }}\n                                </FieldError>\n                            </Field>\n                        </template>\n                    </form.Field>\n                </div>\n                <div class=\"mt-6 flex justify-end space-x-3\">\n                    <SecondaryButton @click=\"close\">Cancel</SecondaryButton>\n                    <form.Subscribe>\n                        <template #default=\"{ canSubmit, isSubmitting }\">\n                            <DangerButton type=\"submit\" :disabled=\"!canSubmit\">\n                                {{ isSubmitting ? 'Deleting...' : 'Delete Member' }}\n                            </DangerButton>\n                        </template>\n                    </form.Subscribe>\n                </div>\n            </form>\n        </div>\n    </Modal>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Common/Member/MemberEditModal.vue",
    "content": "<script setup lang=\"ts\">\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport { computed, onMounted, ref, watch } from 'vue';\nimport type { Member, UpdateMemberBody } from '@/packages/api/src';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport { type MemberBillableKey, useMembersStore } from '@/utils/useMembers';\nimport BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';\nimport { Field, FieldLabel, FieldDescription } from '@/packages/ui/src/field';\nimport {\n    Select,\n    SelectContent,\n    SelectItem,\n    SelectTrigger,\n    SelectValue,\n} from '@/Components/ui/select';\nimport {\n    Tooltip,\n    TooltipContent,\n    TooltipProvider,\n    TooltipTrigger,\n} from '@/packages/ui/src/tooltip';\nimport MemberBillableRateModal from '@/Components/Common/Member/MemberBillableRateModal.vue';\nimport MemberRoleSelect from '@/Components/Common/Member/MemberRoleSelect.vue';\nimport MemberOwnershipTransferConfirmModal from '@/Components/Common/Member/MemberOwnershipTransferConfirmModal.vue';\nimport { getOrganizationCurrencyString } from '@/utils/money';\nimport BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';\nimport { useOrganizationQuery } from '@/utils/useOrganizationQuery';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\n\nconst { updateMember } = useMembersStore();\nconst { organization } = useOrganizationQuery(getCurrentOrganizationId()!);\nconst show = defineModel('show', { default: false });\nconst saving = ref(false);\n\nconst props = defineProps<{\n    member: Member;\n}>();\n\nconst memberBody = ref<UpdateMemberBody>({\n    // @ts-expect-error - The role value is always valid\n    role: props.member.role,\n    billable_rate: props.member.billable_rate,\n});\n\nasync function submitBillableRate() {\n    if (memberBody.value.role === 'owner' && props.member.role !== 'owner') {\n        show.value = false;\n        showOwnershipTransferConfirmModal.value = true;\n    } else {\n        await submit();\n    }\n}\n\nasync function submit() {\n    await updateMember(props.member.id, memberBody.value);\n    show.value = false;\n    showBillableRateModal.value = false;\n    showOwnershipTransferConfirmModal.value = false;\n}\n\nconst showBillableRateModal = ref(false);\nconst showOwnershipTransferConfirmModal = ref(false);\n\nfunction saveWithChecks() {\n    if (memberBody.value.billable_rate !== props.member.billable_rate) {\n        // make sure that the alert modal is not immediately submitted when user presses enter\n        setTimeout(() => {\n            showBillableRateModal.value = true;\n        }, 0);\n        show.value = false;\n    } else if (memberBody.value.role === 'owner' && props.member.role !== 'owner') {\n        show.value = false;\n        showOwnershipTransferConfirmModal.value = true;\n    } else {\n        submitBillableRate();\n    }\n}\n\nconst billableRateSelect = ref<MemberBillableKey>('default-rate');\n\nonMounted(() => {\n    if (props.member.billable_rate !== null) {\n        billableRateSelect.value = 'custom-rate';\n    } else {\n        billableRateSelect.value = 'default-rate';\n    }\n});\nwatch(billableRateSelect, () => {\n    if (billableRateSelect.value === 'default-rate') {\n        memberBody.value.billable_rate = null;\n    } else if (billableRateSelect.value === 'custom-rate') {\n        if (!memberBody.value.billable_rate) {\n            memberBody.value.billable_rate = organization.value?.billable_rate ?? 0;\n        }\n    }\n});\n\nconst displayedRate = computed({\n    get() {\n        if (billableRateSelect.value === 'default-rate') {\n            return organization.value?.billable_rate ?? null;\n        }\n        return memberBody.value.billable_rate;\n    },\n    set(value: number | null) {\n        if (billableRateSelect.value === 'custom-rate') {\n            memberBody.value.billable_rate = value;\n        }\n    },\n});\n\nconst roleDescriptionTexts = {\n    'owner':\n        'The owner has full access of the organization. The owner is the only role that can: delete the organization, transfer the ownership to another user and access to the billing settings',\n    'admin':\n        'The admin has full access to the organization, except for the stuff that only the owner can do.',\n    'manager':\n        'The manager has full access to projects, clients, tags, time entries, and reports, but can not manage the organization or the users.',\n    'employee':\n        'An employee is a user that is only using the application to track time, but has no administrative rights.',\n    'placeholder':\n        'Placeholder users can not do anything in the organization. They are not billed and can be used to remove users from the organization without deleting their time entries.',\n};\n\nconst roleDescription = computed(() => {\n    if (memberBody.value.role && memberBody.value.role in roleDescriptionTexts) {\n        return roleDescriptionTexts[memberBody.value.role];\n    }\n    return '';\n});\n</script>\n\n<template>\n    <MemberBillableRateModal\n        v-model:saving=\"saving\"\n        v-model:show=\"showBillableRateModal\"\n        :member-name=\"member.name\"\n        :new-billable-rate=\"memberBody.billable_rate\"\n        @submit=\"submitBillableRate\"></MemberBillableRateModal>\n    <MemberOwnershipTransferConfirmModal\n        v-model:show=\"showOwnershipTransferConfirmModal\"\n        :member-name=\"member.name\"\n        @submit=\"submit\"></MemberOwnershipTransferConfirmModal>\n    <DialogModal closeable :show=\"show\" @close=\"show = false\">\n        <template #title>\n            <div class=\"flex space-x-2\">\n                <span> Update Member </span>\n            </div>\n        </template>\n\n        <template #content>\n            <div class=\"pb-5 pt-2 divide-y divide-border-secondary\">\n                <div class=\"pb-5\">\n                    <Field>\n                        <FieldLabel for=\"role\">Role</FieldLabel>\n                        <MemberRoleSelect v-model=\"memberBody.role\" name=\"role\"></MemberRoleSelect>\n                        <FieldDescription v-if=\"roleDescription\">{{\n                            roleDescription\n                        }}</FieldDescription>\n                    </Field>\n                </div>\n                <div class=\"pt-5\">\n                    <Field>\n                        <FieldLabel :icon=\"BillableIcon\" for=\"billableRateType\"\n                            >Billable Rate</FieldLabel\n                        >\n                        <div class=\"grid grid-cols-1 sm:grid-cols-2 gap-2\">\n                            <Select v-model=\"billableRateSelect\">\n                                <SelectTrigger id=\"billableRateType\">\n                                    <SelectValue />\n                                </SelectTrigger>\n                                <SelectContent>\n                                    <SelectItem value=\"default-rate\">Default Rate</SelectItem>\n                                    <SelectItem value=\"custom-rate\">Custom Rate</SelectItem>\n                                </SelectContent>\n                            </Select>\n                            <TooltipProvider v-if=\"billableRateSelect === 'default-rate'\">\n                                <Tooltip>\n                                    <TooltipTrigger as-child>\n                                        <div>\n                                            <BillableRateInput\n                                                v-model=\"displayedRate\"\n                                                :currency=\"getOrganizationCurrencyString()\"\n                                                disabled\n                                                name=\"memberBillableRate\" />\n                                        </div>\n                                    </TooltipTrigger>\n                                    <TooltipContent\n                                        >Uses the default rate of the organization</TooltipContent\n                                    >\n                                </Tooltip>\n                            </TooltipProvider>\n                            <BillableRateInput\n                                v-else\n                                v-model=\"displayedRate\"\n                                focus\n                                :currency=\"getOrganizationCurrencyString()\"\n                                name=\"memberBillableRate\"\n                                @keydown.enter=\"saveWithChecks()\" />\n                        </div>\n                    </Field>\n                </div>\n            </div>\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"show = false\"> Cancel</SecondaryButton>\n\n            <PrimaryButton\n                class=\"ms-3\"\n                :class=\"{ 'opacity-25': saving }\"\n                :disabled=\"saving\"\n                @click=\"saveWithChecks()\">\n                Update Member\n            </PrimaryButton>\n        </template>\n    </DialogModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Member/MemberInviteModal.vue",
    "content": "<script setup lang=\"ts\">\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport { ref } from 'vue';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport { useFocus } from '@vueuse/core';\nimport { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';\nimport type { Role } from '@/types/jetstream';\nimport { Link, useForm } from '@inertiajs/vue3';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport { filterRoles } from '@/utils/roles';\nimport { isAllowedToPerformPremiumAction, isBillingActivated } from '@/utils/billing';\nimport { CreditCardIcon, UserGroupIcon } from '@heroicons/vue/20/solid';\nimport { canManageBilling, canUpdateOrganization } from '@/utils/permissions';\nimport { api } from '@/packages/api/src';\nimport type { MemberRole } from '@/packages/api/src';\nimport { z } from 'zod';\nimport { useNotificationsStore } from '@/utils/notification';\n\nconst show = defineModel('show', { default: false });\nconst saving = ref(false);\n\ndefineProps<{\n    availableRoles: Role[];\n}>();\n\nconst errors = ref({\n    email: '',\n    role: '',\n});\n\nconst addTeamMemberForm = useForm({\n    email: '',\n    role: null as string | null,\n});\n\nconst emit = defineEmits(['close']);\nconst { handleApiRequestNotifications } = useNotificationsStore();\n\nasync function submit() {\n    if (addTeamMemberForm.role === null || addTeamMemberForm.email === '') {\n        errors.value.email = z.string().email().safeParse(addTeamMemberForm.email).success\n            ? ''\n            : 'Please enter a valid email address';\n        errors.value.role = addTeamMemberForm.role === null ? 'Please select a role' : '';\n        return;\n    }\n\n    const organizationId = getCurrentOrganizationId();\n    if (organizationId) {\n        await handleApiRequestNotifications(\n            () =>\n                api.invite(\n                    {\n                        email: addTeamMemberForm.email,\n                        role: addTeamMemberForm.role as MemberRole,\n                    },\n                    {\n                        params: {\n                            organization: organizationId,\n                        },\n                    }\n                ),\n            'Member invited',\n            'Failed to invite member',\n            () => {\n                addTeamMemberForm.reset();\n                emit('close');\n                show.value = false;\n            }\n        );\n    }\n}\n\nconst clientNameInput = ref<HTMLInputElement | null>(null);\nuseFocus(clientNameInput, { initialValue: true });\n</script>\n\n<template>\n    <DialogModal closeable :show=\"show\" @close=\"show = false\">\n        <template #title>\n            <div class=\"flex space-x-2\">\n                <span> Invite Member </span>\n            </div>\n        </template>\n\n        <template #content>\n            <div v-if=\"!isAllowedToPerformPremiumAction()\">\n                <div\n                    class=\"rounded-full flex items-center justify-center w-20 h-20 mx-auto border border-border-tertiary bg-secondary\">\n                    <UserGroupIcon class=\"w-12\"></UserGroupIcon>\n                </div>\n                <div class=\"max-w-sm text-center mx-auto py-4 text-base\">\n                    <p class=\"py-1\">The Free plan is <strong>limited to one member</strong></p>\n                    <p class=\"py-1\">\n                        To add new team members to your organization you,\n                        <strong>please upgrade to a paid plan</strong>.\n                    </p>\n\n                    <Link v-if=\"isBillingActivated() && canManageBilling()\" href=\"/billing\">\n                        <PrimaryButton\n                            v-if=\"isBillingActivated() && canUpdateOrganization()\"\n                            type=\"button\"\n                            class=\"mt-6\">\n                            <CreditCardIcon class=\"w-5 h-5 me-2\" />\n                            Go to Billing\n                        </PrimaryButton>\n                    </Link>\n                </div>\n            </div>\n            <div v-else class=\"space-y-4\">\n                <Field class=\"col-span-6 sm:col-span-4 flex-1\">\n                    <FieldLabel for=\"email\">Email</FieldLabel>\n                    <TextInput\n                        id=\"email\"\n                        ref=\"memberEmailInput\"\n                        v-model=\"addTeamMemberForm.email\"\n                        name=\"email\"\n                        type=\"text\"\n                        placeholder=\"Member Email\"\n                        class=\"block w-full\"\n                        required\n                        autocomplete=\"memberName\"\n                        @keydown.enter=\"submit\" />\n                    <FieldError v-if=\"errors.email\">{{ errors.email }}</FieldError>\n                </Field>\n\n                <Field v-if=\"availableRoles.length > 0\">\n                    <FieldLabel for=\"roles\">Role</FieldLabel>\n                    <FieldError v-if=\"errors.role\">{{ errors.role }}</FieldError>\n\n                    <div\n                        class=\"relative z-0 mt-1 border border-card-border rounded-lg bg-card-background cursor-pointer\">\n                        <button\n                            v-for=\"(role, i) in filterRoles(availableRoles)\"\n                            :key=\"role.key\"\n                            type=\"button\"\n                            class=\"relative px-4 py-3 inline-flex w-full rounded-lg focus:z-10 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500\"\n                            :class=\"{\n                                'border-t border-card-border focus:border-none rounded-t-none':\n                                    i > 0,\n                                'rounded-b-none': i != Object.keys(availableRoles).length - 1,\n                            }\"\n                            @click=\"addTeamMemberForm.role = role.key\">\n                            <div\n                                :class=\"{\n                                    'opacity-50':\n                                        addTeamMemberForm.role &&\n                                        addTeamMemberForm.role != role.key,\n                                }\">\n                                <!-- Role Name -->\n                                <div class=\"flex items-center\">\n                                    <div\n                                        class=\"text-sm text-text-primary\"\n                                        :class=\"{\n                                            'font-semibold': addTeamMemberForm.role == role.key,\n                                        }\">\n                                        {{ role.name }}\n                                    </div>\n\n                                    <svg\n                                        v-if=\"addTeamMemberForm.role == role.key\"\n                                        class=\"ms-2 h-5 w-5 text-green-400\"\n                                        xmlns=\"http://www.w3.org/2000/svg\"\n                                        fill=\"none\"\n                                        viewBox=\"0 0 24 24\"\n                                        stroke-width=\"1.5\"\n                                        stroke=\"currentColor\">\n                                        <path\n                                            stroke-linecap=\"round\"\n                                            stroke-linejoin=\"round\"\n                                            d=\"M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n                                    </svg>\n                                </div>\n\n                                <!-- Role Description -->\n                                <div class=\"mt-2 text-xs text-text-secondary text-start\">\n                                    {{ role.description }}\n                                </div>\n                            </div>\n                        </button>\n                    </div>\n                </Field>\n            </div>\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"show = false\"> Cancel</SecondaryButton>\n            <PrimaryButton\n                v-if=\"isAllowedToPerformPremiumAction()\"\n                class=\"ms-3\"\n                :class=\"{ 'opacity-25': saving }\"\n                :disabled=\"saving\"\n                @click=\"submit\">\n                Invite Member\n            </PrimaryButton>\n        </template>\n    </DialogModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Member/MemberMakePlaceholderModal.vue",
    "content": "<script setup lang=\"ts\">\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport { ref } from 'vue';\nimport { api, type Member } from '@/packages/api/src';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport { useMutation, useQueryClient } from '@tanstack/vue-query';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport { useNotificationsStore } from '@/utils/notification';\n\nconst { handleApiRequestNotifications } = useNotificationsStore();\nconst queryClient = useQueryClient();\n\nconst show = defineModel('show', { default: false });\nconst saving = ref(false);\n\nconst props = defineProps<{\n    member: Member;\n}>();\n\nconst turnToPlaceholderMutation = useMutation({\n    mutationFn: async () => {\n        const organizationId = getCurrentOrganizationId();\n        if (organizationId === null) {\n            throw new Error('No current organization id - create report');\n        }\n        return await api.makePlaceholder(undefined, {\n            params: {\n                organization: organizationId,\n                member: props.member.id,\n            },\n        });\n    },\n});\n\nasync function submit() {\n    saving.value = true;\n    await handleApiRequestNotifications(\n        () => turnToPlaceholderMutation.mutateAsync(),\n        'Deactivating the member was successful!',\n        'There was an error deactivating the user.',\n        () => {\n            show.value = false;\n            queryClient.invalidateQueries({ queryKey: ['members'] });\n        }\n    );\n}\n</script>\n\n<template>\n    <DialogModal closeable :show=\"show\" @close=\"show = false\">\n        <template #title>\n            <div class=\"flex space-x-2\">\n                <span> Deactivate User </span>\n            </div>\n        </template>\n\n        <template #content>\n            <p>\n                Deactivating the user <strong>{{ member.name }} </strong> will remove the user's\n                access to the organization. You will not be billed for inactive users and all time\n                entries will be preserved.\n            </p>\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"show = false\"> Cancel</SecondaryButton>\n\n            <PrimaryButton\n                class=\"ms-3\"\n                :class=\"{ 'opacity-25': saving }\"\n                :disabled=\"saving\"\n                @click=\"submit()\">\n                Deactivate\n            </PrimaryButton>\n        </template>\n    </DialogModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Member/MemberMergeModal.vue",
    "content": "<script setup lang=\"ts\">\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport { ref } from 'vue';\nimport { api, type Member } from '@/packages/api/src';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport MemberCombobox from '@/Components/Common/Member/MemberCombobox.vue';\nimport { UserIcon, ArrowRightIcon } from '@heroicons/vue/24/solid';\nimport { Badge } from '@/packages/ui/src';\nimport { useMutation, useQueryClient } from '@tanstack/vue-query';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport { useNotificationsStore } from '@/utils/notification';\nconst queryClient = useQueryClient();\nconst { handleApiRequestNotifications, addNotification } = useNotificationsStore();\n\nconst show = defineModel('show', { default: false });\nconst saving = ref(false);\n\nconst props = defineProps<{\n    member: Member;\n}>();\n\nconst newMember = ref<string>('');\n\nconst mergeMember = useMutation({\n    mutationFn: async (newMemberId: string) => {\n        const organizationId = getCurrentOrganizationId();\n        if (organizationId === null) {\n            throw new Error('No current organization id - create report');\n        }\n        return await api.mergeMember(\n            {\n                member_id: newMemberId,\n            },\n            {\n                params: {\n                    organization: organizationId,\n                    member: props.member.id,\n                },\n            }\n        );\n    },\n});\n\nasync function submit() {\n    const newMemberId = newMember.value;\n    if (newMemberId !== '') {\n        saving.value = true;\n        await handleApiRequestNotifications(\n            () => mergeMember.mutateAsync(newMemberId),\n            'Members successfully merged!',\n            'There was an error merging the members.',\n            () => {\n                queryClient.invalidateQueries({ queryKey: ['members'] });\n                show.value = false;\n            }\n        );\n    } else {\n        addNotification('error', 'Please select a member to merge into.');\n    }\n}\n</script>\n\n<template>\n    <DialogModal closeable :show=\"show\" @close=\"show = false\">\n        <template #title>\n            <div class=\"flex space-x-2\">\n                <span> Merge Member </span>\n            </div>\n        </template>\n\n        <template #content>\n            <p>\n                Merging the user <strong>{{ member.name }} </strong> into another one will transfer\n                all time entries to the new user. <strong>This cannot be reverted!</strong>\n            </p>\n            <div class=\"py-5 flex flex-col md:flex-row gap-6 items-center\">\n                <div class=\"flex-1\">\n                    <Badge\n                        class=\"flex w-full text-base text-left space-x-3 px-3 text-text-secondary font-normal cursor py-1.5\">\n                        <UserIcon class=\"relative z-10 w-4 text-text-secondary\"></UserIcon>\n                        <div class=\"flex-1 font-medium truncate\">\n                            {{ member.name }}\n                        </div>\n                    </Badge>\n                </div>\n                <div>\n                    <ArrowRightIcon class=\"relative z-10 w-4 text-muted\"></ArrowRightIcon>\n                </div>\n                <div class=\"flex-1\">\n                    <MemberCombobox v-model=\"newMember\"></MemberCombobox>\n                </div>\n            </div>\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"show = false\"> Cancel</SecondaryButton>\n\n            <PrimaryButton\n                class=\"ms-3\"\n                :class=\"{ 'opacity-25': saving }\"\n                :disabled=\"saving\"\n                @click=\"submit()\">\n                Merge Member\n            </PrimaryButton>\n        </template>\n    </DialogModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Member/MemberMoreOptionsDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport {\n    TrashIcon,\n    UserCircleIcon,\n    PencilSquareIcon,\n    ArrowDownOnSquareStackIcon,\n} from '@heroicons/vue/20/solid';\nimport type { Member } from '@/packages/api/src';\nimport {\n    canDeleteMembers,\n    canMakeMembersPlaceholders,\n    canMergeMembers,\n    canUpdateMembers,\n} from '@/utils/permissions';\nimport {\n    DropdownMenu,\n    DropdownMenuContent,\n    DropdownMenuItem,\n    DropdownMenuTrigger,\n} from '@/Components/ui/dropdown-menu';\n\nconst emit = defineEmits<{\n    delete: [];\n    edit: [];\n    merge: [];\n    makePlaceholder: [];\n}>();\nconst props = defineProps<{\n    member: Member;\n}>();\n</script>\n\n<template>\n    <DropdownMenu v-if=\"canUpdateMembers() || canDeleteMembers()\">\n        <DropdownMenuTrigger as-child>\n            <button\n                class=\"focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-2 focus-visible:ring-ring focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity text-text-secondary\"\n                :aria-label=\"'Actions for Member ' + props.member.name\">\n                <svg\n                    class=\"h-8 w-8 p-1 rounded-full\"\n                    viewBox=\"0 0 24 24\"\n                    xmlns=\"http://www.w3.org/2000/svg\">\n                    <path\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        stroke-linecap=\"round\"\n                        stroke-linejoin=\"round\"\n                        stroke-width=\"1.5\"\n                        d=\"M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92\" />\n                </svg>\n            </button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent class=\"min-w-[150px]\" align=\"end\">\n            <DropdownMenuItem\n                v-if=\"canUpdateMembers()\"\n                :aria-label=\"'Edit Member ' + props.member.name\"\n                class=\"flex items-center space-x-3 cursor-pointer\"\n                @click=\"emit('edit')\">\n                <PencilSquareIcon class=\"w-5 text-icon-active\" />\n                <span>Edit</span>\n            </DropdownMenuItem>\n            <DropdownMenuItem\n                v-if=\"props.member.role === 'placeholder' && canMergeMembers()\"\n                :aria-label=\"'Merge Member ' + props.member.name\"\n                data-testid=\"member_merge\"\n                class=\"flex items-center space-x-3 cursor-pointer\"\n                @click=\"emit('merge')\">\n                <ArrowDownOnSquareStackIcon class=\"w-5 text-icon-active\" />\n                <span>Merge</span>\n            </DropdownMenuItem>\n            <DropdownMenuItem\n                v-if=\"props.member.role !== 'placeholder' && canMakeMembersPlaceholders()\"\n                :aria-label=\"'Make Member ' + props.member.name + ' a placeholder'\"\n                class=\"flex items-center space-x-3 cursor-pointer\"\n                @click=\"emit('makePlaceholder')\">\n                <UserCircleIcon class=\"w-5 text-icon-active\" />\n                <span>Deactivate</span>\n            </DropdownMenuItem>\n            <DropdownMenuItem\n                v-if=\"canDeleteMembers()\"\n                :aria-label=\"'Delete Member ' + props.member.name\"\n                data-testid=\"member_delete\"\n                class=\"flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive\"\n                @click=\"emit('delete')\">\n                <TrashIcon class=\"w-5\" />\n                <span>Delete</span>\n            </DropdownMenuItem>\n        </DropdownMenuContent>\n    </DropdownMenu>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Member/MemberMultiselectDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport MultiselectDropdown from '@/packages/ui/src/Input/MultiselectDropdown.vue';\nimport { useMembersQuery } from '@/utils/useMembersQuery';\nimport type { Member } from '@/packages/api/src';\n\nconst { members } = useMembersQuery();\n\nfunction getKeyFromItem(item: Member) {\n    return item.id;\n}\n\nfunction getNameForItem(item: Member) {\n    return item.name;\n}\n\nconst emit = defineEmits<{\n    submit: [];\n}>();\n</script>\n\n<template>\n    <MultiselectDropdown\n        search-placeholder=\"Search for a Member...\"\n        :items=\"members\"\n        :get-key-from-item=\"getKeyFromItem\"\n        :get-name-for-item=\"getNameForItem\"\n        @submit=\"emit('submit')\">\n        <template #trigger>\n            <slot name=\"trigger\"></slot>\n        </template>\n    </MultiselectDropdown>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Common/Member/MemberOwnershipTransferConfirmModal.vue",
    "content": "<script setup lang=\"ts\">\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\n\nconst show = defineModel('show', { default: false });\nconst saving = defineModel('saving', { default: false });\n\ndefineProps<{\n    memberName: string;\n}>();\n\nconst emit = defineEmits<{\n    submit: [];\n}>();\n</script>\n\n<template>\n    <DialogModal closeable :show=\"show\" @close=\"show = false\">\n        <template #title>\n            <div class=\"flex justify-center\">\n                <span> Confirm Ownership Transfer </span>\n            </div>\n        </template>\n        <template #content>\n            <div class=\"flex items-center space-x-4\">\n                <div class=\"col-span-6 sm:col-span-4 flex-1\">\n                    <p class=\"py-1 text-center\">\n                        You are about to transfer the ownership of this organization to\n                        {{ memberName }}.\n                    </p>\n                </div>\n            </div>\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"show = false\"> Cancel</SecondaryButton>\n            <PrimaryButton\n                class=\"ms-3\"\n                :class=\"{ 'opacity-25': saving }\"\n                :disabled=\"saving\"\n                @click=\"emit('submit')\">\n                Confirm Transfer\n            </PrimaryButton>\n        </template>\n    </DialogModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Member/MemberRoleSelect.vue",
    "content": "<script setup lang=\"ts\">\nimport {\n    Select,\n    SelectContent,\n    SelectItem,\n    SelectTrigger,\n    SelectValue,\n} from '@/Components/ui/select';\nimport type { Role } from '@/types/jetstream';\nimport { usePage } from '@inertiajs/vue3';\n\nconst model = defineModel<string>({\n    default: 'employee',\n});\n\nconst page = usePage<{\n    availableRoles: Role[];\n}>();\n\nfunction getNameForKey(key: string | undefined) {\n    const item = page.props.availableRoles.find((item) => item.key === key);\n    if (item) {\n        return item.name;\n    }\n    return '';\n}\n</script>\n\n<template>\n    <Select v-model=\"model\">\n        <SelectTrigger>\n            <SelectValue>{{ getNameForKey(model) }}</SelectValue>\n        </SelectTrigger>\n        <SelectContent>\n            <SelectItem v-for=\"role in page.props.availableRoles\" :key=\"role.key\" :value=\"role.key\">\n                {{ role.name }}\n            </SelectItem>\n        </SelectContent>\n    </Select>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Member/MemberTable.vue",
    "content": "<script setup lang=\"ts\">\nimport MemberTableHeading from '@/Components/Common/Member/MemberTableHeading.vue';\nimport MemberTableRow from '@/Components/Common/Member/MemberTableRow.vue';\nimport { useMembersQuery } from '@/utils/useMembersQuery';\nimport type { Member } from '@/packages/api/src';\nimport { computed } from 'vue';\nimport {\n    useVueTable,\n    getCoreRowModel,\n    getSortedRowModel,\n    type SortingState,\n} from '@tanstack/vue-table';\n\nexport type SortColumn = 'name' | 'email' | 'role' | 'billable_rate' | 'status';\nexport type SortDirection = 'asc' | 'desc';\n\nconst props = defineProps<{\n    sortColumn: SortColumn;\n    sortDirection: SortDirection;\n}>();\n\nconst emit = defineEmits<{\n    sort: [column: SortColumn, direction: SortDirection];\n}>();\n\nconst { members } = useMembersQuery();\n\nconst roleOrder: Record<string, number> = {\n    owner: 0,\n    admin: 1,\n    manager: 2,\n    employee: 3,\n    placeholder: 4,\n};\n\nconst sorting = computed<SortingState>(() => [\n    {\n        id: props.sortColumn,\n        desc: props.sortDirection === 'desc',\n    },\n]);\n\nconst columns = [\n    {\n        id: 'name',\n        accessorFn: (row: Member) => row.name.toLowerCase(),\n    },\n    {\n        id: 'email',\n        accessorFn: (row: Member) => row.email.toLowerCase(),\n    },\n    {\n        id: 'role',\n        accessorFn: (row: Member) => roleOrder[row.role] ?? 99,\n    },\n    {\n        id: 'billable_rate',\n        sortDescFirst: true,\n        sortUndefined: 'last' as const,\n        accessorFn: (row: Member) => {\n            if (row.billable_rate === null) return undefined;\n            return row.billable_rate;\n        },\n    },\n    {\n        id: 'status',\n        accessorFn: (row: Member) => (row.is_placeholder ? 1 : 0),\n    },\n];\n\nconst descFirstColumns = new Set<SortColumn>(\n    columns.filter((c) => c.sortDescFirst).map((c) => c.id as SortColumn)\n);\n\nfunction handleSort(column: SortColumn) {\n    if (props.sortColumn === column) {\n        emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');\n    } else {\n        emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');\n    }\n}\n\nconst table = useVueTable({\n    get data() {\n        return members.value;\n    },\n    columns,\n    getCoreRowModel: getCoreRowModel(),\n    getSortedRowModel: getSortedRowModel(),\n    state: {\n        get sorting() {\n            return sorting.value;\n        },\n    },\n    manualSorting: false,\n});\n\nconst sortedMembers = computed(() => {\n    return table.getRowModel().rows.map((row) => row.original);\n});\n</script>\n\n<template>\n    <div class=\"flow-root max-w-[100vw] overflow-x-auto\">\n        <div class=\"inline-block min-w-full align-middle\">\n            <div\n                data-testid=\"member_table\"\n                class=\"grid min-w-full\"\n                style=\"grid-template-columns: 1fr 1fr 180px 180px 150px 130px\">\n                <MemberTableHeading\n                    :sort-column=\"props.sortColumn\"\n                    :sort-direction=\"props.sortDirection\"\n                    :desc-first-columns=\"descFirstColumns\"\n                    @sort=\"handleSort\"></MemberTableHeading>\n                <template v-for=\"member in sortedMembers\" :key=\"member.id\">\n                    <MemberTableRow :member=\"member\"></MemberTableRow>\n                </template>\n            </div>\n        </div>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Common/Member/MemberTableHeading.vue",
    "content": "<script setup lang=\"ts\">\nimport TableHeading from '@/Components/Common/TableHeading.vue';\nimport { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';\nimport type { SortColumn, SortDirection } from '@/Components/Common/Member/MemberTable.vue';\n\nconst props = defineProps<{\n    sortColumn: SortColumn;\n    sortDirection: SortDirection;\n    descFirstColumns: ReadonlySet<SortColumn>;\n}>();\n\nconst emit = defineEmits<{\n    sort: [column: SortColumn];\n}>();\n\nfunction handleSort(column: SortColumn) {\n    emit('sort', column);\n}\n\nfunction isSorted(column: SortColumn): boolean {\n    return props.sortColumn === column;\n}\n\nfunction isChevronDown(column: SortColumn): boolean {\n    if (!isSorted(column)) return false;\n    return props.descFirstColumns.has(column)\n        ? props.sortDirection === 'desc'\n        : props.sortDirection === 'asc';\n}\n\nfunction isChevronUp(column: SortColumn): boolean {\n    if (!isSorted(column)) return false;\n    return !isChevronDown(column);\n}\n</script>\n\n<template>\n    <TableHeading>\n        <div\n            class=\"py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1\"\n            @click=\"handleSort('name')\">\n            Name\n            <ChevronDownIcon v-if=\"isChevronDown('name')\" class=\"w-4 h-4\" />\n            <ChevronUpIcon v-else-if=\"isChevronUp('name')\" class=\"w-4 h-4\" />\n            <span v-else class=\"w-4 h-4\"></span>\n        </div>\n        <div\n            class=\"px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1\"\n            @click=\"handleSort('email')\">\n            Email\n            <ChevronDownIcon v-if=\"isChevronDown('email')\" class=\"w-4 h-4\" />\n            <ChevronUpIcon v-else-if=\"isChevronUp('email')\" class=\"w-4 h-4\" />\n            <span v-else class=\"w-4 h-4\"></span>\n        </div>\n        <div\n            class=\"px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1\"\n            @click=\"handleSort('role')\">\n            Role\n            <ChevronDownIcon v-if=\"isChevronDown('role')\" class=\"w-4 h-4\" />\n            <ChevronUpIcon v-else-if=\"isChevronUp('role')\" class=\"w-4 h-4\" />\n            <span v-else class=\"w-4 h-4\"></span>\n        </div>\n        <div\n            class=\"px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1\"\n            @click=\"handleSort('billable_rate')\">\n            Billable Rate\n            <ChevronDownIcon v-if=\"isChevronDown('billable_rate')\" class=\"w-4 h-4\" />\n            <ChevronUpIcon v-else-if=\"isChevronUp('billable_rate')\" class=\"w-4 h-4\" />\n            <span v-else class=\"w-4 h-4\"></span>\n        </div>\n        <div\n            class=\"px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1\"\n            @click=\"handleSort('status')\">\n            Status\n            <ChevronDownIcon v-if=\"isChevronDown('status')\" class=\"w-4 h-4\" />\n            <ChevronUpIcon v-else-if=\"isChevronUp('status')\" class=\"w-4 h-4\" />\n            <span v-else class=\"w-4 h-4\"></span>\n        </div>\n        <div class=\"relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background\">\n            <span class=\"sr-only\">Edit</span>\n        </div>\n    </TableHeading>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Common/Member/MemberTableRow.vue",
    "content": "<script setup lang=\"ts\">\nimport type { Member, Organization } from '@/packages/api/src';\nimport { api } from '@/packages/api/src';\nimport { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/24/outline';\nimport MemberMoreOptionsDropdown from '@/Components/Common/Member/MemberMoreOptionsDropdown.vue';\nimport TableRow from '@/Components/TableRow.vue';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport { useNotificationsStore } from '@/utils/notification';\nimport { canInvitePlaceholderMembers } from '@/utils/permissions';\nimport { computed, type ComputedRef, inject, ref } from 'vue';\nimport MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';\nimport MemberMergeModal from '@/Components/Common/Member/MemberMergeModal.vue';\nimport MemberMakePlaceholderModal from '@/Components/Common/Member/MemberMakePlaceholderModal.vue';\nimport MemberDeleteModal from '@/Components/Common/Member/MemberDeleteModal.vue';\nimport { capitalizeFirstLetter } from '../../../utils/format';\nimport { formatCents } from '../../../packages/ui/src/utils/money';\n\nconst props = defineProps<{\n    member: Member;\n}>();\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n\nconst showEditMemberModal = ref(false);\nconst showMergeMemberModal = ref(false);\nconst showMakeMemberPlaceholderModal = ref(false);\nconst showDeleteMemberModal = ref(false);\n\nfunction removeMember() {\n    showDeleteMemberModal.value = true;\n}\n\nasync function invitePlaceholder(id: string) {\n    const { handleApiRequestNotifications } = useNotificationsStore();\n    const organizationId = getCurrentOrganizationId();\n    if (organizationId) {\n        await handleApiRequestNotifications(\n            () =>\n                api.invitePlaceholder(undefined, {\n                    params: {\n                        organization: organizationId,\n                        member: id,\n                    },\n                }),\n            'Member invited successfully',\n            'Error inviting member'\n        );\n    }\n}\n\nconst userHasValidMailAddress = computed(() => {\n    return !props.member.email.endsWith('@solidtime-import.test');\n});\n</script>\n\n<template>\n    <TableRow>\n        <div\n            class=\"whitespace-nowrap flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12\">\n            <span>\n                {{ member.name }}\n            </span>\n        </div>\n        <div class=\"whitespace-nowrap px-3 py-4 text-sm text-text-secondary\">\n            {{ member.email }}\n        </div>\n        <div class=\"whitespace-nowrap px-3 py-4 text-sm text-text-secondary\">\n            {{ capitalizeFirstLetter(member.role) }}\n        </div>\n        <div class=\"whitespace-nowrap px-3 py-4 text-sm text-text-secondary\">\n            {{\n                member.billable_rate\n                    ? formatCents(\n                          member.billable_rate,\n                          organization?.currency,\n                          organization?.currency_format,\n                          organization?.currency_symbol,\n                          organization?.number_format\n                      )\n                    : '--'\n            }}\n        </div>\n        <div\n            class=\"whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium\">\n            <template v-if=\"member.is_placeholder === false\">\n                <CheckCircleIcon class=\"w-4 text-icon-default\"></CheckCircleIcon>\n                <span>Active</span>\n            </template>\n            <template v-else>\n                <UserCircleIcon class=\"w-4 text-icon-default\"></UserCircleIcon>\n                <span>Inactive</span>\n            </template>\n        </div>\n        <div\n            class=\"relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12\">\n            <SecondaryButton\n                v-if=\"\n                    member.is_placeholder === true &&\n                    canInvitePlaceholderMembers() &&\n                    userHasValidMailAddress\n                \"\n                size=\"small\"\n                @click=\"invitePlaceholder(member.id)\"\n                >Invite\n            </SecondaryButton>\n            <MemberMoreOptionsDropdown\n                :member=\"member\"\n                @edit=\"showEditMemberModal = true\"\n                @delete=\"removeMember\"\n                @merge=\"showMergeMemberModal = true\"\n                @make-placeholder=\"\n                    showMakeMemberPlaceholderModal = true\n                \"></MemberMoreOptionsDropdown>\n        </div>\n        <MemberEditModal v-model:show=\"showEditMemberModal\" :member=\"member\"></MemberEditModal>\n        <MemberMergeModal v-model:show=\"showMergeMemberModal\" :member=\"member\"></MemberMergeModal>\n        <MemberMakePlaceholderModal\n            v-model:show=\"showMakeMemberPlaceholderModal\"\n            :member=\"member\"></MemberMakePlaceholderModal>\n        <MemberDeleteModal\n            v-model:show=\"showDeleteMemberModal\"\n            :member=\"member\"></MemberDeleteModal>\n    </TableRow>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Notification/Notification.vue",
    "content": "<template>\n    <!-- Global notification live region, render this permanently at the end of the document -->\n    <!-- Notification panel, dynamically insert this into the live region when it needs to be displayed -->\n    <transition\n        enter-active-class=\"transform ease-out duration-300 transition\"\n        enter-from-class=\"translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2\"\n        enter-to-class=\"translate-y-0 opacity-100 sm:translate-x-0\"\n        leave-active-class=\"transition ease-in duration-100\"\n        leave-from-class=\"opacity-100\"\n        leave-to-class=\"opacity-0\">\n        <div\n            v-if=\"show\"\n            class=\"pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg border border-card-border bg-card-background shadow-lg ring-1 ring-black text-text-primary ring-opacity-5\">\n            <div class=\"p-4\">\n                <div class=\"flex items-start\">\n                    <div class=\"flex-shrink-0\">\n                        <CheckCircleIcon\n                            v-if=\"type === 'success'\"\n                            class=\"h-6 w-6 text-green-400\"\n                            aria-hidden=\"true\" />\n                        <XCircleIcon\n                            v-if=\"type === 'error'\"\n                            class=\"h-6 w-6 text-red-400\"\n                            aria-hidden=\"true\" />\n                    </div>\n                    <div class=\"ml-3 w-0 flex-1 pt-0.5\">\n                        <p class=\"text-sm font-medium text-text-primary\">\n                            {{ title }}\n                        </p>\n                        <p v-if=\"message\" class=\"mt-1 text-sm text-text-secondary\">\n                            {{ message }}\n                        </p>\n                    </div>\n                    <div class=\"ml-4 flex flex-shrink-0\">\n                        <button\n                            type=\"button\"\n                            class=\"inline-flex rounded-md bg-card-background text-text-secondary hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2\"\n                            @click=\"show = false\">\n                            <span class=\"sr-only\">Close</span>\n                            <XMarkIcon class=\"h-5 w-5\" aria-hidden=\"true\" />\n                        </button>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </transition>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref } from 'vue';\nimport { CheckCircleIcon, XCircleIcon } from '@heroicons/vue/24/outline';\nimport { XMarkIcon } from '@heroicons/vue/20/solid';\nimport type { NotificationType } from '@/utils/notification';\n\ndefineProps<{\n    title: string;\n    type: NotificationType;\n    message?: string;\n}>();\n\nconst show = ref(true);\n</script>\n"
  },
  {
    "path": "resources/js/Components/Common/Organization/OrganizationBillableRateModal.vue",
    "content": "<script setup lang=\"ts\">\nimport { getOrganizationCurrencyString } from '@/utils/money';\nimport BillableRateModal from '@/packages/ui/src/BillableRateModal.vue';\nimport { formatCents } from '@/packages/ui/src/utils/money';\nimport { inject, type ComputedRef } from 'vue';\nimport type { Organization } from '@/packages/api/src';\n\nconst show = defineModel('show', { default: false });\nconst saving = defineModel('saving', { default: false });\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n\ndefineProps<{\n    newBillableRate?: number | null;\n}>();\n\ndefineEmits<{\n    submit: [];\n}>();\n</script>\n\n<template>\n    <BillableRateModal\n        v-model:show=\"show\"\n        v-model:saving=\"saving\"\n        title=\"Update Organization Billable Rate\"\n        @submit=\"$emit('submit')\">\n        <p class=\"py-0.5 text-center\">\n            The organization billable rate will be updated to\n            <strong>{{\n                newBillableRate\n                    ? formatCents(\n                          newBillableRate,\n                          getOrganizationCurrencyString(),\n                          organization?.currency_format,\n                          organization?.currency_symbol,\n                          organization?.number_format\n                      )\n                    : ' none.'\n            }}</strong\n            >.\n        </p>\n        <p class=\"py-0.5 text-center font-semibold\">\n            Do you want to update all existing time entries, where the organization billable rate\n            applies as well?\n        </p>\n    </BillableRateModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/PageTitle.vue",
    "content": "<script setup lang=\"ts\">\nimport type { Component } from 'vue';\n\ndefineProps<{\n    icon: Component;\n    title: string;\n}>();\n</script>\n\n<template>\n    <h3\n        class=\"text-text-primary font-semibold text-sm sm:text-base flex items-center space-x-2 sm:space-x-2.5\">\n        <component :is=\"icon\" class=\"w-5 text-icon-default\"></component>\n        <span> {{ title }} </span>\n    </h3>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Project/BaseFilterBadge.vue",
    "content": "<script setup lang=\"ts\">\nimport { XMarkIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';\nimport type { Component } from 'vue';\nimport {\n    DropdownMenu,\n    DropdownMenuContent,\n    DropdownMenuTrigger,\n} from '@/Components/ui/dropdown-menu';\n\ndefineProps<{\n    icon: Component;\n    label: string;\n    filterName: string;\n}>();\n\ndefineEmits<{\n    remove: [];\n}>();\n\ndefineSlots<{\n    default(): void;\n}>();\n</script>\n\n<template>\n    <div\n        class=\"inline-flex items-center gap-0.5 rounded-md bg-tertiary dark:bg-secondary border border-border-secondary\">\n        <DropdownMenu>\n            <DropdownMenuTrigger\n                class=\"inline-flex items-center gap-1.5 px-2 py-1 text-sm hover:bg-quaternary dark:hover:bg-tertiary rounded-l-md transition-colors whitespace-nowrap\">\n                <component :is=\"icon\" class=\"h-3.5 w-3.5 text-icon-default\" />\n                <span class=\"font-medium text-foreground\">{{ filterName }}</span>\n                <span class=\"text-muted-foreground\">is</span>\n                <span class=\"text-foreground\">{{ label }}</span>\n                <ChevronDownIcon class=\"h-3 w-3 text-muted-foreground\" />\n            </DropdownMenuTrigger>\n            <DropdownMenuContent align=\"start\">\n                <slot />\n            </DropdownMenuContent>\n        </DropdownMenu>\n\n        <button\n            class=\"px-1.5 py-1 hover:bg-quaternary dark:hover:bg-tertiary h-full rounded-r-md transition-colors group border-l border-border-secondary\"\n            @click=\"$emit('remove')\">\n            <XMarkIcon class=\"h-3.5 w-3.5 text-muted-foreground group-hover:text-foreground\" />\n        </button>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Common/Project/ProjectClientFilterBadge.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue';\nimport { UserGroupIcon } from '@heroicons/vue/16/solid';\nimport { DropdownMenuCheckboxItem, DropdownMenuSeparator } from '@/Components/ui/dropdown-menu';\nimport BaseFilterBadge from './BaseFilterBadge.vue';\nimport type { Client } from '@/packages/api/src';\nimport { NO_CLIENT_ID } from './constants';\n\nconst props = defineProps<{\n    value: string[];\n    clients: Client[];\n}>();\n\nconst emit = defineEmits<{\n    remove: [];\n    'update:value': [value: string[]];\n}>();\n\nconst hasNoClient = computed(() => props.value.includes(NO_CLIENT_ID));\n\nconst label = computed(() => {\n    const count = props.value.length;\n\n    if (count === 0) return 'None';\n    if (count === 1) {\n        if (hasNoClient.value) return 'No client';\n        const client = props.clients.find((c) => c.id === props.value[0]);\n        return client?.name ?? 'Client';\n    }\n    return `${count} selected`;\n});\n\nfunction toggleClient(clientId: string) {\n    const clientIds = props.value.includes(clientId)\n        ? props.value.filter((id) => id !== clientId)\n        : [...props.value, clientId];\n\n    emit('update:value', clientIds);\n}\n\nfunction toggleNoClient() {\n    const clientIds = hasNoClient.value\n        ? props.value.filter((id) => id !== NO_CLIENT_ID)\n        : [...props.value, NO_CLIENT_ID];\n\n    emit('update:value', clientIds);\n}\n</script>\n\n<template>\n    <BaseFilterBadge\n        :icon=\"UserGroupIcon\"\n        :label=\"label\"\n        filter-name=\"Client\"\n        @remove=\"emit('remove')\">\n        <DropdownMenuCheckboxItem :model-value=\"hasNoClient\" @select.prevent=\"toggleNoClient\">\n            No client\n        </DropdownMenuCheckboxItem>\n        <DropdownMenuSeparator />\n        <DropdownMenuCheckboxItem\n            v-for=\"client in clients\"\n            :key=\"client.id\"\n            :model-value=\"value.includes(client.id)\"\n            @select.prevent=\"toggleClient(client.id)\">\n            {{ client.name }}\n        </DropdownMenuCheckboxItem>\n    </BaseFilterBadge>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Common/Project/ProjectDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, nextTick, ref, watch } from 'vue';\nimport { useProjectsQuery } from '@/utils/useProjectsQuery';\nimport Dropdown from '@/packages/ui/src/Input/Dropdown.vue';\nimport {\n    ComboboxAnchor,\n    ComboboxContent,\n    ComboboxInput,\n    ComboboxItem,\n    ComboboxRoot,\n    ComboboxViewport,\n} from 'radix-vue';\nimport { Check, Plus } from 'lucide-vue-next';\nimport type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';\nimport { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';\nimport ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';\nimport { useProjectsStore } from '@/utils/useProjects';\nimport { useClientsStore } from '@/utils/useClients';\nimport { useClientsQuery } from '@/utils/useClientsQuery';\nimport { getOrganizationCurrencyString } from '@/utils/money';\nimport { isAllowedToPerformPremiumAction } from '@/utils/billing';\nimport { canCreateProjects } from '@/utils/permissions';\n\nconst searchValue = ref('');\nconst searchInput = ref<HTMLElement | null>(null);\nconst model = defineModel<string | null>({\n    default: null,\n});\nconst open = ref(false);\nconst showCreateProject = ref(false);\nconst { projects } = useProjectsQuery();\nconst { clients } = useClientsQuery();\nconst emit = defineEmits(['update:modelValue', 'changed']);\n\nconst activeClients = computed(() => clients.value.filter((c) => !c.is_archived));\n\nconst sortedProjects = ref<Project[]>([]);\n\nconst shownProjects = computed(() => {\n    return sortedProjects.value.filter((project) => {\n        return project.name.toLowerCase().includes(searchValue.value?.toLowerCase()?.trim() || '');\n    });\n});\n\nasync function handleCreateProject(projectBody: CreateProjectBody) {\n    const newProject = await useProjectsStore().createProject(projectBody);\n    if (newProject) {\n        model.value = newProject.id;\n        emit('changed');\n    }\n    return newProject;\n}\n\nasync function handleCreateClient(clientBody: CreateClientBody) {\n    return await useClientsStore().createClient(clientBody);\n}\n\nwatch(open, (isOpen) => {\n    if (isOpen) {\n        nextTick(() => {\n            // @ts-expect-error We need to access the actual HTML Element to focus as radix-vue does not support any other way right now\n            searchInput.value?.$el?.focus();\n        });\n\n        sortedProjects.value = [...projects.value].sort((iteratingProject) => {\n            return model.value === iteratingProject.id ? -1 : 1;\n        });\n    }\n});\n\nconst currentProject = computed(() => {\n    return projects.value.find((project) => project.id === model.value);\n});\n\nfunction isProjectSelected(project: Project) {\n    return model.value === project.id;\n}\n\nconst selectedProjectName = computed(() => {\n    return currentProject.value?.name || 'No Project';\n});\n\nconst selectedProjectColor = computed(() => {\n    return currentProject.value?.color || 'var(--theme-color-icon-default)';\n});\n\nfunction updateValue(project: Project) {\n    model.value = project.id;\n    emit('changed');\n}\n</script>\n\n<template>\n    <Dropdown v-model=\"open\" align=\"start\">\n        <template #trigger>\n            <slot\n                name=\"trigger\"\n                :selected-project-name=\"selectedProjectName\"\n                :selected-project-color=\"selectedProjectColor\"></slot>\n        </template>\n\n        <template #content>\n            <UseFocusTrap v-if=\"open\" :options=\"{ immediate: true, allowOutsideClick: true }\">\n                <ComboboxRoot\n                    v-model:search-term=\"searchValue\"\n                    v-model:open=\"open\"\n                    :model-value=\"currentProject\"\n                    class=\"relative\"\n                    @update:model-value=\"updateValue\">\n                    <ComboboxAnchor>\n                        <ComboboxInput\n                            ref=\"searchInput\"\n                            class=\"bg-transparent border-0 placeholder-muted-foreground text-sm text-popover-foreground py-2 px-3 focus:ring-0 border-b border-popover-border focus:border-popover-border w-full\"\n                            placeholder=\"Search for a project...\" />\n                    </ComboboxAnchor>\n                    <ComboboxContent>\n                        <ComboboxViewport\n                            class=\"w-[--reka-popper-anchor-width] max-h-60 overflow-y-scroll p-1\">\n                            <ComboboxItem\n                                v-for=\"project in shownProjects\"\n                                :key=\"project.id\"\n                                :value=\"project\"\n                                class=\"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground\"\n                                :data-project-id=\"project.id\">\n                                <span class=\"flex items-center gap-2\">\n                                    <span\n                                        :style=\"{ backgroundColor: project.color }\"\n                                        class=\"w-3 h-3 rounded-full shrink-0\"></span>\n                                    <span>{{ project.name }}</span>\n                                </span>\n                                <span\n                                    v-if=\"isProjectSelected(project)\"\n                                    class=\"absolute right-2 flex h-3.5 w-3.5 items-center justify-center\">\n                                    <Check class=\"h-4 w-4\" />\n                                </span>\n                            </ComboboxItem>\n                        </ComboboxViewport>\n                        <div\n                            v-if=\"canCreateProjects()\"\n                            class=\"flex items-center gap-2 px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground border-t border-popover-border\"\n                            @click=\"\n                                open = false;\n                                showCreateProject = true;\n                            \">\n                            <Plus class=\"h-4 w-4 shrink-0\" />\n                            <span>Create new Project</span>\n                        </div>\n                    </ComboboxContent>\n                </ComboboxRoot>\n            </UseFocusTrap>\n        </template>\n    </Dropdown>\n    <ProjectCreateModal\n        v-model:show=\"showCreateProject\"\n        :create-project=\"handleCreateProject\"\n        :create-client=\"handleCreateClient\"\n        :clients=\"activeClients\"\n        :currency=\"getOrganizationCurrencyString()\"\n        :enable-estimated-time=\"isAllowedToPerformPremiumAction()\" />\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Project/ProjectEditModal.vue",
    "content": "<script setup lang=\"ts\">\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport { computed, ref } from 'vue';\nimport type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport { useProjectsStore } from '@/utils/useProjects';\nimport { useClientsStore } from '@/utils/useClients';\nimport { useFocus } from '@vueuse/core';\nimport ClientDropdown from '@/packages/ui/src/Client/ClientDropdown.vue';\nimport { useClientsQuery } from '@/utils/useClientsQuery';\nimport ProjectColorSelector from '@/packages/ui/src/Project/ProjectColorSelector.vue';\nimport { Button } from '@/packages/ui/src/Buttons';\nimport { ChevronDown } from 'lucide-vue-next';\nimport { UserCircleIcon } from '@heroicons/vue/20/solid';\nimport EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue';\nimport { Field, FieldGroup, FieldLabel } from '@/packages/ui/src/field';\nimport ProjectBillableRateModal from '@/packages/ui/src/Project/ProjectBillableRateModal.vue';\nimport { getOrganizationCurrencyString } from '@/utils/money';\nimport ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue';\nimport { isAllowedToPerformPremiumAction } from '@/utils/billing';\n\nconst { updateProject } = useProjectsStore();\nconst { clients } = useClientsQuery();\nconst show = defineModel('show', { default: false });\nconst saving = ref(false);\nconst showBillableRateModal = ref(false);\nconst props = defineProps<{\n    originalProject: Project;\n}>();\n\nasync function createClient(body: CreateClientBody) {\n    return await useClientsStore().createClient(body);\n}\n\nconst project = ref<CreateProjectBody>({\n    name: props.originalProject.name,\n    color: props.originalProject.color,\n    client_id: props.originalProject.client_id,\n    billable_rate: props.originalProject.billable_rate,\n    is_billable: props.originalProject.is_billable,\n    estimated_time: props.originalProject.estimated_time,\n});\n\nasync function submit() {\n    if (props.originalProject.billable_rate !== project.value.billable_rate) {\n        // make sure that the alert modal is not immediately submitted when user presses enter\n        setTimeout(() => {\n            showBillableRateModal.value = true;\n        }, 0);\n        return;\n    }\n    await updateProject(props.originalProject.id, project.value);\n    show.value = false;\n}\n\nconst projectNameInput = ref<HTMLInputElement | null>(null);\n\nuseFocus(projectNameInput, { initialValue: true });\n\nconst currentClientName = computed(() => {\n    if (project.value.client_id) {\n        return clients.value.find((client) => client.id === project.value.client_id)?.name;\n    }\n    return 'No Client';\n});\n\nasync function submitBillableRate() {\n    await updateProject(props.originalProject.id, project.value);\n    show.value = false;\n    showBillableRateModal.value = false;\n}\n</script>\n\n<template>\n    <DialogModal closeable :show=\"show\" @close=\"show = false\">\n        <template #title>\n            <div class=\"flex space-x-2\">\n                <span> Edit Project {{ props.originalProject.name }} </span>\n            </div>\n        </template>\n\n        <template #content>\n            <FieldGroup>\n                <FieldGroup class=\"flex-row items-end\">\n                    <Field class=\"w-auto text-center\">\n                        <FieldLabel for=\"color\">Color</FieldLabel>\n                        <ProjectColorSelector v-model=\"project.color\"></ProjectColorSelector>\n                    </Field>\n                    <Field class=\"w-full\">\n                        <FieldLabel for=\"projectName\">Project name</FieldLabel>\n                        <TextInput\n                            id=\"projectName\"\n                            ref=\"projectNameInput\"\n                            v-model=\"project.name\"\n                            type=\"text\"\n                            placeholder=\"Project Name\"\n                            class=\"block w-full\"\n                            required\n                            autocomplete=\"projectName\"\n                            @keydown.enter=\"submit()\" />\n                    </Field>\n                </FieldGroup>\n                <Field>\n                    <FieldLabel for=\"client\" :icon=\"UserCircleIcon\">Client</FieldLabel>\n                    <ClientDropdown v-model=\"project.client_id\" :create-client :clients=\"clients\">\n                        <template #trigger>\n                            <Button variant=\"input\" class=\"w-full justify-between\">\n                                <span class=\"truncate\">{{ currentClientName }}</span>\n                                <ChevronDown class=\"w-4 h-4 text-icon-default\" />\n                            </Button>\n                        </template>\n                    </ClientDropdown>\n                </Field>\n                <ProjectEditBillableSection\n                    v-model:is-billable=\"project.is_billable\"\n                    v-model:billable-rate=\"project.billable_rate\"\n                    :currency=\"getOrganizationCurrencyString()\"\n                    @submit=\"submit\"></ProjectEditBillableSection>\n                <EstimatedTimeSection\n                    v-if=\"isAllowedToPerformPremiumAction()\"\n                    v-model=\"project.estimated_time\"\n                    @submit=\"submit()\"></EstimatedTimeSection>\n            </FieldGroup>\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"show = false\"> Cancel</SecondaryButton>\n\n            <PrimaryButton\n                class=\"ms-3\"\n                :class=\"{ 'opacity-25': saving }\"\n                :disabled=\"saving\"\n                @click=\"submit\">\n                Update Project\n            </PrimaryButton>\n        </template>\n    </DialogModal>\n    <ProjectBillableRateModal\n        v-model:show=\"showBillableRateModal\"\n        :currency=\"getOrganizationCurrencyString()\"\n        :new-billable-rate=\"project.billable_rate\"\n        :project-name=\"project.name\"\n        @submit=\"submitBillableRate\"></ProjectBillableRateModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Project/ProjectMoreOptionsDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport { TrashIcon, PencilSquareIcon, ArchiveBoxIcon } from '@heroicons/vue/20/solid';\nimport type { Project } from '@/packages/api/src';\nimport { canDeleteProjects, canUpdateProjects } from '@/utils/permissions';\nimport {\n    DropdownMenu,\n    DropdownMenuContent,\n    DropdownMenuItem,\n    DropdownMenuTrigger,\n} from '@/Components/ui/dropdown-menu';\n\nconst emit = defineEmits<{\n    delete: [];\n    edit: [];\n    archive: [];\n}>();\nconst props = defineProps<{\n    project: Project;\n}>();\n</script>\n\n<template>\n    <DropdownMenu>\n        <DropdownMenuTrigger as-child>\n            <button\n                class=\"focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-2 focus-visible:ring-ring focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity text-text-secondary\"\n                :aria-label=\"'Actions for Project ' + props.project.name\">\n                <svg\n                    class=\"h-8 w-8 p-1 rounded-full\"\n                    viewBox=\"0 0 24 24\"\n                    xmlns=\"http://www.w3.org/2000/svg\">\n                    <path\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        stroke-linecap=\"round\"\n                        stroke-linejoin=\"round\"\n                        stroke-width=\"1.5\"\n                        d=\"M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92\" />\n                </svg>\n            </button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent class=\"min-w-[150px]\" align=\"end\">\n            <DropdownMenuItem\n                v-if=\"canUpdateProjects()\"\n                :aria-label=\"'Edit Project ' + props.project.name\"\n                data-testid=\"project_edit\"\n                class=\"flex items-center space-x-3 cursor-pointer\"\n                @click.prevent=\"emit('edit')\">\n                <PencilSquareIcon class=\"w-5 text-icon-active\" />\n                <span>Edit</span>\n            </DropdownMenuItem>\n            <DropdownMenuItem\n                v-if=\"canUpdateProjects()\"\n                :aria-label=\"'Archive Project ' + props.project.name\"\n                class=\"flex items-center space-x-3 cursor-pointer\"\n                @click.prevent=\"emit('archive')\">\n                <ArchiveBoxIcon class=\"w-5 text-icon-active\" />\n                <span>{{ project.is_archived ? 'Unarchive' : 'Archive' }}</span>\n            </DropdownMenuItem>\n            <DropdownMenuItem\n                v-if=\"canDeleteProjects()\"\n                :aria-label=\"'Delete Project ' + props.project.name\"\n                data-testid=\"project_delete\"\n                class=\"flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive\"\n                @click.prevent=\"emit('delete')\">\n                <TrashIcon class=\"w-5\" />\n                <span>Delete</span>\n            </DropdownMenuItem>\n        </DropdownMenuContent>\n    </DropdownMenu>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Project/ProjectMultiselectDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport MultiselectDropdown from '@/packages/ui/src/Input/MultiselectDropdown.vue';\nimport { useProjectsQuery } from '@/utils/useProjectsQuery';\nimport type { Project } from '@/packages/api/src';\n\nconst { projects } = useProjectsQuery();\n\nfunction getKeyFromItem(item: Project) {\n    return item.id;\n}\n\nfunction getNameForItem(item: Project) {\n    return item.name;\n}\n\nconst emit = defineEmits<{\n    submit: [];\n}>();\n</script>\n\n<template>\n    <MultiselectDropdown\n        search-placeholder=\"Search for a Project...\"\n        :items=\"projects\"\n        :get-key-from-item=\"getKeyFromItem\"\n        :get-name-for-item=\"getNameForItem\"\n        no-item-label=\"No Project\"\n        @submit=\"emit('submit')\">\n        <template #trigger>\n            <slot name=\"trigger\"></slot>\n        </template>\n    </MultiselectDropdown>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Common/Project/ProjectStatusFilterBadge.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue';\nimport { CircleStackIcon } from '@heroicons/vue/16/solid';\nimport { DropdownMenuItem } from '@/Components/ui/dropdown-menu';\nimport BaseFilterBadge from './BaseFilterBadge.vue';\n\ntype StatusValue = 'active' | 'archived' | 'all';\n\nconst props = defineProps<{\n    value: StatusValue;\n}>();\n\nconst emit = defineEmits<{\n    remove: [];\n    'update:value': [value: StatusValue];\n}>();\n\nconst statusOptions = [\n    { id: 'active' as const, name: 'Active' },\n    { id: 'archived' as const, name: 'Archived' },\n];\n\nconst label = computed(() => {\n    return statusOptions.find((opt) => opt.id === props.value)?.name ?? 'Status';\n});\n\nfunction updateStatus(status: StatusValue) {\n    emit('update:value', status);\n}\n</script>\n\n<template>\n    <BaseFilterBadge\n        :icon=\"CircleStackIcon\"\n        :label=\"label\"\n        filter-name=\"Status\"\n        @remove=\"emit('remove')\">\n        <DropdownMenuItem\n            v-for=\"option in statusOptions\"\n            :key=\"option.id\"\n            :class=\"[value === option.id && 'bg-accent text-accent-foreground']\"\n            @click=\"updateStatus(option.id)\">\n            {{ option.name }}\n        </DropdownMenuItem>\n    </BaseFilterBadge>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Common/Project/ProjectTable.vue",
    "content": "<script setup lang=\"ts\">\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport { FolderPlusIcon } from '@heroicons/vue/24/solid';\nimport { PlusIcon } from '@heroicons/vue/16/solid';\nimport { computed, ref } from 'vue';\nimport ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';\nimport ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';\nimport ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';\n\nexport type SortColumn =\n    | 'name'\n    | 'client_name'\n    | 'spent_time'\n    | 'progress'\n    | 'billable_rate'\n    | 'status';\nexport type SortDirection = 'asc' | 'desc';\nimport { canCreateProjects } from '@/utils/permissions';\nimport type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';\nimport { useProjectsStore } from '@/utils/useProjects';\nimport { useClientsStore } from '@/utils/useClients';\nimport { useClientsQuery } from '@/utils/useClientsQuery';\nimport { getOrganizationCurrencyString } from '@/utils/money';\nimport { isAllowedToPerformPremiumAction } from '@/utils/billing';\nimport {\n    useVueTable,\n    getCoreRowModel,\n    getSortedRowModel,\n    type SortingState,\n} from '@tanstack/vue-table';\n\nconst props = defineProps<{\n    projects: Project[];\n    showBillableRate: boolean;\n    sortColumn: SortColumn;\n    sortDirection: SortDirection;\n}>();\n\nconst emit = defineEmits<{\n    sort: [column: SortColumn, direction: SortDirection];\n}>();\n\nconst { clients } = useClientsQuery();\n\n// Create a map of client names for sorting\nconst clientNameMap = computed(() => {\n    const map = new Map<string, string>();\n    clients.value.forEach((client) => {\n        map.set(client.id, client.name);\n    });\n    return map;\n});\n\n// Convert sort props to TanStack Table format\nconst sorting = computed<SortingState>(() => [\n    {\n        id: props.sortColumn,\n        desc: props.sortDirection === 'desc',\n    },\n]);\n\n// Define column accessors for sorting.\n// Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first,\n// while text columns default to ascending (A-Z) on first click (chevron down).\nconst columns = computed(() => [\n    {\n        id: 'name',\n        accessorFn: (row: Project) => row.name.toLowerCase(),\n    },\n    {\n        id: 'client_name',\n        sortUndefined: 'last' as const,\n        accessorFn: (row: Project) => {\n            if (!row.client_id) return undefined;\n            return (clientNameMap.value.get(row.client_id) ?? '').toLowerCase();\n        },\n    },\n    {\n        id: 'spent_time',\n        sortDescFirst: true,\n        accessorFn: (row: Project) => row.spent_time ?? 0,\n    },\n    {\n        id: 'progress',\n        sortDescFirst: true,\n        sortUndefined: 'last' as const,\n        accessorFn: (row: Project) => {\n            if (!row.estimated_time) return undefined;\n            return (row.spent_time / row.estimated_time) * 100;\n        },\n    },\n    {\n        id: 'billable_rate',\n        sortDescFirst: true,\n        accessorFn: (row: Project) => row.billable_rate ?? 0,\n    },\n    {\n        id: 'status',\n        accessorFn: (row: Project) => (row.is_archived ? 1 : 0),\n    },\n]);\n\n// Columns with sortDescFirst get desc as default direction on first click.\nconst descFirstColumns = new Set<SortColumn>(\n    columns.value.filter((c) => c.sortDescFirst).map((c) => c.id as SortColumn)\n);\n\nfunction handleSort(column: SortColumn) {\n    if (props.sortColumn === column) {\n        emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');\n    } else {\n        emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');\n    }\n}\n\nconst table = useVueTable({\n    get data() {\n        return props.projects;\n    },\n    get columns() {\n        return columns.value;\n    },\n    getCoreRowModel: getCoreRowModel(),\n    getSortedRowModel: getSortedRowModel(),\n    state: {\n        get sorting() {\n            return sorting.value;\n        },\n    },\n    manualSorting: false,\n});\n\nconst sortedProjects = computed(() => {\n    return table.getRowModel().rows.map((row) => row.original);\n});\n\nconst showCreateProjectModal = ref(false);\n\nasync function createProject(project: CreateProjectBody): Promise<Project | undefined> {\n    return await useProjectsStore().createProject(project);\n}\n\nasync function createClient(client: CreateClientBody): Promise<Client | undefined> {\n    return await useClientsStore().createClient(client);\n}\n\nconst gridTemplate = computed(() => {\n    return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) 80px;`;\n});\n</script>\n\n<template>\n    <ProjectCreateModal\n        v-model:show=\"showCreateProjectModal\"\n        :create-project\n        :create-client\n        :currency=\"getOrganizationCurrencyString()\"\n        :clients=\"clients\"\n        :enable-estimated-time=\"isAllowedToPerformPremiumAction()\"></ProjectCreateModal>\n    <div class=\"flow-root max-w-[100vw] overflow-x-auto\">\n        <div class=\"inline-block min-w-full align-middle\">\n            <div data-testid=\"project_table\" class=\"grid min-w-full\" :style=\"gridTemplate\">\n                <ProjectTableHeading\n                    :show-billable-rate=\"props.showBillableRate\"\n                    :sort-column=\"props.sortColumn\"\n                    :sort-direction=\"props.sortDirection\"\n                    :desc-first-columns=\"descFirstColumns\"\n                    @sort=\"handleSort\"></ProjectTableHeading>\n                <div v-if=\"sortedProjects.length === 0\" class=\"col-span-5 py-24 text-center\">\n                    <FolderPlusIcon class=\"w-8 text-icon-default inline pb-2\"></FolderPlusIcon>\n                    <h3 class=\"text-text-primary font-semibold\">\n                        {{\n                            canCreateProjects()\n                                ? 'No projects found'\n                                : 'You are not a member of any projects'\n                        }}\n                    </h3>\n                    <p class=\"pb-5 max-w-md mx-auto text-sm pt-1\">\n                        {{\n                            canCreateProjects()\n                                ? 'Create your first project now!'\n                                : 'Ask your manager to add you to a project as a team member.'\n                        }}\n                    </p>\n                    <SecondaryButton\n                        v-if=\"canCreateProjects()\"\n                        :icon=\"PlusIcon\"\n                        @click=\"showCreateProjectModal = true\"\n                        >Create your First Project\n                    </SecondaryButton>\n                </div>\n                <template v-for=\"project in sortedProjects\" :key=\"project.id\">\n                    <ProjectTableRow\n                        :show-billable-rate=\"props.showBillableRate\"\n                        :project=\"project\"></ProjectTableRow>\n                </template>\n            </div>\n        </div>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Common/Project/ProjectTableHeading.vue",
    "content": "<script setup lang=\"ts\">\nimport TableHeading from '@/Components/Common/TableHeading.vue';\nimport { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';\nimport type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue';\n\nconst props = defineProps<{\n    showBillableRate: boolean;\n    sortColumn: SortColumn;\n    sortDirection: SortDirection;\n    descFirstColumns: ReadonlySet<SortColumn>;\n}>();\n\nconst emit = defineEmits<{\n    sort: [column: SortColumn];\n}>();\n\nfunction handleSort(column: SortColumn) {\n    emit('sort', column);\n}\n\nfunction isSorted(column: SortColumn): boolean {\n    return props.sortColumn === column;\n}\n\nfunction isChevronDown(column: SortColumn): boolean {\n    if (!isSorted(column)) return false;\n    return props.descFirstColumns.has(column)\n        ? props.sortDirection === 'desc'\n        : props.sortDirection === 'asc';\n}\n\nfunction isChevronUp(column: SortColumn): boolean {\n    if (!isSorted(column)) return false;\n    return !isChevronDown(column);\n}\n</script>\n\n<template>\n    <TableHeading>\n        <div\n            class=\"py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1\"\n            @click=\"handleSort('name')\">\n            Name\n            <ChevronDownIcon v-if=\"isChevronDown('name')\" class=\"w-4 h-4\" />\n            <ChevronUpIcon v-else-if=\"isChevronUp('name')\" class=\"w-4 h-4\" />\n            <span v-else class=\"w-4 h-4\"></span>\n        </div>\n        <div\n            class=\"px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1\"\n            @click=\"handleSort('client_name')\">\n            Client\n            <ChevronDownIcon v-if=\"isChevronDown('client_name')\" class=\"w-4 h-4\" />\n            <ChevronUpIcon v-else-if=\"isChevronUp('client_name')\" class=\"w-4 h-4\" />\n            <span v-else class=\"w-4 h-4\"></span>\n        </div>\n        <div\n            class=\"px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1\"\n            @click=\"handleSort('spent_time')\">\n            Total Time\n            <ChevronDownIcon v-if=\"isChevronDown('spent_time')\" class=\"w-4 h-4\" />\n            <ChevronUpIcon v-else-if=\"isChevronUp('spent_time')\" class=\"w-4 h-4\" />\n            <span v-else class=\"w-4 h-4\"></span>\n        </div>\n        <div\n            class=\"px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1\"\n            @click=\"handleSort('progress')\">\n            Progress\n            <ChevronDownIcon v-if=\"isChevronDown('progress')\" class=\"w-4 h-4\" />\n            <ChevronUpIcon v-else-if=\"isChevronUp('progress')\" class=\"w-4 h-4\" />\n            <span v-else class=\"w-4 h-4\"></span>\n        </div>\n        <div\n            v-if=\"showBillableRate\"\n            class=\"px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1\"\n            @click=\"handleSort('billable_rate')\">\n            Billable Rate\n            <ChevronDownIcon v-if=\"isChevronDown('billable_rate')\" class=\"w-4 h-4\" />\n            <ChevronUpIcon v-else-if=\"isChevronUp('billable_rate')\" class=\"w-4 h-4\" />\n            <span v-else class=\"w-4 h-4\"></span>\n        </div>\n        <div\n            class=\"px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1\"\n            @click=\"handleSort('status')\">\n            Status\n            <ChevronDownIcon v-if=\"isChevronDown('status')\" class=\"w-4 h-4\" />\n            <ChevronUpIcon v-else-if=\"isChevronUp('status')\" class=\"w-4 h-4\" />\n            <span v-else class=\"w-4 h-4\"></span>\n        </div>\n        <div class=\"relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12\">\n            <span class=\"sr-only\">Edit</span>\n        </div>\n    </TableHeading>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Project/ProjectTableRow.vue",
    "content": "<script setup lang=\"ts\">\nimport ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';\nimport type { Project } from '@/packages/api/src';\nimport { computed, ref, inject, type ComputedRef } from 'vue';\nimport { CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/vue/24/outline';\nimport { useClientsQuery } from '@/utils/useClientsQuery';\nimport { useTasksQuery } from '@/utils/useTasksQuery';\nimport { useProjectsStore } from '@/utils/useProjects';\nimport TableRow from '@/Components/TableRow.vue';\nimport ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue';\nimport { formatCents } from '@/packages/ui/src/utils/money';\nimport { getOrganizationCurrencyString } from '@/utils/money';\nimport EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';\nimport UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';\nimport { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';\nimport { isAllowedToPerformPremiumAction } from '@/utils/billing';\nimport type { Organization } from '@/packages/api/src';\n\nconst { clients } = useClientsQuery();\nconst { tasks } = useTasksQuery();\n\nconst props = defineProps<{\n    project: Project;\n    showBillableRate: boolean;\n}>();\n\nconst client = computed(() => {\n    return clients.value.find((client) => client.id === props.project.client_id);\n});\n\nconst projectTasksCount = computed(() => {\n    return tasks.value.filter((task) => task.project_id === props.project.id).length;\n});\n\nfunction deleteProject() {\n    useProjectsStore().deleteProject(props.project.id);\n}\n\nfunction archiveProject() {\n    useProjectsStore().updateProject(props.project.id, {\n        ...props.project,\n        is_archived: !props.project.is_archived,\n    });\n}\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n\nconst billableRateInfo = computed(() => {\n    if (props.project.is_billable) {\n        if (props.project.billable_rate) {\n            return formatCents(\n                props.project.billable_rate,\n                getOrganizationCurrencyString(),\n                organization?.value?.currency_format,\n                organization?.value?.currency_symbol,\n                organization?.value?.number_format\n            );\n        } else {\n            return 'Default Rate';\n        }\n    }\n    return '--';\n});\n\nconst showEditProjectModal = ref(false);\n</script>\n\n<template>\n    <ProjectEditModal\n        v-model:show=\"showEditProjectModal\"\n        :original-project=\"project\"></ProjectEditModal>\n    <TableRow :href=\"route('projects.show', { project: project.id })\">\n        <div\n            class=\"whitespace-nowrap min-w-0 flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12\">\n            <div\n                :style=\"{\n                    backgroundColor: project.color,\n                    boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`,\n                }\"\n                class=\"w-3 h-3 rounded-full\"></div>\n            <span class=\"overflow-ellipsis overflow-hidden\">\n                {{ project.name }}\n            </span>\n            <span class=\"text-text-secondary\"> {{ projectTasksCount }} Tasks </span>\n        </div>\n        <div class=\"whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary\">\n            <div v-if=\"project.client_id\" class=\"overflow-ellipsis overflow-hidden\">\n                {{ client?.name }}\n            </div>\n            <div v-else>No client</div>\n        </div>\n        <div class=\"whitespace-nowrap px-3 py-4 text-sm text-text-secondary\">\n            <div v-if=\"project.spent_time\">\n                {{\n                    formatHumanReadableDuration(\n                        project.spent_time,\n                        organization?.interval_format,\n                        organization?.number_format\n                    )\n                }}\n            </div>\n            <div v-else>--</div>\n        </div>\n        <div class=\"whitespace-nowrap px-3 flex items-center text-sm text-text-secondary\">\n            <UpgradeBadge v-if=\"!isAllowedToPerformPremiumAction()\"></UpgradeBadge>\n            <EstimatedTimeProgress\n                v-else-if=\"project.estimated_time\"\n                :estimated=\"project.estimated_time\"\n                :current=\"project.spent_time\"></EstimatedTimeProgress>\n            <span v-else> -- </span>\n        </div>\n        <div\n            v-if=\"showBillableRate\"\n            class=\"whitespace-nowrap px-3 py-4 text-sm text-text-secondary\">\n            {{ billableRateInfo }}\n        </div>\n        <div\n            class=\"whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium\">\n            <template v-if=\"project.is_archived\">\n                <ArchiveBoxIcon class=\"w-4 text-icon-default\"></ArchiveBoxIcon>\n                <span>Archived</span>\n            </template>\n            <template v-else>\n                <CheckCircleIcon class=\"w-4 text-icon-default\"></CheckCircleIcon>\n                <span>Active</span>\n            </template>\n        </div>\n        <div\n            class=\"relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12\">\n            <ProjectMoreOptionsDropdown\n                :project=\"project\"\n                @edit=\"showEditProjectModal = true\"\n                @archive=\"archiveProject\"\n                @delete=\"deleteProject\"></ProjectMoreOptionsDropdown>\n        </div>\n    </TableRow>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Project/ProjectsFilterDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, ref } from 'vue';\nimport { UserGroupIcon, CheckCircleIcon } from '@heroicons/vue/16/solid';\nimport ListFilterIcon from '@/packages/ui/src/Icons/ListFilterIcon.vue';\nimport {\n    DropdownMenu,\n    DropdownMenuContent,\n    DropdownMenuItem,\n    DropdownMenuTrigger,\n    DropdownMenuSub,\n    DropdownMenuSubTrigger,\n    DropdownMenuSubContent,\n    DropdownMenuCheckboxItem,\n    DropdownMenuSeparator,\n} from '@/Components/ui/dropdown-menu';\nimport { Button } from '@/packages/ui/src';\nimport type { Client } from '@/packages/api/src';\nimport { NO_CLIENT_ID } from './constants';\n\nexport interface ProjectFilters {\n    status: 'active' | 'archived' | 'all';\n    clientIds: string[];\n}\n\nconst props = defineProps<{\n    filters: ProjectFilters;\n    clients: Client[];\n}>();\n\nconst emit = defineEmits<{\n    'update:filters': [filters: ProjectFilters];\n}>();\n\nconst statusOptions = [\n    { id: 'active' as const, name: 'Active' },\n    { id: 'archived' as const, name: 'Archived' },\n];\n\nconst open = ref(false);\n\nfunction updateStatus(status: 'active' | 'archived' | 'all') {\n    emit('update:filters', {\n        ...props.filters,\n        status,\n    });\n    open.value = false;\n}\n\nfunction toggleClient(clientId: string) {\n    const clientIds = props.filters.clientIds.includes(clientId)\n        ? props.filters.clientIds.filter((id) => id !== clientId)\n        : [...props.filters.clientIds, clientId];\n\n    emit('update:filters', {\n        ...props.filters,\n        clientIds,\n    });\n}\n\nfunction toggleNoClient() {\n    const clientIds = props.filters.clientIds.includes(NO_CLIENT_ID)\n        ? props.filters.clientIds.filter((id) => id !== NO_CLIENT_ID)\n        : [...props.filters.clientIds, NO_CLIENT_ID];\n\n    emit('update:filters', {\n        ...props.filters,\n        clientIds,\n    });\n}\n\nconst hasActiveFilters = computed(() => {\n    return props.filters.status !== 'all' || props.filters.clientIds.length > 0;\n});\n</script>\n\n<template>\n    <DropdownMenu v-model:open=\"open\">\n        <DropdownMenuTrigger as-child>\n            <Button variant=\"ghost\" size=\"xs\" aria-label=\"Filter projects\">\n                <ListFilterIcon\n                    :class=\"[hasActiveFilters ? '' : '-ml-0.5', 'h-4 w-4 text-icon-default']\" />\n                <span v-if=\"!hasActiveFilters\" class=\"text-nowrap\">Filter</span>\n            </Button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent align=\"start\" class=\"w-56\">\n            <!-- Status Filter -->\n            <DropdownMenuSub>\n                <DropdownMenuSubTrigger class=\"gap-2\">\n                    <CheckCircleIcon class=\"h-4 w-4 text-icon-default\" />\n                    <span>Status</span>\n                </DropdownMenuSubTrigger>\n                <DropdownMenuSubContent>\n                    <DropdownMenuItem\n                        v-for=\"option in statusOptions\"\n                        :key=\"option.id\"\n                        :class=\"[\n                            filters.status === option.id && 'bg-accent text-accent-foreground',\n                        ]\"\n                        @click=\"updateStatus(option.id)\">\n                        {{ option.name }}\n                    </DropdownMenuItem>\n                </DropdownMenuSubContent>\n            </DropdownMenuSub>\n\n            <!-- Client Filter -->\n            <DropdownMenuSub v-if=\"clients.length > 0\">\n                <DropdownMenuSubTrigger class=\"gap-2\">\n                    <UserGroupIcon class=\"h-4 w-4 text-icon-default\" />\n                    <span>Client</span>\n                </DropdownMenuSubTrigger>\n                <DropdownMenuSubContent class=\"max-h-[300px] overflow-y-auto\">\n                    <DropdownMenuCheckboxItem\n                        :model-value=\"filters.clientIds.includes(NO_CLIENT_ID)\"\n                        @select.prevent=\"toggleNoClient\">\n                        No client\n                    </DropdownMenuCheckboxItem>\n                    <DropdownMenuSeparator />\n                    <DropdownMenuCheckboxItem\n                        v-for=\"client in clients\"\n                        :key=\"client.id\"\n                        :model-value=\"filters.clientIds.includes(client.id)\"\n                        @select.prevent=\"toggleClient(client.id)\">\n                        {{ client.name }}\n                    </DropdownMenuCheckboxItem>\n                </DropdownMenuSubContent>\n            </DropdownMenuSub>\n        </DropdownMenuContent>\n    </DropdownMenu>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Common/Project/constants.ts",
    "content": "export const NO_CLIENT_ID = '__no_client__';\n"
  },
  {
    "path": "resources/js/Components/Common/ProjectMember/ProjectMemberBillableRateModal.vue",
    "content": "<script setup lang=\"ts\">\nimport { getOrganizationCurrencyString } from '@/utils/money';\nimport BillableRateModal from '@/packages/ui/src/BillableRateModal.vue';\nimport { formatCents } from '@/packages/ui/src/utils/money';\nimport { inject, type ComputedRef } from 'vue';\nimport type { Organization } from '@/packages/api/src';\n\nconst show = defineModel('show', { default: false });\nconst saving = defineModel('saving', { default: false });\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n\ndefineProps<{\n    newBillableRate?: number | null;\n    memberName?: string;\n}>();\n\ndefineEmits<{\n    submit: [];\n}>();\n</script>\n\n<template>\n    <BillableRateModal\n        v-model:show=\"show\"\n        v-model:saving=\"saving\"\n        title=\"Update Project Member Billable Rate\"\n        @submit=\"$emit('submit')\">\n        <p class=\"py-1 text-center\">\n            The billable rate of {{ memberName }} will be updated to\n            <strong>{{\n                newBillableRate\n                    ? formatCents(\n                          newBillableRate,\n                          getOrganizationCurrencyString(),\n                          organization?.currency_format,\n                          organization?.currency_symbol,\n                          organization?.number_format\n                      )\n                    : ' the default rate of the project'\n            }}</strong\n            >.\n        </p>\n        <p class=\"py-1 text-center font-semibold max-w-md mx-auto\">\n            Do you want to update all existing time entries, where the project member billable rate\n            applies as well?\n        </p>\n    </BillableRateModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/ProjectMember/ProjectMemberCreateModal.vue",
    "content": "<script setup lang=\"ts\">\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport { ref } from 'vue';\nimport type { CreateProjectMemberBody, ProjectMember } from '@/packages/api/src';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport { useFocus } from '@vueuse/core';\nimport { useProjectMembersStore } from '@/utils/useProjectMembers';\nimport MemberCombobox from '@/Components/Common/Member/MemberCombobox.vue';\nimport BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';\nimport { getOrganizationCurrencyString } from '@/utils/money';\nconst { createProjectMember } = useProjectMembersStore();\nconst show = defineModel('show', { default: false });\nconst saving = ref(false);\n\nconst props = defineProps<{\n    projectId: string;\n    existingMembers: ProjectMember[];\n}>();\n\nconst projectMember = ref<CreateProjectMemberBody>({\n    member_id: '',\n    billable_rate: null,\n});\n\nasync function submit() {\n    await createProjectMember(props.projectId, projectMember.value);\n    show.value = false;\n    projectMember.value = {\n        member_id: '',\n        billable_rate: null,\n    };\n}\n\nconst projectNameInput = ref<HTMLInputElement | null>(null);\n\nuseFocus(projectNameInput, { initialValue: true });\n</script>\n\n<template>\n    <DialogModal closeable :show=\"show\" @close=\"show = false\">\n        <template #title>\n            <div class=\"flex space-x-2\">\n                <span>Add Project Member</span>\n            </div>\n        </template>\n\n        <template #content>\n            <div class=\"grid grid-cols-3 items-center space-x-4\">\n                <div class=\"col-span-3 sm:col-span-2\">\n                    <MemberCombobox\n                        v-model=\"projectMember.member_id\"\n                        :hidden-members=\"props.existingMembers\"></MemberCombobox>\n                </div>\n                <div class=\"col-span-3 sm:col-span-1 flex-1\">\n                    <BillableRateInput\n                        v-model=\"projectMember.billable_rate\"\n                        name=\"billable_rate\"\n                        :currency=\"getOrganizationCurrencyString()\"></BillableRateInput>\n                </div>\n            </div>\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"show = false\">Cancel</SecondaryButton>\n            <PrimaryButton\n                class=\"ms-3\"\n                :class=\"{ 'opacity-25': saving }\"\n                :disabled=\"saving\"\n                @click=\"submit\">\n                Add Project Member\n            </PrimaryButton>\n        </template>\n    </DialogModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/ProjectMember/ProjectMemberEditModal.vue",
    "content": "<script setup lang=\"ts\">\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport { ref, watch } from 'vue';\nimport type { ProjectMember, UpdateProjectMemberBody } from '@/packages/api/src';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport { useFocus } from '@vueuse/core';\nimport { useProjectMembersStore } from '@/utils/useProjectMembers';\nimport BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';\nimport { UserIcon } from '@heroicons/vue/24/solid';\nimport ProjectMemberBillableRateModal from '@/Components/Common/ProjectMember/ProjectMemberBillableRateModal.vue';\nimport { Field, FieldLabel } from '@/packages/ui/src/field';\nimport { getOrganizationCurrencyString } from '@/utils/money';\nconst { updateProjectMember } = useProjectMembersStore();\n\nconst show = defineModel('show', { default: false });\nconst saving = ref(false);\n\nconst props = defineProps<{\n    projectMember: ProjectMember;\n    name?: string;\n}>();\n\nconst projectMemberBody = ref<UpdateProjectMemberBody>({\n    billable_rate: props.projectMember.billable_rate,\n});\nconst showBillableRateModal = ref(false);\nasync function submit() {\n    if (props.projectMember.billable_rate !== projectMemberBody.value.billable_rate) {\n        // make sure that the alert modal is not immediately submitted when user presses enter\n        setTimeout(() => {\n            showBillableRateModal.value = true;\n        }, 0);\n        return;\n    }\n    await updateProjectMember(props.projectMember.id, projectMemberBody.value);\n    show.value = false;\n    projectMemberBody.value = {\n        billable_rate: null,\n    };\n}\n\nasync function submitBillableRate() {\n    await updateProjectMember(props.projectMember.id, projectMemberBody.value);\n    show.value = false;\n    showBillableRateModal.value = false;\n}\n\nwatch(\n    () => show.value,\n    (value) => {\n        if (value) {\n            projectMemberBody.value = {\n                billable_rate: props.projectMember.billable_rate,\n            };\n        }\n    }\n);\n\nconst projectNameInput = ref<HTMLInputElement | null>(null);\n\nuseFocus(projectNameInput, { initialValue: true });\n</script>\n\n<template>\n    <DialogModal closeable :show=\"show\" @close=\"show = false\">\n        <template #title>\n            <div class=\"flex space-x-2\">\n                <span>Edit Project Member</span>\n            </div>\n        </template>\n\n        <template #content>\n            <ProjectMemberBillableRateModal\n                v-model:show=\"showBillableRateModal\"\n                :member-name=\"props.name\"\n                :new-billable-rate=\"projectMemberBody.billable_rate\"\n                @close=\"showBillableRateModal = false\"\n                @submit=\"submitBillableRate\"></ProjectMemberBillableRateModal>\n            <div class=\"grid grid-cols-3 items-center space-x-4\">\n                <div class=\"col-span-3 sm:col-span-2 space-x-2 flex items-center\">\n                    <UserIcon class=\"w-4 text-text-secondary\"></UserIcon>\n                    <span>{{ props.name }}</span>\n                </div>\n                <Field class=\"col-span-3 sm:col-span-1 flex-1\">\n                    <FieldLabel for=\"billable_rate\">Billable Rate</FieldLabel>\n                    <BillableRateInput\n                        v-model=\"projectMemberBody.billable_rate\"\n                        :currency=\"getOrganizationCurrencyString()\"\n                        name=\"billable_rate\"\n                        @keydown.enter=\"submit\"></BillableRateInput>\n                </Field>\n            </div>\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"show = false\">Cancel</SecondaryButton>\n            <PrimaryButton\n                class=\"ms-3\"\n                :class=\"{ 'opacity-25': saving }\"\n                :disabled=\"saving\"\n                @click=\"submit\">\n                Update Project Member\n            </PrimaryButton>\n        </template>\n    </DialogModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/ProjectMember/ProjectMemberMoreOptionsDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport { TrashIcon, PencilSquareIcon } from '@heroicons/vue/20/solid';\nimport type { ProjectMember } from '@/packages/api/src';\nimport { useMembersQuery } from '@/utils/useMembersQuery';\nimport { computed } from 'vue';\nimport {\n    DropdownMenu,\n    DropdownMenuContent,\n    DropdownMenuItem,\n    DropdownMenuTrigger,\n} from '@/Components/ui/dropdown-menu';\n\nconst emit = defineEmits<{\n    delete: [];\n    edit: [];\n}>();\nconst props = defineProps<{\n    projectMember: ProjectMember;\n}>();\n\nconst { members } = useMembersQuery();\n\nconst currentMember = computed(() => {\n    return members.value.find((member) => member.id === props.projectMember.user_id);\n});\n</script>\n\n<template>\n    <DropdownMenu>\n        <DropdownMenuTrigger as-child>\n            <button\n                class=\"focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-2 focus-visible:ring-ring focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity text-text-secondary\"\n                :aria-label=\"'Actions for Project Member ' + currentMember?.name\">\n                <svg\n                    class=\"h-8 w-8 p-1 rounded-full\"\n                    viewBox=\"0 0 24 24\"\n                    xmlns=\"http://www.w3.org/2000/svg\">\n                    <path\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        stroke-linecap=\"round\"\n                        stroke-linejoin=\"round\"\n                        stroke-width=\"1.5\"\n                        d=\"M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92\" />\n                </svg>\n            </button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent class=\"min-w-[150px]\" align=\"end\">\n            <DropdownMenuItem\n                :aria-label=\"'Edit Project Member ' + currentMember?.name\"\n                class=\"flex items-center space-x-3 cursor-pointer\"\n                @click.prevent=\"emit('edit')\">\n                <PencilSquareIcon class=\"w-5 text-icon-active\" />\n                <span>Edit</span>\n            </DropdownMenuItem>\n            <DropdownMenuItem\n                :aria-label=\"'Delete Project Member ' + currentMember?.name\"\n                data-testid=\"project_delete\"\n                class=\"flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive\"\n                @click.prevent=\"emit('delete')\">\n                <TrashIcon class=\"w-5\" />\n                <span>Remove from Team</span>\n            </DropdownMenuItem>\n        </DropdownMenuContent>\n    </DropdownMenu>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/ProjectMember/ProjectMemberTable.vue",
    "content": "<script setup lang=\"ts\">\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport { PlusIcon } from '@heroicons/vue/16/solid';\nimport { ref } from 'vue';\nimport ProjectMemberTableRow from '@/Components/Common/ProjectMember/ProjectMemberTableRow.vue';\nimport { UserGroupIcon } from '@heroicons/vue/24/solid';\nimport ProjectMemberTableHeading from '@/Components/Common/ProjectMember/ProjectMemberTableHeading.vue';\nimport ProjectMemberCreateModal from '@/Components/Common/ProjectMember/ProjectMemberCreateModal.vue';\nimport type { ProjectMember } from '@/packages/api/src';\n\ndefineProps<{\n    projectId: string;\n    projectMembers: ProjectMember[];\n}>();\n\nconst createProjectMember = ref(false);\n</script>\n\n<template>\n    <ProjectMemberCreateModal\n        v-model:show=\"createProjectMember\"\n        :existing-members=\"projectMembers\"\n        :project-id=\"projectId\"></ProjectMemberCreateModal>\n    <div class=\"flow-root\">\n        <div class=\"inline-block min-w-full align-middle\">\n            <div\n                data-testid=\"project_member_table\"\n                class=\"grid min-w-full\"\n                style=\"grid-template-columns: 1fr 150px 150px 80px\">\n                <ProjectMemberTableHeading></ProjectMemberTableHeading>\n                <div v-if=\"projectMembers.length === 0\" class=\"col-span-5 py-24 text-center\">\n                    <UserGroupIcon class=\"w-8 text-icon-default inline pb-2\"></UserGroupIcon>\n                    <h3 class=\"text-text-primary font-semibold\">No project members</h3>\n                    <p class=\"pb-5\">Add the first project member!</p>\n                    <SecondaryButton :icon=\"PlusIcon\" @click=\"createProjectMember = true\"\n                        >Add a new Project Member\n                    </SecondaryButton>\n                </div>\n                <template v-for=\"projectMember in projectMembers\" :key=\"projectMember.id\">\n                    <ProjectMemberTableRow :project-member=\"projectMember\"></ProjectMemberTableRow>\n                </template>\n            </div>\n        </div>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Common/ProjectMember/ProjectMemberTableHeading.vue",
    "content": "<script setup lang=\"ts\">\nimport TableHeading from '@/Components/Common/TableHeading.vue';\n</script>\n\n<template>\n    <TableHeading>\n        <div class=\"py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12\">\n            Name\n        </div>\n        <div class=\"px-3 py-1.5 text-left text-text-tertiary\">Billable Rate</div>\n        <div class=\"px-3 py-1.5 text-left text-text-tertiary\">Role</div>\n        <div class=\"relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12\">\n            <span class=\"sr-only\">Edit</span>\n        </div>\n    </TableHeading>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/ProjectMember/ProjectMemberTableRow.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ProjectMember } from '@/packages/api/src';\nimport { computed, ref, inject, type ComputedRef } from 'vue';\nimport TableRow from '@/Components/TableRow.vue';\nimport { useMembersQuery } from '@/utils/useMembersQuery';\nimport { useProjectMembersStore } from '@/utils/useProjectMembers';\nimport ProjectMemberMoreOptionsDropdown from '@/Components/Common/ProjectMember/ProjectMemberMoreOptionsDropdown.vue';\nimport { formatCents } from '@/packages/ui/src/utils/money';\nimport { capitalizeFirstLetter } from '@/utils/format';\nimport ProjectMemberEditModal from '@/Components/Common/ProjectMember/ProjectMemberEditModal.vue';\nimport { getOrganizationCurrencyString } from '@/utils/money';\nimport type { Organization } from '@/packages/api/src';\n\nconst props = defineProps<{\n    projectMember: ProjectMember;\n}>();\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n\nfunction deleteProjectMember() {\n    useProjectMembersStore().deleteProjectMember(\n        props.projectMember.project_id,\n        props.projectMember.id\n    );\n}\n\nfunction editProjectMember() {\n    showEditModal.value = true;\n}\n\nconst { members } = useMembersQuery();\nconst member = computed(() => {\n    return members.value.find((member) => member.id === props.projectMember.member_id);\n});\nconst showEditModal = ref(false);\n</script>\n\n<template>\n    <TableRow>\n        <ProjectMemberEditModal\n            v-model:show=\"showEditModal\"\n            :name=\"member?.name\"\n            :project-member=\"projectMember\"></ProjectMemberEditModal>\n        <div\n            class=\"whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12\">\n            <span>\n                {{ member?.name }}\n            </span>\n        </div>\n        <div class=\"whitespace-nowrap px-3 py-4 text-sm text-text-secondary\">\n            {{\n                projectMember.billable_rate\n                    ? formatCents(\n                          projectMember.billable_rate,\n                          getOrganizationCurrencyString(),\n                          organization?.currency_format,\n                          organization?.currency_symbol,\n                          organization?.number_format\n                      )\n                    : '--'\n            }}\n        </div>\n        <div class=\"whitespace-nowrap px-3 py-4 text-sm text-text-secondary\">\n            {{ capitalizeFirstLetter(member?.role ?? '') }}\n        </div>\n        <div\n            class=\"relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12\">\n            <ProjectMemberMoreOptionsDropdown\n                :project-member=\"projectMember\"\n                @delete=\"deleteProjectMember\"\n                @edit=\"editProjectMember\"></ProjectMemberMoreOptionsDropdown>\n        </div>\n    </TableRow>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Report/ReportCreateModal.vue",
    "content": "<script setup lang=\"ts\">\nimport TextInput from '../../../packages/ui/src/Input/TextInput.vue';\nimport SecondaryButton from '../../../packages/ui/src/Buttons/SecondaryButton.vue';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport { ref } from 'vue';\nimport PrimaryButton from '../../../packages/ui/src/Buttons/PrimaryButton.vue';\nimport { Field, FieldLabel } from '@/packages/ui/src/field';\nimport type { CreateReportBody, CreateReportBodyProperties } from '@/packages/api/src';\nimport { useMutation, useQueryClient } from '@tanstack/vue-query';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport { api } from '@/packages/api/src';\nimport { Checkbox } from '@/packages/ui/src';\nimport DatePicker from '@/packages/ui/src/Input/DatePicker.vue';\nimport { useNotificationsStore } from '@/utils/notification';\nimport { getDayJsInstance } from '@/packages/ui/src/utils/time';\nimport { router } from '@inertiajs/vue3';\n\nconst show = defineModel('show', { default: false });\nconst saving = ref(false);\nconst queryClient = useQueryClient();\n\nconst createReportMutation = useMutation({\n    mutationFn: async (report: CreateReportBody) => {\n        const organizationId = getCurrentOrganizationId();\n        if (organizationId === null) {\n            throw new Error('No current organization id - create report');\n        }\n        return await api.createReport(report, {\n            params: {\n                organization: organizationId,\n            },\n        });\n    },\n    onSuccess: () => {\n        queryClient.invalidateQueries({\n            queryKey: ['reports'],\n        });\n    },\n});\n\nconst props = defineProps<{\n    properties: CreateReportBodyProperties;\n}>();\n\nconst report = ref({\n    name: '',\n    description: '',\n    is_public: true,\n    public_until: null,\n});\n\nconst { handleApiRequestNotifications } = useNotificationsStore();\n\nasync function submit() {\n    const publicUntil = report.value.public_until\n        ? getDayJsInstance()(report.value.public_until).utc().format()\n        : null;\n    await handleApiRequestNotifications(\n        () =>\n            createReportMutation.mutateAsync({\n                ...report.value,\n                public_until: publicUntil,\n                properties: { ...props.properties },\n            }),\n        'Success',\n        'Error',\n        () => {\n            report.value = {\n                name: '',\n                description: '',\n                is_public: false,\n                public_until: null,\n            };\n            show.value = false;\n            router.visit(route('reporting.shared'));\n        }\n    );\n}\n</script>\n\n<template>\n    <DialogModal closeable :show=\"show\" @close=\"show = false\">\n        <template #title>\n            <div class=\"flex space-x-2\">\n                <span> Create Report </span>\n            </div>\n        </template>\n\n        <template #content>\n            <div class=\"items-center space-y-4 w-full\">\n                <Field class=\"w-full\">\n                    <FieldLabel for=\"name\">Name</FieldLabel>\n                    <TextInput id=\"name\" v-model=\"report.name\" class=\"w-full\"></TextInput>\n                </Field>\n                <Field>\n                    <FieldLabel for=\"description\">Description</FieldLabel>\n                    <TextInput\n                        id=\"description\"\n                        v-model=\"report.description\"\n                        class=\"w-full\"></TextInput>\n                </Field>\n                <Field>\n                    <FieldLabel>Visibility</FieldLabel>\n                    <div class=\"flex items-center space-x-12\">\n                        <Field orientation=\"horizontal\" class=\"px-2 py-3\">\n                            <Checkbox id=\"is_public\" v-model:checked=\"report.is_public\"></Checkbox>\n                            <FieldLabel for=\"is_public\">Public</FieldLabel>\n                        </Field>\n                        <Field v-if=\"report.is_public\" class=\"flex-row items-center space-x-4\">\n                            <div>\n                                <FieldLabel for=\"public_until\">Expires at</FieldLabel>\n                                <div class=\"text-text-tertiary font-medium\">(optional)</div>\n                            </div>\n                            <DatePicker v-model=\"report.public_until\"></DatePicker>\n                        </Field>\n                    </div>\n                </Field>\n            </div>\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"show = false\"> Cancel</SecondaryButton>\n            <PrimaryButton\n                class=\"ms-3\"\n                :class=\"{ 'opacity-25': saving }\"\n                :disabled=\"saving\"\n                @click=\"submit\">\n                Create Report\n            </PrimaryButton>\n        </template>\n    </DialogModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Report/ReportEditModal.vue",
    "content": "<script setup lang=\"ts\">\nimport TextInput from '../../../packages/ui/src/Input/TextInput.vue';\nimport SecondaryButton from '../../../packages/ui/src/Buttons/SecondaryButton.vue';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport { computed, ref, watch } from 'vue';\nimport PrimaryButton from '../../../packages/ui/src/Buttons/PrimaryButton.vue';\nimport { Field, FieldLabel } from '@/packages/ui/src/field';\nimport type { UpdateReportBody } from '@/packages/api/src';\nimport { useMutation, useQueryClient } from '@tanstack/vue-query';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport { api } from '@/packages/api/src';\nimport { Checkbox } from '@/packages/ui/src';\nimport DatePicker from '@/packages/ui/src/Input/DatePicker.vue';\nimport { useNotificationsStore } from '@/utils/notification';\nimport type { Report } from '@/packages/api/src';\nimport { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';\n\nconst show = defineModel('show', { default: false });\nconst saving = ref(false);\nconst queryClient = useQueryClient();\n\nconst updateReportMutation = useMutation({\n    mutationFn: async (report: UpdateReportBody) => {\n        const organizationId = getCurrentOrganizationId();\n        if (organizationId === null) {\n            throw new Error('No current organization id - update report');\n        }\n        return await api.updateReport(report, {\n            params: {\n                organization: organizationId,\n                report: props.originalReport.id,\n            },\n        });\n    },\n    onSuccess: () => {\n        queryClient.invalidateQueries({\n            queryKey: ['reports'],\n        });\n    },\n});\n\nconst props = defineProps<{\n    originalReport: Report;\n}>();\n\nconst report = ref<UpdateReportBody>({\n    name: props.originalReport.name,\n    description: props.originalReport.description,\n    is_public: props.originalReport.is_public,\n    public_until: props.originalReport.public_until,\n});\n\nwatch(\n    () => props.originalReport,\n    () => {\n        report.value = {\n            name: props.originalReport.name,\n            description: props.originalReport.description,\n            is_public: props.originalReport.is_public,\n            public_until: props.originalReport.public_until,\n        };\n    }\n);\n\n// Intermediate local variable for DatePicker (converts between UTC and localized)\nconst localPublicUntil = computed({\n    get: () => {\n        if (!report.value.public_until) return null;\n        return getLocalizedDayJs(report.value.public_until).format();\n    },\n    set: (value: string | null) => {\n        report.value.public_until = value ? getDayJsInstance()(value).utc().format() : null;\n    },\n});\n\nconst { handleApiRequestNotifications } = useNotificationsStore();\n\nasync function submit() {\n    // public_until is already in UTC format from the computed setter\n    await handleApiRequestNotifications(\n        () => updateReportMutation.mutateAsync(report.value),\n        'Success',\n        'Error',\n        () => {\n            report.value = {\n                name: '',\n                description: '',\n                is_public: false,\n                public_until: null,\n                properties: {},\n            };\n            show.value = false;\n        }\n    );\n}\n</script>\n\n<template>\n    <DialogModal closeable :show=\"show\" @close=\"show = false\">\n        <template #title>\n            <div class=\"flex space-x-2\">\n                <span> Create Report </span>\n            </div>\n        </template>\n\n        <template #content>\n            <div class=\"items-center space-y-4 w-full\">\n                <Field class=\"w-full\">\n                    <FieldLabel for=\"name\">Name</FieldLabel>\n                    <TextInput id=\"name\" v-model=\"report.name\" class=\"w-full\"></TextInput>\n                </Field>\n                <Field>\n                    <FieldLabel for=\"description\">Description</FieldLabel>\n                    <TextInput\n                        id=\"description\"\n                        v-model=\"report.description\"\n                        class=\"w-full\"></TextInput>\n                </Field>\n                <Field>\n                    <FieldLabel>Visibility</FieldLabel>\n                    <div class=\"flex items-center space-x-12\">\n                        <Field orientation=\"horizontal\" class=\"px-2 py-3\">\n                            <Checkbox id=\"is_public\" v-model:checked=\"report.is_public\"></Checkbox>\n                            <FieldLabel for=\"is_public\">Public</FieldLabel>\n                        </Field>\n                        <Field v-if=\"report.is_public\" orientation=\"horizontal\">\n                            <FieldLabel for=\"public_until\">Expires at</FieldLabel>\n                            <DatePicker v-model=\"localPublicUntil\"></DatePicker>\n                        </Field>\n                    </div>\n                </Field>\n            </div>\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"show = false\"> Cancel</SecondaryButton>\n            <PrimaryButton\n                class=\"ms-3\"\n                :class=\"{ 'opacity-25': saving }\"\n                :disabled=\"saving\"\n                @click=\"submit\">\n                Update Report\n            </PrimaryButton>\n        </template>\n    </DialogModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Report/ReportMoreOptionsDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport { TrashIcon, PencilSquareIcon } from '@heroicons/vue/20/solid';\nimport type { Report } from '@/packages/api/src';\nimport {\n    DropdownMenu,\n    DropdownMenuContent,\n    DropdownMenuItem,\n    DropdownMenuTrigger,\n} from '@/Components/ui/dropdown-menu';\nimport { canDeleteReport, canUpdateReport } from '@/utils/permissions';\n\nconst emit = defineEmits<{\n    delete: [];\n    edit: [];\n    archive: [];\n}>();\nconst props = defineProps<{\n    report: Report;\n}>();\n</script>\n\n<template>\n    <DropdownMenu>\n        <DropdownMenuTrigger as-child>\n            <button\n                class=\"focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-2 focus-visible:ring-ring focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity text-text-secondary\"\n                :aria-label=\"'Actions for Project ' + props.report.name\">\n                <svg\n                    class=\"h-8 w-8 p-1 rounded-full\"\n                    viewBox=\"0 0 24 24\"\n                    xmlns=\"http://www.w3.org/2000/svg\">\n                    <path\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        stroke-linecap=\"round\"\n                        stroke-linejoin=\"round\"\n                        stroke-width=\"1.5\"\n                        d=\"M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92\" />\n                </svg>\n            </button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent class=\"min-w-[150px]\" align=\"end\">\n            <DropdownMenuItem\n                v-if=\"canUpdateReport()\"\n                :aria-label=\"'Edit Report ' + props.report.name\"\n                class=\"flex items-center space-x-3 cursor-pointer\"\n                @click.prevent=\"emit('edit')\">\n                <PencilSquareIcon class=\"w-5 text-icon-active\" />\n                <span>Edit</span>\n            </DropdownMenuItem>\n            <DropdownMenuItem\n                v-if=\"canDeleteReport()\"\n                :aria-label=\"'Delete Report ' + props.report.name\"\n                class=\"flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive\"\n                @click.prevent=\"emit('delete')\">\n                <TrashIcon class=\"w-5\" />\n                <span>Delete</span>\n            </DropdownMenuItem>\n        </DropdownMenuContent>\n    </DropdownMenu>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Report/ReportSaveButton.vue",
    "content": "<script setup lang=\"ts\">\nimport { SecondaryButton } from '@/packages/ui/src';\nimport ReportCreateModal from '@/Components/Common/Report/ReportCreateModal.vue';\nimport { h, ref } from 'vue';\nimport type { CreateReportBodyProperties } from '@/packages/api/src';\nimport { isAllowedToPerformPremiumAction } from '@/utils/billing';\nimport UpgradeModal from '@/Components/Common/UpgradeModal.vue';\nimport { canCreateReports } from '@/utils/permissions';\ndefineProps<{\n    reportProperties: CreateReportBodyProperties;\n}>();\n\nconst showCreateReportModal = ref(false);\nconst showPremiumModal = ref(false);\nconst SaveIcon = h('div', {\n    innerHTML:\n        '<svg viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z\"/><path d=\"M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7M7 3v4a1 1 0 0 0 1 1h7\"/></g></svg>',\n});\n\nfunction onSaveReportClick() {\n    if (isAllowedToPerformPremiumAction()) {\n        showCreateReportModal.value = true;\n    } else {\n        showPremiumModal.value = true;\n    }\n}\n</script>\n\n<template>\n    <ReportCreateModal\n        v-model:show=\"showCreateReportModal\"\n        :properties=\"reportProperties\"></ReportCreateModal>\n    <UpgradeModal v-model:show=\"showPremiumModal\">\n        <strong>Sharable Reports</strong> is only available in solidtime Professional.\n    </UpgradeModal>\n    <SecondaryButton v-if=\"canCreateReports()\" :icon=\"SaveIcon\" @click=\"onSaveReportClick\"\n        >Save Report</SecondaryButton\n    >\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Report/ReportTable.vue",
    "content": "<script setup lang=\"ts\">\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport { FolderPlusIcon } from '@heroicons/vue/24/solid';\nimport { PlusIcon } from '@heroicons/vue/16/solid';\nimport { computed } from 'vue';\nimport { canCreateProjects } from '@/utils/permissions';\nimport type { Report } from '@/packages/api/src';\nimport ReportTableHeading from '@/Components/Common/Report/ReportTableHeading.vue';\nimport ReportTableRow from '@/Components/Common/Report/ReportTableRow.vue';\nimport { router } from '@inertiajs/vue3';\n\ndefineProps<{\n    reports: Report[];\n}>();\n\nconst gridTemplate = computed(() => {\n    return `grid-template-columns: minmax(150px, auto) minmax(200px, 1fr) minmax(100px, 120px) minmax(80px, 100px) minmax(100px, 120px) minmax(130px, auto) 80px;`;\n});\n</script>\n\n<template>\n    <div class=\"flow-root max-w-[100vw] overflow-x-auto\">\n        <div class=\"inline-block min-w-full align-middle\">\n            <div data-testid=\"report_table\" class=\"grid min-w-full\" :style=\"gridTemplate\">\n                <ReportTableHeading></ReportTableHeading>\n                <div v-if=\"reports.length === 0\" class=\"col-span-7 py-24 text-center\">\n                    <FolderPlusIcon class=\"w-8 text-icon-default inline pb-2\"></FolderPlusIcon>\n                    <h3 class=\"text-text-primary font-semibold\">No shared reports found</h3>\n                    <p v-if=\"canCreateProjects()\" class=\"pb-5\">\n                        Go to the overview to create a report\n                    </p>\n                    <SecondaryButton :icon=\"PlusIcon\" @click=\"router.visit(route('reporting'))\"\n                        >Go to overview\n                    </SecondaryButton>\n                </div>\n                <template v-for=\"report in reports\" :key=\"report.id\">\n                    <ReportTableRow :report=\"report\"></ReportTableRow>\n                </template>\n            </div>\n        </div>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Common/Report/ReportTableHeading.vue",
    "content": "<script setup lang=\"ts\">\nimport TableHeading from '@/Components/Common/TableHeading.vue';\n</script>\n\n<template>\n    <TableHeading>\n        <div class=\"py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12\">\n            Name\n        </div>\n        <div class=\"px-3 py-1.5 text-left text-text-tertiary\">Description</div>\n        <div class=\"px-3 py-1.5 text-left text-text-tertiary\">Created At</div>\n        <div class=\"px-3 py-1.5 text-left text-text-tertiary\">Visibility</div>\n        <div class=\"px-3 py-1.5 text-left text-text-tertiary\">Expires At</div>\n        <div class=\"px-3 py-1.5 text-left text-text-tertiary\">Public URL</div>\n        <div class=\"relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12\">\n            <span class=\"sr-only\">Edit</span>\n        </div>\n    </TableHeading>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Report/ReportTableRow.vue",
    "content": "<script setup lang=\"ts\">\nimport { type ComputedRef, computed, inject, ref } from 'vue';\nimport TableRow from '@/Components/TableRow.vue';\nimport { api, type Report, type Organization } from '@/packages/api/src';\nimport ReportMoreOptionsDropdown from '@/Components/Common/Report/ReportMoreOptionsDropdown.vue';\nimport ReportEditModal from '@/Components/Common/Report/ReportEditModal.vue';\nimport { SecondaryButton } from '@/packages/ui/src';\nimport { useClipboard } from '@vueuse/core';\nimport { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';\nimport { GlobeAltIcon, LockClosedIcon } from '@heroicons/vue/24/outline';\nimport { useMutation, useQueryClient } from '@tanstack/vue-query';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport { useNotificationsStore } from '@/utils/notification';\nimport { formatDateLocalized } from '@/packages/ui/src/utils/time';\n\nconst props = defineProps<{\n    report: Report;\n}>();\n\nconst showEditReportModal = ref(false);\n\nconst { copy, copied, isSupported } = useClipboard({ legacy: true });\nconst { handleApiRequestNotifications } = useNotificationsStore();\nconst organization = inject<ComputedRef<Organization | undefined>>('organization');\nconst dateFormat = computed(() => organization?.value?.date_format);\n\nfunction openSharableLink() {\n    const link = props.report.shareable_link;\n    if (link) {\n        window.open(link, '_blank')?.focus();\n    }\n}\n\nconst queryClient = useQueryClient();\nconst deleteReportMutation = useMutation({\n    mutationFn: async (reportId: string) => {\n        const organizationId = getCurrentOrganizationId();\n        if (organizationId === null) {\n            throw new Error('No current organization id - update report');\n        }\n        return await api.deleteReport(undefined, {\n            params: {\n                organization: organizationId,\n                report: reportId,\n            },\n        });\n    },\n    onSuccess: () => {\n        queryClient.invalidateQueries({\n            queryKey: ['reports'],\n        });\n    },\n});\nasync function deleteReport() {\n    await handleApiRequestNotifications(\n        () => deleteReportMutation.mutateAsync(props.report.id),\n        'Success',\n        'Error'\n    );\n}\n</script>\n\n<template>\n    <ReportEditModal v-model:show=\"showEditReportModal\" :original-report=\"report\"></ReportEditModal>\n    <TableRow>\n        <div\n            class=\"whitespace-nowrap min-w-0 flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12\">\n            <span class=\"overflow-ellipsis overflow-hidden\">\n                {{ report.name }}\n            </span>\n        </div>\n        <div class=\"whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary\">\n            <span class=\"overflow-ellipsis overflow-hidden\">\n                {{ report.description }}\n            </span>\n        </div>\n        <div class=\"whitespace-nowrap px-3 py-4 text-sm text-text-secondary\">\n            {{ formatDateLocalized(report.created_at, dateFormat) }}\n        </div>\n        <div\n            class=\"whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex items-center gap-1.5\">\n            <GlobeAltIcon v-if=\"report.is_public\" class=\"w-4 h-4 shrink-0 text-text-tertiary\" />\n            <LockClosedIcon v-else class=\"w-4 h-4 shrink-0 text-text-tertiary\" />\n            <span>{{ report.is_public ? 'Public' : 'Private' }}</span>\n        </div>\n        <div class=\"whitespace-nowrap px-3 py-4 text-sm text-text-secondary\">\n            <span v-if=\"report.public_until\">\n                {{ formatDateLocalized(report.public_until, dateFormat) }}\n            </span>\n            <span v-else>Never</span>\n        </div>\n        <div class=\"whitespace-nowrap px-3 flex items-center text-sm text-text-secondary\">\n            <div v-if=\"report.shareable_link\" class=\"space-x-2 flex items-center\">\n                <SecondaryButton v-if=\"isSupported\" @click=\"copy(report.shareable_link)\">\n                    <span v-if=\"!copied\">Copy URL</span>\n                    <span v-else>Copied!</span>\n                </SecondaryButton>\n                <button\n                    class=\"outline-0 focus-visible:ring-2 w-6 h-6 flex items-center justify-center rounded focus-visible:ring-ring\"\n                    @click=\"openSharableLink\">\n                    <ArrowTopRightOnSquareIcon\n                        class=\"w-4 text-text-tertiary hover:text-text-secondary transition\"></ArrowTopRightOnSquareIcon>\n                </button>\n            </div>\n            <span v-else> -- </span>\n        </div>\n        <div\n            class=\"relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12\">\n            <ReportMoreOptionsDropdown\n                :report=\"report\"\n                @edit=\"showEditReportModal = true\"\n                @delete=\"deleteReport\"></ReportMoreOptionsDropdown>\n        </div>\n    </TableRow>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Reporting/ReportingChart.vue",
    "content": "<script setup lang=\"ts\">\nimport VChart, { THEME_KEY } from 'vue-echarts';\nimport { computed, provide, inject, shallowRef, type ComputedRef } from 'vue';\nimport LinearGradient from 'zrender/lib/graphic/LinearGradient';\nimport { formatDate, formatHumanReadableDuration, formatWeek } from '@/packages/ui/src/utils/time';\nimport { use } from 'echarts/core';\nimport { CanvasRenderer } from 'echarts/renderers';\nimport { BarChart } from 'echarts/charts';\nimport {\n    GridComponent,\n    LegendComponent,\n    TitleComponent,\n    TooltipComponent,\n} from 'echarts/components';\nimport type { AggregatedTimeEntries, Organization } from '@/packages/api/src';\nimport { useCssVariable } from '@/utils/useCssVariable';\n\nuse([CanvasRenderer, BarChart, TitleComponent, GridComponent, TooltipComponent, LegendComponent]);\n\nprovide(THEME_KEY, 'dark');\n\nconst organization = inject<ComputedRef<Organization>>('organization');\nconst chart = shallowRef(null);\ntype GroupedData = AggregatedTimeEntries['grouped_data'];\n\nconst props = defineProps<{\n    groupedData: GroupedData;\n    groupedType: string | null;\n}>();\n\nconst xAxisLabels = computed(() => {\n    if (props.groupedType === 'week') {\n        return props?.groupedData?.map((el) => formatWeek(el.key));\n    }\n    return props?.groupedData?.map((el) =>\n        formatDate(el.key ?? '', organization?.value?.date_format)\n    );\n});\nconst accentColor = useCssVariable('--theme-color-chart');\nconst labelColor = useCssVariable('--color-text-secondary');\nconst markLineColor = useCssVariable('--color-border-secondary');\nconst splitLineColor = useCssVariable('--color-border-tertiary');\n\nconst seriesData = computed(() => {\n    return props?.groupedData?.map((el) => {\n        return {\n            value: el.seconds,\n            ...{\n                itemStyle: {\n                    borderColor: new LinearGradient(0, 0, 0, 1, [\n                        {\n                            offset: 0,\n                            color: 'rgba(' + accentColor.value + ',0.7)',\n                        },\n                        {\n                            offset: 1,\n                            color: 'rgba(' + accentColor.value + ',0.5)',\n                        },\n                    ]),\n                    emphasis: {\n                        color: new LinearGradient(0, 0, 0, 1, [\n                            {\n                                offset: 0,\n                                color: 'rgba(' + accentColor.value + ',0.9)',\n                            },\n                            {\n                                offset: 1,\n                                color: 'rgba(' + accentColor.value + ',0.7)',\n                            },\n                        ]),\n                    },\n                    borderRadius: [12, 12, 0, 0],\n                    color: new LinearGradient(0, 0, 0, 1, [\n                        {\n                            offset: 0,\n                            color: 'rgba(' + accentColor.value + ',0.7)',\n                        },\n                        {\n                            offset: 1,\n                            color: 'rgba(' + accentColor.value + ',0.5)',\n                        },\n                    ]),\n                },\n            },\n        };\n    });\n});\n\nconst option = computed(() => ({\n    tooltip: {\n        trigger: 'item',\n    },\n    grid: {\n        top: 0,\n        right: 0,\n        bottom: 50,\n        left: 0,\n    },\n    backgroundColor: 'transparent',\n    xAxis: {\n        type: 'category',\n        data: xAxisLabels.value,\n        markLine: {\n            lineStyle: {\n                color: markLineColor.value,\n                type: 'dashed',\n            },\n        },\n        axisLine: {\n            show: false,\n        },\n        axisLabel: {\n            fontSize: 12,\n            fontWeight: 400,\n            color: labelColor.value,\n            margin: 16,\n            fontFamily: 'Inter, sans-serif',\n        },\n        axisTick: {\n            show: false,\n        },\n    },\n    yAxis: {\n        type: 'value',\n        axisLabel: {\n            show: false,\n        },\n        splitLine: {\n            lineStyle: {\n                color: splitLineColor.value,\n            },\n        },\n    },\n    series: [\n        {\n            data: seriesData.value,\n            type: 'bar',\n            tooltip: {\n                valueFormatter: (value: number) => {\n                    return formatHumanReadableDuration(\n                        value,\n                        organization?.value?.interval_format,\n                        organization?.value?.number_format\n                    );\n                },\n            },\n        },\n    ],\n}));\n</script>\n\n<template>\n    <div class=\"w-[calc(100%-1px)]\">\n        <v-chart\n            v-if=\"groupedData && groupedData?.length > 0\"\n            ref=\"chart\"\n            :autoresize=\"true\"\n            class=\"chart\"\n            :option=\"option\" />\n        <div v-else class=\"chart flex flex-col items-center justify-center\">\n            <p class=\"text-lg text-text-primary font-semibold\">No time entries found</p>\n            <p>Try to change the filters and time range</p>\n        </div>\n    </div>\n</template>\n\n<style scoped>\n.chart {\n    height: 300px;\n    background: transparent;\n}\n</style>\n"
  },
  {
    "path": "resources/js/Components/Common/Reporting/ReportingExportButton.vue",
    "content": "<script setup lang=\"ts\">\nimport { SecondaryButton } from '@/packages/ui/src';\nimport { ArrowDownTrayIcon, LockClosedIcon } from '@heroicons/vue/20/solid';\nimport {\n    DropdownMenu,\n    DropdownMenuContent,\n    DropdownMenuItem,\n    DropdownMenuTrigger,\n} from '@/Components/ui/dropdown-menu';\nimport type { ExportFormat } from '@/types/reporting';\nimport { ref } from 'vue';\nimport { isAllowedToPerformPremiumAction } from '@/utils/billing';\nimport UpgradeModal from '@/Components/Common/UpgradeModal.vue';\n\nconst props = defineProps<{\n    download: (format: ExportFormat) => Promise<void>;\n}>();\nconst loading = ref(false);\nconst showPremiumModal = ref(false);\nfunction triggerDownload(format: ExportFormat) {\n    if (format === 'pdf' && !isAllowedToPerformPremiumAction()) {\n        showPremiumModal.value = true;\n        return;\n    }\n    loading.value = true;\n    props.download(format).finally(() => {\n        loading.value = false;\n    });\n}\n</script>\n\n<template>\n    <DropdownMenu>\n        <DropdownMenuTrigger as-child>\n            <SecondaryButton :icon=\"ArrowDownTrayIcon\" :loading> Export </SecondaryButton>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent align=\"end\">\n            <DropdownMenuItem @click=\"triggerDownload('pdf')\">\n                <div class=\"flex items-center space-x-2\">\n                    <span>Export as PDF</span>\n                    <LockClosedIcon\n                        v-if=\"!isAllowedToPerformPremiumAction()\"\n                        class=\"w-3.5 text-text-tertiary\" />\n                </div>\n            </DropdownMenuItem>\n            <DropdownMenuItem @click=\"triggerDownload('xlsx')\"> Export as Excel </DropdownMenuItem>\n            <DropdownMenuItem @click=\"triggerDownload('csv')\"> Export as CSV </DropdownMenuItem>\n            <DropdownMenuItem @click=\"triggerDownload('ods')\"> Export as ODS </DropdownMenuItem>\n        </DropdownMenuContent>\n    </DropdownMenu>\n    <UpgradeModal v-model:show=\"showPremiumModal\">\n        <strong>PDF Reports</strong> are only available in solidtime Professional.\n    </UpgradeModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Reporting/ReportingExportModal.vue",
    "content": "<script setup lang=\"ts\">\nimport { ArrowDownTrayIcon, CheckCircleIcon, XMarkIcon } from '@heroicons/vue/20/solid';\nimport { Modal, PrimaryButton } from '@/packages/ui/src';\nconst props = defineProps<{\n    exportUrl: string | null;\n}>();\n\nconst showExportModal = defineModel('show', { default: false });\n\nfunction downloadCurrentExport() {\n    if (props.exportUrl) {\n        window.open(props.exportUrl, '_blank')?.focus();\n    }\n}\n</script>\n\n<template>\n    <Modal closeable max-width=\"lg\" :show=\"showExportModal\" @close=\"showExportModal = false\">\n        <button\n            class=\"text-text-tertiary w-6 mx-auto absolute focus-visible:outline-none focus-visible:ring-2 rounded-full focus-visible:ring-ring transition focus-visible:text-text-primary hover:text-text-primary top-2 right-2\">\n            <XMarkIcon @click=\"showExportModal = false\"></XMarkIcon>\n        </button>\n        <div class=\"text-center text-text-primary py-6\">\n            <div class=\"flex items-center font-semibold text-lg justify-center space-x-2 pb-2\">\n                <CheckCircleIcon class=\"text-text-tertiary w-6\"></CheckCircleIcon>\n                <span> Export Successful! </span>\n            </div>\n            <div class=\"text-center text-sm max-w-64 mx-auto\">\n                <p class=\"pb-5\">Your export is ready, you can download it with the button below.</p>\n                <PrimaryButton :icon=\"ArrowDownTrayIcon\" @click=\"downloadCurrentExport\"\n                    >Download</PrimaryButton\n                >\n            </div>\n        </div>\n    </Modal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Reporting/ReportingFilterBadge.vue",
    "content": "<script setup lang=\"ts\">\nimport { Button } from '@/packages/ui/src';\n\nconst props = defineProps<{\n    icon: Component;\n    title: string;\n    count?: number;\n    active?: boolean;\n}>();\nimport { type Component, computed } from 'vue';\nimport { twMerge } from 'tailwind-merge';\n\nconst activeClass = computed(() => {\n    if (props.active) {\n        return 'border-accent-300/50 bg-accent-50 hover:bg-accent-100 dark:border-accent-300/50 dark:bg-accent-300/5 dark:hover:bg-accent-300/10';\n    }\n    return '';\n});\n\nconst iconClass = computed(() => {\n    return twMerge(\n        '-ml-0.5 h-4 w-4',\n        props.active ? 'dark:text-accent-300/80 text-accent-400/80' : 'text-text-quaternary'\n    );\n});\n</script>\n\n<template>\n    <Button variant=\"outline\" size=\"sm\" :class=\"twMerge(activeClass)\">\n        <component :is=\"icon\" :class=\"iconClass\"></component>\n        <span class=\"text-nowrap\"> {{ title }} </span>\n        <div\n            v-if=\"count\"\n            class=\"bg-accent-300/20 w-5 h-5 font-medium rounded flex items-center transition justify-center\">\n            {{ count }}\n        </div>\n    </Button>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Reporting/ReportingFilterBar.vue",
    "content": "<script setup lang=\"ts\">\nimport { CheckCircleIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';\nimport { FolderIcon } from '@heroicons/vue/16/solid';\nimport BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';\nimport ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';\nimport TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';\nimport ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';\nimport MemberMultiselectDropdown from '@/Components/Common/Member/MemberMultiselectDropdown.vue';\nimport ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';\nimport ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue';\nimport {\n    Select,\n    SelectContent,\n    SelectItem,\n    SelectTrigger,\n    SelectValue,\n} from '@/Components/ui/select';\nimport MainContainer from '@/packages/ui/src/MainContainer.vue';\nimport DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';\nimport TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';\nimport { useTagsQuery } from '@/utils/useTagsQuery';\nimport { useTagsStore } from '@/utils/useTags';\n\ntype TimeEntryRoundingType = 'up' | 'down' | 'nearest';\n\nconst selectedMembers = defineModel<string[]>('selectedMembers', { required: true });\nconst selectedProjects = defineModel<string[]>('selectedProjects', { required: true });\nconst selectedTasks = defineModel<string[]>('selectedTasks', { required: true });\nconst selectedClients = defineModel<string[]>('selectedClients', { required: true });\nconst selectedTags = defineModel<string[]>('selectedTags', { required: true });\nconst billable = defineModel<'true' | 'false' | null>('billable', { required: true });\nconst roundingEnabled = defineModel<boolean>('roundingEnabled', { required: true });\nconst roundingType = defineModel<TimeEntryRoundingType>('roundingType', { required: true });\nconst roundingMinutes = defineModel<number>('roundingMinutes', { required: true });\nconst startDate = defineModel<string>('startDate', { required: true });\nconst endDate = defineModel<string>('endDate', { required: true });\n\nconst emit = defineEmits<{\n    submit: [];\n}>();\n\nconst { tags } = useTagsQuery();\n\nasync function createTag(name: string) {\n    return await useTagsStore().createTag(name);\n}\n</script>\n\n<template>\n    <div class=\"py-2.5 w-full border-b border-default-background-separator\">\n        <MainContainer class=\"sm:flex space-y-4 sm:space-y-0 justify-between\">\n            <div class=\"flex flex-wrap items-center space-y-2 sm:space-y-0 space-x-3\">\n                <div class=\"text-sm font-medium\">Filters</div>\n                <MemberMultiselectDropdown v-model=\"selectedMembers\" @submit=\"emit('submit')\">\n                    <template #trigger>\n                        <ReportingFilterBadge\n                            :count=\"selectedMembers.length\"\n                            :active=\"selectedMembers.length > 0\"\n                            title=\"Members\"\n                            :icon=\"UserGroupIcon\" />\n                    </template>\n                </MemberMultiselectDropdown>\n                <ProjectMultiselectDropdown v-model=\"selectedProjects\" @submit=\"emit('submit')\">\n                    <template #trigger>\n                        <ReportingFilterBadge\n                            :count=\"selectedProjects.length\"\n                            :active=\"selectedProjects.length > 0\"\n                            title=\"Projects\"\n                            :icon=\"FolderIcon\" />\n                    </template>\n                </ProjectMultiselectDropdown>\n                <TaskMultiselectDropdown v-model=\"selectedTasks\" @submit=\"emit('submit')\">\n                    <template #trigger>\n                        <ReportingFilterBadge\n                            :count=\"selectedTasks.length\"\n                            :active=\"selectedTasks.length > 0\"\n                            title=\"Tasks\"\n                            :icon=\"CheckCircleIcon\" />\n                    </template>\n                </TaskMultiselectDropdown>\n                <ClientMultiselectDropdown v-model=\"selectedClients\" @submit=\"emit('submit')\">\n                    <template #trigger>\n                        <ReportingFilterBadge\n                            :count=\"selectedClients.length\"\n                            :active=\"selectedClients.length > 0\"\n                            title=\"Clients\"\n                            :icon=\"FolderIcon\" />\n                    </template>\n                </ClientMultiselectDropdown>\n                <TagDropdown\n                    v-model=\"selectedTags\"\n                    :create-tag\n                    :tags=\"tags\"\n                    @submit=\"emit('submit')\">\n                    <template #trigger>\n                        <ReportingFilterBadge\n                            :count=\"selectedTags.length\"\n                            :active=\"selectedTags.length > 0\"\n                            title=\"Tags\"\n                            :icon=\"TagIcon\" />\n                    </template>\n                </TagDropdown>\n\n                <Select v-model=\"billable\" @update:model-value=\"emit('submit')\">\n                    <SelectTrigger\n                        size=\"sm\"\n                        variant=\"outline\"\n                        :active=\"billable !== null\"\n                        :show-chevron=\"false\">\n                        <SelectValue class=\"flex items-center gap-2\">\n                            <BillableIcon\n                                class=\"h-4\"\n                                :class=\"\n                                    billable !== null\n                                        ? 'dark:text-accent-300/80 text-accent-400/80'\n                                        : 'text-text-quaternary'\n                                \" />\n                            <span class=\"text-text-secondary\">{{\n                                billable === 'false' ? 'Non Billable' : 'Billable'\n                            }}</span>\n                        </SelectValue>\n                    </SelectTrigger>\n                    <SelectContent>\n                        <SelectItem :value=\"null\">Both</SelectItem>\n                        <SelectItem value=\"true\">Billable</SelectItem>\n                        <SelectItem value=\"false\">Non Billable</SelectItem>\n                    </SelectContent>\n                </Select>\n                <ReportingRoundingControls\n                    v-model:enabled=\"roundingEnabled\"\n                    v-model:type=\"roundingType\"\n                    v-model:minutes=\"roundingMinutes\"\n                    @change=\"emit('submit')\" />\n            </div>\n            <div>\n                <DateRangePicker\n                    v-model:start=\"startDate\"\n                    v-model:end=\"endDate\"\n                    @submit=\"emit('submit')\" />\n            </div>\n        </MainContainer>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Common/Reporting/ReportingGroupBySelect.vue",
    "content": "<script setup lang=\"ts\">\nimport {\n    Select,\n    SelectContent,\n    SelectItem,\n    SelectTrigger,\n    SelectValue,\n} from '@/Components/ui/select';\nimport { type Component, computed } from 'vue';\n\nconst model = defineModel<string | null>({ default: null });\nconst props = defineProps<{\n    groupByOptions: { value: string; label: string; icon: Component }[];\n}>();\nconst emit = defineEmits<{\n    changed: [];\n}>();\nconst icon = computed(() => {\n    return props.groupByOptions.find((option) => option.value === model.value)?.icon;\n});\nconst title = computed(() => {\n    return props.groupByOptions.find((option) => option.value === model.value)?.label;\n});\n</script>\n\n<template>\n    <Select v-model=\"model\" @update:model-value=\"emit('changed')\">\n        <SelectTrigger size=\"sm\" :show-chevron=\"false\">\n            <SelectValue class=\"flex items-center gap-2\">\n                <component :is=\"icon\" class=\"h-4 text-icon-default\" />\n                <span>{{ title }}</span>\n            </SelectValue>\n        </SelectTrigger>\n        <SelectContent>\n            <SelectItem v-for=\"option in groupByOptions\" :key=\"option.value\" :value=\"option.value\">\n                {{ option.label }}\n            </SelectItem>\n        </SelectContent>\n    </Select>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Reporting/ReportingOverview.vue",
    "content": "<script setup lang=\"ts\">\nimport {\n    ChartBarIcon,\n    ArrowDownTrayIcon,\n    EllipsisVerticalIcon,\n    LockClosedIcon,\n} from '@heroicons/vue/20/solid';\nimport { SaveIcon } from 'lucide-vue-next';\nimport { getOrganizationCurrencyString } from '@/utils/money';\nimport {\n    formatHumanReadableDuration,\n    getDayJsInstance,\n    getLocalizedDayJs,\n} from '@/packages/ui/src/utils/time';\nimport { formatCents } from '@/packages/ui/src/utils/money';\nimport ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';\nimport ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';\nimport PageTitle from '@/Components/Common/PageTitle.vue';\nimport ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';\nimport ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';\nimport MainContainer from '@/packages/ui/src/MainContainer.vue';\nimport ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';\nimport ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';\nimport ReportingFilterBar from '@/Components/Common/Reporting/ReportingFilterBar.vue';\nimport { SecondaryButton } from '@/packages/ui/src';\nimport {\n    DropdownMenu,\n    DropdownMenuContent,\n    DropdownMenuItem,\n    DropdownMenuTrigger,\n} from '@/Components/ui/dropdown-menu';\nimport ReportCreateModal from '@/Components/Common/Report/ReportCreateModal.vue';\nimport UpgradeModal from '@/Components/Common/UpgradeModal.vue';\nimport { canCreateReports } from '@/utils/permissions';\nimport { isAllowedToPerformPremiumAction } from '@/utils/billing';\nimport { computed, type ComputedRef, inject, ref, watch } from 'vue';\nimport { type GroupingOption, useReportingStore } from '@/utils/useReporting';\nimport {\n    type AggregatedTimeEntries,\n    type AggregatedTimeEntriesQueryParams,\n    api,\n    type CreateReportBodyProperties,\n    type Organization,\n} from '@/packages/api/src';\nimport { getCurrentMembershipId, getCurrentOrganizationId, getCurrentRole } from '@/utils/useUser';\nimport { useSessionStorage, useStorage } from '@vueuse/core';\nimport { useNotificationsStore } from '@/utils/notification';\nimport type { ExportFormat } from '@/types/reporting';\nimport { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';\nimport { useProjectsQuery } from '@/utils/useProjectsQuery';\nimport { useAggregatedTimeEntriesQuery } from '@/utils/useAggregatedTimeEntriesQuery';\n\ntype TimeEntryRoundingType = 'up' | 'down' | 'nearest';\n\nconst { handleApiRequestNotifications } = useNotificationsStore();\n\nconst startDate = useSessionStorage<string>(\n    'reporting-start-date',\n    getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()\n);\nconst endDate = useSessionStorage<string>(\n    'reporting-end-date',\n    getLocalizedDayJs(getDayJsInstance()().format()).format()\n);\nconst selectedTags = ref<string[]>([]);\nconst selectedProjects = ref<string[]>([]);\nconst selectedMembers = ref<string[]>([]);\nconst selectedTasks = ref<string[]>([]);\nconst selectedClients = ref<string[]>([]);\n\nconst billable = ref<'true' | 'false' | null>(null);\nconst roundingEnabled = ref<boolean>(false);\nconst roundingType = ref<TimeEntryRoundingType>('nearest');\nconst roundingMinutes = ref<number>(15);\n\nconst group = useStorage<GroupingOption>('reporting-group', 'project');\nconst subGroup = useStorage<GroupingOption>('reporting-sub-group', 'task');\n\nconst reportingStore = useReportingStore();\nconst { groupByOptions, getNameForReportingRowEntry, emptyPlaceholder } = reportingStore;\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n\nconst showBillableRate = computed(() => {\n    return !!(\n        getCurrentRole() !== 'employee' || organization?.value?.employees_can_see_billable_rates\n    );\n});\n\n// Ensure sub-group falls back when it collides with group\nwatch(\n    group,\n    () => {\n        if (group.value === subGroup.value) {\n            const fallbackOption = groupByOptions.find((el) => el.value !== group.value);\n            if (fallbackOption?.value) {\n                subGroup.value = fallbackOption.value;\n            }\n        }\n    },\n    { immediate: true }\n);\n\nfunction getOptimalGroupingOption(start: string, end: string): 'day' | 'week' | 'month' {\n    const diffInDays = getDayJsInstance()(end).diff(getDayJsInstance()(start), 'd');\n\n    if (diffInDays <= 31) {\n        return 'day';\n    } else if (diffInDays <= 200) {\n        return 'week';\n    } else {\n        return 'month';\n    }\n}\n\nconst filterParams = computed<AggregatedTimeEntriesQueryParams>(() => {\n    return {\n        start: getLocalizedDayJs(startDate.value).startOf('day').utc().format(),\n        end: getLocalizedDayJs(endDate.value).endOf('day').utc().format(),\n        member_ids: selectedMembers.value.length > 0 ? selectedMembers.value : undefined,\n        project_ids: selectedProjects.value.length > 0 ? selectedProjects.value : undefined,\n        task_ids: selectedTasks.value.length > 0 ? selectedTasks.value : undefined,\n        client_ids: selectedClients.value.length > 0 ? selectedClients.value : undefined,\n        tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,\n        billable: billable.value !== null ? billable.value : undefined,\n        member_id: getCurrentRole() === 'employee' ? getCurrentMembershipId() : undefined,\n        rounding_type: roundingEnabled.value ? roundingType.value : undefined,\n        rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,\n    };\n});\n\nconst graphQueryParams = computed<AggregatedTimeEntriesQueryParams>(() => {\n    return {\n        ...filterParams.value,\n        fill_gaps_in_time_groups: 'true',\n        group: getOptimalGroupingOption(startDate.value, endDate.value),\n    };\n});\n\nconst tableQueryParams = computed<AggregatedTimeEntriesQueryParams>(() => {\n    return {\n        ...filterParams.value,\n        group: group.value,\n        sub_group: subGroup.value,\n    };\n});\n\nconst { data: graphResponse } = useAggregatedTimeEntriesQuery('graph', graphQueryParams);\nconst { data: tableResponse } = useAggregatedTimeEntriesQuery('table', tableQueryParams);\n\nconst aggregatedGraphTimeEntries = computed<AggregatedTimeEntries | undefined>(() => {\n    return graphResponse.value?.data as AggregatedTimeEntries | undefined;\n});\n\nconst aggregatedTableTimeEntries = computed<AggregatedTimeEntries | undefined>(() => {\n    return tableResponse.value?.data as AggregatedTimeEntries | undefined;\n});\n\nconst reportProperties = computed(() => {\n    const { billable: billableFilter, ...rest } = filterParams.value;\n\n    let billableValue: boolean | null = null;\n    if (billableFilter === 'true') {\n        billableValue = true;\n    } else if (billableFilter === 'false') {\n        billableValue = false;\n    }\n\n    return {\n        ...rest,\n        billable: billableValue,\n        group: group.value,\n        sub_group: subGroup.value,\n        history_group: getOptimalGroupingOption(startDate.value, endDate.value),\n    } as CreateReportBodyProperties;\n});\n\nasync function downloadExport(format: ExportFormat) {\n    const organizationId = getCurrentOrganizationId();\n    if (organizationId) {\n        const response = await handleApiRequestNotifications(\n            () =>\n                api.exportAggregatedTimeEntries({\n                    params: {\n                        organization: organizationId,\n                    },\n                    queries: {\n                        ...filterParams.value,\n                        group: group.value,\n                        sub_group: subGroup.value,\n                        history_group: getOptimalGroupingOption(startDate.value, endDate.value),\n                        format: format,\n                    },\n                }),\n            'Export successful',\n            'Export failed'\n        );\n\n        if (response?.download_url) {\n            showExportModal.value = true;\n            exportUrl.value = response.download_url as string;\n        }\n    }\n}\n\nconst { projects } = useProjectsQuery();\nconst showExportModal = ref(false);\nconst exportUrl = ref<string | null>(null);\nconst showCreateReportModal = ref(false);\nconst showPremiumModal = ref(false);\nconst exportLoading = ref(false);\n\nfunction triggerExport(format: ExportFormat) {\n    if (format === 'pdf' && !isAllowedToPerformPremiumAction()) {\n        showPremiumModal.value = true;\n        return;\n    }\n    exportLoading.value = true;\n    downloadExport(format).finally(() => {\n        exportLoading.value = false;\n    });\n}\n\nfunction onSaveReportClick() {\n    if (isAllowedToPerformPremiumAction()) {\n        showCreateReportModal.value = true;\n    } else {\n        showPremiumModal.value = true;\n    }\n}\n\nconst groupedPieChartData = computed(() => {\n    return (\n        aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {\n            const name = getNameForReportingRowEntry(\n                entry.key,\n                aggregatedTableTimeEntries.value?.grouped_type ?? null\n            );\n            let color = getRandomColorWithSeed(entry.key ?? 'none');\n            if (\n                name &&\n                aggregatedTableTimeEntries.value?.grouped_type &&\n                emptyPlaceholder[aggregatedTableTimeEntries.value.grouped_type] === name\n            ) {\n                color = '#CCCCCC';\n            } else if (aggregatedTableTimeEntries.value?.grouped_type === 'project') {\n                color =\n                    projects.value?.find((project) => project.id === entry.key)?.color ?? '#CCCCCC';\n            }\n            return {\n                value: entry.seconds,\n                name:\n                    getNameForReportingRowEntry(\n                        entry.key,\n                        aggregatedTableTimeEntries.value?.grouped_type ?? null\n                    ) ?? '',\n                color: color,\n            };\n        }) ?? []\n    );\n});\n\nconst tableData = computed(() => {\n    return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {\n        return {\n            seconds: entry.seconds,\n            cost: entry.cost,\n            description: getNameForReportingRowEntry(\n                entry.key,\n                aggregatedTableTimeEntries.value?.grouped_type ?? null\n            ),\n            grouped_data:\n                entry.grouped_data?.map((el) => {\n                    return {\n                        seconds: el.seconds,\n                        cost: el.cost,\n                        description: getNameForReportingRowEntry(el.key, entry.grouped_type),\n                    };\n                }) ?? [],\n        };\n    });\n});\n</script>\n\n<template>\n    <ReportingExportModal\n        v-model:show=\"showExportModal\"\n        :export-url=\"exportUrl\"></ReportingExportModal>\n    <ReportCreateModal\n        v-model:show=\"showCreateReportModal\"\n        :properties=\"reportProperties\"></ReportCreateModal>\n    <UpgradeModal v-model:show=\"showPremiumModal\">\n        This feature is only available in solidtime Professional.\n    </UpgradeModal>\n    <MainContainer\n        class=\"h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center\">\n        <div class=\"flex items-center space-x-3 sm:space-x-6\">\n            <PageTitle :icon=\"ChartBarIcon\" title=\"Reporting\"></PageTitle>\n            <ReportingTabNavbar active=\"reporting\" class=\"hidden sm:flex\"></ReportingTabNavbar>\n        </div>\n        <div class=\"hidden sm:flex space-x-2\">\n            <DropdownMenu>\n                <DropdownMenuTrigger as-child>\n                    <SecondaryButton :icon=\"ArrowDownTrayIcon\" :loading=\"exportLoading\">\n                        Export\n                    </SecondaryButton>\n                </DropdownMenuTrigger>\n                <DropdownMenuContent align=\"end\">\n                    <DropdownMenuItem @click=\"triggerExport('pdf')\">\n                        <div class=\"flex items-center space-x-2\">\n                            <span>Export as PDF</span>\n                            <LockClosedIcon\n                                v-if=\"!isAllowedToPerformPremiumAction()\"\n                                class=\"w-3.5 text-text-tertiary\" />\n                        </div>\n                    </DropdownMenuItem>\n                    <DropdownMenuItem @click=\"triggerExport('xlsx')\">\n                        Export as Excel\n                    </DropdownMenuItem>\n                    <DropdownMenuItem @click=\"triggerExport('csv')\">\n                        Export as CSV\n                    </DropdownMenuItem>\n                    <DropdownMenuItem @click=\"triggerExport('ods')\">\n                        Export as ODS\n                    </DropdownMenuItem>\n                </DropdownMenuContent>\n            </DropdownMenu>\n            <SecondaryButton v-if=\"canCreateReports()\" :icon=\"SaveIcon\" @click=\"onSaveReportClick\">\n                Save Report\n            </SecondaryButton>\n        </div>\n        <DropdownMenu>\n            <DropdownMenuTrigger as-child class=\"sm:hidden\">\n                <button\n                    class=\"p-1.5 rounded-lg border border-border-tertiary text-text-secondary hover:text-text-primary hover:bg-secondary transition\"\n                    aria-label=\"More options\">\n                    <EllipsisVerticalIcon class=\"w-5 h-5\" />\n                </button>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent align=\"end\">\n                <DropdownMenuItem @click=\"triggerExport('pdf')\">\n                    <div class=\"flex items-center space-x-2\">\n                        <span>Export as PDF</span>\n                        <LockClosedIcon\n                            v-if=\"!isAllowedToPerformPremiumAction()\"\n                            class=\"w-3.5 text-text-tertiary\" />\n                    </div>\n                </DropdownMenuItem>\n                <DropdownMenuItem @click=\"triggerExport('xlsx')\">\n                    Export as Excel\n                </DropdownMenuItem>\n                <DropdownMenuItem @click=\"triggerExport('csv')\"> Export as CSV </DropdownMenuItem>\n                <DropdownMenuItem @click=\"triggerExport('ods')\"> Export as ODS </DropdownMenuItem>\n                <DropdownMenuItem v-if=\"canCreateReports()\" @click=\"onSaveReportClick\">\n                    Save Report\n                </DropdownMenuItem>\n            </DropdownMenuContent>\n        </DropdownMenu>\n    </MainContainer>\n    <MainContainer class=\"sm:hidden py-2 border-b border-default-background-separator\">\n        <ReportingTabNavbar active=\"reporting\"></ReportingTabNavbar>\n    </MainContainer>\n    <ReportingFilterBar\n        v-model:selected-members=\"selectedMembers\"\n        v-model:selected-projects=\"selectedProjects\"\n        v-model:selected-tasks=\"selectedTasks\"\n        v-model:selected-clients=\"selectedClients\"\n        v-model:selected-tags=\"selectedTags\"\n        v-model:billable=\"billable\"\n        v-model:rounding-enabled=\"roundingEnabled\"\n        v-model:rounding-type=\"roundingType\"\n        v-model:rounding-minutes=\"roundingMinutes\"\n        v-model:start-date=\"startDate\"\n        v-model:end-date=\"endDate\" />\n    <MainContainer>\n        <div class=\"pt-10 w-full px-3 relative\">\n            <ReportingChart\n                :grouped-type=\"aggregatedGraphTimeEntries?.grouped_type ?? null\"\n                :grouped-data=\"aggregatedGraphTimeEntries?.grouped_data ?? null\"></ReportingChart>\n        </div>\n    </MainContainer>\n    <MainContainer>\n        <div class=\"sm:grid grid-cols-4 pt-6 items-start\">\n            <div class=\"col-span-3 bg-secondary rounded-lg border border-card-border pt-3\">\n                <div\n                    class=\"text-sm flex text-text-primary items-center space-x-3 font-medium px-6 border-b border-card-background-separator pb-3\">\n                    <span>Group by</span>\n                    <ReportingGroupBySelect\n                        v-model=\"group\"\n                        :group-by-options=\"groupByOptions\"></ReportingGroupBySelect>\n                    <span>and</span>\n                    <ReportingGroupBySelect\n                        v-model=\"subGroup\"\n                        :group-by-options=\"\n                            groupByOptions.filter((el) => el.value !== group)\n                        \"></ReportingGroupBySelect>\n                </div>\n                <div\n                    class=\"grid items-center\"\n                    :style=\"`grid-template-columns: 1fr 100px ${showBillableRate ? '150px' : ''}`\">\n                    <div\n                        class=\"contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:bg-secondary [&>*]:pb-1.5 [&>*]:pt-1 text-text-tertiary text-sm\">\n                        <div class=\"pl-6\">Name</div>\n                        <div class=\"text-right\" :class=\"!showBillableRate ? 'pr-6' : ''\">\n                            Duration\n                        </div>\n                        <div v-if=\"showBillableRate\" class=\"text-right pr-6\">Cost</div>\n                    </div>\n                    <template\n                        v-if=\"\n                            aggregatedTableTimeEntries?.grouped_data &&\n                            aggregatedTableTimeEntries.grouped_data?.length > 0\n                        \">\n                        <ReportingRow\n                            v-for=\"entry in tableData\"\n                            :key=\"entry.description ?? 'none'\"\n                            :currency=\"getOrganizationCurrencyString()\"\n                            :type=\"aggregatedTableTimeEntries.grouped_type\"\n                            :show-cost=\"showBillableRate\"\n                            :entry=\"entry\"></ReportingRow>\n                        <div class=\"contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]\">\n                            <div class=\"flex items-center pl-6 font-medium\">\n                                <span>Total</span>\n                            </div>\n                            <div\n                                class=\"justify-end flex items-center font-medium\"\n                                :class=\"!showBillableRate ? 'pr-6' : ''\">\n                                {{\n                                    formatHumanReadableDuration(\n                                        aggregatedTableTimeEntries.seconds,\n                                        organization?.interval_format,\n                                        organization?.number_format\n                                    )\n                                }}\n                            </div>\n                            <div\n                                v-if=\"showBillableRate\"\n                                class=\"justify-end pr-6 flex items-center font-medium\">\n                                {{\n                                    aggregatedTableTimeEntries.cost\n                                        ? formatCents(\n                                              aggregatedTableTimeEntries.cost,\n                                              getOrganizationCurrencyString(),\n                                              organization?.currency_format,\n                                              organization?.currency_symbol,\n                                              organization?.number_format\n                                          )\n                                        : '--'\n                                }}\n                            </div>\n                        </div>\n                    </template>\n                    <div\n                        v-else\n                        class=\"chart flex flex-col items-center justify-center py-12\"\n                        :class=\"showBillableRate ? 'col-span-3' : 'col-span-2'\">\n                        <p class=\"text-lg text-text-primary font-medium\">No time entries found</p>\n                        <p>Try to change the filters and time range</p>\n                    </div>\n                </div>\n            </div>\n            <div class=\"px-2 lg:px-4\">\n                <ReportingPieChart :data=\"groupedPieChartData\"></ReportingPieChart>\n            </div>\n        </div>\n    </MainContainer>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Reporting/ReportingPieChart.vue",
    "content": "<script setup lang=\"ts\">\nimport VChart, { THEME_KEY } from 'vue-echarts';\nimport { computed, provide, inject, type ComputedRef } from 'vue';\nimport { use } from 'echarts/core';\nimport { CanvasRenderer } from 'echarts/renderers';\nimport { PieChart } from 'echarts/charts';\nimport {\n    GridComponent,\n    LegendComponent,\n    TitleComponent,\n    TooltipComponent,\n} from 'echarts/components';\nimport { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';\nimport { useCssVariable } from '@/utils/useCssVariable';\nimport type { Organization } from '@/packages/api/src';\n\nuse([CanvasRenderer, PieChart, TitleComponent, GridComponent, TooltipComponent, LegendComponent]);\n\nprovide(THEME_KEY, 'dark');\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n\ntype ReportingChartDataEntry = {\n    value: number;\n    name: string;\n    color: string;\n}[];\n\nconst props = defineProps<{\n    data: ReportingChartDataEntry | null;\n}>();\nconst labelColor = useCssVariable('--color-text-secondary');\n\nconst seriesData = computed(() => {\n    return props.data?.map((el) => {\n        return {\n            ...el,\n            ...{\n                itemStyle: {\n                    color: `${el.color}BB`,\n                },\n                emphasis: {\n                    itemStyle: {\n                        color: `${el.color}`,\n                    },\n                },\n            },\n        };\n    });\n});\nconst option = computed(() => ({\n    tooltip: {\n        trigger: 'item',\n    },\n    legend: {\n        show: true,\n        top: '250px',\n        textStyle: {\n            color: labelColor.value,\n        },\n    },\n    backgroundColor: 'transparent',\n    series: [\n        {\n            label: {\n                show: false,\n            },\n            tooltip: {\n                valueFormatter: (value: number) => {\n                    return formatHumanReadableDuration(\n                        value,\n                        organization?.value?.interval_format,\n                        organization?.value?.number_format\n                    );\n                },\n            },\n            data: seriesData.value,\n            radius: ['30%', '60%'],\n            top: '-45%',\n            type: 'pie',\n        },\n    ],\n}));\n</script>\n\n<template>\n    <v-chart\n        class=\"background-transparent max-w-[300px] mx-auto h-[460px]\"\n        :autoresize=\"true\"\n        :option=\"option\" />\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Reporting/ReportingRoundingControls.vue",
    "content": "<script setup lang=\"ts\">\nimport { Switch } from '@/Components/ui/switch';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/packages/ui/src';\nimport { Button } from '@/packages/ui/src';\nimport {\n    Select,\n    SelectContent,\n    SelectItem,\n    SelectTrigger,\n    SelectValue,\n} from '@/Components/ui/select';\nimport { Field, FieldLabel } from '@/packages/ui/src/field';\nimport {\n    NumberField,\n    NumberFieldInput,\n    NumberFieldContent,\n    NumberFieldIncrement,\n    NumberFieldDecrement,\n} from '@/Components/ui/number-field';\nimport { ArrowsUpDownIcon } from '@heroicons/vue/20/solid';\nimport { computed, ref, watch } from 'vue';\nimport { twMerge } from 'tailwind-merge';\nimport { isAllowedToPerformPremiumAction } from '@/utils/billing';\nimport { Link } from '@inertiajs/vue3';\nimport { CreditCardIcon } from '@heroicons/vue/20/solid';\n// TimeEntryRoundingType definition\nconst TimeEntryRoundingType = {\n    Up: 'up' as const,\n    Down: 'down' as const,\n    Nearest: 'nearest' as const,\n} as const;\n\ntype TimeEntryRoundingType = (typeof TimeEntryRoundingType)[keyof typeof TimeEntryRoundingType];\n\ninterface Props {\n    enabled: boolean;\n    type: TimeEntryRoundingType;\n    minutes: number;\n}\n\nconst props = defineProps<Props>();\n\nconst emit = defineEmits<{\n    'update:enabled': [value: boolean];\n    'update:type': [value: TimeEntryRoundingType];\n    'update:minutes': [value: number];\n    'change': [];\n}>();\n\nfunction updateEnabled(value: boolean) {\n    emit('update:enabled', value);\n    emit('change');\n}\n\nfunction updateType(value: TimeEntryRoundingType) {\n    emit('update:type', value);\n    emit('change');\n}\n\nfunction updateMinutes(value: number) {\n    emit('update:minutes', value);\n    emit('change');\n}\n\n// Predefined intervals\nconst predefinedIntervals = [\n    { value: '5', label: '5 minutes' },\n    { value: '6', label: '6 minutes' },\n    { value: '10', label: '10 minutes' },\n    { value: '15', label: '15 minutes' },\n    { value: '30', label: '30 minutes' },\n    { value: '60', label: '1 hour' },\n    { value: 'custom', label: 'Custom' },\n];\n\nconst showCustomInput = ref(false);\nconst customMinutes = ref(props.minutes);\nconst selectedInterval = ref('');\n\n// Compute the current interval value based on props\nconst currentInterval = computed(() => {\n    const predefined = predefinedIntervals.find(\n        (interval) => interval.value !== 'custom' && parseInt(interval.value) === props.minutes\n    );\n    return predefined ? predefined.value : 'custom';\n});\n\n// Initialize selectedInterval\nconst initializeSelectedInterval = () => {\n    selectedInterval.value = currentInterval.value;\n    showCustomInput.value = selectedInterval.value === 'custom';\n    if (showCustomInput.value) {\n        customMinutes.value = props.minutes;\n    }\n};\n\nfunction handleIntervalChange(value: string) {\n    selectedInterval.value = value;\n    if (value === 'custom') {\n        showCustomInput.value = true;\n        // Update minutes to current custom value to ensure \"custom\" shows as selected\n        updateMinutes(customMinutes.value);\n    } else {\n        showCustomInput.value = false;\n        const minutes = parseInt(value);\n        updateMinutes(minutes);\n    }\n}\n\nfunction handleCustomMinutesChange(value: string | number) {\n    const numValue = typeof value === 'string' ? parseInt(value) : value;\n    if (!isNaN(numValue) && numValue > 0) {\n        customMinutes.value = numValue;\n        updateMinutes(numValue);\n    }\n}\n\n// Watch for changes in props.minutes\nwatch(\n    () => props.minutes,\n    (newMinutes) => {\n        customMinutes.value = newMinutes;\n        initializeSelectedInterval();\n    },\n    { immediate: true }\n);\n\nwatch(currentInterval, () => {\n    initializeSelectedInterval();\n});\n\n// Active styling similar to ReportingFilterBadge\nconst activeClass = computed(() => {\n    if (props.enabled) {\n        return 'border-accent-300/50 bg-accent-50 hover:bg-accent-100 dark:border-accent-300/50 dark:bg-accent-300/5 dark:hover:bg-accent-300/10';\n    }\n    return '';\n});\n\nconst iconClass = computed(() => {\n    return twMerge(\n        'w-4 h-4',\n        props.enabled\n            ? 'dark:text-accent-300/80 text-accent-400/80'\n            : 'text-muted-foreground opacity-50'\n    );\n});\n</script>\n\n<template>\n    <Popover>\n        <PopoverTrigger as-child>\n            <Button variant=\"outline\" size=\"sm\" :class=\"twMerge(activeClass)\">\n                <ArrowsUpDownIcon :class=\"iconClass\" />\n                Rounding {{ enabled ? 'on' : 'off' }}\n            </Button>\n        </PopoverTrigger>\n        <PopoverContent class=\"w-72 p-4\">\n            <div v-if=\"!isAllowedToPerformPremiumAction()\" class=\"flex flex-col space-y-2\">\n                <span class=\"font-semibold text-xs\">Premium</span>\n                <span class=\"text-xs text-text-secondary flex-1\"\n                    >Rounding is a premium feature. Upgrade to unlock this feature.</span\n                >\n                <Link href=\"/billing\">\n                    <Button size=\"sm\" variant=\"outline\" class=\"items-center space-x-1\">\n                        <CreditCardIcon class=\"w-3.5 h-3.5 text-text-tertiary mr-1\" />\n                        Go to Billing\n                    </Button>\n                </Link>\n            </div>\n            <div v-else class=\"space-y-4\">\n                <div>\n                    <Field orientation=\"horizontal\" class=\"justify-between\">\n                        <FieldLabel for=\"enable-rounding\">Enable Rounding</FieldLabel>\n                        <Switch\n                            id=\"enable-rounding\"\n                            :model-value=\"enabled\"\n                            class=\"data-[state=checked]:bg-accent-500\"\n                            @update:model-value=\"updateEnabled\" />\n                    </Field>\n                    <div\n                        class=\"mb-3 pb-2 pt-1 text-xs text-muted-foreground border-b border-border-secondary text-text-tertiary\">\n                        Rounding is applied to each individual time entry, not to the accumulated\n                        total.\n                    </div>\n                </div>\n\n                <Field>\n                    <FieldLabel for=\"rounding-type\">Rounding Type</FieldLabel>\n                    <Select\n                        :model-value=\"type\"\n                        :disabled=\"!enabled\"\n                        @update:model-value=\"(value) => updateType(value as TimeEntryRoundingType)\">\n                        <SelectTrigger\n                            id=\"rounding-type\"\n                            size=\"sm\"\n                            class=\"w-full\"\n                            :disabled=\"!enabled\">\n                            <SelectValue placeholder=\"Select rounding type\" />\n                        </SelectTrigger>\n                        <SelectContent>\n                            <SelectItem value=\"up\">Round Up</SelectItem>\n                            <SelectItem value=\"down\">Round Down</SelectItem>\n                            <SelectItem value=\"nearest\">Round Nearest</SelectItem>\n                        </SelectContent>\n                    </Select>\n                </Field>\n                <Field>\n                    <FieldLabel for=\"minutes-interval\">Minutes Interval</FieldLabel>\n                    <Select\n                        :model-value=\"selectedInterval\"\n                        :disabled=\"!enabled\"\n                        @update:model-value=\"(value) => handleIntervalChange(value as string)\">\n                        <SelectTrigger\n                            id=\"minutes-interval\"\n                            size=\"sm\"\n                            class=\"w-full\"\n                            :disabled=\"!enabled\">\n                            <SelectValue placeholder=\"Select interval\" />\n                        </SelectTrigger>\n                        <SelectContent>\n                            <SelectItem\n                                v-for=\"interval in predefinedIntervals\"\n                                :key=\"interval.value\"\n                                :value=\"interval.value\">\n                                {{ interval.label }}\n                            </SelectItem>\n                        </SelectContent>\n                    </Select>\n\n                    <div v-if=\"showCustomInput\" class=\"mt-2\">\n                        <NumberField\n                            id=\"custom-minutes\"\n                            :model-value=\"customMinutes\"\n                            :min=\"1\"\n                            :max=\"1440\"\n                            :disabled=\"!enabled\"\n                            class=\"text-sm\"\n                            @update:model-value=\"handleCustomMinutesChange\">\n                            <NumberFieldContent>\n                                <NumberFieldDecrement :disabled=\"!enabled\" />\n                                <NumberFieldInput\n                                    placeholder=\"Enter custom minutes\"\n                                    :disabled=\"!enabled\" />\n                                <NumberFieldIncrement :disabled=\"!enabled\" />\n                            </NumberFieldContent>\n                        </NumberField>\n                    </div>\n                </Field>\n            </div>\n        </PopoverContent>\n    </Popover>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Common/Reporting/ReportingRow.vue",
    "content": "<script setup lang=\"ts\">\nimport { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';\nimport { formatCents } from '@/packages/ui/src/utils/money';\nimport GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';\nimport { ref, inject, type ComputedRef } from 'vue';\nimport { twMerge } from 'tailwind-merge';\nimport type { Organization } from '@/packages/api/src';\n\ntype AggregatedGroupedData = GroupedData & {\n    grouped_data?: GroupedData[] | null;\n};\n\ntype GroupedData = {\n    seconds: number;\n    cost: number | null;\n    description: string | null | undefined;\n};\n\nconst props = defineProps<{\n    entry: AggregatedGroupedData;\n    indent?: boolean;\n    currency: string;\n    showCost?: boolean;\n}>();\n\nconst expanded = ref(false);\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n</script>\n\n<template>\n    <div\n        class=\"contents text-text-primary [&>*]:transition [&>*]:border-card-background-separator [&>*]:border-b [&>*]:h-[50px]\">\n        <div :class=\"twMerge('pl-6 flex items-center space-x-3', props.indent ? 'pl-16' : '')\">\n            <GroupedItemsCountButton\n                v-if=\"entry.grouped_data && entry.grouped_data?.length > 0\"\n                :expanded=\"expanded\"\n                @click=\"expanded = !expanded\">\n                {{ entry.grouped_data?.length }}\n            </GroupedItemsCountButton>\n            <span>\n                {{ entry.description }}\n            </span>\n        </div>\n        <div class=\"justify-end flex items-center\" :class=\"!showCost ? 'pr-6' : ''\">\n            {{\n                formatHumanReadableDuration(\n                    entry.seconds,\n                    organization?.interval_format,\n                    organization?.number_format\n                )\n            }}\n        </div>\n        <div v-if=\"showCost\" class=\"justify-end pr-6 flex items-center\">\n            {{\n                entry.cost\n                    ? formatCents(\n                          entry.cost,\n                          props.currency,\n                          organization?.currency_format,\n                          organization?.currency_symbol,\n                          organization?.number_format\n                      )\n                    : '--'\n            }}\n        </div>\n    </div>\n    <div\n        v-if=\"expanded && entry.grouped_data\"\n        :class=\"showCost ? 'col-span-3' : 'col-span-2'\"\n        class=\"grid bg-tertiary\"\n        :style=\"`grid-template-columns: 1fr 150px ${showCost ? '150px' : ''}`\">\n        <ReportingRow\n            v-for=\"subEntry in entry.grouped_data\"\n            :key=\"subEntry.description ?? 'none'\"\n            :currency=\"props.currency\"\n            :show-cost=\"showCost\"\n            indent\n            :entry=\"subEntry\"></ReportingRow>\n    </div>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Reporting/ReportingTabNavbar.vue",
    "content": "<script setup lang=\"ts\">\nimport { router } from '@inertiajs/vue3';\nimport { canViewReport } from '@/utils/permissions';\nimport { computed } from 'vue';\nimport TabBar from '@/Components/Common/TabBar/TabBar.vue';\nimport TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';\n\nconst props = defineProps<{\n    active: 'reporting' | 'detailed' | 'shared';\n}>();\n\nconst showSharedReports = computed(() => canViewReport());\n\nconst tabs = computed(() => {\n    const items = [\n        { value: 'reporting', label: 'Overview', href: route('reporting') },\n        { value: 'detailed', label: 'Detailed', href: route('reporting.detailed') },\n    ];\n    if (showSharedReports.value) {\n        items.push({\n            value: 'shared',\n            label: 'Shared',\n            href: route('reporting.shared'),\n        });\n    }\n    return items;\n});\n\nfunction hrefForTab(value: string) {\n    return tabs.value.find((tab) => tab.value === value)?.href;\n}\n\nfunction onTabChange(value: string | number) {\n    const href = hrefForTab(String(value));\n    if (href) {\n        router.visit(href);\n    }\n}\n\nfunction onTabHover(value: string) {\n    const href = hrefForTab(value);\n    if (href) {\n        router.prefetch(href, {}, { cacheFor: '1m' });\n    }\n}\n</script>\n\n<template>\n    <TabBar :default-value=\"props.active\" @update:model-value=\"onTabChange\">\n        <TabBarItem\n            v-for=\"tab in tabs\"\n            :key=\"tab.value\"\n            :value=\"tab.value\"\n            @mouseenter=\"onTabHover(tab.value)\">\n            {{ tab.label }}\n        </TabBarItem>\n    </TabBar>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Common/StatCard.vue",
    "content": "<script setup lang=\"ts\">\ndefineProps<{\n    title: string;\n    value?: string;\n}>();\n</script>\n\n<template>\n    <div class=\"rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5\">\n        <dt class=\"font-semibold text-sm text-text-secondary\">{{ title }}</dt>\n        <dd class=\"text-2xl text-text-primary pt-1 font-semibold\">\n            {{ value ?? '--' }}\n        </dd>\n    </div>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/TabBar/TabBar.vue",
    "content": "<script setup lang=\"ts\">\nimport { Tabs, TabsList } from '@/Components/ui/tabs';\n\ndefineProps<{\n    defaultValue?: string;\n}>();\n</script>\n\n<template>\n    <Tabs :default-value=\"defaultValue\" class=\"w-full\">\n        <TabsList class=\"flex items-center space-x-0.5 sm:space-x-1\">\n            <slot></slot>\n        </TabsList>\n    </Tabs>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/TabBar/TabBarItem.vue",
    "content": "<script setup lang=\"ts\">\nimport { TabsTrigger } from '@/Components/ui/tabs';\nimport { twMerge } from 'tailwind-merge';\nimport type { Component } from 'vue';\n\nconst props = defineProps<{\n    value: string;\n    class?: string;\n    icon?: Component;\n}>();\n</script>\n\n<template>\n    <TabsTrigger\n        :value=\"value\"\n        :icon=\"icon\"\n        :class=\"\n            twMerge(\n                'rounded-md px-2 sm:px-3 border py-1.5 text-xs sm:text-sm font-medium text-text-tertiary hover:text-text-primary focus-visible:outline-none data-[state=active]:bg-tab-background data-[state=active]:border-input-border data-[state=active]:text-text-primary border-tab-border',\n                props.class\n            )\n        \">\n        <slot></slot>\n    </TabsTrigger>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/TableHeading.vue",
    "content": "<script setup lang=\"ts\"></script>\n\n<template>\n    <div\n        class=\"contents [&>*]:border-row-separator text-xs [&>*]:border-b [&>*]:border-t [&>*]:bg-row-heading-background\">\n        <slot></slot>\n    </div>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Tag/TagEditModal.vue",
    "content": "<script setup lang=\"ts\">\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport { ref } from 'vue';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport { useFocus } from '@vueuse/core';\nimport { useTagsStore } from '@/utils/useTags';\nimport type { Tag, UpdateTagBody } from '@/packages/api/src';\n\nconst { updateTag } = useTagsStore();\nconst show = defineModel('show', { default: false });\nconst saving = ref(false);\n\nconst props = defineProps<{\n    tag: Tag;\n}>();\n\nconst tagBody = ref<UpdateTagBody>({\n    name: props.tag.name,\n});\n\nasync function submit() {\n    saving.value = true;\n    try {\n        await updateTag({ tagId: props.tag.id, tagBody: tagBody.value });\n        show.value = false;\n    } finally {\n        saving.value = false;\n    }\n}\n\nconst tagNameInput = ref<HTMLInputElement | null>(null);\n\nuseFocus(tagNameInput, { initialValue: true });\n</script>\n\n<template>\n    <DialogModal closeable :show=\"show\" @close=\"show = false\">\n        <template #title>\n            <div class=\"flex space-x-2\">\n                <span> Update Tag </span>\n            </div>\n        </template>\n\n        <template #content>\n            <div class=\"flex items-center space-x-4\">\n                <div class=\"col-span-6 sm:col-span-4 flex-1\">\n                    <TextInput\n                        id=\"tagName\"\n                        ref=\"tagNameInput\"\n                        v-model=\"tagBody.name\"\n                        type=\"text\"\n                        placeholder=\"Tag Name\"\n                        class=\"mt-1 block w-full\"\n                        required\n                        autocomplete=\"tagName\"\n                        @keydown.enter=\"submit()\" />\n                </div>\n            </div>\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"show = false\"> Cancel </SecondaryButton>\n            <PrimaryButton\n                class=\"ms-3\"\n                :class=\"{ 'opacity-25': saving }\"\n                :disabled=\"saving\"\n                @click=\"submit\">\n                Update Tag\n            </PrimaryButton>\n        </template>\n    </DialogModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Tag/TagMoreOptionsDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport { TrashIcon, PencilSquareIcon } from '@heroicons/vue/20/solid';\nimport { canDeleteTags, canUpdateTags } from '@/utils/permissions';\nimport type { Tag } from '@/packages/api/src';\nimport {\n    DropdownMenu,\n    DropdownMenuContent,\n    DropdownMenuItem,\n    DropdownMenuTrigger,\n} from '@/Components/ui/dropdown-menu';\n\nconst emit = defineEmits<{\n    edit: [];\n    delete: [];\n}>();\nconst props = defineProps<{\n    tag: Tag;\n}>();\n</script>\n\n<template>\n    <DropdownMenu>\n        <DropdownMenuTrigger as-child>\n            <button\n                class=\"focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-2 focus-visible:ring-ring focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity text-text-secondary\"\n                :aria-label=\"'Actions for Tag ' + props.tag.name\">\n                <svg\n                    class=\"h-8 w-8 p-1 rounded-full\"\n                    viewBox=\"0 0 24 24\"\n                    xmlns=\"http://www.w3.org/2000/svg\">\n                    <path\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        stroke-linecap=\"round\"\n                        stroke-linejoin=\"round\"\n                        stroke-width=\"1.5\"\n                        d=\"M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92\" />\n                </svg>\n            </button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent class=\"min-w-[150px]\" align=\"end\">\n            <DropdownMenuItem\n                v-if=\"canUpdateTags()\"\n                :aria-label=\"'Edit Tag ' + props.tag.name\"\n                data-testid=\"tag_edit\"\n                class=\"flex items-center space-x-3 cursor-pointer\"\n                @click=\"emit('edit')\">\n                <PencilSquareIcon class=\"w-5 text-icon-active\" />\n                <span>Edit</span>\n            </DropdownMenuItem>\n            <DropdownMenuItem\n                v-if=\"canDeleteTags()\"\n                :aria-label=\"'Delete Tag ' + props.tag.name\"\n                data-testid=\"tag_delete\"\n                class=\"flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive\"\n                @click=\"emit('delete')\">\n                <TrashIcon class=\"w-5\" />\n                <span>Delete</span>\n            </DropdownMenuItem>\n        </DropdownMenuContent>\n    </DropdownMenu>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Tag/TagTable.vue",
    "content": "<script setup lang=\"ts\">\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport { FolderPlusIcon } from '@heroicons/vue/24/solid';\nimport { PlusIcon } from '@heroicons/vue/16/solid';\nimport { computed, ref } from 'vue';\nimport { useTagsQuery } from '@/utils/useTagsQuery';\nimport TagTableRow from '@/Components/Common/Tag/TagTableRow.vue';\nimport TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';\nimport TagTableHeading from '@/Components/Common/Tag/TagTableHeading.vue';\nimport { canCreateTags } from '@/utils/permissions';\nimport type { Tag } from '@/packages/api/src';\nimport {\n    useVueTable,\n    getCoreRowModel,\n    getSortedRowModel,\n    type SortingState,\n} from '@tanstack/vue-table';\n\nexport type SortColumn = 'name';\nexport type SortDirection = 'asc' | 'desc';\n\nconst props = defineProps<{\n    createTag: (name: string) => Promise<Tag | undefined>;\n    sortColumn: SortColumn;\n    sortDirection: SortDirection;\n}>();\n\nconst emit = defineEmits<{\n    sort: [column: SortColumn, direction: SortDirection];\n}>();\n\nconst { tags } = useTagsQuery();\nconst showCreateTagModal = ref(false);\n\nconst sorting = computed<SortingState>(() => [\n    {\n        id: props.sortColumn,\n        desc: props.sortDirection === 'desc',\n    },\n]);\n\nconst columns = [\n    {\n        id: 'name',\n        accessorFn: (row: Tag) => row.name.toLowerCase(),\n    },\n];\n\nconst descFirstColumns = new Set<SortColumn>(\n    columns.filter((c) => 'sortDescFirst' in c && c.sortDescFirst).map((c) => c.id as SortColumn)\n);\n\nfunction handleSort(column: SortColumn) {\n    if (props.sortColumn === column) {\n        emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');\n    } else {\n        emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');\n    }\n}\n\nconst table = useVueTable({\n    get data() {\n        return tags.value;\n    },\n    columns,\n    getCoreRowModel: getCoreRowModel(),\n    getSortedRowModel: getSortedRowModel(),\n    state: {\n        get sorting() {\n            return sorting.value;\n        },\n    },\n    manualSorting: false,\n});\n\nconst sortedTags = computed(() => {\n    return table.getRowModel().rows.map((row) => row.original);\n});\n</script>\n\n<template>\n    <TagCreateModal v-model:show=\"showCreateTagModal\" :create-tag></TagCreateModal>\n    <div class=\"flow-root\">\n        <div class=\"inline-block min-w-full align-middle\">\n            <div\n                data-testid=\"tag_table\"\n                class=\"grid min-w-full\"\n                style=\"grid-template-columns: 1fr 80px\">\n                <TagTableHeading\n                    :sort-column=\"props.sortColumn\"\n                    :sort-direction=\"props.sortDirection\"\n                    :desc-first-columns=\"descFirstColumns\"\n                    @sort=\"handleSort\"></TagTableHeading>\n                <div v-if=\"sortedTags.length === 0\" class=\"col-span-5 py-24 text-center\">\n                    <FolderPlusIcon class=\"w-8 text-icon-default inline pb-2\"></FolderPlusIcon>\n                    <h3 class=\"text-text-primary font-semibold\">No tags found</h3>\n                    <p v-if=\"canCreateTags()\" class=\"pb-5\">Create your first tag now!</p>\n                    <SecondaryButton\n                        v-if=\"canCreateTags()\"\n                        :icon=\"PlusIcon\"\n                        @click=\"showCreateTagModal = true\"\n                        >Create your First Tag</SecondaryButton\n                    >\n                </div>\n                <template v-for=\"tag in sortedTags\" :key=\"tag.id\">\n                    <TagTableRow :tag=\"tag\"></TagTableRow>\n                </template>\n            </div>\n        </div>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Common/Tag/TagTableHeading.vue",
    "content": "<script setup lang=\"ts\">\nimport TableHeading from '@/Components/Common/TableHeading.vue';\nimport { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';\nimport type { SortColumn, SortDirection } from '@/Components/Common/Tag/TagTable.vue';\n\nconst props = defineProps<{\n    sortColumn: SortColumn;\n    sortDirection: SortDirection;\n    descFirstColumns: ReadonlySet<SortColumn>;\n}>();\n\nconst emit = defineEmits<{\n    sort: [column: SortColumn];\n}>();\n\nfunction handleSort(column: SortColumn) {\n    emit('sort', column);\n}\n\nfunction isSorted(column: SortColumn): boolean {\n    return props.sortColumn === column;\n}\n\nfunction isChevronDown(column: SortColumn): boolean {\n    if (!isSorted(column)) return false;\n    return props.descFirstColumns.has(column)\n        ? props.sortDirection === 'desc'\n        : props.sortDirection === 'asc';\n}\n\nfunction isChevronUp(column: SortColumn): boolean {\n    if (!isSorted(column)) return false;\n    return !isChevronDown(column);\n}\n</script>\n\n<template>\n    <TableHeading>\n        <div\n            class=\"py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1\"\n            @click=\"handleSort('name')\">\n            Name\n            <ChevronDownIcon v-if=\"isChevronDown('name')\" class=\"w-4 h-4\" />\n            <ChevronUpIcon v-else-if=\"isChevronUp('name')\" class=\"w-4 h-4\" />\n            <span v-else class=\"w-4 h-4\"></span>\n        </div>\n        <div class=\"relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12\">\n            <span class=\"sr-only\">Edit</span>\n        </div>\n    </TableHeading>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Common/Tag/TagTableRow.vue",
    "content": "<script setup lang=\"ts\">\nimport type { Tag } from '@/packages/api/src';\nimport { useTagsStore } from '@/utils/useTags';\nimport TagMoreOptionsDropdown from '@/Components/Common/Tag/TagMoreOptionsDropdown.vue';\nimport TagEditModal from '@/Components/Common/Tag/TagEditModal.vue';\nimport TableRow from '@/Components/TableRow.vue';\nimport { canDeleteTags, canUpdateTags } from '@/utils/permissions';\nimport { ref } from 'vue';\n\nconst props = defineProps<{\n    tag: Tag;\n}>();\n\nconst showTagEditModal = ref(false);\n\nfunction deleteTag() {\n    useTagsStore().deleteTag(props.tag.id);\n}\n</script>\n\n<template>\n    <TableRow>\n        <div\n            class=\"whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12\">\n            <span>\n                {{ tag.name }}\n            </span>\n        </div>\n        <div\n            class=\"relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12\">\n            <TagMoreOptionsDropdown\n                v-if=\"canDeleteTags() || canUpdateTags()\"\n                :tag=\"tag\"\n                @edit=\"showTagEditModal = true\"\n                @delete=\"deleteTag\"></TagMoreOptionsDropdown>\n        </div>\n        <TagEditModal v-model:show=\"showTagEditModal\" :tag=\"tag\"></TagEditModal>\n    </TableRow>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Task/TaskCreateModal.vue",
    "content": "<script setup lang=\"ts\">\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport { ref, watch } from 'vue';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport { useFocus } from '@vueuse/core';\nimport { useTasksStore } from '@/utils/useTasks';\nimport ProjectDropdown from '@/Components/Common/Project/ProjectDropdown.vue';\nimport EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue';\nimport { isAllowedToPerformPremiumAction } from '@/utils/billing';\nimport { Field, FieldGroup, FieldLabel } from '@/packages/ui/src/field';\nimport { Button } from '@/packages/ui/src/Buttons';\nimport { ChevronDown } from 'lucide-vue-next';\nimport { FolderIcon } from '@heroicons/vue/20/solid';\n\nconst { createTask } = useTasksStore();\nconst show = defineModel('show', { default: false });\nconst saving = ref(false);\n\nconst taskName = ref('');\nconst estimatedTime = ref<number | null>(null);\n\nconst props = defineProps<{\n    projectId: string;\n}>();\n\nconst taskProjectId = ref<string>(props.projectId);\n\nwatch(\n    () => props.projectId,\n    (value) => {\n        taskProjectId.value = value;\n    }\n);\n\nasync function submit() {\n    await createTask({\n        name: taskName.value,\n        project_id: taskProjectId.value,\n        estimated_time: estimatedTime.value,\n    });\n    show.value = false;\n    taskName.value = '';\n}\n\nconst taskNameInput = ref<HTMLInputElement | null>(null);\n\nuseFocus(taskNameInput, { initialValue: true });\n</script>\n\n<template>\n    <DialogModal closeable :show=\"show\" @close=\"show = false\">\n        <template #title>\n            <div class=\"flex space-x-2\">\n                <span> Create Task </span>\n            </div>\n        </template>\n\n        <template #content>\n            <FieldGroup>\n                <Field class=\"w-full\">\n                    <FieldLabel for=\"taskName\">Task name</FieldLabel>\n                    <TextInput\n                        id=\"taskName\"\n                        ref=\"taskNameInput\"\n                        v-model=\"taskName\"\n                        type=\"text\"\n                        placeholder=\"Task Name\"\n                        class=\"block w-full\"\n                        required\n                        autocomplete=\"taskName\"\n                        @keydown.enter=\"submit()\" />\n                </Field>\n                <Field class=\"w-auto\">\n                    <FieldLabel :icon=\"FolderIcon\" for=\"project\">Project</FieldLabel>\n                    <ProjectDropdown v-model=\"taskProjectId\">\n                        <template #trigger=\"{ selectedProjectName, selectedProjectColor }\">\n                            <Button variant=\"input\" class=\"w-full justify-between\">\n                                <span class=\"flex items-center gap-2 truncate\">\n                                    <span\n                                        :style=\"{ backgroundColor: selectedProjectColor }\"\n                                        class=\"w-3 h-3 rounded-full shrink-0\"></span>\n                                    <span class=\"truncate\">{{ selectedProjectName }}</span>\n                                </span>\n                                <ChevronDown class=\"w-4 h-4 text-icon-default\" />\n                            </Button>\n                        </template>\n                    </ProjectDropdown>\n                </Field>\n                <EstimatedTimeSection\n                    v-if=\"isAllowedToPerformPremiumAction()\"\n                    v-model=\"estimatedTime\"\n                    @submit=\"submit()\"></EstimatedTimeSection>\n            </FieldGroup>\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"show = false\"> Cancel </SecondaryButton>\n            <PrimaryButton\n                class=\"ms-3\"\n                :class=\"{ 'opacity-25': saving }\"\n                :disabled=\"saving\"\n                @click=\"submit\">\n                Create Task\n            </PrimaryButton>\n        </template>\n    </DialogModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Task/TaskEditModal.vue",
    "content": "<script setup lang=\"ts\">\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport { ref } from 'vue';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport { useFocus } from '@vueuse/core';\nimport { useTasksStore } from '@/utils/useTasks';\nimport type { Task, UpdateTaskBody } from '@/packages/api/src';\nimport EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue';\nimport { isAllowedToPerformPremiumAction } from '@/utils/billing';\nimport { Field, FieldGroup, FieldLabel } from '@/packages/ui/src/field';\n\nconst { updateTask } = useTasksStore();\nconst show = defineModel('show', { default: false });\nconst saving = ref(false);\n\nconst props = defineProps<{\n    task: Task;\n}>();\n\nconst taskBody = ref<UpdateTaskBody>({\n    name: props.task.name,\n    estimated_time: props.task.estimated_time,\n});\n\nasync function submit() {\n    await updateTask(props.task.id, taskBody.value);\n    show.value = false;\n}\n\nconst taskNameInput = ref<HTMLInputElement | null>(null);\n\nuseFocus(taskNameInput, { initialValue: true });\n</script>\n\n<template>\n    <DialogModal closeable :show=\"show\" @close=\"show = false\">\n        <template #title>\n            <div class=\"flex space-x-2\">\n                <span> Update Task </span>\n            </div>\n        </template>\n\n        <template #content>\n            <FieldGroup>\n                <Field>\n                    <FieldLabel for=\"taskName\">Task name</FieldLabel>\n                    <TextInput\n                        id=\"taskName\"\n                        ref=\"taskNameInput\"\n                        v-model=\"taskBody.name\"\n                        type=\"text\"\n                        placeholder=\"Task Name\"\n                        class=\"block w-full\"\n                        required\n                        autocomplete=\"taskName\"\n                        @keydown.enter=\"submit()\" />\n                </Field>\n                <EstimatedTimeSection\n                    v-if=\"isAllowedToPerformPremiumAction()\"\n                    v-model=\"taskBody.estimated_time\"\n                    @submit=\"submit()\"></EstimatedTimeSection>\n            </FieldGroup>\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"show = false\"> Cancel </SecondaryButton>\n            <PrimaryButton\n                class=\"ms-3\"\n                :class=\"{ 'opacity-25': saving }\"\n                :disabled=\"saving\"\n                @click=\"submit\">\n                Update Task\n            </PrimaryButton>\n        </template>\n    </DialogModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Task/TaskMoreOptionsDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport { TrashIcon, PencilSquareIcon, CheckCircleIcon } from '@heroicons/vue/20/solid';\nimport type { Task } from '@/packages/api/src';\nimport { canDeleteTasks, canUpdateTasks } from '@/utils/permissions';\nimport {\n    DropdownMenu,\n    DropdownMenuContent,\n    DropdownMenuItem,\n    DropdownMenuTrigger,\n} from '@/Components/ui/dropdown-menu';\n\nconst emit = defineEmits<{\n    delete: [];\n    edit: [];\n    done: [];\n}>();\nconst props = defineProps<{\n    task: Task;\n}>();\n</script>\n\n<template>\n    <DropdownMenu>\n        <DropdownMenuTrigger as-child>\n            <button\n                class=\"focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-2 focus-visible:ring-ring focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity text-text-secondary\"\n                :aria-label=\"'Actions for Task ' + props.task.name\">\n                <svg\n                    class=\"h-8 w-8 p-1 rounded-full\"\n                    viewBox=\"0 0 24 24\"\n                    xmlns=\"http://www.w3.org/2000/svg\">\n                    <path\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        stroke-linecap=\"round\"\n                        stroke-linejoin=\"round\"\n                        stroke-width=\"1.5\"\n                        d=\"M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92\" />\n                </svg>\n            </button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent class=\"min-w-[150px]\" align=\"end\">\n            <DropdownMenuItem\n                v-if=\"canUpdateTasks()\"\n                :aria-label=\"'Edit Task ' + props.task.name\"\n                data-testid=\"task_edit\"\n                class=\"flex items-center space-x-3 cursor-pointer\"\n                @click=\"emit('edit')\">\n                <PencilSquareIcon class=\"w-5 text-icon-active\" />\n                <span>Edit</span>\n            </DropdownMenuItem>\n            <DropdownMenuItem\n                v-if=\"canUpdateTasks()\"\n                :aria-label=\"'Mark Task ' + props.task.name + ' as done'\"\n                class=\"flex items-center space-x-3 cursor-pointer\"\n                @click=\"emit('done')\">\n                <CheckCircleIcon class=\"w-5 text-icon-active\" />\n                <span v-if=\"props.task.is_done\">Mark as active</span>\n                <span v-else>Mark as done</span>\n            </DropdownMenuItem>\n            <DropdownMenuItem\n                v-if=\"canDeleteTasks()\"\n                :aria-label=\"'Delete Task ' + props.task.name\"\n                data-testid=\"task_delete\"\n                class=\"flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive\"\n                @click=\"emit('delete')\">\n                <TrashIcon class=\"w-5\" />\n                <span>Delete</span>\n            </DropdownMenuItem>\n        </DropdownMenuContent>\n    </DropdownMenu>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Task/TaskMultiselectDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport MultiselectDropdown from '@/packages/ui/src/Input/MultiselectDropdown.vue';\nimport type { Task } from '@/packages/api/src';\nimport { useTasksQuery } from '@/utils/useTasksQuery';\n\nconst { tasks } = useTasksQuery();\n\nfunction getKeyFromItem(item: Task) {\n    return item.id;\n}\n\nfunction getNameForItem(item: Task) {\n    return item.name;\n}\n\nconst emit = defineEmits<{\n    submit: [];\n}>();\n</script>\n\n<template>\n    <MultiselectDropdown\n        search-placeholder=\"Search for a Task...\"\n        :items=\"tasks\"\n        :get-key-from-item=\"getKeyFromItem\"\n        :get-name-for-item=\"getNameForItem\"\n        no-item-label=\"No Task\"\n        @submit=\"emit('submit')\">\n        <template #trigger>\n            <slot name=\"trigger\"></slot>\n        </template>\n    </MultiselectDropdown>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Common/Task/TaskTable.vue",
    "content": "<script setup lang=\"ts\">\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport { PlusCircleIcon } from '@heroicons/vue/24/solid';\nimport { PlusIcon } from '@heroicons/vue/16/solid';\nimport { ref } from 'vue';\nimport TaskTableRow from '@/Components/Common/Task/TaskTableRow.vue';\nimport TaskTableHeading from '@/Components/Common/Task/TaskTableHeading.vue';\nimport TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue';\nimport { canCreateTasks } from '@/utils/permissions';\nimport type { Task } from '@/packages/api/src';\n\nconst props = defineProps<{\n    projectId: string;\n    tasks: Task[];\n}>();\n\nconst createTask = ref(false);\n</script>\n\n<template>\n    <TaskCreateModal v-model:show=\"createTask\" :project-id=\"props.projectId\"></TaskCreateModal>\n    <div class=\"flow-root\">\n        <div class=\"inline-block min-w-full align-middle\">\n            <div\n                data-testid=\"task_table\"\n                role=\"table\"\n                class=\"grid min-w-full\"\n                style=\"\n                    grid-template-columns:\n                        1fr minmax(80px, auto) minmax(120px, auto) minmax(50px, auto)\n                        80px;\n                \">\n                <TaskTableHeading></TaskTableHeading>\n                <div v-if=\"tasks.length === 0\" class=\"col-span-5 py-24 text-center\">\n                    <PlusCircleIcon class=\"w-8 text-icon-default inline pb-2\"></PlusCircleIcon>\n                    <h3 class=\"text-text-primary font-semibold\">No tasks found</h3>\n                    <p v-if=\"canCreateTasks()\" class=\"pb-5\">Create your first task now!</p>\n                    <SecondaryButton\n                        v-if=\"canCreateTasks()\"\n                        :icon=\"PlusIcon\"\n                        @click=\"createTask = true\"\n                        >Create your First Task\n                    </SecondaryButton>\n                </div>\n                <template v-for=\"task in tasks\" :key=\"task.id\">\n                    <TaskTableRow :task=\"task\"></TaskTableRow>\n                </template>\n            </div>\n        </div>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Common/Task/TaskTableHeading.vue",
    "content": "<script setup lang=\"ts\">\nimport TableHeading from '@/Components/Common/TableHeading.vue';\n</script>\n\n<template>\n    <TableHeading>\n        <div class=\"py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12\">\n            Task Name\n        </div>\n        <div class=\"px-3 py-1.5 text-left text-text-tertiary\">Total Time</div>\n        <div class=\"px-3 py-1.5 text-left text-text-tertiary\">Progress</div>\n        <div class=\"px-3 py-1.5 text-left text-text-tertiary\">Status</div>\n        <div class=\"relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12\">\n            <span class=\"sr-only\">Edit</span>\n        </div>\n    </TableHeading>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/Task/TaskTableRow.vue",
    "content": "<script setup lang=\"ts\">\nimport type { Task } from '@/packages/api/src';\nimport { CheckCircleIcon } from '@heroicons/vue/20/solid';\nimport { useTasksStore } from '@/utils/useTasks';\nimport TaskMoreOptionsDropdown from '@/Components/Common/Task/TaskMoreOptionsDropdown.vue';\nimport TableRow from '@/Components/TableRow.vue';\nimport { canDeleteTasks } from '@/utils/permissions';\nimport TaskEditModal from '@/Components/Common/Task/TaskEditModal.vue';\nimport { ref, inject, type ComputedRef } from 'vue';\nimport { isAllowedToPerformPremiumAction } from '@/utils/billing';\nimport EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';\nimport UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';\nimport { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';\nimport type { Organization } from '@/packages/api/src';\n\nconst props = defineProps<{\n    task: Task;\n}>();\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n\nfunction deleteTask() {\n    useTasksStore().deleteTask(props.task.id);\n}\n\nfunction markTaskAsDone() {\n    useTasksStore().updateTask(props.task.id, {\n        ...props.task,\n        is_done: !props.task.is_done,\n    });\n}\n\nconst showTaskEditModal = ref(false);\n</script>\n\n<template>\n    <TableRow>\n        <div\n            class=\"whitespace-nowrap min-w-0 flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12\">\n            <span class=\"overflow-ellipsis overflow-hidden\">\n                {{ task.name }}\n            </span>\n        </div>\n        <div\n            class=\"whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium\">\n            <span v-if=\"task.spent_time\">\n                {{\n                    formatHumanReadableDuration(\n                        task.spent_time,\n                        organization?.interval_format,\n                        organization?.number_format\n                    )\n                }}\n            </span>\n            <span v-else> -- </span>\n        </div>\n        <div class=\"whitespace-nowrap px-3 flex items-center text-sm text-text-secondary\">\n            <UpgradeBadge v-if=\"!isAllowedToPerformPremiumAction()\"></UpgradeBadge>\n            <EstimatedTimeProgress\n                v-else-if=\"task.estimated_time\"\n                :estimated=\"task.estimated_time\"\n                :current=\"task.spent_time\"></EstimatedTimeProgress>\n            <span v-else> -- </span>\n        </div>\n        <div\n            class=\"whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium\">\n            <template v-if=\"task.is_done\">\n                <CheckCircleIcon class=\"w-5\"></CheckCircleIcon>\n                <span>Done</span>\n            </template>\n            <template v-else>\n                <span>Active</span>\n            </template>\n        </div>\n        <div\n            class=\"relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12\">\n            <TaskMoreOptionsDropdown\n                v-if=\"canDeleteTasks()\"\n                :task=\"task\"\n                @done=\"markTaskAsDone\"\n                @edit=\"showTaskEditModal = true\"\n                @delete=\"deleteTask\"></TaskMoreOptionsDropdown>\n        </div>\n        <TaskEditModal v-model:show=\"showTaskEditModal\" :task=\"task\"></TaskEditModal>\n    </TableRow>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/UpgradeBadge.vue",
    "content": "<script setup lang=\"ts\">\nimport { LockClosedIcon } from '@heroicons/vue/20/solid';\nimport UpgradeModal from '@/Components/Common/UpgradeModal.vue';\nimport { ref } from 'vue';\nconst showUpgradeModal = ref(false);\n</script>\n\n<template>\n    <UpgradeModal v-model:show=\"showUpgradeModal\">\n        <strong>Project and Task Estimates</strong> is only available in solidtime Professional.\n    </UpgradeModal>\n    <button\n        class=\"inline-flex bg-secondary hover:bg-tertiary px-2 py-1 rounded border border-border-secondary hover:border-border-tertiary items-center space-x-1\"\n        @click.prevent=\"showUpgradeModal = true\">\n        <LockClosedIcon class=\"w-3 text-text-tertiary\"></LockClosedIcon>\n        <span class=\"text-xs text-text-secondary font-semibold\"> Upgrade </span>\n    </button>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/UpgradeModal.vue",
    "content": "<script setup lang=\"ts\">\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport { Link } from '@inertiajs/vue3';\nimport { isBillingActivated } from '@/utils/billing';\nimport { CreditCardIcon, UserGroupIcon } from '@heroicons/vue/20/solid';\nimport { canManageBilling, canUpdateOrganization } from '@/utils/permissions';\nimport { SecondaryButton } from '@/packages/ui/src';\n\nconst show = defineModel('show', { default: false });\n</script>\n\n<template>\n    <DialogModal closeable :show=\"show\" @close=\"show = false\">\n        <template #title>\n            <div class=\"flex space-x-2\">\n                <span> Upgrade Plan </span>\n            </div>\n        </template>\n\n        <template #content>\n            <div>\n                <div\n                    class=\"rounded-full flex items-center justify-center w-20 h-20 mx-auto border border-border-tertiary bg-secondary\">\n                    <UserGroupIcon class=\"w-12\"></UserGroupIcon>\n                </div>\n                <div class=\"max-w-sm text-center mx-auto py-4 text-base\">\n                    <p class=\"py-1\">\n                        <slot></slot>\n                    </p>\n                    <p class=\"py-1 text-sm\">\n                        If you want to use this feature,\n                        <strong class=\"font-semibold text-text-primary\"\n                            >please upgrade to a paid plan</strong\n                        >\n                        or\n                        <strong class=\"font-semibold text-text-primary\"\n                            >request a free trial</strong\n                        >\n                        via\n                        <a\n                            class=\"text-accent-200/80 transition text-accent-300\"\n                            href=\"mailto:hello@solidtime.io\"\n                            >hello@solidtime.io</a\n                        >\n                        to try out this feature.\n                    </p>\n\n                    <Link v-if=\"isBillingActivated() && canManageBilling()\" href=\"/billing\">\n                        <PrimaryButton\n                            v-if=\"isBillingActivated() && canUpdateOrganization()\"\n                            type=\"button\"\n                            class=\"mt-6\"\n                            :icon=\"CreditCardIcon\">\n                            Go to Billing\n                        </PrimaryButton>\n                    </Link>\n                </div>\n            </div>\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"show = false\">Close</SecondaryButton>\n        </template>\n    </DialogModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Common/User/UserTimezoneMismatchModal.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref } from 'vue';\nimport { useForm, usePage } from '@inertiajs/vue3';\nimport type { User } from '@/types/models';\nimport TimezoneMismatchModal from '@/packages/ui/src/TimezoneMismatchModal.vue';\n\nconst show = defineModel('show', { default: false });\nconst saving = ref(false);\n\nconst page = usePage<{\n    auth: {\n        user: User;\n    };\n}>();\n\nfunction handleUpdate(timezone: string) {\n    saving.value = true;\n    const form = useForm({\n        _method: 'PUT',\n        timezone: timezone,\n        name: page.props.auth.user.name,\n        email: page.props.auth.user.email,\n        week_start: page.props.auth.user.week_start,\n    });\n\n    form.post(route('user-profile-information.update'), {\n        errorBag: 'updateProfileInformation',\n        preserveScroll: true,\n        onSuccess: () => {\n            saving.value = false;\n            show.value = false;\n            location.reload();\n        },\n        onError: () => {\n            saving.value = false;\n        },\n    });\n}\n</script>\n\n<template>\n    <TimezoneMismatchModal v-model:show=\"show\" :saving=\"saving\" @update=\"handleUpdate\" />\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/ConfirmationModal.vue",
    "content": "<script setup lang=\"ts\">\nimport Modal from '@/packages/ui/src/Modal.vue';\n\nconst emit = defineEmits(['close']);\n\ndefineProps({\n    show: {\n        type: Boolean,\n        default: false,\n    },\n    maxWidth: {\n        type: String,\n        default: '2xl',\n    },\n    closeable: {\n        type: Boolean,\n        default: true,\n    },\n});\n\nconst close = () => {\n    emit('close');\n};\n</script>\n\n<template>\n    <Modal :show=\"show\" :max-width=\"maxWidth\" :closeable=\"closeable\" @close=\"close\">\n        <div class=\"bg-card-background px-4 pt-5 pb-4 sm:p-6 sm:pb-4\">\n            <div class=\"sm:flex sm:items-start\">\n                <div\n                    class=\"mx-auto shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10\">\n                    <svg\n                        class=\"h-6 w-6 text-red-400\"\n                        xmlns=\"http://www.w3.org/2000/svg\"\n                        fill=\"none\"\n                        viewBox=\"0 0 24 24\"\n                        stroke-width=\"1.5\"\n                        stroke=\"currentColor\">\n                        <path\n                            stroke-linecap=\"round\"\n                            stroke-linejoin=\"round\"\n                            d=\"M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z\" />\n                    </svg>\n                </div>\n\n                <div class=\"mt-3 text-center sm:mt-0 sm:ms-4 sm:text-start\">\n                    <h3 class=\"text-lg font-medium text-text-primary\">\n                        <slot name=\"title\" />\n                    </h3>\n\n                    <div class=\"mt-4 text-sm text-text-secondary\">\n                        <slot name=\"content\" />\n                    </div>\n                </div>\n            </div>\n        </div>\n\n        <div class=\"flex flex-row justify-end px-6 py-4 bg-card-background text-end\">\n            <slot name=\"footer\" />\n        </div>\n    </Modal>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ConfirmsPassword.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, reactive, nextTick } from 'vue';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport { Field, FieldError } from '@/packages/ui/src/field';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\nimport axios from 'axios';\n\nconst emit = defineEmits(['confirmed']);\n\ndefineProps({\n    title: {\n        type: String,\n        default: 'Confirm Password',\n    },\n    content: {\n        type: String,\n        default: 'For your security, please confirm your password to continue.',\n    },\n    button: {\n        type: String,\n        default: 'Confirm',\n    },\n});\n\nconst confirmingPassword = ref(false);\n\nconst form = reactive({\n    password: '',\n    error: '',\n    processing: false,\n});\n\nconst passwordInput = ref<HTMLInputElement | null>(null);\n\nconst startConfirmingPassword = () => {\n    axios.get(route('password.confirmation')).then((response) => {\n        if (response.data.confirmed) {\n            emit('confirmed');\n        } else {\n            confirmingPassword.value = true;\n\n            setTimeout(() => passwordInput.value?.focus(), 250);\n        }\n    });\n};\n\nconst confirmPassword = () => {\n    form.processing = true;\n\n    axios\n        .post(route('password.confirm'), {\n            password: form.password,\n        })\n        .then(() => {\n            form.processing = false;\n\n            closeModal();\n            nextTick().then(() => emit('confirmed'));\n        })\n        .catch((error) => {\n            form.processing = false;\n            form.error = error.response.data.errors.password[0];\n            passwordInput.value?.focus();\n        });\n};\n\nconst closeModal = () => {\n    confirmingPassword.value = false;\n    form.password = '';\n    form.error = '';\n};\n</script>\n\n<template>\n    <span>\n        <span @click=\"startConfirmingPassword\">\n            <slot />\n        </span>\n\n        <DialogModal :show=\"confirmingPassword\" @close=\"closeModal\">\n            <template #title>\n                {{ title }}\n            </template>\n\n            <template #content>\n                {{ content }}\n\n                <Field class=\"mt-4\">\n                    <TextInput\n                        ref=\"passwordInput\"\n                        v-model=\"form.password\"\n                        type=\"password\"\n                        class=\"block w-3/4\"\n                        placeholder=\"Password\"\n                        autocomplete=\"current-password\"\n                        @keyup.enter=\"confirmPassword\" />\n\n                    <FieldError v-if=\"form.error\">{{ form.error }}</FieldError>\n                </Field>\n            </template>\n\n            <template #footer>\n                <SecondaryButton @click=\"closeModal\"> Cancel </SecondaryButton>\n\n                <PrimaryButton\n                    class=\"ms-3\"\n                    :class=\"{ 'opacity-25': form.processing }\"\n                    :disabled=\"form.processing\"\n                    @click=\"confirmPassword\">\n                    {{ button }}\n                </PrimaryButton>\n            </template>\n        </DialogModal>\n    </span>\n</template>\n"
  },
  {
    "path": "resources/js/Components/CurrentSidebarTimer.vue",
    "content": "<script setup lang=\"ts\">\nimport { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';\nimport { storeToRefs } from 'pinia';\nimport { computed } from 'vue';\nimport dayjs from 'dayjs';\nimport { formatDuration } from '@/packages/ui/src/utils/time';\nimport TimeTrackerStartStop from '@/packages/ui/src/TimeTrackerStartStop.vue';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\n\nconst store = useCurrentTimeEntryStore();\nconst { currentTimeEntry, now, isActive } = storeToRefs(store);\nconst { setActiveState } = store;\n\nconst currentTime = computed(() => {\n    if (now.value && currentTimeEntry.value.start) {\n        const startTime = dayjs(currentTimeEntry.value.start);\n        const diff = now.value.diff(startTime, 's');\n        return formatDuration(diff);\n    }\n    return formatDuration(0);\n});\n\nconst isRunningInDifferentOrganization = computed(() => {\n    return (\n        currentTimeEntry.value.organization_id &&\n        getCurrentOrganizationId() &&\n        currentTimeEntry.value.organization_id !== getCurrentOrganizationId()\n    );\n});\n</script>\n\n<template>\n    <div class=\"pt-3 pb-2.5 px-2 flex justify-between items-center relative\">\n        <div\n            v-if=\"isRunningInDifferentOrganization\"\n            class=\"absolute w-full h-full backdrop-blur-sm z-10 flex items-center justify-center\">\n            <div\n                class=\"w-full h-[calc(100%+10px)] absolute bg-default-background opacity-75 backdrop-blur-sm\"></div>\n            <div class=\"flex space-x-3 items-center w-full z-20 justify-center\">\n                <span class=\"text-xs text-center text-text-primary\">\n                    The Timer is running in a different organization.\n                </span>\n            </div>\n        </div>\n        <div>\n            <div class=\"text-text-secondary font-medium text-xs\">Current Timer</div>\n            <div class=\"text-text-primary font-medium text-lg\">\n                {{ currentTime }}\n            </div>\n        </div>\n        <TimeTrackerStartStop\n            :active=\"isActive\"\n            size=\"base\"\n            variant=\"secondary\"\n            @changed=\"setActiveState\"></TimeTrackerStartStop>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Dashboard/ActivityGraphCard.vue",
    "content": "<script lang=\"ts\" setup>\nimport VChart, { THEME_KEY } from 'vue-echarts';\nimport { provide, computed, inject, ref, type ComputedRef } from 'vue';\nimport { use } from 'echarts/core';\nimport { useElementSize } from '@vueuse/core';\nimport DashboardCard from '@/Components/Dashboard/DashboardCard.vue';\nimport { BoltIcon } from '@heroicons/vue/20/solid';\nimport { HeatmapChart } from 'echarts/charts';\nimport {\n    CalendarComponent,\n    TitleComponent,\n    TooltipComponent,\n    VisualMapComponent,\n} from 'echarts/components';\nimport { CanvasRenderer } from 'echarts/renderers';\nimport {\n    firstDayIndex,\n    formatDate,\n    formatHumanReadableDuration,\n    getDayJsInstance,\n} from '@/packages/ui/src/utils/time';\nimport chroma from 'chroma-js';\nimport { useCssVariable } from '@/utils/useCssVariable';\nimport { useQuery } from '@tanstack/vue-query';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport { api, type Organization } from '@/packages/api/src';\nimport { LoadingSpinner } from '@/packages/ui/src';\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n\n// Get the organization ID using the utility function\nconst organizationId = computed(() => getCurrentOrganizationId());\n\nconst { data: dailyHoursTracked, isLoading } = useQuery({\n    queryKey: ['dailyTrackedHours', organizationId],\n    queryFn: () => {\n        return api.dailyTrackedHours({\n            params: {\n                organization: organizationId.value!,\n            },\n        });\n    },\n    enabled: computed(() => !!organizationId.value),\n});\n\nuse([\n    TitleComponent,\n    TooltipComponent,\n    VisualMapComponent,\n    CalendarComponent,\n    HeatmapChart,\n    CanvasRenderer,\n]);\n\nprovide(THEME_KEY, 'dark');\n\nconst max = computed(() => {\n    if (!isLoading.value && dailyHoursTracked.value) {\n        return Math.max(Math.max(...dailyHoursTracked.value.map((el) => el.duration)), 1);\n    } else {\n        return 1;\n    }\n});\n\nconst backgroundColor = useCssVariable('--theme-color-card-background');\nconst borderColor = useCssVariable('--color-border');\nconst labelColor = useCssVariable('--color-text-secondary');\nconst chartColorRaw = useCssVariable('--theme-color-chart');\n\nconst chartEmptyColorRaw = useCssVariable('--color-bg-tertiary');\nconst chartEmptyColor = computed(() => {\n    if (!chartEmptyColorRaw.value) return '#2a2c32';\n    return chroma(chartEmptyColorRaw.value).hex();\n});\nconst chartColor = computed(() => {\n    if (!chartColorRaw.value) return '#bae6fd';\n    return `rgb(${chartColorRaw.value})`;\n});\n\n// Track chart container size\nconst chartContainer = ref<HTMLElement | null>(null);\nconst { width: containerWidth } = useElementSize(chartContainer);\n\n// Calculate number of weeks based on available width\n// Rough estimate: 40px per cell + 80px for labels = ~360px for 7 weeks\nconst numberOfWeeks = computed(() => {\n    const availableWidth = containerWidth.value || 400;\n    const minCellSize = 25; // Minimum cell size in pixels\n    const labelSpace = 80; // Space for day labels\n    const usableWidth = availableWidth - labelSpace;\n    const maxWeeks = Math.floor(usableWidth / minCellSize);\n    // Clamp between 4 and 12 weeks for reasonable display\n    return Math.max(4, Math.min(12, maxWeeks));\n});\n\n// Calculate date range based on dynamic number of weeks\nconst dateRange = computed(() => {\n    const today = getDayJsInstance()();\n    const startOfWeek = today.startOf('week');\n    // Go back (numberOfWeeks - 1) weeks from the start of current week\n    const rangeStart = startOfWeek.subtract(numberOfWeeks.value - 1, 'week');\n    return [today.format('YYYY-MM-DD'), rangeStart.format('YYYY-MM-DD')];\n});\n\nconst option = computed(() => {\n    return {\n        tooltip: {},\n        visualMap: {\n            type: 'piecewise',\n            orient: 'horizontal',\n            left: 'center',\n            top: 'center',\n            pieces: [\n                { value: 0, color: chartEmptyColor.value },\n                {\n                    gt: 0,\n                    lte: max.value * 0.25,\n                    color: chroma.mix(chartEmptyColor.value, chartColor.value, 0.3).hex(),\n                },\n                {\n                    gt: max.value * 0.25,\n                    lte: max.value * 0.5,\n                    color: chroma.mix(chartEmptyColor.value, chartColor.value, 0.6).hex(),\n                },\n                {\n                    gt: max.value * 0.5,\n                    lte: max.value * 0.75,\n                    color: chroma.mix(chartEmptyColor.value, chartColor.value, 0.8).hex(),\n                },\n                { gt: max.value * 0.75, lte: max.value, color: chartColor.value },\n            ],\n            show: false,\n        },\n        calendar: {\n            top: 35,\n            bottom: 20,\n            left: 35,\n            right: 5,\n            cellSize: 'auto',\n            orient: 'horizontal',\n            dayLabel: {\n                firstDay: firstDayIndex.value,\n                color: labelColor.value,\n                fontFamily: 'Inter, sans-serif',\n            },\n            monthLabel: {\n                color: labelColor.value,\n                fontFamily: 'Inter, sans-serif',\n            },\n            splitLine: {\n                show: false,\n            },\n            range: dateRange.value,\n            itemStyle: {\n                color: 'transparent',\n                borderWidth: 8,\n                borderColor: backgroundColor.value,\n            },\n            yearLabel: { show: false },\n        },\n        series: {\n            type: 'heatmap',\n            coordinateSystem: 'calendar',\n            data: dailyHoursTracked?.value?.map((el) => [el.date, el.duration]) ?? [],\n            itemStyle: {\n                borderRadius: 5,\n                borderColor: borderColor.value,\n                borderWidth: 1,\n            },\n            tooltip: {\n                valueFormatter: (value: number, dataIndex: number) => {\n                    if (dailyHoursTracked?.value) {\n                        return (\n                            formatDate(\n                                dailyHoursTracked?.value[dataIndex]?.date ?? '',\n                                organization?.value?.date_format\n                            ) +\n                            ': ' +\n                            formatHumanReadableDuration(\n                                value,\n                                organization?.value?.interval_format,\n                                organization?.value?.number_format\n                            )\n                        );\n                    } else {\n                        return '';\n                    }\n                },\n            },\n        },\n        backgroundColor: 'transparent',\n    };\n});\n</script>\n\n<template>\n    <DashboardCard title=\"Activity Graph\" :icon=\"BoltIcon\">\n        <div class=\"px-2\">\n            <div v-if=\"isLoading\" class=\"flex justify-center items-center h-40\">\n                <LoadingSpinner />\n            </div>\n            <div v-else-if=\"dailyHoursTracked\" ref=\"chartContainer\">\n                <v-chart\n                    class=\"chart\"\n                    :autoresize=\"true\"\n                    :option=\"option\"\n                    style=\"height: 260px; background-color: transparent\" />\n            </div>\n            <div v-else class=\"text-center text-gray-500 py-8\">No activity data available</div>\n        </div>\n    </DashboardCard>\n</template>\n\n<style></style>\n"
  },
  {
    "path": "resources/js/Components/Dashboard/DashboardCard.vue",
    "content": "<template>\n    <section class=\"flex overflow-hidden flex-col gap-1.5\">\n        <CardTitle :title=\"title\" :icon=\"icon\"></CardTitle>\n\n        <div\n            class=\"flex-1 flex items-stretch rounded-lg bg-card-background border border-card-border\">\n            <div class=\"w-full flex flex-col\">\n                <slot></slot>\n            </div>\n        </div>\n    </section>\n</template>\n\n<script setup lang=\"ts\">\nimport type { Component } from 'vue';\nimport CardTitle from '@/packages/ui/src/CardTitle.vue';\n\ndefineProps<{\n    title: string;\n    icon?: Component;\n}>();\n</script>\n"
  },
  {
    "path": "resources/js/Components/Dashboard/DayOverviewCardChart.vue",
    "content": "<script setup lang=\"ts\">\nimport VChart from 'vue-echarts';\nimport { computed } from 'vue';\nimport { useCssVariable } from '@/utils/useCssVariable';\n\nconst props = defineProps<{\n    history: number[];\n}>();\n\nconst accentColor = useCssVariable('--theme-color-chart');\n\nconst seriesData = computed(() =>\n    props.history.map((el) => {\n        return {\n            value: el,\n            ...{\n                itemStyle: {\n                    borderWidth: 1,\n                    borderColor: 'rgba(' + accentColor.value + ',0.8)',\n                    borderRadius: [2, 2, 0, 0],\n                    color: 'rgba(' + accentColor.value + ',0.8)',\n                },\n            },\n        };\n    })\n);\nconst option = computed(() => ({\n    grid: {\n        top: 0,\n        right: 0,\n        left: 0,\n        bottom: 0,\n    },\n    backgroundColor: 'transparent',\n    xAxis: {\n        type: 'category',\n        data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],\n        show: false,\n    },\n    yAxis: {\n        type: 'value',\n        show: false,\n    },\n    series: [\n        {\n            data: seriesData.value,\n            type: 'bar',\n        },\n    ],\n}));\n</script>\n\n<template>\n    <v-chart style=\"height: 20px; width: 80px\" class=\"chart\" :option=\"option\" />\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/Dashboard/DayOverviewCardEntry.vue",
    "content": "<script setup lang=\"ts\">\nimport DayOverviewCardChart from '@/Components/Dashboard/DayOverviewCardChart.vue';\nimport { formatHumanReadableDate, formatHumanReadableDuration } from '@/packages/ui/src/utils/time';\nimport { inject, type ComputedRef } from 'vue';\nimport type { Organization } from '@/packages/api/src';\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n\ndefineProps<{\n    date: string;\n    duration: number;\n    history: number[];\n}>();\n</script>\n\n<template>\n    <div class=\"px-3.5 py-2 flex justify-between @container\">\n        <div class=\"flex items-center min-w-[70px]\">\n            <p class=\"text-sm text-text-primary\">\n                {{ formatHumanReadableDate(date) }}\n            </p>\n        </div>\n        <div class=\"items-center justify-center flex-1 hidden @2xs:flex\">\n            <DayOverviewCardChart :history=\"history\"></DayOverviewCardChart>\n        </div>\n        <div class=\"flex text-sm items-center justify-center text-text-secondary min-w-[65px]\">\n            {{\n                formatHumanReadableDuration(\n                    duration,\n                    organization?.interval_format,\n                    organization?.number_format\n                )\n            }}\n        </div>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Dashboard/LastSevenDaysCard.vue",
    "content": "<script setup lang=\"ts\">\nimport { useQuery } from '@tanstack/vue-query';\nimport { computed } from 'vue';\nimport DashboardCard from '@/Components/Dashboard/DashboardCard.vue';\nimport DayOverviewCardEntry from '@/Components/Dashboard/DayOverviewCardEntry.vue';\nimport { CalendarIcon } from '@heroicons/vue/20/solid';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport { api } from '@/packages/api/src';\nimport { LoadingSpinner } from '@/packages/ui/src';\n\n// Get the organization ID using the utility function\nconst organizationId = computed(() => getCurrentOrganizationId());\n\n// Set up the query\nconst { data: last7Days, isLoading } = useQuery({\n    queryKey: ['lastSevenDays', organizationId],\n    queryFn: () => {\n        return api.lastSevenDays({\n            params: {\n                organization: organizationId.value!,\n            },\n        });\n    },\n    enabled: computed(() => !!organizationId.value),\n    placeholderData: Array.from({ length: 7 }, (_, i) => ({\n        date: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString().split('T')[0]!,\n        duration: 0,\n        history: Array(8).fill(0) as number[],\n    })),\n});\n</script>\n\n<template>\n    <DashboardCard title=\"Last 7 Days\" :icon=\"CalendarIcon\">\n        <div v-if=\"isLoading\" class=\"flex justify-center items-center h-40\">\n            <LoadingSpinner />\n        </div>\n        <div v-else-if=\"last7Days\">\n            <DayOverviewCardEntry\n                v-for=\"day in last7Days\"\n                :key=\"day.date\"\n                :class=\"last7Days.length === 7 ? 'last:border-0 first:pt-3' : ''\"\n                :date=\"day.date\"\n                :history=\"day.history\"\n                :duration=\"day.duration\"></DayOverviewCardEntry>\n        </div>\n        <div v-else class=\"text-center text-gray-500 py-8\">No data available</div>\n    </DashboardCard>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Dashboard/ProjectsChartCard.vue",
    "content": "<script setup lang=\"ts\">\nimport VChart, { THEME_KEY } from 'vue-echarts';\nimport { provide, inject, type ComputedRef } from 'vue';\nimport { use } from 'echarts/core';\nimport { CanvasRenderer } from 'echarts/renderers';\nimport { PieChart } from 'echarts/charts';\nimport {\n    GridComponent,\n    LegendComponent,\n    TitleComponent,\n    TooltipComponent,\n} from 'echarts/components';\nimport { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';\nimport { useCssVariable } from '@/utils/useCssVariable';\nimport type { Organization } from '@/packages/api/src';\n\nuse([CanvasRenderer, PieChart, TitleComponent, GridComponent, TooltipComponent, LegendComponent]);\n\nprovide(THEME_KEY, 'dark');\nconst labelColor = useCssVariable('--color-text-secondary');\n\nconst props = defineProps<{\n    weeklyProjectOverview: {\n        value: number;\n        name: string;\n        color: string;\n    }[];\n}>();\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n\nconst seriesData = props.weeklyProjectOverview.map((el) => {\n    return {\n        ...el,\n        ...{\n            itemStyle: {\n                color: `${el.color}BB`,\n            },\n            emphasis: {\n                itemStyle: {\n                    color: `${el.color}`,\n                },\n            },\n        },\n    };\n});\nimport { computed } from 'vue';\n\nconst option = computed(() => ({\n    tooltip: {\n        trigger: 'item',\n    },\n    legend: {\n        bottom: 'bottom',\n        top: '250px',\n        textStyle: {\n            color: labelColor.value,\n        },\n    },\n    backgroundColor: 'transparent',\n    series: [\n        {\n            label: {\n                show: false,\n            },\n            tooltip: {\n                valueFormatter: (value: number) => {\n                    return formatHumanReadableDuration(\n                        value,\n                        organization?.value?.interval_format,\n                        organization?.value?.number_format\n                    );\n                },\n            },\n            data: seriesData,\n            top: '-45%',\n            radius: ['30%', '60%'],\n            type: 'pie',\n        },\n    ],\n}));\n</script>\n\n<template>\n    <v-chart\n        class=\"h-[420px] max-w-[300px] mx-auto bg-transparent\"\n        :autoresize=\"true\"\n        :option=\"option\" />\n</template>\n"
  },
  {
    "path": "resources/js/Components/Dashboard/RecentlyTrackedTasksCard.vue",
    "content": "<script setup lang=\"ts\">\nimport { useQuery } from '@tanstack/vue-query';\nimport { computed } from 'vue';\nimport RecentlyTrackedTasksCardEntry from '@/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue';\nimport DashboardCard from '@/Components/Dashboard/DashboardCard.vue';\nimport { CheckCircleIcon } from '@heroicons/vue/24/solid';\nimport { PlusCircleIcon } from '@heroicons/vue/24/solid';\nimport { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';\nimport { api } from '@/packages/api/src';\nimport { LoadingSpinner } from '@/packages/ui/src';\n\n// Get the organization ID using the utility function\nconst organizationId = computed(() => getCurrentOrganizationId());\n\n// Function to fetch latest tasks using the API client\n\n// Set up the query\nconst {\n    data: timeEntriesResponse,\n    isLoading,\n    refetch,\n} = useQuery({\n    queryKey: ['timeEntries', organizationId],\n    queryFn: () => {\n        return api.getTimeEntries({\n            params: {\n                organization: organizationId.value!,\n            },\n            queries: {\n                member_id: getCurrentMembershipId(),\n            },\n        });\n    },\n    enabled: computed(() => !!organizationId.value),\n});\n\nconst latestTasks = computed(() => {\n    if (!timeEntriesResponse.value) {\n        return [];\n    }\n\n    return timeEntriesResponse.value.data;\n});\n\nconst filteredLatestTasks = computed(() => {\n    // do not include running time entries\n    const finishedTimeEntries = latestTasks.value.filter((item) => item.end !== null);\n\n    // filter out duplicates based on description, task, project, tags and billable\n    return finishedTimeEntries\n        .filter((item, index, self) => {\n            return (\n                index ===\n                self.findIndex(\n                    (t) =>\n                        t.description === item.description &&\n                        t.task_id === item.task_id &&\n                        t.project_id === item.project_id &&\n                        t.tags.length === item.tags.length &&\n                        t.tags.every((tag) => item.tags.includes(tag)) &&\n                        t.billable === item.billable\n                )\n            );\n        })\n        .slice(0, 4);\n});\n\n// Listen for dashboard refresh events\nwindow.addEventListener('dashboard:refresh', () => {\n    refetch();\n});\n</script>\n\n<template>\n    <DashboardCard title=\"Recent Time Entries\" :icon=\"CheckCircleIcon\">\n        <div v-if=\"isLoading\" class=\"flex justify-center items-center h-40\">\n            <LoadingSpinner />\n        </div>\n        <div v-else-if=\"filteredLatestTasks && filteredLatestTasks.length > 0\">\n            <RecentlyTrackedTasksCardEntry\n                v-for=\"lastTask in filteredLatestTasks\"\n                :key=\"lastTask.id\"\n                :time-entry=\"lastTask\"\n                :class=\"\n                    filteredLatestTasks.length === 4 ? 'last:border-0' : ''\n                \"></RecentlyTrackedTasksCardEntry>\n        </div>\n        <div v-else class=\"text-center flex flex-1 justify-center items-center py-5\">\n            <div>\n                <PlusCircleIcon class=\"w-8 text-icon-default inline pb-2\"></PlusCircleIcon>\n                <h3 class=\"text-text-primary font-semibold text-sm\">No recent time entries</h3>\n                <p class=\"text-sm\">Start tracking your time!</p>\n            </div>\n        </div>\n    </DashboardCard>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue",
    "content": "<script setup lang=\"ts\">\nimport ProjectBadge from '@/packages/ui/src/Project/ProjectBadge.vue';\nimport TimeTrackerStartStop from '@/packages/ui/src/TimeTrackerStartStop.vue';\nimport { useProjectsQuery } from '@/utils/useProjectsQuery';\nimport { computed } from 'vue';\nimport { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';\nimport { storeToRefs } from 'pinia';\nimport { getDayJsInstance } from '@/packages/ui/src/utils/time';\nimport type { TimeEntry } from '@/packages/api/src';\nimport { useTasksQuery } from '@/utils/useTasksQuery';\nimport { ChevronRightIcon } from '@heroicons/vue/16/solid';\n\nconst props = defineProps<{\n    timeEntry: TimeEntry;\n}>();\n\nconst { projects } = useProjectsQuery();\n\nconst project = computed(() => {\n    return projects.value.find((project) => project.id === props.timeEntry.project_id);\n});\n\nconst { tasks } = useTasksQuery();\n\nconst task = computed(() => {\n    return tasks.value.find((task) => task.id === props.timeEntry.task_id);\n});\n\nconst { currentTimeEntry } = storeToRefs(useCurrentTimeEntryStore());\nconst { setActiveState } = useCurrentTimeEntryStore();\n\nasync function startTaskTimer() {\n    if (currentTimeEntry.value.id) {\n        await setActiveState(false);\n    }\n    currentTimeEntry.value.description = props.timeEntry.description;\n    currentTimeEntry.value.project_id = props.timeEntry.project_id;\n    currentTimeEntry.value.task_id = props.timeEntry.task_id;\n    currentTimeEntry.value.tags = props.timeEntry.tags;\n    currentTimeEntry.value.billable = props.timeEntry.billable;\n    currentTimeEntry.value.start = getDayJsInstance().utc().format();\n    await setActiveState(true);\n    useCurrentTimeEntryStore().fetchCurrentTimeEntry();\n}\n</script>\n\n<template>\n    <div class=\"px-3.5 py-2 grid grid-cols-5\">\n        <div class=\"col-span-4\">\n            <p class=\"text-text-secondary text-sm pb-1.5 truncate\">\n                <span v-if=\"timeEntry.description\"> {{ timeEntry.description }}</span>\n                <span v-else>No description</span>\n            </p>\n            <ProjectBadge size=\"base\" class=\"min-w-0 max-w-full\" :color=\"project?.color\">\n                <div class=\"flex items-center lg:space-x-0.5 min-w-0\">\n                    <span class=\"whitespace-nowrap\">\n                        {{ project?.name ?? 'No Project' }}\n                    </span>\n                    <ChevronRightIcon\n                        v-if=\"task\"\n                        class=\"w-4 text-text-secondary shrink-0\"></ChevronRightIcon>\n                    <div v-if=\"task\" class=\"min-w-0 shrink truncate\">\n                        {{ task.name }}\n                    </div>\n                </div>\n            </ProjectBadge>\n        </div>\n        <div class=\"flex items-center justify-end pr-1\">\n            <TimeTrackerStartStop\n                variant=\"secondary\"\n                size=\"base\"\n                class=\"w-9 h-9\"\n                @changed=\"startTaskTimer\"></TimeTrackerStartStop>\n        </div>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Dashboard/TeamActivityCard.vue",
    "content": "<script lang=\"ts\" setup>\nimport { useQuery } from '@tanstack/vue-query';\nimport { computed } from 'vue';\nimport DashboardCard from '@/Components/Dashboard/DashboardCard.vue';\nimport TeamActivityCardEntry from '@/Components/Dashboard/TeamActivityCardEntry.vue';\nimport { UserGroupIcon } from '@heroicons/vue/20/solid';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport { api } from '@/packages/api/src';\nimport { LoadingSpinner } from '@/packages/ui/src';\nimport { router } from '@inertiajs/vue3';\n\n// Get the organization ID using the utility function\nconst organizationId = computed(() => getCurrentOrganizationId());\n\n// Set up the query\nconst { data: latestTeamActivity, isLoading } = useQuery({\n    queryKey: ['latestTeamActivity', organizationId],\n    queryFn: () => {\n        return api.latestTeamActivity({\n            params: {\n                organization: organizationId.value!,\n            },\n        });\n    },\n    enabled: computed(() => !!organizationId.value),\n});\n</script>\n\n<template>\n    <DashboardCard title=\"Team Activity\" :icon=\"UserGroupIcon\">\n        <div v-if=\"isLoading\" class=\"flex justify-center items-center h-40\">\n            <LoadingSpinner />\n        </div>\n        <div v-else-if=\"latestTeamActivity\">\n            <TeamActivityCardEntry\n                v-for=\"activity in latestTeamActivity\"\n                :key=\"activity.time_entry_id\"\n                :class=\"latestTeamActivity.length === 4 ? 'last:border-0' : ''\"\n                :name=\"activity.name\"\n                :description=\"activity.description\"\n                :working=\"activity.status\"></TeamActivityCardEntry>\n        </div>\n        <div v-else class=\"text-center text-gray-500 py-8\">No team activity found</div>\n        <div\n            v-if=\"latestTeamActivity && latestTeamActivity.length <= 1\"\n            :class=\"latestTeamActivity?.length === 1 ? 'pb-5' : 'py-5'\"\n            class=\"text-center flex flex-1 justify-center items-center\">\n            <div>\n                <UserGroupIcon class=\"w-8 text-icon-default inline pb-2\"></UserGroupIcon>\n                <h3 class=\"text-text-primary font-semibold text-sm\">Invite your co-workers</h3>\n                <p class=\"pb-5 text-sm\">You can invite your entire team.</p>\n                <SecondaryButton @click=\"router.visit(route('members'))\"\n                    >Go to Members\n                </SecondaryButton>\n            </div>\n        </div>\n    </DashboardCard>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Dashboard/TeamActivityCardEntry.vue",
    "content": "<script lang=\"ts\" setup>\ndefineProps<{\n    name: string;\n    description: string | null;\n    working?: boolean;\n}>();\n</script>\n\n<template>\n    <div class=\"px-3.5 py-2 2xl:py-3\">\n        <div class=\"col-span-2\">\n            <div class=\"flex justify-between\">\n                <p\n                    class=\"text-xs min-w-0 overflow-ellipsis overflow-hidden flex-1 text-text-secondary\">\n                    {{ name }}\n                </p>\n                <div v-if=\"working\" class=\"flex space-x-1.5 items-center justify-end\">\n                    <span class=\"relative flex h-3 w-3 justify-center items-center\">\n                        <span\n                            class=\"animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75\"></span>\n                        <span class=\"relative inline-flex rounded-full h-2 w-2 bg-green-500\"></span>\n                    </span>\n                    <span class=\"text-green-500 font-medium text-sm block pb-0.5\"> working </span>\n                </div>\n            </div>\n            <div\n                class=\"text-text-secondary text-sm font-medium text-ellipsis whitespace-nowrap max-w-full overflow-hidden\">\n                {{ description }}\n            </div>\n        </div>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/Dashboard/ThisWeekOverview.vue",
    "content": "<script setup lang=\"ts\">\nimport { use } from 'echarts/core';\nimport { CanvasRenderer } from 'echarts/renderers';\nimport { BarChart } from 'echarts/charts';\nimport {\n    GridComponent,\n    LegendComponent,\n    TitleComponent,\n    TooltipComponent,\n} from 'echarts/components';\nimport VChart, { THEME_KEY } from 'vue-echarts';\nimport { computed, provide, inject, type ComputedRef } from 'vue';\nimport StatCard from '@/Components/Common/StatCard.vue';\nimport { ClockIcon } from '@heroicons/vue/20/solid';\nimport CardTitle from '@/packages/ui/src/CardTitle.vue';\nimport LinearGradient from 'zrender/lib/graphic/LinearGradient';\nimport ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue';\nimport ThisWeekReportingTable from '@/Components/Dashboard/ThisWeekReportingTable.vue';\nimport { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';\nimport { formatCents } from '@/packages/ui/src/utils/money';\nimport { getWeekStart } from '@/packages/ui/src/utils/settings';\nimport { useCssVariable } from '@/utils/useCssVariable';\nimport { getOrganizationCurrencyString } from '@/utils/money';\nimport { useQuery } from '@tanstack/vue-query';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport { api, type Organization } from '@/packages/api/src';\n\nuse([CanvasRenderer, BarChart, TitleComponent, GridComponent, TooltipComponent, LegendComponent]);\n\nprovide(THEME_KEY, 'dark');\n\nconst weekdays = computed(() => {\n    const daysOrder = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];\n    const dayMapping: Record<string, string> = {\n        monday: 'Mon',\n        tuesday: 'Tue',\n        wednesday: 'Wed',\n        thursday: 'Thu',\n        friday: 'Fri',\n        saturday: 'Sat',\n        sunday: 'Sun',\n    };\n    if (dayMapping[getWeekStart()]) {\n        const customOrder = [];\n        const startIndex = daysOrder.indexOf(dayMapping[getWeekStart()]!);\n\n        for (let i = startIndex; i < 7 + startIndex; i++) {\n            customOrder.push(daysOrder[i % daysOrder.length]!);\n        }\n\n        return customOrder;\n    } else {\n        return daysOrder;\n    }\n});\n\nconst accentColor = useCssVariable('--theme-color-chart');\n\n// Get the organization ID using the utility function\nconst organizationId = computed(() => getCurrentOrganizationId());\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n\n// Set up the queries\nconst { data: weeklyProjectOverview } = useQuery({\n    queryKey: ['weeklyProjectOverview', organizationId],\n    queryFn: () => {\n        return api.weeklyProjectOverview({\n            params: {\n                organization: organizationId.value!,\n            },\n        });\n    },\n    enabled: computed(() => !!organizationId.value),\n    staleTime: 1000 * 30, // 30 seconds\n});\n\nconst { data: totalWeeklyTime } = useQuery({\n    queryKey: ['totalWeeklyTime', organizationId],\n    queryFn: () => {\n        return api.totalWeeklyTime({\n            params: {\n                organization: organizationId.value!,\n            },\n        });\n    },\n    enabled: computed(() => !!organizationId.value),\n    staleTime: 1000 * 30, // 30 seconds\n});\n\nconst { data: totalWeeklyBillableTime } = useQuery({\n    queryKey: ['totalWeeklyBillableTime', organizationId],\n    queryFn: () => {\n        return api.totalWeeklyBillableTime({\n            params: {\n                organization: organizationId.value!,\n            },\n        });\n    },\n    enabled: computed(() => !!organizationId.value),\n    staleTime: 1000 * 30, // 30 seconds\n});\n\nconst { data: totalWeeklyBillableAmount } = useQuery({\n    queryKey: ['totalWeeklyBillableAmount', organizationId],\n    queryFn: () => {\n        return api.totalWeeklyBillableAmount({\n            params: {\n                organization: organizationId.value!,\n            },\n        });\n    },\n    enabled: computed(() => !!organizationId.value),\n    staleTime: 1000 * 30, // 30 seconds\n});\n\nconst { data: weeklyHistory } = useQuery({\n    queryKey: ['weeklyHistory', organizationId],\n    queryFn: () => {\n        return api.weeklyHistory({\n            params: {\n                organization: organizationId.value!,\n            },\n        });\n    },\n    enabled: computed(() => !!organizationId.value),\n    staleTime: 1000 * 30, // 30 seconds\n});\n\nconst seriesData = computed(() => {\n    if (!weeklyHistory.value) {\n        return [];\n    }\n    return weeklyHistory.value?.map((el) => {\n        return {\n            value: el.duration,\n            ...{\n                itemStyle: {\n                    borderColor: new LinearGradient(0, 0, 0, 1, [\n                        {\n                            offset: 0,\n                            color: 'rgba(' + accentColor.value + ',0.7)',\n                        },\n                        {\n                            offset: 1,\n                            color: 'rgba(' + accentColor.value + ',0.5)',\n                        },\n                    ]),\n                    emphasis: {\n                        color: new LinearGradient(0, 0, 0, 1, [\n                            {\n                                offset: 0,\n                                color: 'rgba(' + accentColor.value + ',0.9)',\n                            },\n                            {\n                                offset: 1,\n                                color: 'rgba(' + accentColor.value + ',0.7)',\n                            },\n                        ]),\n                    },\n                    borderRadius: [12, 12, 0, 0],\n                    color: new LinearGradient(0, 0, 0, 1, [\n                        {\n                            offset: 0,\n                            color: 'rgba(' + accentColor.value + ',0.7)',\n                        },\n                        {\n                            offset: 1,\n                            color: 'rgba(' + accentColor.value + ',0.5)',\n                        },\n                    ]),\n                },\n            },\n        };\n    });\n});\n\nconst markLineColor = useCssVariable('--color-border-secondary');\nconst labelColor = useCssVariable('--color-text-secondary');\nconst option = computed(() => {\n    return {\n        tooltip: {\n            trigger: 'item',\n        },\n        grid: {\n            top: 0,\n            right: 0,\n            bottom: 50,\n            left: 0,\n        },\n        backgroundColor: 'transparent',\n        xAxis: {\n            type: 'category',\n            data: weekdays.value,\n            axisLine: {\n                show: false,\n            },\n            axisLabel: {\n                fontSize: 14,\n                fontWeight: 500,\n                margin: 24,\n                fontFamily: 'Inter, sans-serif',\n                color: labelColor.value,\n            },\n            axisTick: {\n                show: false,\n            },\n        },\n        yAxis: {\n            type: 'value',\n            axisLabel: {\n                show: false,\n            },\n            splitLine: {\n                lineStyle: {\n                    color: markLineColor.value,\n                },\n            },\n        },\n        series: [\n            {\n                data: seriesData.value,\n                type: 'bar',\n                tooltip: {\n                    valueFormatter: (value: number) => {\n                        return formatHumanReadableDuration(\n                            value,\n                            organization?.value?.interval_format,\n                            organization?.value?.number_format\n                        );\n                    },\n                },\n            },\n        ],\n    };\n});\n</script>\n\n<template>\n    <div\n        class=\"grid space-y-5 sm:space-y-0 sm:gap-x-6 xl:gap-x-6 grid-cols-1 lg:grid-cols-3 xl:grid-cols-4\">\n        <div class=\"col-span-2 xl:col-span-3\">\n            <CardTitle title=\"This Week\" class=\"pb-8\" :icon=\"ClockIcon\"></CardTitle>\n            <v-chart v-if=\"weeklyHistory\" :autoresize=\"true\" class=\"chart\" :option=\"option\" />\n\n            <div class=\"mt-6\">\n                <ThisWeekReportingTable></ThisWeekReportingTable>\n            </div>\n        </div>\n        <div class=\"space-y-6\">\n            <StatCard\n                title=\"Spent Time\"\n                :value=\"\n                    totalWeeklyTime\n                        ? formatHumanReadableDuration(\n                              totalWeeklyTime,\n                              organization?.interval_format,\n                              organization?.number_format\n                          )\n                        : '--'\n                \" />\n            <StatCard\n                title=\"Billable Time\"\n                :value=\"\n                    totalWeeklyBillableTime\n                        ? formatHumanReadableDuration(\n                              totalWeeklyBillableTime,\n                              organization?.interval_format,\n                              organization?.number_format\n                          )\n                        : '--'\n                \" />\n            <StatCard\n                title=\"Billable Amount\"\n                :value=\"\n                    totalWeeklyBillableAmount\n                        ? formatCents(\n                              totalWeeklyBillableAmount.value,\n                              getOrganizationCurrencyString(),\n                              organization?.currency_format,\n                              organization?.currency_symbol,\n                              organization?.number_format\n                          )\n                        : '--'\n                \" />\n            <ProjectsChartCard\n                v-if=\"weeklyProjectOverview\"\n                :weekly-project-overview=\"weeklyProjectOverview\"></ProjectsChartCard>\n        </div>\n    </div>\n</template>\n\n<style scoped>\n.chart {\n    height: 280px;\n    background: transparent;\n}\n</style>\n"
  },
  {
    "path": "resources/js/Components/Dashboard/ThisWeekReportingTable.vue",
    "content": "<script setup lang=\"ts\">\nimport ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';\nimport ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';\nimport {\n    formatHumanReadableDuration,\n    getDayJsInstance,\n    getLocalizedDayJs,\n} from '@/packages/ui/src/utils/time';\nimport { formatCents } from '@/packages/ui/src/utils/money';\nimport { getOrganizationCurrencyString } from '@/utils/money';\nimport { type GroupingOption, useReportingStore } from '@/utils/useReporting';\nimport { getCurrentMembershipId, getCurrentOrganizationId, getCurrentRole } from '@/utils/useUser';\nimport {\n    api,\n    type AggregatedTimeEntries,\n    type AggregatedTimeEntriesQueryParams,\n    type Organization,\n} from '@/packages/api/src';\nimport { useQuery } from '@tanstack/vue-query';\nimport { useStorage } from '@vueuse/core';\nimport { computed, inject, type ComputedRef, watch } from 'vue';\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n\nconst group = useStorage<GroupingOption>('dashboard-reporting-group', 'project');\nconst subGroup = useStorage<GroupingOption>('dashboard-reporting-sub-group', 'task');\n\nconst reportingStore = useReportingStore();\nconst { groupByOptions, getNameForReportingRowEntry } = reportingStore;\n\nwatch(\n    group,\n    () => {\n        if (group.value === subGroup.value) {\n            const fallbackOption = groupByOptions.find((el) => el.value !== group.value);\n            if (fallbackOption?.value) {\n                subGroup.value = fallbackOption.value;\n            }\n        }\n    },\n    { immediate: true }\n);\n\nconst organizationId = computed(() => getCurrentOrganizationId());\n\nconst weekStartUtc = computed(() => {\n    return getLocalizedDayJs(getDayJsInstance()().format())\n        .startOf('week')\n        .startOf('day')\n        .utc()\n        .format();\n});\n\nconst weekEndUtc = computed(() => {\n    return getLocalizedDayJs(getDayJsInstance()().format()).endOf('day').utc().format();\n});\n\nconst queryParams = computed<AggregatedTimeEntriesQueryParams>(() => {\n    return {\n        start: weekStartUtc.value,\n        end: weekEndUtc.value,\n        group: group.value,\n        sub_group: subGroup.value,\n        member_id: getCurrentRole() === 'employee' ? getCurrentMembershipId() : undefined,\n    };\n});\n\nconst { data: reportingResponse, isLoading } = useQuery({\n    queryKey: [\n        'dashboardThisWeekReporting',\n        organizationId,\n        weekStartUtc,\n        weekEndUtc,\n        group,\n        subGroup,\n    ],\n    queryFn: () => {\n        return api.getAggregatedTimeEntries({\n            params: {\n                organization: organizationId.value!,\n            },\n            queries: queryParams.value,\n        });\n    },\n    enabled: computed(() => !!organizationId.value),\n});\n\nconst aggregatedTableTimeEntries = computed<AggregatedTimeEntries | null>(() => {\n    return (reportingResponse.value?.data as AggregatedTimeEntries | undefined) ?? null;\n});\n\nconst tableData = computed(() => {\n    return (\n        aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {\n            return {\n                seconds: entry.seconds,\n                cost: entry.cost,\n                description: getNameForReportingRowEntry(\n                    entry.key,\n                    aggregatedTableTimeEntries.value?.grouped_type ?? null\n                ),\n                grouped_data:\n                    entry.grouped_data?.map((el) => {\n                        return {\n                            seconds: el.seconds,\n                            cost: el.cost,\n                            description: getNameForReportingRowEntry(\n                                el.key,\n                                entry.grouped_type ?? null\n                            ),\n                        };\n                    }) ?? [],\n            };\n        }) ?? []\n    );\n});\n\nconst showBillableRate = computed(() => {\n    return !!(\n        getCurrentRole() !== 'employee' || organization?.value?.employees_can_see_billable_rates\n    );\n});\n</script>\n\n<template>\n    <div class=\"rounded-lg bg-card-background border border-card-border\">\n        <div\n            class=\"text-sm flex text-text-primary pt-3 items-center space-x-3 font-medium px-6 border-b border-card-background-separator pb-3\">\n            <span>Group by</span>\n            <ReportingGroupBySelect\n                v-model=\"group\"\n                :group-by-options=\"groupByOptions\"></ReportingGroupBySelect>\n            <span>and</span>\n            <ReportingGroupBySelect\n                v-model=\"subGroup\"\n                :group-by-options=\"\n                    groupByOptions.filter((el) => el.value !== group)\n                \"></ReportingGroupBySelect>\n        </div>\n\n        <div\n            class=\"grid items-center\"\n            :style=\"`grid-template-columns: 1fr 100px ${showBillableRate ? '150px' : ''}`\">\n            <div\n                class=\"contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:pb-1.5 [&>*]:pt-1 text-text-tertiary text-sm\">\n                <div class=\"pl-6\">Name</div>\n                <div class=\"text-right\" :class=\"!showBillableRate ? 'pr-6' : ''\">Duration</div>\n                <div v-if=\"showBillableRate\" class=\"text-right pr-6\">Cost</div>\n            </div>\n\n            <div\n                v-if=\"isLoading\"\n                class=\"flex justify-center py-10 text-text-tertiary\"\n                :class=\"showBillableRate ? 'col-span-3' : 'col-span-2'\">\n                Loading reporting data…\n            </div>\n\n            <template\n                v-else-if=\"\n                    aggregatedTableTimeEntries?.grouped_data &&\n                    aggregatedTableTimeEntries.grouped_data?.length > 0\n                \">\n                <ReportingRow\n                    v-for=\"entry in tableData\"\n                    :key=\"entry.description ?? 'none'\"\n                    :currency=\"getOrganizationCurrencyString()\"\n                    :show-cost=\"showBillableRate\"\n                    :entry=\"entry\"></ReportingRow>\n                <div class=\"contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]\">\n                    <div class=\"flex items-center pl-6 font-medium\">\n                        <span>Total</span>\n                    </div>\n                    <div\n                        class=\"justify-end flex items-center font-medium\"\n                        :class=\"!showBillableRate ? 'pr-6' : ''\">\n                        {{\n                            formatHumanReadableDuration(\n                                aggregatedTableTimeEntries.seconds,\n                                organization?.interval_format,\n                                organization?.number_format\n                            )\n                        }}\n                    </div>\n                    <div\n                        v-if=\"showBillableRate\"\n                        class=\"justify-end pr-6 flex items-center font-medium\">\n                        {{\n                            aggregatedTableTimeEntries.cost\n                                ? formatCents(\n                                      aggregatedTableTimeEntries.cost,\n                                      getOrganizationCurrencyString(),\n                                      organization?.currency_format,\n                                      organization?.currency_symbol,\n                                      organization?.number_format\n                                  )\n                                : '--'\n                        }}\n                    </div>\n                </div>\n            </template>\n\n            <div\n                v-else\n                class=\"chart flex flex-col items-center justify-center py-12\"\n                :class=\"showBillableRate ? 'col-span-3' : 'col-span-2'\">\n                <p class=\"text-lg text-text-primary font-medium\">No time entries found</p>\n                <p>Try to track some time entries this week</p>\n            </div>\n        </div>\n    </div>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/DropdownLink.vue",
    "content": "<script setup lang=\"ts\">\nimport { Link } from '@inertiajs/vue3';\n\ndefineProps<{\n    href?: string;\n    as?: string;\n}>();\n</script>\n\n<template>\n    <div>\n        <button\n            v-if=\"as == 'button'\"\n            type=\"submit\"\n            v-bind=\"$attrs\"\n            class=\"block w-full px-4 py-2 text-start text-sm leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out\">\n            <slot />\n        </button>\n        <a\n            v-else-if=\"as == 'a'\"\n            :href=\"href\"\n            class=\"block px-4 py-2 text-sm leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out\">\n            <slot />\n        </a>\n\n        <Link\n            v-else\n            :href=\"href ?? ''\"\n            prefetch\n            class=\"block px-4 py-2 text-sm leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out\">\n            <slot />\n        </Link>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/FormSection.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, useSlots } from 'vue';\nimport SectionTitle from './SectionTitle.vue';\n\ndefineEmits(['submitted']);\n\nconst hasActions = computed(() => !!useSlots().actions);\n</script>\n\n<template>\n    <div class=\"md:grid md:grid-cols-3 md:gap-6\">\n        <SectionTitle>\n            <template #title>\n                <slot name=\"title\" />\n            </template>\n            <template #description>\n                <slot name=\"description\" />\n            </template>\n        </SectionTitle>\n\n        <div class=\"mt-5 md:mt-0 md:col-span-2\">\n            <form @submit.prevent=\"$emit('submitted')\">\n                <div\n                    class=\"px-4 py-5 bg-card-background sm:p-6 shadow\"\n                    :class=\"hasActions ? 'sm:rounded-tl-md sm:rounded-tr-md' : 'sm:rounded-md'\">\n                    <div class=\"grid grid-cols-6 gap-6\">\n                        <slot name=\"form\" />\n                    </div>\n                </div>\n\n                <div\n                    v-if=\"hasActions\"\n                    class=\"flex items-center justify-end px-4 py-3 bg-card-background border-t border-card-background-separator text-end sm:px-6 shadow sm:rounded-bl-md sm:rounded-br-md\">\n                    <slot name=\"actions\" />\n                </div>\n            </form>\n        </div>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/NavLink.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue';\nimport { Link } from '@inertiajs/vue3';\n\nconst props = defineProps<{\n    href?: string;\n    active?: boolean;\n}>();\n\nconst classes = computed(() => {\n    return props.active\n        ? 'inline-flex items-center px-1 pt-1 border-b-2 border-indigo-400 text-sm font-medium leading-5 text-gray-900 focus:outline-none focus:border-indigo-700 transition duration-150 ease-in-out'\n        : 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none focus:text-gray-700 focus:border-gray-300 transition duration-150 ease-in-out';\n});\n</script>\n\n<template>\n    <Link :href=\"href ?? ''\" :class=\"classes\" prefetch=\"mount\">\n        <slot />\n    </Link>\n</template>\n"
  },
  {
    "path": "resources/js/Components/NavigationSidebarItem.vue",
    "content": "<script setup lang=\"ts\">\nimport { type Component } from 'vue';\nimport NavigationSidebarLink from '@/Components/NavigationSidebarLink.vue';\nimport { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'radix-vue';\nimport { useSessionStorage } from '@vueuse/core';\nimport { ChevronRightIcon } from '@heroicons/vue/20/solid';\n\nconst props = defineProps<{\n    title: string;\n    icon?: Component;\n    current?: boolean;\n    href: string;\n    subItems?: { title: string; route: string; show: boolean }[];\n}>();\n\nconst open = useSessionStorage('nav-collapse-state-' + props.title, true);\n</script>\n\n<template>\n    <li class=\"relative\">\n        <NavigationSidebarLink\n            v-if=\"!subItems\"\n            class=\"py-0.5\"\n            :title\n            :icon\n            :current\n            :href></NavigationSidebarLink>\n        <CollapsibleRoot v-else v-model:open=\"open\"\n            ><CollapsibleTrigger class=\"w-full group py-0.5\">\n                <div\n                    class=\"text-text-secondary group-hover:text-text-primary group-hover:bg-menu-active group flex gap-x-2 rounded-md transition leading-6 py-0.5 px-2 font-medium text-sm items-center justify-between\">\n                    <div class=\"flex items-center gap-x-2\">\n                        <component\n                            :is=\"icon\"\n                            v-if=\"icon\"\n                            :class=\"[\n                                current\n                                    ? 'text-icon-active'\n                                    : 'text-icon-default group-hover:text-icon-active',\n                                'transition h-4 w-4 shrink-0',\n                            ]\"\n                            aria-hidden=\"true\" />\n                        <span>\n                            {{ title }}\n                        </span>\n                    </div>\n\n                    <ChevronRightIcon\n                        :class=\"[\n                            'w-5 text-text-secondary',\n                            { 'transform rotate-90': open },\n                        ]\"></ChevronRightIcon>\n                </div>\n            </CollapsibleTrigger>\n            <CollapsibleContent class=\"CollapsibleContent\">\n                <div class=\"px-3.5\">\n                    <ul\n                        v-if=\"subItems\"\n                        class=\"flex min-w-0 flex-col border-l border-border-secondary px-3 w-full my-0.5\">\n                        <li\n                            v-for=\"subItem in subItems\"\n                            :key=\"subItem.title\"\n                            class=\"w-full relative\">\n                            <NavigationSidebarLink\n                                v-if=\"subItem.show\"\n                                :title=\"subItem.title\"\n                                :current=\"route().current(subItem.route)\"\n                                :href=\"route(subItem.route)\"></NavigationSidebarLink>\n                        </li>\n                    </ul>\n                </div>\n            </CollapsibleContent>\n        </CollapsibleRoot>\n    </li>\n</template>\n<style scoped>\n.CollapsibleContent {\n    overflow: hidden;\n}\n.CollapsibleContent[data-state='open'] {\n    animation: slideDown 300ms ease-out;\n}\n.CollapsibleContent[data-state='closed'] {\n    animation: slideUp 300ms ease-out;\n}\n\n@keyframes slideDown {\n    from {\n        height: 0;\n    }\n    to {\n        height: var(--radix-collapsible-content-height);\n    }\n}\n\n@keyframes slideUp {\n    from {\n        height: var(--radix-collapsible-content-height);\n    }\n    to {\n        height: 0;\n    }\n}\n</style>\n"
  },
  {
    "path": "resources/js/Components/NavigationSidebarLink.vue",
    "content": "<script setup lang=\"ts\">\nimport { Link } from '@inertiajs/vue3';\nimport type { Component } from 'vue';\ndefineProps<{\n    title: string;\n    icon?: Component;\n    current?: boolean;\n    href: string;\n}>();\n</script>\n\n<template>\n    <Link :href=\"href\" class=\"block group\" prefetch>\n        <div\n            :class=\"[\n                current\n                    ? 'bg-menu-active text-text-primary'\n                    : 'text-text-secondary group-hover:text-text-primary group-hover:bg-menu-active ',\n                'group flex gap-x-2 rounded-md transition leading-6 py-0.5 px-2 font-medium text-sm items-center',\n            ]\">\n            <component\n                :is=\"icon\"\n                v-if=\"icon\"\n                :class=\"[\n                    current ? 'text-icon-active' : 'text-icon-default group-hover:text-icon-active',\n                    'transition h-4 w-4 shrink-0',\n                ]\"\n                aria-hidden=\"true\" />\n            {{ title }}\n        </div>\n    </Link>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/NotificationContainer.vue",
    "content": "<template>\n    <div\n        aria-live=\"assertive\"\n        class=\"pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-end sm:p-6 z-[70]\">\n        <div class=\"flex w-full flex-col items-center space-y-4 sm:items-end\">\n            <Notification\n                v-for=\"notification in notifications\"\n                :key=\"notification.uuid\"\n                :type=\"notification.type\"\n                :title=\"notification.title\"\n                :message=\"notification.message\"></Notification>\n        </div>\n    </div>\n    <DialogModal :show=\"showActionBlockedModal\">\n        <template #title>\n            <div class=\"flex space-x-2\">\n                <span> Action blocked </span>\n            </div>\n        </template>\n\n        <template #content>\n            <div\n                class=\"rounded-full flex items-center justify-center w-16 h-16 mx-auto border border-border-tertiary bg-secondary\">\n                <XCircleIcon class=\"w-10\"></XCircleIcon>\n            </div>\n            <div class=\"max-w-sm text-center mx-auto py-4 text-base\">\n                <p class=\"py-1\">\n                    Your organization is currently\n                    <strong class=\"font-semibold\">blocked from performing this action</strong>\n                </p>\n                <p class=\"py-1\">\n                    To unblock your organization, please\n                    <strong class=\"font-semibold\"> upgrade to a premium plan</strong>\n                    or remove all users except the owner.\n                </p>\n\n                <Link v-if=\"isBillingActivated() && canManageBilling()\" href=\"/billing\">\n                    <PrimaryButton :icon=\"CreditCardIcon\" type=\"button\" class=\"mt-6\">\n                        Go to Billing\n                    </PrimaryButton>\n                </Link>\n            </div>\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"showActionBlockedModal = false\"> Cancel</SecondaryButton>\n        </template>\n    </DialogModal>\n</template>\n\n<script setup lang=\"ts\">\nimport Notification from '@/Components/Common/Notification/Notification.vue';\nimport { storeToRefs } from 'pinia';\nimport { useNotificationsStore } from '@/utils/notification';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport { isBillingActivated } from '@/utils/billing';\nimport { canManageBilling } from '@/utils/permissions';\nimport { CreditCardIcon, XCircleIcon } from '@heroicons/vue/20/solid';\nimport { Link } from '@inertiajs/vue3';\nimport PrimaryButton from '../packages/ui/src/Buttons/PrimaryButton.vue';\nimport SecondaryButton from '../packages/ui/src/Buttons/SecondaryButton.vue';\n\nconst { notifications, showActionBlockedModal } = storeToRefs(useNotificationsStore());\n</script>\n"
  },
  {
    "path": "resources/js/Components/OrganizationSwitcher.vue",
    "content": "<script setup lang=\"ts\">\nimport { ChevronDownIcon } from '@heroicons/vue/20/solid';\nimport { Link, usePage } from '@inertiajs/vue3';\nimport {\n    Cog6ToothIcon,\n    PlusCircleIcon,\n    CheckCircleIcon,\n    ArrowRightIcon,\n} from '@heroicons/vue/24/solid';\nimport type { Organization, User } from '@/types/models';\nimport { isBillingActivated } from '@/utils/billing';\nimport { canManageBilling } from '@/utils/permissions';\nimport { switchOrganization } from '@/utils/useOrganization';\nimport {\n    DropdownMenu,\n    DropdownMenuTrigger,\n    DropdownMenuContent,\n    DropdownMenuItem,\n    DropdownMenuLabel,\n} from '@/Components/ui/dropdown-menu';\n\nconst page = usePage<{\n    jetstream: {\n        canCreateTeams: boolean;\n        hasTeamFeatures: boolean;\n        managesProfilePhotos: boolean;\n        hasApiFeatures: boolean;\n    };\n    auth: {\n        user: User & {\n            all_teams: Organization[];\n        };\n    };\n}>();\n\nconst switchToTeam = (organization: Organization) => {\n    switchOrganization(organization.id);\n};\n</script>\n\n<template>\n    <DropdownMenu v-if=\"page.props.jetstream.hasTeamFeatures\">\n        <DropdownMenuTrigger\n            class=\"flex w-full text-left hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-ring cursor-pointer transition pl-2 py-1 rounded w-full items-center justify-between\"\n            as-child>\n            <button data-testid=\"organization_switcher\">\n                <div class=\"flex flex-1 space-x-2 items-center w-[calc(100%-30px)]\">\n                    <div\n                        class=\"rounded bg-blue-900 font-medium text-xs flex-shrink-0 text-white w-5 h-5 flex items-center justify-center\">\n                        {{ page.props.auth.user.current_team.name.slice(0, 1).toUpperCase() }}\n                    </div>\n                    <span class=\"text-xs flex-1 truncate font-medium\">\n                        {{ page.props.auth.user.current_team.name }}\n                    </span>\n                </div>\n                <div class=\"w-[30px]\">\n                    <div class=\"p-1 rounded-full flex items-center w-6 h-6\">\n                        <ChevronDownIcon class=\"w-4 sm:w-full mt-[1px]\"></ChevronDownIcon>\n                    </div>\n                </div>\n            </button>\n        </DropdownMenuTrigger>\n\n        <DropdownMenuContent align=\"start\">\n            <div class=\"w-60\">\n                <DropdownMenuLabel>Manage Organization</DropdownMenuLabel>\n\n                <DropdownMenuItem as-child>\n                    <Link\n                        :href=\"route('teams.show', page.props.auth.user.current_team.id)\"\n                        class=\"inline-flex items-center gap-2.5 w-full\">\n                        <Cog6ToothIcon class=\"w-5 h-5 text-icon-default\" />\n                        <span>Organization Settings</span>\n                    </Link>\n                </DropdownMenuItem>\n\n                <DropdownMenuItem v-if=\"canManageBilling() && isBillingActivated()\" as-child>\n                    <Link href=\"/billing\" class=\"inline-flex items-center w-full\"> Billing </Link>\n                </DropdownMenuItem>\n\n                <DropdownMenuItem v-if=\"page.props.jetstream.canCreateTeams\" as-child>\n                    <Link\n                        :href=\"route('teams.create')\"\n                        class=\"inline-flex items-center gap-2.5 w-full\">\n                        <PlusCircleIcon class=\"w-5 h-5 text-icon-default\" />\n                        <span>Create new organization</span>\n                    </Link>\n                </DropdownMenuItem>\n\n                <template v-if=\"page.props.auth.user.all_teams.length > 1\">\n                    <div class=\"border-t border-card-background-separator\" />\n\n                    <DropdownMenuLabel>Switch Organizations</DropdownMenuLabel>\n\n                    <template v-for=\"team in page.props.auth.user.all_teams\" :key=\"team.id\">\n                        <form @submit.prevent=\"switchToTeam(team)\">\n                            <DropdownMenuItem\n                                as-child\n                                class=\"inline-flex gap-2.5 items-center w-full\">\n                                <button type=\"submit\">\n                                    <CheckCircleIcon\n                                        v-if=\"team.id == page.props.auth.user.current_team_id\"\n                                        class=\"h-5 w-5 text-green-400\" />\n                                    <ArrowRightIcon v-else class=\"h-5 w-5 text-icon-default\" />\n\n                                    <div class=\"w-full truncate text-left\">\n                                        {{ team.name }}\n                                    </div>\n                                </button>\n                            </DropdownMenuItem>\n                        </form>\n                    </template>\n                </template>\n            </div>\n        </DropdownMenuContent>\n    </DropdownMenu>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ResponsiveNavLink.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue';\nimport { Link } from '@inertiajs/vue3';\n\nconst props = defineProps<{\n    active?: boolean;\n    href?: string;\n    as?: string;\n}>();\n\nconst classes = computed(() => {\n    return props.active\n        ? 'block w-full ps-3 pe-4 py-2 border-l-4 border-indigo-400 text-start text-base font-medium text-indigo-700 bg-indigo-50 focus:outline-none focus:text-indigo-800 focus:bg-indigo-100 focus:border-indigo-700 transition duration-150 ease-in-out'\n        : 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-text-secondary hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 focus:outline-none focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300 transition duration-150 ease-in-out';\n});\n</script>\n\n<template>\n    <div>\n        <button v-if=\"as == 'button'\" :class=\"classes\" class=\"w-full text-start\">\n            <slot />\n        </button>\n\n        <Link v-else :href=\"href ?? ''\" :class=\"classes\" prefetch>\n            <slot />\n        </Link>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/SectionBorder.vue",
    "content": "<template>\n    <div class=\"hidden sm:block\">\n        <div class=\"py-8\">\n            <div class=\"border-t border-default-background-separator\" />\n        </div>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/SectionTitle.vue",
    "content": "<template>\n    <div class=\"md:col-span-1 flex justify-between\">\n        <div class=\"px-4 sm:px-0\">\n            <h3 class=\"text-lg font-medium text-text-primary\">\n                <slot name=\"title\" />\n            </h3>\n\n            <p class=\"mt-1 text-sm text-text-secondary\">\n                <slot name=\"description\" />\n            </p>\n        </div>\n\n        <div class=\"px-4 sm:px-0\">\n            <slot name=\"aside\" />\n        </div>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/TableRow.vue",
    "content": "<script setup lang=\"ts\">\nimport { Link } from '@inertiajs/vue3';\nimport { twMerge } from 'tailwind-merge';\n\ndefineProps<{\n    href?: string;\n}>();\n</script>\n\n<template>\n    <Component\n        :is=\"href ? Link : 'div'\"\n        :href=\"href\"\n        role=\"row\"\n        :class=\"\n            twMerge(\n                'contents group [&>*]:transition [&>*]:border-row-separator [&>*]:bg-row-background [&>*]:border-b',\n                href ? '[&>*]:cursor-pointer [&>*]:hover:bg-white/5' : ''\n            )\n        \">\n        <slot></slot>\n    </Component>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/TimeTracker.vue",
    "content": "<script setup lang=\"ts\">\nimport { ClockIcon } from '@heroicons/vue/20/solid';\nimport CardTitle from '@/packages/ui/src/CardTitle.vue';\nimport { usePage } from '@inertiajs/vue3';\nimport { type User } from '@/types/models';\nimport { computed, onMounted, watch } from 'vue';\nimport dayjs from 'dayjs';\nimport utc from 'dayjs/plugin/utc';\nimport duration from 'dayjs/plugin/duration';\n\nimport { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';\nimport { storeToRefs } from 'pinia';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport { switchOrganization } from '@/utils/useOrganization';\nimport { useProjectsQuery } from '@/utils/useProjectsQuery';\nimport { useTasksQuery } from '@/utils/useTasksQuery';\nimport { useTagsQuery } from '@/utils/useTagsQuery';\nimport { useClientsQuery } from '@/utils/useClientsQuery';\nimport { useTagsStore } from '@/utils/useTags';\nimport { useProjectsStore } from '@/utils/useProjects';\nimport TimeTrackerControls from '@/packages/ui/src/TimeTracker/TimeTrackerControls.vue';\nimport type {\n    CreateClientBody,\n    CreateProjectBody,\n    CreateTimeEntryBody,\n    Project,\n    Tag,\n} from '@/packages/api/src';\nimport TimeTrackerRunningInDifferentOrganizationOverlay from '@/packages/ui/src/TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue';\nimport TimeTrackerMoreOptionsDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerMoreOptionsDropdown.vue';\nimport TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';\nimport { useClientsStore } from '@/utils/useClients';\nimport { getOrganizationCurrencyString } from '@/utils/money';\nimport { isAllowedToPerformPremiumAction } from '@/utils/billing';\nimport { canCreateProjects } from '@/utils/permissions';\nimport { ref } from 'vue';\nimport { useNotificationsStore } from '@/utils/notification';\nimport { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';\nimport { useTimeEntriesInfiniteQuery } from '@/utils/useTimeEntriesInfiniteQuery';\n\nconst page = usePage<{\n    auth: {\n        user: User;\n    };\n}>();\ndayjs.extend(duration);\n\ndayjs.extend(utc);\n\nconst currentTimeEntryStore = useCurrentTimeEntryStore();\nconst { currentTimeEntry, isActive, now } = storeToRefs(currentTimeEntryStore);\nconst { startLiveTimer, stopLiveTimer, setActiveState } = currentTimeEntryStore;\n\nconst { projects } = useProjectsQuery();\nconst { tasks } = useTasksQuery();\nconst { clients } = useClientsQuery();\n\nconst emit = defineEmits<{\n    change: [];\n}>();\n\nconst showManualTimeEntryModal = ref(false);\n\nconst { createTimeEntry: createTimeEntryMutation, deleteTimeEntry } = useTimeEntriesMutations();\nconst { data: timeEntriesData } = useTimeEntriesInfiniteQuery();\nconst timeEntries = computed(() => timeEntriesData.value?.pages.flatMap((page) => page.data) || []);\n\nwatch(isActive, () => {\n    if (isActive.value) {\n        startLiveTimer();\n    } else {\n        stopLiveTimer();\n    }\n    emit('change');\n});\n\nonMounted(async () => {\n    if (page.props.auth.user.current_team_id) {\n        await currentTimeEntryStore.fetchCurrentTimeEntry();\n        now.value = dayjs().utc();\n    }\n});\n\nfunction updateTimeEntry() {\n    if (currentTimeEntry.value.id) {\n        useCurrentTimeEntryStore().updateTimer();\n    }\n}\n\nconst isRunningInDifferentOrganization = computed(() => {\n    return (\n        currentTimeEntry.value.organization_id &&\n        getCurrentOrganizationId() &&\n        currentTimeEntry.value.organization_id !== getCurrentOrganizationId()\n    );\n});\n\nasync function createProject(project: CreateProjectBody): Promise<Project | undefined> {\n    const newProject = await useProjectsStore().createProject(project);\n    if (newProject) {\n        currentTimeEntry.value.project_id = newProject.id;\n    }\n    return newProject;\n}\nasync function createClient(client: CreateClientBody) {\n    return await useClientsStore().createClient(client);\n}\n\nfunction switchToTimeEntryOrganization() {\n    if (currentTimeEntry.value.organization_id) {\n        switchOrganization(currentTimeEntry.value.organization_id);\n    }\n}\nasync function createTag(tag: string): Promise<Tag | undefined> {\n    return await useTagsStore().createTag(tag);\n}\n\nasync function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) {\n    await createTimeEntryMutation(timeEntry);\n    showManualTimeEntryModal.value = false;\n}\n\nasync function createTimeEntryFromCurrentEntry() {\n    const { start, end, description, project_id, task_id, billable, tags } = currentTimeEntry.value;\n    await createTimeEntry({ start, end, description, project_id, task_id, billable, tags });\n    currentTimeEntryStore.$reset();\n}\n\nconst { handleApiRequestNotifications } = useNotificationsStore();\n\nasync function discardCurrentTimeEntry() {\n    if (currentTimeEntry.value.id) {\n        await handleApiRequestNotifications(\n            () => deleteTimeEntry(currentTimeEntry.value.id),\n            'Time entry discarded successfully',\n            'Failed to discard time entry'\n        );\n        await currentTimeEntryStore.fetchCurrentTimeEntry();\n    }\n}\n\nconst { tags } = useTagsQuery();\n</script>\n\n<template>\n    <TimeEntryCreateModal\n        v-model:show=\"showManualTimeEntryModal\"\n        :enable-estimated-time=\"isAllowedToPerformPremiumAction()\"\n        :create-project=\"createProject\"\n        :create-client=\"createClient\"\n        :create-tag=\"createTag\"\n        :create-time-entry=\"createTimeEntry\"\n        :currency=\"getOrganizationCurrencyString()\"\n        :can-create-project=\"canCreateProjects()\"\n        :projects\n        :tasks\n        :tags\n        :clients></TimeEntryCreateModal>\n    <CardTitle title=\"Time Tracker\" :icon=\"ClockIcon\"></CardTitle>\n    <div class=\"relative pt-1\">\n        <TimeTrackerRunningInDifferentOrganizationOverlay\n            v-if=\"isRunningInDifferentOrganization\"\n            @switch-organization=\"\n                switchToTimeEntryOrganization\n            \"></TimeTrackerRunningInDifferentOrganizationOverlay>\n\n        <div class=\"flex w-full items-center gap-2\">\n            <div class=\"flex w-full items-center gap-2\">\n                <div class=\"flex-1\">\n                    <TimeTrackerControls\n                        v-model:current-time-entry=\"currentTimeEntry\"\n                        v-model:live-timer=\"now\"\n                        :create-project\n                        :enable-estimated-time=\"isAllowedToPerformPremiumAction()\"\n                        :can-create-project=\"canCreateProjects()\"\n                        :create-client\n                        :clients\n                        :tags\n                        :tasks\n                        :projects\n                        :time-entries\n                        :create-tag\n                        :is-active\n                        :currency=\"getOrganizationCurrencyString()\"\n                        @start-live-timer=\"startLiveTimer\"\n                        @stop-live-timer=\"stopLiveTimer\"\n                        @start-timer=\"setActiveState(true)\"\n                        @stop-timer=\"setActiveState(false)\"\n                        @update-time-entry=\"updateTimeEntry\"\n                        @create-time-entry=\"createTimeEntryFromCurrentEntry\"></TimeTrackerControls>\n                </div>\n                <TimeTrackerMoreOptionsDropdown\n                    :has-active-timer=\"isActive\"\n                    @manual-entry=\"showManualTimeEntryModal = true\"\n                    @discard=\"discardCurrentTimeEntry\"></TimeTrackerMoreOptionsDropdown>\n            </div>\n        </div>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/UpdateSidebarNotification.vue",
    "content": "<script setup lang=\"ts\">\nimport { BellAlertIcon, XMarkIcon } from '@heroicons/vue/20/solid';\nimport { SecondaryButton } from '@/packages/ui/src';\nimport { useStorage } from '@vueuse/core';\nconst showReleaseInfo = useStorage('showReleaseInfo-desktop', true);\n\nfunction openDesktopGithubRepo() {\n    window.open('https://github.com/solidtime-io/solidtime-desktop', '_blank')?.focus();\n}\n</script>\n\n<template>\n    <div v-if=\"showReleaseInfo\" class=\"py-4 hidden lg:block\">\n        <div class=\"rounded-lg px-2.5 py-2 bg-card-background border border-border-secondary\">\n            <div class=\"flex items-start justify-between\">\n                <div\n                    class=\"text-xs pb-1.5 font-semibold text-text-tertiary flex items-center space-x-1\">\n                    <BellAlertIcon class=\"w-3.5\"></BellAlertIcon>\n                    <span> New Update </span>\n                </div>\n                <button>\n                    <XMarkIcon\n                        class=\"w-3.5 text-text-tertiary hover:text-text-secondary\"\n                        @click=\"showReleaseInfo = false\"></XMarkIcon>\n                </button>\n            </div>\n\n            <p class=\"text-xs\">\n                <span class=\"font-semibold\">Solidtime Desktop Beta</span> is here! Test our brand\n                new clients for Windows, macOS and Linux now.\n            </p>\n            <SecondaryButton\n                size=\"small\"\n                class=\"w-full text-center justify-center mt-1.5\"\n                @click=\"openDesktopGithubRepo\"\n                >Download now</SecondaryButton\n            >\n        </div>\n    </div>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/Components/UserSettingsIcon.vue",
    "content": "<script setup lang=\"ts\">\nimport { Link, router, usePage } from '@inertiajs/vue3';\nimport type { Organization, User } from '@/types/models';\nimport {\n    DropdownMenu,\n    DropdownMenuTrigger,\n    DropdownMenuContent,\n    DropdownMenuItem,\n    DropdownMenuLabel,\n} from '@/Components/ui/dropdown-menu';\nimport {\n    UserCircleIcon,\n    KeyIcon,\n    ArrowLeftOnRectangleIcon,\n    ChatBubbleLeftRightIcon,\n} from '@heroicons/vue/24/solid';\nimport { openFeedback } from '@/utils/feedback';\n\nconst page = usePage<{\n    has_services_extension?: boolean;\n    has_billing_extension?: boolean;\n    jetstream: {\n        canCreateTeams: boolean;\n        hasTeamFeatures: boolean;\n        managesProfilePhotos: boolean;\n        hasApiFeatures: boolean;\n    };\n    auth: {\n        user: User & {\n            all_teams: Organization[];\n        };\n    };\n}>();\n\nconst logout = () => {\n    router.post(route('logout'));\n};\n</script>\n<template>\n    <div class=\"relative\">\n        <DropdownMenu>\n            <DropdownMenuTrigger\n                class=\"flex text-sm border-2 outline-none border-transparent rounded-full focus-visible:ring-2 focus-visible:ring-ring transition\"\n                as-child>\n                <button data-testid=\"current_user_button\">\n                    <img\n                        class=\"h-7 w-7 rounded-full object-cover\"\n                        :src=\"page.props.auth.user.profile_photo_url\"\n                        :alt=\"page.props.auth.user.name\" />\n                </button>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent align=\"center\" class=\"max-w-48\">\n                <DropdownMenuLabel>Manage Account</DropdownMenuLabel>\n\n                <DropdownMenuItem as-child>\n                    <Link\n                        :href=\"route('profile.show')\"\n                        class=\"inline-flex items-center gap-2.5 w-full\">\n                        <UserCircleIcon class=\"w-5 h-5 text-icon-default\" />\n                        <span>Profile Settings</span>\n                    </Link>\n                </DropdownMenuItem>\n\n                <DropdownMenuItem v-if=\"page.props.jetstream.hasApiFeatures\" as-child>\n                    <Link\n                        :href=\"route('api-tokens.index')\"\n                        class=\"inline-flex items-center gap-2.5 w-full\">\n                        <KeyIcon class=\"w-5 h-5 text-icon-default\" />\n                        <span>API Tokens</span>\n                    </Link>\n                </DropdownMenuItem>\n\n                <DropdownMenuItem v-if=\"page.props.has_services_extension\" as-child>\n                    <button\n                        type=\"button\"\n                        class=\"inline-flex items-center gap-2.5 w-full\"\n                        @click=\"openFeedback\">\n                        <ChatBubbleLeftRightIcon class=\"w-5 h-5 text-icon-default\" />\n                        <span>Feedback</span>\n                    </button>\n                </DropdownMenuItem>\n\n                <form class=\"w-full\" @submit.prevent=\"logout\">\n                    <DropdownMenuItem as-child class=\"inline-flex items-center gap-2.5 w-full\">\n                        <button type=\"submit\" data-testid=\"logout_button\">\n                            <ArrowLeftOnRectangleIcon class=\"w-5 h-5 text-icon-default\" />\n                            <span>Log Out</span>\n                        </button>\n                    </DropdownMenuItem>\n                </form>\n            </DropdownMenuContent>\n        </DropdownMenu>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/alert-dialog/AlertDialog.vue",
    "content": "<script setup lang=\"ts\">\nimport {\n    type AlertDialogEmits,\n    type AlertDialogProps,\n    AlertDialogRoot,\n    useForwardPropsEmits,\n} from 'reka-ui';\n\nconst props = defineProps<AlertDialogProps>();\nconst emits = defineEmits<AlertDialogEmits>();\n\nconst forwarded = useForwardPropsEmits(props, emits);\n</script>\n\n<template>\n    <AlertDialogRoot v-bind=\"forwarded\">\n        <slot />\n    </AlertDialogRoot>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/alert-dialog/AlertDialogAction.vue",
    "content": "<script setup lang=\"ts\">\nimport { buttonVariants } from '@/packages/ui/src';\nimport { AlertDialogAction, type AlertDialogActionProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\nimport { twMerge } from 'tailwind-merge';\nconst props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n</script>\n\n<template>\n    <AlertDialogAction v-bind=\"delegatedProps\" :class=\"twMerge(buttonVariants(), props.class)\">\n        <slot />\n    </AlertDialogAction>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/alert-dialog/AlertDialogCancel.vue",
    "content": "<script setup lang=\"ts\">\nimport { buttonVariants } from '@/packages/ui/src';\nimport { AlertDialogCancel, type AlertDialogCancelProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\nimport { twMerge } from 'tailwind-merge';\n\nconst props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n</script>\n\n<template>\n    <AlertDialogCancel\n        v-bind=\"delegatedProps\"\n        :class=\"twMerge(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', props.class)\">\n        <slot />\n    </AlertDialogCancel>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/alert-dialog/AlertDialogContent.vue",
    "content": "<script setup lang=\"ts\">\nimport {\n    AlertDialogContent,\n    type AlertDialogContentEmits,\n    type AlertDialogContentProps,\n    AlertDialogOverlay,\n    AlertDialogPortal,\n    useForwardPropsEmits,\n} from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\nimport { cn } from '@/lib/utils';\n\nconst props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes['class'] }>();\nconst emits = defineEmits<AlertDialogContentEmits>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits);\n</script>\n\n<template>\n    <AlertDialogPortal>\n        <AlertDialogOverlay\n            class=\"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\" />\n        <AlertDialogContent\n            v-bind=\"forwarded\"\n            :class=\"\n                cn(\n                    'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',\n                    props.class\n                )\n            \">\n            <slot />\n        </AlertDialogContent>\n    </AlertDialogPortal>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/alert-dialog/AlertDialogDescription.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport { AlertDialogDescription, type AlertDialogDescriptionProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n</script>\n\n<template>\n    <AlertDialogDescription\n        v-bind=\"delegatedProps\"\n        :class=\"cn('text-sm text-muted-foreground', props.class)\">\n        <slot />\n    </AlertDialogDescription>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/alert-dialog/AlertDialogFooter.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport { cn } from '@/lib/utils';\n\nconst props = defineProps<{\n    class?: HTMLAttributes['class'];\n}>();\n</script>\n\n<template>\n    <div :class=\"cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2', props.class)\">\n        <slot />\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/alert-dialog/AlertDialogHeader.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport { cn } from '@/lib/utils';\n\nconst props = defineProps<{\n    class?: HTMLAttributes['class'];\n}>();\n</script>\n\n<template>\n    <div :class=\"cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)\">\n        <slot />\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/alert-dialog/AlertDialogTitle.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport { AlertDialogTitle, type AlertDialogTitleProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n</script>\n\n<template>\n    <AlertDialogTitle v-bind=\"delegatedProps\" :class=\"cn('text-lg font-semibold', props.class)\">\n        <slot />\n    </AlertDialogTitle>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/alert-dialog/AlertDialogTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport { AlertDialogTrigger, type AlertDialogTriggerProps } from 'reka-ui';\n\nconst props = defineProps<AlertDialogTriggerProps>();\n</script>\n\n<template>\n    <AlertDialogTrigger v-bind=\"props\">\n        <slot />\n    </AlertDialogTrigger>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/alert-dialog/index.ts",
    "content": "export { default as AlertDialog } from './AlertDialog.vue';\nexport { default as AlertDialogAction } from './AlertDialogAction.vue';\nexport { default as AlertDialogCancel } from './AlertDialogCancel.vue';\nexport { default as AlertDialogContent } from './AlertDialogContent.vue';\nexport { default as AlertDialogDescription } from './AlertDialogDescription.vue';\nexport { default as AlertDialogFooter } from './AlertDialogFooter.vue';\nexport { default as AlertDialogHeader } from './AlertDialogHeader.vue';\nexport { default as AlertDialogTitle } from './AlertDialogTitle.vue';\nexport { default as AlertDialogTrigger } from './AlertDialogTrigger.vue';\n"
  },
  {
    "path": "resources/js/Components/ui/calendar/Calendar.vue",
    "content": "<script lang=\"ts\" setup>\nimport { cn } from '@/lib/utils';\nimport {\n    CalendarRoot,\n    type CalendarRootEmits,\n    type CalendarRootProps,\n    useForwardPropsEmits,\n} from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\nimport {\n    CalendarCell,\n    CalendarCellTrigger,\n    CalendarGrid,\n    CalendarGridBody,\n    CalendarGridHead,\n    CalendarGridRow,\n    CalendarHeadCell,\n    CalendarHeader,\n    CalendarHeading,\n    CalendarNextButton,\n    CalendarPrevButton,\n} from '.';\n\nconst props = defineProps<CalendarRootProps & { class?: HTMLAttributes['class'] }>();\n\nconst emits = defineEmits<CalendarRootEmits>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits);\n</script>\n\n<template>\n    <CalendarRoot v-slot=\"{ grid, weekDays }\" :class=\"cn('p-3', props.class)\" v-bind=\"forwarded\">\n        <CalendarHeader>\n            <CalendarPrevButton />\n            <CalendarHeading />\n            <CalendarNextButton />\n        </CalendarHeader>\n\n        <div class=\"flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0\">\n            <CalendarGrid v-for=\"month in grid\" :key=\"month.value.toString()\">\n                <CalendarGridHead>\n                    <CalendarGridRow>\n                        <CalendarHeadCell v-for=\"day in weekDays\" :key=\"day\">\n                            {{ day }}\n                        </CalendarHeadCell>\n                    </CalendarGridRow>\n                </CalendarGridHead>\n                <CalendarGridBody>\n                    <CalendarGridRow\n                        v-for=\"(weekDates, index) in month.rows\"\n                        :key=\"`weekDate-${index}`\"\n                        class=\"mt-2 w-full\">\n                        <CalendarCell\n                            v-for=\"weekDate in weekDates\"\n                            :key=\"weekDate.toString()\"\n                            :date=\"weekDate\">\n                            <CalendarCellTrigger :day=\"weekDate\" :month=\"month.value\" />\n                        </CalendarCell>\n                    </CalendarGridRow>\n                </CalendarGridBody>\n            </CalendarGrid>\n        </div>\n    </CalendarRoot>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/calendar/CalendarCell.vue",
    "content": "<script lang=\"ts\" setup>\nimport { CalendarCell, type CalendarCellProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\nimport { twMerge } from 'tailwind-merge';\n\nconst props = defineProps<CalendarCellProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <CalendarCell\n        :class=\"\n            twMerge(\n                'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent [&:has([data-selected][data-outside-view])]:bg-accent/50',\n                props.class\n            )\n        \"\n        v-bind=\"forwardedProps\">\n        <slot />\n    </CalendarCell>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/calendar/CalendarCellTrigger.vue",
    "content": "<script lang=\"ts\" setup>\nimport { cn, buttonVariants } from '@/packages/ui/src';\nimport { CalendarCellTrigger, type CalendarCellTriggerProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<CalendarCellTriggerProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <CalendarCellTrigger\n        :class=\"\n            cn(\n                buttonVariants({ variant: 'ghost' }),\n                'h-8 w-8 p-0 font-normal',\n                '[&[data-today]:not([data-selected])]:border-accent [&[data-today]:not([data-selected])]:border [&[data-today]:not([data-selected])]:text-accent-foreground',\n                // Selected\n                'data-[selected]:bg-quaternary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-quaternary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',\n                // Disabled\n                'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',\n                // Unavailable\n                'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',\n                // Outside months\n                'data-[outside-view]:text-muted-foreground data-[outside-view]:opacity-50 [&[data-outside-view][data-selected]]:bg-accent/50 [&[data-outside-view][data-selected]]:text-muted-foreground [&[data-outside-view][data-selected]]:opacity-30',\n                props.class\n            )\n        \"\n        v-bind=\"forwardedProps\">\n        <slot />\n    </CalendarCellTrigger>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/calendar/CalendarDateInput.vue",
    "content": "<script setup lang=\"ts\">\nimport { Popover, PopoverContent, PopoverTrigger } from '@/packages/ui/src';\nimport { Button } from '@/packages/ui/src';\nimport { Calendar } from '@/Components/ui/calendar';\nimport { CalendarIcon, XIcon } from 'lucide-vue-next';\nimport { formatDate } from '@/packages/ui/src/utils/time';\nimport { parseDate } from '@internationalized/date';\nimport { computed, inject, type ComputedRef } from 'vue';\nimport { type Organization } from '@/packages/api/src';\n\nconst model = defineModel<string | null>();\nconst emit = defineEmits<{\n    blur: [];\n}>();\n\ndefineProps<{\n    clearable?: boolean;\n}>();\n\nconst handleChange = (date: string) => {\n    model.value = date;\n};\n\nconst handleBlur = () => {\n    emit('blur');\n};\n\nconst handleClear = (event: Event) => {\n    event.stopPropagation();\n    model.value = null;\n};\n\nconst date = computed(() => {\n    return model.value ? parseDate(model.value) : undefined;\n});\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n</script>\n\n<template>\n    <Popover>\n        <PopoverTrigger as-child>\n            <Button\n                variant=\"input\"\n                :class=\"[\n                    'w-full justify-start text-left font-normal',\n                    !model && 'text-muted-foreground',\n                ]\">\n                <CalendarIcon class=\"mr-2 h-4 w-4\" />\n                <span class=\"flex-1\">\n                    {{ model ? formatDate(model, organization?.date_format) : 'Pick a date' }}\n                </span>\n                <button\n                    v-if=\"clearable && model\"\n                    class=\"ml-2 hover:bg-muted rounded p-1 transition-colors\"\n                    type=\"button\"\n                    @click=\"handleClear\">\n                    <XIcon class=\"h-4 w-4\" />\n                </button>\n            </Button>\n        </PopoverTrigger>\n        <PopoverContent class=\"w-auto p-0\">\n            <Calendar\n                mode=\"single\"\n                :model-value=\"date\"\n                :initial-focus=\"true\"\n                @update:model-value=\"(date) => handleChange(date ? date.toString() : '')\"\n                @blur=\"handleBlur\" />\n        </PopoverContent>\n    </Popover>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/calendar/CalendarGrid.vue",
    "content": "<script lang=\"ts\" setup>\nimport { cn } from '@/lib/utils';\nimport { CalendarGrid, type CalendarGridProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<CalendarGridProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <CalendarGrid\n        :class=\"cn('w-full border-collapse space-y-1', props.class)\"\n        v-bind=\"forwardedProps\">\n        <slot />\n    </CalendarGrid>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/calendar/CalendarGridBody.vue",
    "content": "<script lang=\"ts\" setup>\nimport { CalendarGridBody, type CalendarGridBodyProps } from 'reka-ui';\n\nconst props = defineProps<CalendarGridBodyProps>();\n</script>\n\n<template>\n    <CalendarGridBody v-bind=\"props\">\n        <slot />\n    </CalendarGridBody>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/calendar/CalendarGridHead.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { HTMLAttributes } from 'vue';\nimport { CalendarGridHead, type CalendarGridHeadProps } from 'reka-ui';\n\nconst props = defineProps<CalendarGridHeadProps & { class?: HTMLAttributes['class'] }>();\n</script>\n\n<template>\n    <CalendarGridHead v-bind=\"props\">\n        <slot />\n    </CalendarGridHead>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/calendar/CalendarGridRow.vue",
    "content": "<script lang=\"ts\" setup>\nimport { cn } from '@/lib/utils';\nimport { CalendarGridRow, type CalendarGridRowProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<CalendarGridRowProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <CalendarGridRow :class=\"cn('flex', props.class)\" v-bind=\"forwardedProps\">\n        <slot />\n    </CalendarGridRow>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/calendar/CalendarHeadCell.vue",
    "content": "<script lang=\"ts\" setup>\nimport { cn } from '@/lib/utils';\nimport { CalendarHeadCell, type CalendarHeadCellProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<CalendarHeadCellProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <CalendarHeadCell\n        :class=\"cn('w-8 rounded-md text-[0.8rem] font-normal text-muted-foreground', props.class)\"\n        v-bind=\"forwardedProps\">\n        <slot />\n    </CalendarHeadCell>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/calendar/CalendarHeader.vue",
    "content": "<script lang=\"ts\" setup>\nimport { cn } from '@/lib/utils';\nimport { CalendarHeader, type CalendarHeaderProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<CalendarHeaderProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <CalendarHeader\n        :class=\"cn('relative flex w-full items-center justify-between pt-1', props.class)\"\n        v-bind=\"forwardedProps\">\n        <slot />\n    </CalendarHeader>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/calendar/CalendarHeading.vue",
    "content": "<script lang=\"ts\" setup>\nimport { cn } from '@/lib/utils';\nimport { CalendarHeading, type CalendarHeadingProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<CalendarHeadingProps & { class?: HTMLAttributes['class'] }>();\n\ndefineSlots<{\n    default: (props: { headingValue: string }) => unknown;\n}>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <CalendarHeading\n        v-slot=\"{ headingValue }\"\n        :class=\"cn('text-sm font-medium', props.class)\"\n        v-bind=\"forwardedProps\">\n        <slot :heading-value>\n            {{ headingValue }}\n        </slot>\n    </CalendarHeading>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/calendar/CalendarNextButton.vue",
    "content": "<script lang=\"ts\" setup>\nimport { cn, buttonVariants } from '@/packages/ui/src/index';\nimport { ChevronRight } from 'lucide-vue-next';\nimport { CalendarNext, type CalendarNextProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<CalendarNextProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <CalendarNext\n        :class=\"\n            cn(\n                buttonVariants({ variant: 'outline' }),\n                'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',\n                props.class\n            )\n        \"\n        v-bind=\"forwardedProps\">\n        <slot>\n            <ChevronRight class=\"h-4 w-4\" />\n        </slot>\n    </CalendarNext>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/calendar/CalendarPrevButton.vue",
    "content": "<script lang=\"ts\" setup>\nimport { cn, buttonVariants } from '@/packages/ui/src';\nimport { ChevronLeft } from 'lucide-vue-next';\nimport { CalendarPrev, type CalendarPrevProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<CalendarPrevProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <CalendarPrev\n        :class=\"\n            cn(\n                buttonVariants({ variant: 'outline' }),\n                'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',\n                props.class\n            )\n        \"\n        v-bind=\"forwardedProps\">\n        <slot>\n            <ChevronLeft class=\"h-4 w-4\" />\n        </slot>\n    </CalendarPrev>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/calendar/index.ts",
    "content": "export { default as Calendar } from './Calendar.vue';\nexport { default as CalendarCell } from './CalendarCell.vue';\nexport { default as CalendarCellTrigger } from './CalendarCellTrigger.vue';\nexport { default as CalendarGrid } from './CalendarGrid.vue';\nexport { default as CalendarGridBody } from './CalendarGridBody.vue';\nexport { default as CalendarGridHead } from './CalendarGridHead.vue';\nexport { default as CalendarGridRow } from './CalendarGridRow.vue';\nexport { default as CalendarHeadCell } from './CalendarHeadCell.vue';\nexport { default as CalendarHeader } from './CalendarHeader.vue';\nexport { default as CalendarHeading } from './CalendarHeading.vue';\nexport { default as CalendarNextButton } from './CalendarNextButton.vue';\nexport { default as CalendarPrevButton } from './CalendarPrevButton.vue';\n"
  },
  {
    "path": "resources/js/Components/ui/dialog/Dialog.vue",
    "content": "<script setup lang=\"ts\">\nimport {\n    DialogRoot,\n    type DialogRootEmits,\n    type DialogRootProps,\n    useForwardPropsEmits,\n} from 'reka-ui';\n\nconst props = defineProps<DialogRootProps>();\nconst emits = defineEmits<DialogRootEmits>();\n\nconst forwarded = useForwardPropsEmits(props, emits);\n</script>\n\n<template>\n    <DialogRoot v-bind=\"forwarded\">\n        <slot />\n    </DialogRoot>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/dialog/DialogClose.vue",
    "content": "<script setup lang=\"ts\">\nimport { DialogClose, type DialogCloseProps } from 'reka-ui';\n\nconst props = defineProps<DialogCloseProps>();\n</script>\n\n<template>\n    <DialogClose v-bind=\"props\">\n        <slot />\n    </DialogClose>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/dialog/DialogContent.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport {\n    DialogContent,\n    type DialogContentEmits,\n    type DialogContentProps,\n    DialogOverlay,\n    DialogPortal,\n    useForwardPropsEmits,\n} from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>();\nconst emits = defineEmits<DialogContentEmits>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits);\n</script>\n\n<template>\n    <DialogPortal>\n        <DialogOverlay\n            class=\"fixed inset-0 z-50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\">\n            <div class=\"absolute inset-0 bg-default-background opacity-30\" />\n        </DialogOverlay>\n        <div\n            :class=\"\n                cn(\n                    'fixed top-0 left-0 z-50 pointer-events-none w-screen h-screen flex items-start px-2 pt-3 md:pt-20 xl:pt-32 justify-center overflow-auto'\n                )\n            \">\n            <DialogContent\n                v-bind=\"forwarded\"\n                :class=\"\n                    cn(\n                        'pointer-events-auto bg-default-background grid w-full max-w-lg border border-border-tertiary shadow-lg duration-200 rounded-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',\n                        props.class\n                    )\n                \">\n                <slot />\n            </DialogContent>\n        </div>\n    </DialogPortal>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/dialog/DialogDescription.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport { DialogDescription, type DialogDescriptionProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <DialogDescription\n        v-bind=\"forwardedProps\"\n        :class=\"cn('text-sm text-muted-foreground', props.class)\">\n        <slot />\n    </DialogDescription>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/dialog/DialogFooter.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport { cn } from '@/lib/utils';\n\nconst props = defineProps<{ class?: HTMLAttributes['class'] }>();\n</script>\n\n<template>\n    <div :class=\"cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2', props.class)\">\n        <slot />\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/dialog/DialogHeader.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport { cn } from '@/lib/utils';\n\nconst props = defineProps<{\n    class?: HTMLAttributes['class'];\n}>();\n</script>\n\n<template>\n    <div :class=\"cn('flex flex-col gap-y-1.5 text-center sm:text-left', props.class)\">\n        <slot />\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/dialog/DialogScrollContent.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport { X } from 'lucide-vue-next';\nimport {\n    DialogClose,\n    DialogContent,\n    type DialogContentEmits,\n    type DialogContentProps,\n    DialogOverlay,\n    DialogPortal,\n    useForwardPropsEmits,\n} from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>();\nconst emits = defineEmits<DialogContentEmits>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits);\n</script>\n\n<template>\n    <DialogPortal>\n        <DialogOverlay\n            class=\"fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\">\n            <DialogContent\n                :class=\"\n                    cn(\n                        'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',\n                        props.class\n                    )\n                \"\n                v-bind=\"forwarded\"\n                @pointer-down-outside=\"\n                    (event) => {\n                        const originalEvent = event.detail.originalEvent;\n                        const target = originalEvent.target as HTMLElement;\n                        if (\n                            originalEvent.offsetX > target.clientWidth ||\n                            originalEvent.offsetY > target.clientHeight\n                        ) {\n                            event.preventDefault();\n                        }\n                    }\n                \">\n                <slot />\n\n                <DialogClose\n                    class=\"absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary\">\n                    <X class=\"w-4 h-4\" />\n                    <span class=\"sr-only\">Close</span>\n                </DialogClose>\n            </DialogContent>\n        </DialogOverlay>\n    </DialogPortal>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/dialog/DialogTitle.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport { DialogTitle, type DialogTitleProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <DialogTitle\n        v-bind=\"forwardedProps\"\n        :class=\"cn('text-lg font-semibold leading-none tracking-tight', props.class)\">\n        <slot />\n    </DialogTitle>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/dialog/DialogTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport { DialogTrigger, type DialogTriggerProps } from 'reka-ui';\n\nconst props = defineProps<DialogTriggerProps>();\n</script>\n\n<template>\n    <DialogTrigger v-bind=\"props\">\n        <slot />\n    </DialogTrigger>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/dialog/index.ts",
    "content": "export { default as Dialog } from './Dialog.vue';\nexport { default as DialogClose } from './DialogClose.vue';\nexport { default as DialogContent } from './DialogContent.vue';\nexport { default as DialogDescription } from './DialogDescription.vue';\nexport { default as DialogFooter } from './DialogFooter.vue';\nexport { default as DialogHeader } from './DialogHeader.vue';\nexport { default as DialogScrollContent } from './DialogScrollContent.vue';\nexport { default as DialogTitle } from './DialogTitle.vue';\nexport { default as DialogTrigger } from './DialogTrigger.vue';\n"
  },
  {
    "path": "resources/js/Components/ui/dropdown-menu/DropdownMenu.vue",
    "content": "<script setup lang=\"ts\">\nimport {\n    DropdownMenuRoot,\n    type DropdownMenuRootEmits,\n    type DropdownMenuRootProps,\n    useForwardPropsEmits,\n} from 'reka-ui';\n\nconst props = defineProps<DropdownMenuRootProps>();\nconst emits = defineEmits<DropdownMenuRootEmits>();\n\nconst forwarded = useForwardPropsEmits(props, emits);\n</script>\n\n<template>\n    <DropdownMenuRoot v-bind=\"forwarded\">\n        <slot />\n    </DropdownMenuRoot>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport { Check } from 'lucide-vue-next';\nimport {\n    DropdownMenuCheckboxItem,\n    type DropdownMenuCheckboxItemEmits,\n    type DropdownMenuCheckboxItemProps,\n    DropdownMenuItemIndicator,\n    useForwardPropsEmits,\n} from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes['class'] }>();\nconst emits = defineEmits<DropdownMenuCheckboxItemEmits>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits);\n</script>\n\n<template>\n    <DropdownMenuCheckboxItem\n        v-bind=\"forwarded\"\n        :class=\"\n            cn(\n                'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n                props.class\n            )\n        \">\n        <span class=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n            <DropdownMenuItemIndicator>\n                <Check class=\"w-4 h-4\" />\n            </DropdownMenuItemIndicator>\n        </span>\n        <slot />\n    </DropdownMenuCheckboxItem>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/dropdown-menu/DropdownMenuContent.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport {\n    DropdownMenuContent,\n    type DropdownMenuContentEmits,\n    type DropdownMenuContentProps,\n    DropdownMenuPortal,\n    useForwardPropsEmits,\n} from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = withDefaults(\n    defineProps<DropdownMenuContentProps & { class?: HTMLAttributes['class'] }>(),\n    {\n        sideOffset: 4,\n    }\n);\nconst emits = defineEmits<DropdownMenuContentEmits>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits);\n</script>\n\n<template>\n    <DropdownMenuPortal>\n        <DropdownMenuContent\n            v-bind=\"forwarded\"\n            :class=\"\n                cn(\n                    'z-50 min-w-32 overflow-hidden rounded-md border border-border-secondary bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n                    props.class\n                )\n            \">\n            <slot />\n        </DropdownMenuContent>\n    </DropdownMenuPortal>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/dropdown-menu/DropdownMenuGroup.vue",
    "content": "<script setup lang=\"ts\">\nimport { DropdownMenuGroup, type DropdownMenuGroupProps } from 'reka-ui';\n\nconst props = defineProps<DropdownMenuGroupProps>();\n</script>\n\n<template>\n    <DropdownMenuGroup v-bind=\"props\">\n        <slot />\n    </DropdownMenuGroup>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/dropdown-menu/DropdownMenuItem.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport { DropdownMenuItem, type DropdownMenuItemProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<\n    DropdownMenuItemProps & { class?: HTMLAttributes['class']; inset?: boolean }\n>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <DropdownMenuItem\n        v-bind=\"forwardedProps\"\n        :class=\"\n            cn(\n                'relative flex cursor-default select-none items-center rounded-sm gap-2 px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',\n                inset && 'pl-8',\n                props.class\n            )\n        \">\n        <slot />\n    </DropdownMenuItem>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/dropdown-menu/DropdownMenuLabel.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport { DropdownMenuLabel, type DropdownMenuLabelProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<\n    DropdownMenuLabelProps & { class?: HTMLAttributes['class']; inset?: boolean }\n>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <DropdownMenuLabel\n        v-bind=\"forwardedProps\"\n        :class=\"cn('block px-2 py-2 text-xs text-gray-400', inset && 'pl-8', props.class)\">\n        <slot />\n    </DropdownMenuLabel>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/dropdown-menu/DropdownMenuRadioGroup.vue",
    "content": "<script setup lang=\"ts\">\nimport {\n    DropdownMenuRadioGroup,\n    type DropdownMenuRadioGroupEmits,\n    type DropdownMenuRadioGroupProps,\n    useForwardPropsEmits,\n} from 'reka-ui';\n\nconst props = defineProps<DropdownMenuRadioGroupProps>();\nconst emits = defineEmits<DropdownMenuRadioGroupEmits>();\n\nconst forwarded = useForwardPropsEmits(props, emits);\n</script>\n\n<template>\n    <DropdownMenuRadioGroup v-bind=\"forwarded\">\n        <slot />\n    </DropdownMenuRadioGroup>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/dropdown-menu/DropdownMenuRadioItem.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport { Circle } from 'lucide-vue-next';\nimport {\n    DropdownMenuItemIndicator,\n    DropdownMenuRadioItem,\n    type DropdownMenuRadioItemEmits,\n    type DropdownMenuRadioItemProps,\n    useForwardPropsEmits,\n} from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes['class'] }>();\n\nconst emits = defineEmits<DropdownMenuRadioItemEmits>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits);\n</script>\n\n<template>\n    <DropdownMenuRadioItem\n        v-bind=\"forwarded\"\n        :class=\"\n            cn(\n                'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n                props.class\n            )\n        \">\n        <span class=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n            <DropdownMenuItemIndicator>\n                <Circle class=\"h-4 w-4 fill-current\" />\n            </DropdownMenuItemIndicator>\n        </span>\n        <slot />\n    </DropdownMenuRadioItem>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/dropdown-menu/DropdownMenuSeparator.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport { DropdownMenuSeparator, type DropdownMenuSeparatorProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<\n    DropdownMenuSeparatorProps & {\n        class?: HTMLAttributes['class'];\n    }\n>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n</script>\n\n<template>\n    <DropdownMenuSeparator\n        v-bind=\"delegatedProps\"\n        :class=\"cn('-mx-1 my-1 h-px bg-muted', props.class)\" />\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/dropdown-menu/DropdownMenuShortcut.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport { cn } from '@/lib/utils';\n\nconst props = defineProps<{\n    class?: HTMLAttributes['class'];\n}>();\n</script>\n\n<template>\n    <span :class=\"cn('ml-auto text-xs tracking-widest opacity-60', props.class)\">\n        <slot />\n    </span>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/dropdown-menu/DropdownMenuSub.vue",
    "content": "<script setup lang=\"ts\">\nimport {\n    DropdownMenuSub,\n    type DropdownMenuSubEmits,\n    type DropdownMenuSubProps,\n    useForwardPropsEmits,\n} from 'reka-ui';\n\nconst props = defineProps<DropdownMenuSubProps>();\nconst emits = defineEmits<DropdownMenuSubEmits>();\n\nconst forwarded = useForwardPropsEmits(props, emits);\n</script>\n\n<template>\n    <DropdownMenuSub v-bind=\"forwarded\">\n        <slot />\n    </DropdownMenuSub>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/dropdown-menu/DropdownMenuSubContent.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport {\n    DropdownMenuSubContent,\n    type DropdownMenuSubContentEmits,\n    type DropdownMenuSubContentProps,\n    useForwardPropsEmits,\n} from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes['class'] }>();\nconst emits = defineEmits<DropdownMenuSubContentEmits>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits);\n</script>\n\n<template>\n    <DropdownMenuSubContent\n        v-bind=\"forwarded\"\n        :class=\"\n            cn(\n                'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n                props.class\n            )\n        \">\n        <slot />\n    </DropdownMenuSubContent>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/dropdown-menu/DropdownMenuSubTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport { ChevronRight } from 'lucide-vue-next';\nimport { DropdownMenuSubTrigger, type DropdownMenuSubTriggerProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <DropdownMenuSubTrigger\n        v-bind=\"forwardedProps\"\n        :class=\"\n            cn(\n                'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',\n                props.class\n            )\n        \">\n        <slot />\n        <ChevronRight class=\"ml-auto h-4 w-4\" />\n    </DropdownMenuSubTrigger>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/dropdown-menu/DropdownMenuTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport { DropdownMenuTrigger, type DropdownMenuTriggerProps, useForwardProps } from 'reka-ui';\n\nconst props = defineProps<DropdownMenuTriggerProps>();\n\nconst forwardedProps = useForwardProps(props);\n</script>\n\n<template>\n    <DropdownMenuTrigger class=\"outline-none\" v-bind=\"forwardedProps\">\n        <slot />\n    </DropdownMenuTrigger>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/dropdown-menu/index.ts",
    "content": "export { default as DropdownMenu } from './DropdownMenu.vue';\n\nexport { default as DropdownMenuCheckboxItem } from './DropdownMenuCheckboxItem.vue';\nexport { default as DropdownMenuContent } from './DropdownMenuContent.vue';\nexport { default as DropdownMenuGroup } from './DropdownMenuGroup.vue';\nexport { default as DropdownMenuItem } from './DropdownMenuItem.vue';\nexport { default as DropdownMenuLabel } from './DropdownMenuLabel.vue';\nexport { default as DropdownMenuRadioGroup } from './DropdownMenuRadioGroup.vue';\nexport { default as DropdownMenuRadioItem } from './DropdownMenuRadioItem.vue';\nexport { default as DropdownMenuSeparator } from './DropdownMenuSeparator.vue';\nexport { default as DropdownMenuShortcut } from './DropdownMenuShortcut.vue';\nexport { default as DropdownMenuSub } from './DropdownMenuSub.vue';\nexport { default as DropdownMenuSubContent } from './DropdownMenuSubContent.vue';\nexport { default as DropdownMenuSubTrigger } from './DropdownMenuSubTrigger.vue';\nexport { default as DropdownMenuTrigger } from './DropdownMenuTrigger.vue';\nexport { DropdownMenuPortal } from 'reka-ui';\n"
  },
  {
    "path": "resources/js/Components/ui/label/Label.vue",
    "content": "<script setup lang=\"ts\">\nimport type { LabelProps } from 'reka-ui';\nimport type { HTMLAttributes } from 'vue';\nimport { reactiveOmit } from '@vueuse/core';\nimport { Label } from 'reka-ui';\nimport { cn } from '@/lib/utils';\n\nconst props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = reactiveOmit(props, 'class');\n</script>\n\n<template>\n    <Label\n        v-bind=\"delegatedProps\"\n        :class=\"\n            cn(\n                'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',\n                props.class\n            )\n        \">\n        <slot />\n    </Label>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/label/index.ts",
    "content": "export { default as Label } from './Label.vue';\n"
  },
  {
    "path": "resources/js/Components/ui/number-field/NumberField.vue",
    "content": "<script setup lang=\"ts\">\nimport type { NumberFieldRootEmits, NumberFieldRootProps } from 'reka-ui';\nimport { cn } from '@/lib/utils';\nimport { NumberFieldRoot, useForwardPropsEmits } from 'reka-ui';\nimport { computed, type HTMLAttributes, inject, type ComputedRef } from 'vue';\nimport type { Organization } from '@/packages/api/src';\n\nconst props = defineProps<\n    NumberFieldRootProps & {\n        class?: HTMLAttributes['class'];\n        formatOptions?: {\n            maximumFractionDigits?: number;\n            minimumFractionDigits?: number;\n        };\n    }\n>();\nconst emits = defineEmits<NumberFieldRootEmits>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, formatOptions: __, ...delegated } = props;\n    return delegated;\n});\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n\nconst locale = computed(() => {\n    const format = organization?.value?.number_format || 'comma-point';\n\n    // space poin is not supported in reka-ui\n    switch (format) {\n        case 'point-comma':\n            return 'de-DE'; // Uses point for thousands and comma for decimal\n        case 'comma-point':\n            return 'en-US'; // Uses comma for thousands and point for decimal\n        case 'space-comma':\n            return 'sv-SE'; // Uses space for thousands and comma for decimal\n        case 'apostrophe-point':\n            return 'de-CH'; // Uses apostrophe for thousands and point for decimal\n        default:\n            return 'en-US';\n    }\n});\n\nconst defaultFormatOptions = {\n    maximumFractionDigits: 2,\n};\n\nconst formatOptions = computed(() => ({\n    ...defaultFormatOptions,\n    ...props.formatOptions,\n}));\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits);\n</script>\n\n<template>\n    <NumberFieldRoot\n        v-bind=\"forwarded\"\n        :locale=\"locale\"\n        :format-options=\"formatOptions\"\n        :class=\"cn('grid gap-1.5', props.class)\">\n        <slot />\n    </NumberFieldRoot>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/number-field/NumberFieldContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport { cn } from '@/lib/utils';\n\nconst props = defineProps<{\n    class?: HTMLAttributes['class'];\n}>();\n</script>\n\n<template>\n    <div\n        :class=\"\n            cn(\n                'relative [&>[data-slot=input]]:has-[[data-slot=increment]]:pr-5 [&>[data-slot=input]]:has-[[data-slot=decrement]]:pl-5',\n                props.class\n            )\n        \">\n        <slot />\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/number-field/NumberFieldDecrement.vue",
    "content": "<script setup lang=\"ts\">\nimport type { NumberFieldDecrementProps } from 'reka-ui';\nimport { cn } from '@/lib/utils';\nimport { Minus } from 'lucide-vue-next';\nimport { NumberFieldDecrement, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<NumberFieldDecrementProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwarded = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <NumberFieldDecrement\n        data-slot=\"decrement\"\n        v-bind=\"forwarded\"\n        :class=\"\n            cn(\n                'absolute top-1/2 -translate-y-1/2 left-0 p-3 disabled:cursor-not-allowed disabled:opacity-20',\n                props.class\n            )\n        \">\n        <slot>\n            <Minus class=\"h-4 w-4\" />\n        </slot>\n    </NumberFieldDecrement>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/number-field/NumberFieldIncrement.vue",
    "content": "<script setup lang=\"ts\">\nimport type { NumberFieldIncrementProps } from 'reka-ui';\nimport { cn } from '@/lib/utils';\nimport { Plus } from 'lucide-vue-next';\nimport { NumberFieldIncrement, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<NumberFieldIncrementProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwarded = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <NumberFieldIncrement\n        data-slot=\"increment\"\n        v-bind=\"forwarded\"\n        :class=\"\n            cn(\n                'absolute top-1/2 -translate-y-1/2 right-0 disabled:cursor-not-allowed disabled:opacity-20 p-3',\n                props.class\n            )\n        \">\n        <slot>\n            <Plus class=\"h-4 w-4\" />\n        </slot>\n    </NumberFieldIncrement>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/number-field/NumberFieldInput.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport { cn } from '@/lib/utils';\nimport { NumberFieldInput } from 'reka-ui';\n\nconst props = defineProps<{\n    class?: HTMLAttributes['class'];\n}>();\n</script>\n\n<template>\n    <NumberFieldInput\n        data-slot=\"input\"\n        :class=\"\n            cn(\n                'flex h-9 w-full rounded-md border border-input-border bg-input-background px-3 py-1 text-base sm:text-sm text-center shadow-sm transition-colors placeholder:text-muted-foreground focus:border-input-border focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50',\n                props.class\n            )\n        \" />\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/number-field/index.ts",
    "content": "export { default as NumberField } from './NumberField.vue';\nexport { default as NumberFieldContent } from './NumberFieldContent.vue';\nexport { default as NumberFieldDecrement } from './NumberFieldDecrement.vue';\nexport { default as NumberFieldIncrement } from './NumberFieldIncrement.vue';\nexport { default as NumberFieldInput } from './NumberFieldInput.vue';\n"
  },
  {
    "path": "resources/js/Components/ui/select/Select.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SelectRootEmits, SelectRootProps } from 'reka-ui';\nimport { SelectRoot, useForwardPropsEmits } from 'reka-ui';\n\nconst props = defineProps<SelectRootProps>();\nconst emits = defineEmits<SelectRootEmits>();\n\nconst forwarded = useForwardPropsEmits(props, emits);\n</script>\n\n<template>\n    <SelectRoot v-bind=\"forwarded\">\n        <slot />\n    </SelectRoot>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/select/SelectContent.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport {\n    SelectContent,\n    type SelectContentEmits,\n    type SelectContentProps,\n    SelectPortal,\n    SelectViewport,\n    useForwardPropsEmits,\n} from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\nimport { SelectScrollDownButton, SelectScrollUpButton } from '.';\n\ndefineOptions({\n    inheritAttrs: false,\n});\n\nconst props = withDefaults(\n    defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>(),\n    {\n        position: 'popper',\n    }\n);\nconst emits = defineEmits<SelectContentEmits>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits);\n</script>\n\n<template>\n    <SelectPortal>\n        <SelectContent\n            v-bind=\"{ ...forwarded, ...$attrs }\"\n            :class=\"\n                cn(\n                    'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border border-popover-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n                    position === 'popper' &&\n                        'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',\n                    props.class\n                )\n            \">\n            <SelectScrollUpButton />\n            <SelectViewport\n                :class=\"\n                    cn(\n                        'p-1',\n                        position === 'popper' &&\n                            'h-[--reka-select-trigger-height] w-full min-w-[--reka-select-trigger-width]'\n                    )\n                \">\n                <slot />\n            </SelectViewport>\n            <SelectScrollDownButton />\n        </SelectContent>\n    </SelectPortal>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/select/SelectGroup.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport { SelectGroup, type SelectGroupProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<SelectGroupProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n</script>\n\n<template>\n    <SelectGroup :class=\"cn('p-1 w-full', props.class)\" v-bind=\"delegatedProps\">\n        <slot />\n    </SelectGroup>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/select/SelectItem.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport { Check } from 'lucide-vue-next';\nimport {\n    SelectItem,\n    SelectItemIndicator,\n    type SelectItemProps,\n    SelectItemText,\n    useForwardProps,\n} from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<SelectItemProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <SelectItem\n        v-bind=\"forwardedProps\"\n        :class=\"\n            cn(\n                'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n                props.class\n            )\n        \">\n        <span class=\"absolute right-2 flex h-3.5 w-3.5 items-center justify-center\">\n            <SelectItemIndicator>\n                <Check class=\"h-4 w-4\" />\n            </SelectItemIndicator>\n        </span>\n\n        <SelectItemText>\n            <slot />\n        </SelectItemText>\n    </SelectItem>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/select/SelectItemText.vue",
    "content": "<script setup lang=\"ts\">\nimport { SelectItemText, type SelectItemTextProps } from 'reka-ui';\n\nconst props = defineProps<SelectItemTextProps>();\n</script>\n\n<template>\n    <SelectItemText v-bind=\"props\">\n        <slot />\n    </SelectItemText>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/select/SelectLabel.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport { cn } from '@/lib/utils';\nimport { SelectLabel, type SelectLabelProps } from 'reka-ui';\n\nconst props = defineProps<SelectLabelProps & { class?: HTMLAttributes['class'] }>();\n</script>\n\n<template>\n    <SelectLabel :class=\"cn('px-2 py-1.5 text-sm font-semibold', props.class)\">\n        <slot />\n    </SelectLabel>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/select/SelectScrollDownButton.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport { ChevronDown } from 'lucide-vue-next';\nimport { SelectScrollDownButton, type SelectScrollDownButtonProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <SelectScrollDownButton\n        v-bind=\"forwardedProps\"\n        :class=\"cn('flex cursor-default items-center justify-center py-1', props.class)\">\n        <slot>\n            <ChevronDown />\n        </slot>\n    </SelectScrollDownButton>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/select/SelectScrollUpButton.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport { ChevronUp } from 'lucide-vue-next';\nimport { SelectScrollUpButton, type SelectScrollUpButtonProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <SelectScrollUpButton\n        v-bind=\"forwardedProps\"\n        :class=\"cn('flex cursor-default items-center justify-center py-1', props.class)\">\n        <slot>\n            <ChevronUp />\n        </slot>\n    </SelectScrollUpButton>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/select/SelectSeparator.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport { SelectSeparator, type SelectSeparatorProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n</script>\n\n<template>\n    <SelectSeparator v-bind=\"delegatedProps\" :class=\"cn('-mx-1 my-1 h-px bg-muted', props.class)\" />\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/select/SelectTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport { ChevronDown } from 'lucide-vue-next';\nimport { SelectIcon, SelectTrigger, type SelectTriggerProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = withDefaults(\n    defineProps<\n        SelectTriggerProps & {\n            size?: 'default' | 'sm' | 'lg';\n            class?: HTMLAttributes['class'];\n            showChevron?: boolean;\n            variant?: 'default' | 'outline';\n            active?: boolean;\n        }\n    >(),\n    {\n        showChevron: true,\n        variant: 'default',\n        active: false,\n        size: 'default',\n    }\n);\n\nconst delegatedProps = computed(() => {\n    const {\n        class: _,\n        showChevron: __,\n        variant: ___,\n        active: ____,\n        size: _____,\n        ...delegated\n    } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n\nconst sizeClasses = computed(() => {\n    switch (props.size) {\n        case 'sm':\n            return 'h-8 px-3 text-xs';\n        case 'lg':\n            return 'h-10 px-4 text-sm';\n        default:\n            return 'h-9 px-3 text-sm';\n    }\n});\n\nconst variantClasses = computed(() => {\n    if (props.variant === 'outline') {\n        if (props.active) {\n            return 'border border-accent-300/50 bg-accent-50 hover:bg-accent-100 dark:border-accent-300/50 dark:bg-accent-300/5 dark:hover:bg-accent-300/10';\n        }\n        return 'border shadow-xs hover:text-text-primary bg-card-background dark:bg-transparent border-input dark:border-input hover:bg-white/5';\n    }\n    return 'border border-input-border bg-input-background shadow-sm';\n});\n</script>\n\n<template>\n    <SelectTrigger\n        v-bind=\"forwardedProps\"\n        :class=\"\n            cn(\n                'flex items-center justify-between gap-3 whitespace-nowrap rounded-md data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start font-medium transition-colors',\n                sizeClasses,\n                variantClasses,\n                props.class\n            )\n        \">\n        <slot />\n        <SelectIcon v-if=\"showChevron\" as-child>\n            <ChevronDown class=\"w-4 h-4 text-icon-default shrink-0\" />\n        </SelectIcon>\n    </SelectTrigger>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/select/SelectValue.vue",
    "content": "<script setup lang=\"ts\">\nimport { SelectValue, type SelectValueProps } from 'reka-ui';\n\nconst props = defineProps<SelectValueProps>();\n</script>\n\n<template>\n    <SelectValue v-bind=\"props\">\n        <slot />\n    </SelectValue>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/select/index.ts",
    "content": "export { default as Select } from './Select.vue';\nexport { default as SelectContent } from './SelectContent.vue';\nexport { default as SelectGroup } from './SelectGroup.vue';\nexport { default as SelectItem } from './SelectItem.vue';\nexport { default as SelectItemText } from './SelectItemText.vue';\nexport { default as SelectLabel } from './SelectLabel.vue';\nexport { default as SelectScrollDownButton } from './SelectScrollDownButton.vue';\nexport { default as SelectScrollUpButton } from './SelectScrollUpButton.vue';\nexport { default as SelectSeparator } from './SelectSeparator.vue';\nexport { default as SelectTrigger } from './SelectTrigger.vue';\nexport { default as SelectValue } from './SelectValue.vue';\n"
  },
  {
    "path": "resources/js/Components/ui/switch/Switch.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport {\n    SwitchRoot,\n    type SwitchRootEmits,\n    type SwitchRootProps,\n    SwitchThumb,\n    useForwardPropsEmits,\n} from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<SwitchRootProps & { class?: HTMLAttributes['class'] }>();\n\nconst emits = defineEmits<SwitchRootEmits>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits);\n</script>\n\n<template>\n    <SwitchRoot\n        v-bind=\"forwarded\"\n        :class=\"\n            cn(\n                'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',\n                props.class\n            )\n        \">\n        <SwitchThumb\n            :class=\"\n                cn(\n                    'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4'\n                )\n            \">\n            <slot name=\"thumb\" />\n        </SwitchThumb>\n    </SwitchRoot>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/switch/index.ts",
    "content": "export { default as Switch } from './Switch.vue';\n"
  },
  {
    "path": "resources/js/Components/ui/table/Table.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport { cn } from '@/lib/utils';\n\nconst props = defineProps<{\n    class?: HTMLAttributes['class'];\n}>();\n</script>\n\n<template>\n    <div class=\"relative w-full overflow-auto\">\n        <table :class=\"cn('w-full caption-bottom text-sm', props.class)\">\n            <slot />\n        </table>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/table/TableBody.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport { cn } from '@/lib/utils';\n\nconst props = defineProps<{\n    class?: HTMLAttributes['class'];\n}>();\n</script>\n\n<template>\n    <tbody :class=\"cn('[&_tr:last-child]:border-0', props.class)\">\n        <slot />\n    </tbody>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/table/TableCaption.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport { cn } from '@/lib/utils';\n\nconst props = defineProps<{\n    class?: HTMLAttributes['class'];\n}>();\n</script>\n\n<template>\n    <caption :class=\"cn('mt-4 text-sm text-muted-foreground', props.class)\">\n        <slot />\n    </caption>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/table/TableCell.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport { cn } from '@/lib/utils';\n\nconst props = defineProps<{\n    class?: HTMLAttributes['class'];\n}>();\n</script>\n\n<template>\n    <td\n        :class=\"\n            cn(\n                'p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-0.5',\n                props.class\n            )\n        \">\n        <slot />\n    </td>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/table/TableEmpty.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport { computed, type HTMLAttributes } from 'vue';\nimport TableCell from './TableCell.vue';\nimport TableRow from './TableRow.vue';\n\nconst props = withDefaults(\n    defineProps<{\n        class?: HTMLAttributes['class'];\n        colspan?: number;\n    }>(),\n    {\n        colspan: 1,\n    }\n);\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n</script>\n\n<template>\n    <TableRow>\n        <TableCell\n            :class=\"cn('p-4 whitespace-nowrap align-middle text-sm text-foreground', props.class)\"\n            v-bind=\"delegatedProps\">\n            <div class=\"flex items-center justify-center py-10\">\n                <slot />\n            </div>\n        </TableCell>\n    </TableRow>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/table/TableFooter.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport { cn } from '@/lib/utils';\n\nconst props = defineProps<{\n    class?: HTMLAttributes['class'];\n}>();\n</script>\n\n<template>\n    <tfoot :class=\"cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', props.class)\">\n        <slot />\n    </tfoot>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/table/TableHead.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport { cn } from '@/lib/utils';\n\nconst props = defineProps<{\n    class?: HTMLAttributes['class'];\n}>();\n</script>\n\n<template>\n    <th\n        :class=\"\n            cn(\n                'h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-0.5',\n                props.class\n            )\n        \">\n        <slot />\n    </th>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/table/TableHeader.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport { cn } from '@/lib/utils';\n\nconst props = defineProps<{\n    class?: HTMLAttributes['class'];\n}>();\n</script>\n\n<template>\n    <thead :class=\"cn('[&_tr]:border-b', props.class)\">\n        <slot />\n    </thead>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/table/TableRow.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport { cn } from '@/lib/utils';\n\nconst props = defineProps<{\n    class?: HTMLAttributes['class'];\n}>();\n</script>\n\n<template>\n    <tr\n        :class=\"\n            cn(\n                'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',\n                props.class\n            )\n        \">\n        <slot />\n    </tr>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/table/index.ts",
    "content": "export { default as Table } from './Table.vue';\nexport { default as TableBody } from './TableBody.vue';\nexport { default as TableCaption } from './TableCaption.vue';\nexport { default as TableCell } from './TableCell.vue';\nexport { default as TableEmpty } from './TableEmpty.vue';\nexport { default as TableFooter } from './TableFooter.vue';\nexport { default as TableHead } from './TableHead.vue';\nexport { default as TableHeader } from './TableHeader.vue';\nexport { default as TableRow } from './TableRow.vue';\n"
  },
  {
    "path": "resources/js/Components/ui/tabs/Tabs.vue",
    "content": "<script setup lang=\"ts\">\nimport type { TabsRootEmits, TabsRootProps } from 'reka-ui';\nimport { TabsRoot, useForwardPropsEmits } from 'reka-ui';\n\nconst props = defineProps<TabsRootProps>();\nconst emits = defineEmits<TabsRootEmits>();\n\nconst forwarded = useForwardPropsEmits(props, emits);\n</script>\n\n<template>\n    <TabsRoot v-bind=\"forwarded\">\n        <slot />\n    </TabsRoot>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/tabs/TabsContent.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport { TabsContent, type TabsContentProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<TabsContentProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n</script>\n\n<template>\n    <TabsContent\n        :class=\"\n            cn(\n                'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',\n                props.class\n            )\n        \"\n        v-bind=\"delegatedProps\">\n        <slot />\n    </TabsContent>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/tabs/TabsList.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport { TabsList, type TabsListProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<TabsListProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n</script>\n\n<template>\n    <TabsList\n        v-bind=\"delegatedProps\"\n        :class=\"cn('inline-flex items-center rounded-lg text-muted-foreground', props.class)\">\n        <slot />\n    </TabsList>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/tabs/TabsTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport { TabsTrigger, type TabsTriggerProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes, type Component } from 'vue';\n\nconst props = defineProps<\n    TabsTriggerProps & { class?: HTMLAttributes['class']; icon?: Component }\n>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <TabsTrigger\n        v-bind=\"forwardedProps\"\n        :class=\"\n            cn(\n                'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium  transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',\n                props.class\n            )\n        \">\n        <div v-if=\"props.icon\" class=\"mr-2 h-4 w-4\">\n            <props.icon />\n        </div>\n        <span class=\"truncate\">\n            <slot />\n        </span>\n    </TabsTrigger>\n</template>\n"
  },
  {
    "path": "resources/js/Components/ui/tabs/index.ts",
    "content": "export { default as Tabs } from './Tabs.vue';\nexport { default as TabsContent } from './TabsContent.vue';\nexport { default as TabsList } from './TabsList.vue';\nexport { default as TabsTrigger } from './TabsTrigger.vue';\n"
  },
  {
    "path": "resources/js/Layouts/AppLayout.vue",
    "content": "<script setup lang=\"ts\">\nimport { Head, usePage } from '@inertiajs/vue3';\nimport Banner from '@/Components/Banner.vue';\nimport OrganizationSwitcher from '@/Components/OrganizationSwitcher.vue';\nimport CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';\nimport {\n    CalendarIcon,\n    ChartBarIcon,\n    ClockIcon,\n    Cog6ToothIcon,\n    CreditCardIcon,\n    FolderIcon,\n    HomeIcon,\n    MagnifyingGlassIcon,\n    TagIcon,\n    UserCircleIcon,\n    UserGroupIcon,\n    XMarkIcon,\n    DocumentTextIcon,\n} from '@heroicons/vue/20/solid';\nimport { PanelLeft } from 'lucide-vue-next';\nimport NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';\nimport UserSettingsIcon from '@/Components/UserSettingsIcon.vue';\nimport MainContainer from '@/packages/ui/src/MainContainer.vue';\nimport { nextTick, onMounted, provide, ref } from 'vue';\nimport NotificationContainer from '@/Components/NotificationContainer.vue';\nimport { initializeStores } from '@/utils/init';\nimport { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';\nimport {\n    canManageBilling,\n    canUpdateOrganization,\n    canViewClients,\n    canViewInvoices,\n    canViewMembers,\n    canViewProjects,\n    canViewReport,\n    canViewTags,\n} from '@/utils/permissions';\nimport { isBillingActivated, isInvoicingActivated } from '@/utils/billing';\nimport type { User } from '@/types/models';\nimport { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';\nimport { fetchToken, isTokenValid } from '@/utils/session';\nimport UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vue';\nimport BillingBanner from '@/Components/Billing/BillingBanner.vue';\nimport UserTimezoneMismatchModal from '@/Components/Common/User/UserTimezoneMismatchModal.vue';\nimport { useTheme } from '@/utils/theme';\nimport { useOrganizationQuery } from '@/utils/useOrganizationQuery';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';\nimport { twMerge } from 'tailwind-merge';\nimport { Button } from '@/packages/ui/src/Buttons';\nimport { openFeedback } from '@/utils/feedback';\nimport { CommandPaletteProvider } from '@/Components/CommandPalette';\nimport { useCommandPalette } from '@/utils/useCommandPalette';\n\nconst { openPalette } = useCommandPalette();\n\ndefineProps({\n    title: String,\n    mainClass: String,\n});\n\nconst showSidebarMenu = ref(false);\nconst sidebarVisible = ref(false);\n\nfunction openSidebar() {\n    showSidebarMenu.value = true;\n    nextTick(() => {\n        requestAnimationFrame(() => {\n            sidebarVisible.value = true;\n        });\n    });\n}\n\nfunction closeSidebar() {\n    sidebarVisible.value = false;\n    setTimeout(() => {\n        showSidebarMenu.value = false;\n    }, 200);\n}\n\nconst isUnloading = ref(false);\n\nconst { organization, isLoading: isOrganizationLoading } = useOrganizationQuery(\n    getCurrentOrganizationId()!\n);\n\nprovide('organization', organization);\n\nonMounted(async () => {\n    useTheme();\n    // make sure that the initial requests are only loaded once, this can be removed once we move away from inertia\n    if (window.initialDataLoaded !== true) {\n        window.initialDataLoaded = true;\n        initializeStores();\n    }\n    window.onbeforeunload = () => {\n        isUnloading.value = true;\n    };\n    window.onfocus = async () => {\n        if (!isTokenValid()) {\n            await fetchToken();\n        }\n        setTimeout(() => {\n            // TanStack Query automatically refetches on window focus\n            // Only refresh non-migrated stores\n            if (isUnloading.value === false) {\n                useCurrentTimeEntryStore().fetchCurrentTimeEntry();\n            }\n        }, 100);\n    };\n});\nconst page = usePage<{\n    has_services_extension?: boolean;\n    auth: {\n        user: User;\n    };\n}>();\n</script>\n\n<template>\n    <div v-bind=\"$attrs\" class=\"flex flex-wrap bg-background text-text-secondary\">\n        <!-- Mobile sidebar overlay -->\n        <Teleport to=\"body\">\n            <div v-if=\"showSidebarMenu\" class=\"fixed inset-0 z-40 lg:hidden\" @click=\"closeSidebar\">\n                <div\n                    class=\"absolute inset-0 bg-default-background transition-opacity duration-200\"\n                    :class=\"sidebarVisible ? 'opacity-50' : 'opacity-0'\" />\n            </div>\n        </Teleport>\n\n        <div\n            :class=\"[\n                sidebarVisible\n                    ? 'max-lg:translate-x-0 max-lg:shadow-xl'\n                    : 'max-lg:-translate-x-full',\n            ]\"\n            class=\"flex-shrink-0 h-screen fixed w-[280px] px-2.5 py-4 hidden lg:flex flex-col justify-between bg-background border-r border-default-background-separator max-lg:z-50 max-lg:transition-transform max-lg:duration-200 max-lg:ease-in-out lg:w-[230px] 2xl:w-[250px] 2xl:px-3 lg:border-r-0\"\n            :style=\"showSidebarMenu ? { display: 'flex' } : undefined\">\n            <div class=\"flex flex-col h-full\">\n                <div\n                    class=\"border-b border-default-background-separator pb-2 flex items-center gap-1\">\n                    <div class=\"flex-1 min-w-0 overflow-hidden\">\n                        <OrganizationSwitcher></OrganizationSwitcher>\n                    </div>\n                    <Button\n                        variant=\"ghost\"\n                        size=\"icon\"\n                        class=\"h-7 w-7 flex-shrink-0\"\n                        data-testid=\"command_palette_button\"\n                        @click=\"openPalette\">\n                        <MagnifyingGlassIcon class=\"h-4 w-4 text-icon-default\" />\n                    </Button>\n                    <Button\n                        variant=\"ghost\"\n                        size=\"icon\"\n                        class=\"h-7 w-7 flex-shrink-0 lg:hidden\"\n                        @click=\"closeSidebar\">\n                        <XMarkIcon class=\"h-4 w-4 text-icon-default\" />\n                    </Button>\n                </div>\n                <div class=\"border-b border-default-background-separator\">\n                    <CurrentSidebarTimer></CurrentSidebarTimer>\n                </div>\n                <div\n                    class=\"overflow-y-scroll flex-1 w-full\"\n                    style=\"\n                        scrollbar-width: thin;\n                        scrollbar-color: var(--color-bg-primary) transparent;\n                    \">\n                    <nav class=\"pt-2\">\n                        <ul>\n                            <NavigationSidebarItem\n                                title=\"Dashboard\"\n                                :icon=\"HomeIcon\"\n                                :href=\"route('dashboard')\"\n                                :current=\"route().current('dashboard')\"></NavigationSidebarItem>\n                            <NavigationSidebarItem\n                                title=\"Time\"\n                                :icon=\"ClockIcon\"\n                                :current=\"route().current('time')\"\n                                :href=\"route('time')\"></NavigationSidebarItem>\n                            <NavigationSidebarItem\n                                title=\"Calendar\"\n                                :icon=\"CalendarIcon\"\n                                :current=\"route().current('calendar')\"\n                                :href=\"route('calendar')\"></NavigationSidebarItem>\n                            <NavigationSidebarItem\n                                title=\"Reporting\"\n                                :icon=\"ChartBarIcon\"\n                                :sub-items=\"[\n                                    {\n                                        title: 'Overview',\n                                        route: 'reporting',\n                                        show: true,\n                                    },\n                                    {\n                                        title: 'Detailed',\n                                        route: 'reporting.detailed',\n                                        show: true,\n                                    },\n                                    {\n                                        title: 'Shared',\n                                        route: 'reporting.shared',\n                                        show: canViewReport(),\n                                    },\n                                ]\"\n                                :current=\"\n                                    route().current('reporting') ||\n                                    route().current('reporting.detailed') ||\n                                    route().current('reporting.shared')\n                                \"\n                                :href=\"route('reporting')\">\n                            </NavigationSidebarItem>\n                        </ul>\n                    </nav>\n\n                    <div class=\"text-text-tertiary text-xs font-semibold pt-5 pb-1.5\">Manage</div>\n\n                    <nav>\n                        <ul>\n                            <NavigationSidebarItem\n                                v-if=\"canViewProjects()\"\n                                title=\"Projects\"\n                                :icon=\"FolderIcon\"\n                                :href=\"route('projects')\"\n                                :current=\"route().current('projects')\"></NavigationSidebarItem>\n                            <NavigationSidebarItem\n                                v-if=\"canViewClients()\"\n                                title=\"Clients\"\n                                :icon=\"UserCircleIcon\"\n                                :current=\"route().current('clients')\"\n                                :href=\"route('clients')\"></NavigationSidebarItem>\n                            <NavigationSidebarItem\n                                v-if=\"canViewMembers()\"\n                                title=\"Members\"\n                                :icon=\"UserGroupIcon\"\n                                :current=\"route().current('members')\"\n                                :href=\"route('members')\"></NavigationSidebarItem>\n                            <NavigationSidebarItem\n                                v-if=\"canViewTags()\"\n                                title=\"Tags\"\n                                :icon=\"TagIcon\"\n                                :current=\"route().current('tags')\"\n                                :href=\"route('tags')\"></NavigationSidebarItem>\n                            <NavigationSidebarItem\n                                v-if=\"isInvoicingActivated() && canViewInvoices()\"\n                                title=\"Invoices\"\n                                :icon=\"DocumentTextIcon\"\n                                :current=\"route().current('invoices')\"\n                                href=\"/invoices\"></NavigationSidebarItem>\n                        </ul>\n                    </nav>\n                    <div\n                        v-if=\"canUpdateOrganization()\"\n                        class=\"text-text-tertiary text-xs font-semibold pt-5 pb-1.5\">\n                        Admin\n                    </div>\n\n                    <nav>\n                        <ul>\n                            <NavigationSidebarItem\n                                v-if=\"canManageBilling() && isBillingActivated()\"\n                                title=\"Billing\"\n                                :icon=\"CreditCardIcon\"\n                                href=\"/billing\"></NavigationSidebarItem>\n                            <NavigationSidebarItem\n                                v-if=\"canUpdateOrganization()\"\n                                title=\"Import / Export\"\n                                :icon=\"ArrowsRightLeftIcon\"\n                                :current=\"route().current('import')\"\n                                :href=\"route('import')\"></NavigationSidebarItem>\n                            <NavigationSidebarItem\n                                v-if=\"canUpdateOrganization()\"\n                                title=\"Settings\"\n                                :icon=\"Cog6ToothIcon\"\n                                :href=\"route('teams.show', page.props.auth.user.current_team.id)\"\n                                :current=\"\n                                    route().current(\n                                        'teams.show',\n                                        page.props.auth.user.current_team.id\n                                    )\n                                \"></NavigationSidebarItem>\n                        </ul>\n                    </nav>\n                </div>\n                <div class=\"justify-self-end\">\n                    <UpdateSidebarNotification></UpdateSidebarNotification>\n                    <ul\n                        class=\"border-t border-default-background-separator pt-3 gap-1 pr-2 flex justify-between items-center\">\n                        <UserSettingsIcon></UserSettingsIcon>\n\n                        <NavigationSidebarItem\n                            class=\"flex-1\"\n                            title=\"Profile Settings\"\n                            :icon=\"Cog6ToothIcon\"\n                            :href=\"route('profile.show')\"></NavigationSidebarItem>\n\n                        <Button\n                            v-if=\"page.props.has_services_extension\"\n                            variant=\"outline\"\n                            size=\"xs\"\n                            class=\"rounded-full ml-2 flex h-6 w-6 items-center text-xs text-icon-default justify-center\"\n                            @click=\"openFeedback\">\n                            ?\n                        </Button>\n                    </ul>\n                </div>\n            </div>\n        </div>\n        <div class=\"flex-1 lg:ml-[230px] 2xl:ml-[250px] min-w-0\">\n            <div\n                class=\"h-screen overflow-y-auto flex flex-col bg-default-background border-l border-default-background-separator\">\n                <div\n                    class=\"lg:hidden w-full px-3 py-1 border-b border-b-default-background-separator text-text-secondary flex justify-between items-center\">\n                    <Button\n                        variant=\"ghost\"\n                        size=\"icon\"\n                        class=\"h-7 w-7 shrink-0\"\n                        @click=\"openSidebar\">\n                        <PanelLeft class=\"h-4 w-4 text-icon-default\" />\n                    </Button>\n                    <div class=\"flex items-center gap-1\">\n                        <OrganizationSwitcher></OrganizationSwitcher>\n                        <Button\n                            variant=\"ghost\"\n                            size=\"icon\"\n                            class=\"h-7 w-7 shrink-0\"\n                            data-testid=\"command_palette_button_mobile\"\n                            @click=\"openPalette\">\n                            <MagnifyingGlassIcon class=\"h-4 w-4 text-icon-default\" />\n                        </Button>\n                    </div>\n                </div>\n\n                <Head :title=\"title\" />\n\n                <!-- Page Heading -->\n                <Banner />\n                <BillingBanner v-if=\"isBillingActivated()\" />\n\n                <header\n                    v-if=\"$slots.header\"\n                    class=\"bg-default-background border-b border-default-background-separator shadow\">\n                    <div class=\"pt-8 pb-3\">\n                        <MainContainer>\n                            <slot name=\"header\" />\n                        </MainContainer>\n                    </div>\n                </header>\n\n                <!-- Page Content -->\n                <main :class=\"twMerge('pb-28 relative flex-1', mainClass)\">\n                    <div\n                        v-if=\"isOrganizationLoading\"\n                        class=\"flex items-center justify-center h-screen\">\n                        <LoadingSpinner />\n                    </div>\n                    <slot v-else />\n                </main>\n            </div>\n        </div>\n    </div>\n    <NotificationContainer></NotificationContainer>\n    <UserTimezoneMismatchModal></UserTimezoneMismatchModal>\n    <CommandPaletteProvider></CommandPaletteProvider>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/API/Index.vue",
    "content": "<script setup lang=\"ts\">\nimport ApiTokenManager from '@/Pages/API/Partials/ApiTokenManager.vue';\nimport AppLayout from '@/Layouts/AppLayout.vue';\nimport type { Token } from '@/types/jetstream';\n\ndefineProps<{\n    tokens: Token[];\n    availablePermissions: string[];\n    defaultPermissions: string[];\n}>();\n</script>\n\n<template>\n    <AppLayout title=\"API Tokens\">\n        <template #header>\n            <h2 class=\"font-semibold text-xl text-text-primary leading-tight\">API Tokens</h2>\n        </template>\n\n        <div>\n            <div class=\"max-w-7xl mx-auto py-10 sm:px-6 lg:px-8\">\n                <ApiTokenManager\n                    :tokens=\"tokens\"\n                    :available-permissions=\"availablePermissions\"\n                    :default-permissions=\"defaultPermissions\" />\n            </div>\n        </div>\n    </AppLayout>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/API/Partials/ApiTokenManager.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref } from 'vue';\nimport { useForm, usePage } from '@inertiajs/vue3';\nimport ActionMessage from '@/Components/ActionMessage.vue';\nimport ActionSection from '@/Components/ActionSection.vue';\nimport Checkbox from '@/packages/ui/src/Input/Checkbox.vue';\nimport ConfirmationModal from '@/Components/ConfirmationModal.vue';\nimport DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport FormSection from '@/Components/FormSection.vue';\nimport { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport SectionBorder from '@/Components/SectionBorder.vue';\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\nimport type { Token } from '@/types/jetstream';\n\nconst props = defineProps<{\n    tokens: Token[];\n    availablePermissions: string[];\n    defaultPermissions: string[];\n}>();\n\nconst createApiTokenForm = useForm({\n    name: '',\n    permissions: props.defaultPermissions,\n});\n\nconst page = usePage<{\n    jetstream: {\n        flash: {\n            token: string;\n        };\n    };\n}>();\n\nconst updateApiTokenForm = useForm<{\n    permissions: string[];\n}>({\n    permissions: [],\n});\n\nconst deleteApiTokenForm = useForm({});\n\nconst displayingToken = ref(false);\nconst managingPermissionsFor = ref<Token | null>(null);\nconst apiTokenBeingDeleted = ref<Token | null>(null);\n\nconst createApiToken = () => {\n    createApiTokenForm.post(route('api-tokens.store'), {\n        preserveScroll: true,\n        onSuccess: () => {\n            displayingToken.value = true;\n            createApiTokenForm.reset();\n        },\n    });\n};\n\nconst manageApiTokenPermissions = (token: Token) => {\n    updateApiTokenForm.permissions = token.abilities;\n    managingPermissionsFor.value = token;\n};\n\nconst updateApiToken = () => {\n    updateApiTokenForm.put(route('api-tokens.update', managingPermissionsFor.value?.id), {\n        preserveScroll: true,\n        preserveState: true,\n        onSuccess: () => (managingPermissionsFor.value = null),\n    });\n};\n\nconst confirmApiTokenDeletion = (token: Token) => {\n    apiTokenBeingDeleted.value = token;\n};\n\nconst deleteApiToken = () => {\n    deleteApiTokenForm.delete(route('api-tokens.destroy', apiTokenBeingDeleted.value?.id), {\n        preserveScroll: true,\n        preserveState: true,\n        onSuccess: () => (apiTokenBeingDeleted.value = null),\n    });\n};\n</script>\n\n<template>\n    <div>\n        <!-- Generate API Token -->\n        <FormSection @submitted=\"createApiToken\">\n            <template #title> Create API Token </template>\n\n            <template #description>\n                API tokens allow third-party services to authenticate with our application on your\n                behalf.\n            </template>\n\n            <template #form>\n                <!-- Token Name -->\n                <Field class=\"col-span-6 sm:col-span-4\">\n                    <FieldLabel for=\"name\">Name</FieldLabel>\n                    <TextInput\n                        id=\"name\"\n                        v-model=\"createApiTokenForm.name\"\n                        type=\"text\"\n                        class=\"block w-full\"\n                        autofocus />\n                    <FieldError v-if=\"createApiTokenForm.errors.name\">{{\n                        createApiTokenForm.errors.name\n                    }}</FieldError>\n                </Field>\n\n                <!-- Token Permissions -->\n                <div v-if=\"availablePermissions.length > 0\" class=\"col-span-6\">\n                    <FieldLabel for=\"permissions\">Permissions</FieldLabel>\n\n                    <div class=\"mt-2 grid grid-cols-1 md:grid-cols-2 gap-4\">\n                        <div v-for=\"permission in availablePermissions\" :key=\"permission\">\n                            <label class=\"flex items-center\">\n                                <Checkbox\n                                    v-model:checked=\"createApiTokenForm.permissions\"\n                                    :value=\"permission\" />\n                                <span class=\"ms-2 text-sm text-text-secondary\">{{\n                                    permission\n                                }}</span>\n                            </label>\n                        </div>\n                    </div>\n                </div>\n            </template>\n\n            <template #actions>\n                <ActionMessage :on=\"createApiTokenForm.recentlySuccessful\" class=\"me-3\">\n                    Created.\n                </ActionMessage>\n\n                <PrimaryButton\n                    :class=\"{ 'opacity-25': createApiTokenForm.processing }\"\n                    :disabled=\"createApiTokenForm.processing\">\n                    Create\n                </PrimaryButton>\n            </template>\n        </FormSection>\n\n        <div v-if=\"tokens.length > 0\">\n            <SectionBorder />\n\n            <!-- Manage API Tokens -->\n            <div class=\"mt-10 sm:mt-0\">\n                <ActionSection>\n                    <template #title> Manage API Tokens </template>\n\n                    <template #description>\n                        You may delete any of your existing tokens if they are no longer needed.\n                    </template>\n\n                    <!-- API Token List -->\n                    <template #content>\n                        <div class=\"space-y-6\">\n                            <div\n                                v-for=\"token in tokens\"\n                                :key=\"token.id\"\n                                class=\"flex items-center justify-between\">\n                                <div class=\"break-all text-text-primary\">\n                                    {{ token.name }}\n                                </div>\n\n                                <div class=\"flex items-center ms-2\">\n                                    <div v-if=\"token.last_used_ago\" class=\"text-sm text-gray-400\">\n                                        Last used {{ token.last_used_ago }}\n                                    </div>\n\n                                    <button\n                                        v-if=\"availablePermissions.length > 0\"\n                                        class=\"cursor-pointer ms-6 text-sm text-gray-400 underline\"\n                                        @click=\"manageApiTokenPermissions(token)\">\n                                        Permissions\n                                    </button>\n\n                                    <button\n                                        class=\"cursor-pointer ms-6 text-sm text-red-500\"\n                                        @click=\"confirmApiTokenDeletion(token)\">\n                                        Delete\n                                    </button>\n                                </div>\n                            </div>\n                        </div>\n                    </template>\n                </ActionSection>\n            </div>\n        </div>\n\n        <!-- Token Value Modal -->\n        <DialogModal :show=\"displayingToken\" @close=\"displayingToken = false\">\n            <template #title> API Token </template>\n\n            <template #content>\n                <div>\n                    Please copy your new API token. For your security, it won't be shown again.\n                </div>\n\n                <div\n                    v-if=\"page.props.jetstream.flash.token\"\n                    class=\"mt-4 bg-card-backgroundpx-4 py-2 rounded font-mono text-sm text-gray-500 break-all\">\n                    {{ page.props.jetstream.flash.token }}\n                </div>\n            </template>\n\n            <template #footer>\n                <SecondaryButton @click=\"displayingToken = false\"> Close </SecondaryButton>\n            </template>\n        </DialogModal>\n\n        <!-- API Token Permissions Modal -->\n        <DialogModal :show=\"managingPermissionsFor != null\" @close=\"managingPermissionsFor = null\">\n            <template #title> API Token Permissions </template>\n\n            <template #content>\n                <div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n                    <div v-for=\"permission in availablePermissions\" :key=\"permission\">\n                        <label class=\"flex items-center\">\n                            <Checkbox\n                                v-model:checked=\"updateApiTokenForm.permissions\"\n                                :value=\"permission\" />\n                            <span class=\"ms-2 text-sm text-muted\">{{ permission }}</span>\n                        </label>\n                    </div>\n                </div>\n            </template>\n\n            <template #footer>\n                <SecondaryButton @click=\"managingPermissionsFor = null\"> Cancel </SecondaryButton>\n\n                <PrimaryButton\n                    class=\"ms-3\"\n                    :class=\"{ 'opacity-25': updateApiTokenForm.processing }\"\n                    :disabled=\"updateApiTokenForm.processing\"\n                    @click=\"updateApiToken\">\n                    Save\n                </PrimaryButton>\n            </template>\n        </DialogModal>\n\n        <!-- Delete Token Confirmation Modal -->\n        <ConfirmationModal\n            :show=\"apiTokenBeingDeleted != null\"\n            @close=\"apiTokenBeingDeleted = null\">\n            <template #title> Delete API Token </template>\n\n            <template #content> Are you sure you would like to delete this API token? </template>\n\n            <template #footer>\n                <SecondaryButton @click=\"apiTokenBeingDeleted = null\"> Cancel </SecondaryButton>\n\n                <DangerButton\n                    class=\"ms-3\"\n                    :class=\"{ 'opacity-25': deleteApiTokenForm.processing }\"\n                    :disabled=\"deleteApiTokenForm.processing\"\n                    @click=\"deleteApiToken\">\n                    Delete\n                </DangerButton>\n            </template>\n        </ConfirmationModal>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Auth/ConfirmPassword.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref } from 'vue';\nimport { Head, useForm } from '@inertiajs/vue3';\nimport AuthenticationCard from '@/Components/AuthenticationCard.vue';\nimport AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue';\nimport { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\n\nconst form = useForm({\n    password: '',\n});\n\nconst passwordInput = ref<HTMLInputElement | null>(null);\n\nconst submit = () => {\n    form.post(route('password.confirm'), {\n        onFinish: () => {\n            form.reset();\n\n            passwordInput.value?.focus();\n        },\n    });\n};\n</script>\n\n<template>\n    <Head title=\"Secure Area\" />\n\n    <AuthenticationCard>\n        <template #logo>\n            <AuthenticationCardLogo />\n        </template>\n\n        <div class=\"mb-4 text-sm text-text-secondary\">\n            This is a secure area of the application. Please confirm your password before\n            continuing.\n        </div>\n\n        <form @submit.prevent=\"submit\">\n            <Field>\n                <FieldLabel for=\"password\">Password</FieldLabel>\n                <TextInput\n                    id=\"password\"\n                    ref=\"passwordInput\"\n                    v-model=\"form.password\"\n                    type=\"password\"\n                    class=\"block w-full\"\n                    required\n                    autocomplete=\"current-password\"\n                    autofocus />\n                <FieldError v-if=\"form.errors.password\">{{ form.errors.password }}</FieldError>\n            </Field>\n\n            <div class=\"flex justify-end mt-4\">\n                <PrimaryButton\n                    class=\"ms-4\"\n                    :class=\"{ 'opacity-25': form.processing }\"\n                    :disabled=\"form.processing\">\n                    Confirm\n                </PrimaryButton>\n            </div>\n        </form>\n    </AuthenticationCard>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Auth/ForgotPassword.vue",
    "content": "<script setup lang=\"ts\">\nimport { Head, useForm } from '@inertiajs/vue3';\nimport AuthenticationCard from '@/Components/AuthenticationCard.vue';\nimport AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue';\nimport { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\n\ndefineProps({\n    status: String,\n});\n\nconst form = useForm({\n    email: '',\n});\n\nconst submit = () => {\n    form.post(route('password.email'));\n};\n</script>\n\n<template>\n    <Head title=\"Forgot Password\" />\n\n    <AuthenticationCard>\n        <template #logo>\n            <AuthenticationCardLogo />\n        </template>\n\n        <div class=\"mb-4 text-sm text-text-secondary\">\n            Forgot your password? No problem. Just let us know your email address and we will email\n            you a password reset link that will allow you to choose a new one.\n        </div>\n\n        <div v-if=\"status\" class=\"mb-4 font-medium text-sm text-green-400\">\n            {{ status }}\n        </div>\n\n        <form @submit.prevent=\"submit\">\n            <Field>\n                <FieldLabel for=\"email\">Email</FieldLabel>\n                <TextInput\n                    id=\"email\"\n                    v-model=\"form.email\"\n                    type=\"email\"\n                    class=\"block w-full\"\n                    required\n                    autofocus\n                    autocomplete=\"username\" />\n                <FieldError v-if=\"form.errors.email\">{{ form.errors.email }}</FieldError>\n            </Field>\n\n            <div class=\"flex items-center justify-end mt-4\">\n                <PrimaryButton\n                    :class=\"{ 'opacity-25': form.processing }\"\n                    :disabled=\"form.processing\">\n                    Email Password Reset Link\n                </PrimaryButton>\n            </div>\n        </form>\n    </AuthenticationCard>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Auth/Login.vue",
    "content": "<script setup lang=\"ts\">\nimport { Head, Link, useForm, usePage } from '@inertiajs/vue3';\nimport AuthenticationCard from '@/Components/AuthenticationCard.vue';\nimport AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue';\nimport { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\n\ndefineProps({\n    canResetPassword: Boolean,\n    status: String,\n});\n\nconst form = useForm({\n    email: '',\n    password: '',\n    remember: true,\n});\n\nconst submit = () => {\n    form.transform((data) => ({\n        ...data,\n        remember: form.remember ? 'on' : '',\n    })).post(route('login'), {\n        onFinish: () => form.reset('password'),\n    });\n};\n\nconst page = usePage<{\n    flash: {\n        message: string;\n    };\n}>();\n</script>\n\n<template>\n    <Head title=\"Log in\" />\n\n    <AuthenticationCard>\n        <template #logo>\n            <AuthenticationCardLogo />\n        </template>\n\n        <template #actions>\n            <Link\n                class=\"py-8 text-text-secondary text-sm font-medium opacity-90 hover:opacity-100 transition\"\n                :href=\"route('register')\">\n                No account yet? <span class=\"text-text-primary\">Register here!</span>\n            </Link>\n        </template>\n\n        <div v-if=\"status\" class=\"mb-4 font-medium text-sm text-green-400\">\n            {{ status }}\n        </div>\n        <div\n            v-if=\"page.props.flash?.message\"\n            class=\"bg-red-400 text-black text-center w-full px-3 py-1 mb-4 rounded-lg\">\n            {{ page.props.flash?.message }}\n        </div>\n\n        <form @submit.prevent=\"submit\">\n            <Field>\n                <FieldLabel for=\"email\">Email</FieldLabel>\n                <TextInput\n                    id=\"email\"\n                    v-model=\"form.email\"\n                    type=\"email\"\n                    class=\"block w-full\"\n                    required\n                    autofocus\n                    autocomplete=\"username\" />\n                <FieldError v-if=\"form.errors.email\">{{ form.errors.email }}</FieldError>\n            </Field>\n\n            <Field class=\"mt-4\">\n                <FieldLabel for=\"password\">Password</FieldLabel>\n                <TextInput\n                    id=\"password\"\n                    v-model=\"form.password\"\n                    type=\"password\"\n                    class=\"block w-full\"\n                    required\n                    autocomplete=\"current-password\" />\n                <FieldError v-if=\"form.errors.password\">{{ form.errors.password }}</FieldError>\n            </Field>\n\n            <div class=\"flex items-center justify-end mt-4\">\n                <Link\n                    v-if=\"canResetPassword\"\n                    :href=\"route('password.request')\"\n                    class=\"underline text-sm text-text-secondary hover:text-text-primary rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500\">\n                    Forgot your password?\n                </Link>\n\n                <PrimaryButton\n                    class=\"ms-4\"\n                    :class=\"{ 'opacity-25': form.processing }\"\n                    :disabled=\"form.processing\">\n                    Log in\n                </PrimaryButton>\n            </div>\n        </form>\n    </AuthenticationCard>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Auth/Register.vue",
    "content": "<script setup lang=\"ts\">\nimport { Head, Link, useForm, usePage } from '@inertiajs/vue3';\nimport AuthenticationCard from '@/Components/AuthenticationCard.vue';\nimport AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue';\nimport Checkbox from '@/packages/ui/src/Input/Checkbox.vue';\nimport { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\n\nconst form = useForm({\n    name: '',\n    email: '',\n    password: '',\n    password_confirmation: '',\n    terms: false,\n    timezone: Intl.DateTimeFormat().resolvedOptions().timeZone ?? null,\n    newsletter_consent: false,\n});\n\nconst submit = () => {\n    form.post(route('register'), {\n        onSuccess: () => {\n            form.reset('password', 'password_confirmation');\n        },\n    });\n};\n\nconst page = usePage<{\n    terms_url: string | null;\n    privacy_policy_url: string | null;\n    newsletter_consent: boolean;\n    jetstream: {\n        hasTermsAndPrivacyPolicyFeature: boolean;\n    };\n    flash: {\n        message: string;\n    };\n}>();\n</script>\n\n<template>\n    <Head title=\"Register\" />\n\n    <AuthenticationCard>\n        <template #logo>\n            <AuthenticationCardLogo />\n        </template>\n\n        <template #actions>\n            <Link\n                class=\"py-8 text-text-secondary text-sm font-medium opacity-90 hover:opacity-100 transition\"\n                :href=\"route('login')\">\n                Already have an account?\n                <span class=\"text-text-primary\">Login here!</span>\n            </Link>\n        </template>\n\n        <div\n            v-if=\"page.props.flash?.message\"\n            class=\"bg-red-400 text-black text-center w-full px-3 py-1 mb-4 rounded-lg\">\n            {{ page.props.flash?.message }}\n        </div>\n\n        <form @submit.prevent=\"submit\">\n            <Field>\n                <FieldLabel for=\"name\">Name</FieldLabel>\n                <TextInput\n                    id=\"name\"\n                    v-model=\"form.name\"\n                    type=\"text\"\n                    class=\"block w-full\"\n                    required\n                    autofocus\n                    autocomplete=\"name\" />\n                <FieldError v-if=\"form.errors.name\">{{ form.errors.name }}</FieldError>\n            </Field>\n\n            <Field class=\"mt-4\">\n                <FieldLabel for=\"email\">Email</FieldLabel>\n                <TextInput\n                    id=\"email\"\n                    v-model=\"form.email\"\n                    type=\"email\"\n                    class=\"block w-full\"\n                    required\n                    autocomplete=\"username\" />\n                <FieldError v-if=\"form.errors.email\">{{ form.errors.email }}</FieldError>\n            </Field>\n\n            <Field class=\"mt-4\">\n                <FieldLabel for=\"password\">Password</FieldLabel>\n                <TextInput\n                    id=\"password\"\n                    v-model=\"form.password\"\n                    type=\"password\"\n                    class=\"block w-full\"\n                    required\n                    autocomplete=\"new-password\" />\n                <FieldError v-if=\"form.errors.password\">{{ form.errors.password }}</FieldError>\n            </Field>\n\n            <Field class=\"mt-4\">\n                <FieldLabel for=\"password_confirmation\">Confirm Password</FieldLabel>\n                <TextInput\n                    id=\"password_confirmation\"\n                    v-model=\"form.password_confirmation\"\n                    type=\"password\"\n                    class=\"block w-full\"\n                    required\n                    autocomplete=\"new-password\" />\n                <FieldError v-if=\"form.errors.password_confirmation\">{{\n                    form.errors.password_confirmation\n                }}</FieldError>\n            </Field>\n\n            <div\n                v-if=\"\n                    page.props.jetstream.hasTermsAndPrivacyPolicyFeature &&\n                    page.props.terms_url !== null &&\n                    page.props.privacy_policy_url !== null\n                \"\n                class=\"mt-4\">\n                <Field orientation=\"horizontal\">\n                    <Checkbox id=\"terms\" v-model:checked=\"form.terms\" name=\"terms\" />\n                    <FieldLabel for=\"terms\">\n                        I agree to the\n                        <a\n                            target=\"_blank\"\n                            :href=\"page.props.terms_url\"\n                            class=\"underline text-sm text-text-secondary hover:text-white rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500\"\n                            >Terms of Service</a\n                        >\n                        and\n                        <a\n                            target=\"_blank\"\n                            :href=\"page.props.privacy_policy_url\"\n                            class=\"underline text-sm text-text-secondary hover:text-white rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500\"\n                            >Privacy Policy</a\n                        >\n                    </FieldLabel>\n                    <FieldError v-if=\"form.errors.terms\">{{ form.errors.terms }}</FieldError>\n                </Field>\n            </div>\n\n            <div v-if=\"page.props.newsletter_consent\" class=\"mt-4\">\n                <Field orientation=\"horizontal\">\n                    <Checkbox\n                        id=\"newsletter_consent\"\n                        v-model:checked=\"form.newsletter_consent\"\n                        name=\"newsletter_consent\" />\n                    <FieldLabel for=\"newsletter_consent\">\n                        I agree to receive emails about product related updates\n                    </FieldLabel>\n                    <FieldError v-if=\"form.errors.newsletter_consent\">{{\n                        form.errors.newsletter_consent\n                    }}</FieldError>\n                </Field>\n            </div>\n\n            <div class=\"flex items-center justify-end mt-4\">\n                <Link\n                    :href=\"route('login')\"\n                    class=\"underline text-sm text-text-secondary hover:text-white rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500\">\n                    Already registered?\n                </Link>\n\n                <PrimaryButton\n                    class=\"ms-4\"\n                    :class=\"{ 'opacity-25': form.processing }\"\n                    :disabled=\"form.processing\">\n                    Register\n                </PrimaryButton>\n            </div>\n        </form>\n    </AuthenticationCard>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Auth/ResetPassword.vue",
    "content": "<script setup lang=\"ts\">\nimport { Head, useForm } from '@inertiajs/vue3';\nimport AuthenticationCard from '@/Components/AuthenticationCard.vue';\nimport AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue';\nimport { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\n\nconst props = defineProps({\n    email: String,\n    token: String,\n});\n\nconst form = useForm({\n    token: props.token,\n    email: props.email,\n    password: '',\n    password_confirmation: '',\n});\n\nconst submit = () => {\n    form.post(route('password.update'), {\n        onFinish: () => form.reset('password', 'password_confirmation'),\n    });\n};\n</script>\n\n<template>\n    <Head title=\"Reset Password\" />\n\n    <AuthenticationCard>\n        <template #logo>\n            <AuthenticationCardLogo />\n        </template>\n\n        <form @submit.prevent=\"submit\">\n            <Field>\n                <FieldLabel for=\"email\">Email</FieldLabel>\n                <TextInput\n                    id=\"email\"\n                    v-model=\"form.email\"\n                    type=\"email\"\n                    class=\"block w-full\"\n                    required\n                    autofocus\n                    autocomplete=\"username\" />\n                <FieldError v-if=\"form.errors.email\">{{ form.errors.email }}</FieldError>\n            </Field>\n\n            <Field class=\"mt-4\">\n                <FieldLabel for=\"password\">Password</FieldLabel>\n                <TextInput\n                    id=\"password\"\n                    v-model=\"form.password\"\n                    type=\"password\"\n                    class=\"block w-full\"\n                    required\n                    autocomplete=\"new-password\" />\n                <FieldError v-if=\"form.errors.password\">{{ form.errors.password }}</FieldError>\n            </Field>\n\n            <Field class=\"mt-4\">\n                <FieldLabel for=\"password_confirmation\">Confirm Password</FieldLabel>\n                <TextInput\n                    id=\"password_confirmation\"\n                    v-model=\"form.password_confirmation\"\n                    type=\"password\"\n                    class=\"block w-full\"\n                    required\n                    autocomplete=\"new-password\" />\n                <FieldError v-if=\"form.errors.password_confirmation\">{{\n                    form.errors.password_confirmation\n                }}</FieldError>\n            </Field>\n\n            <div class=\"flex items-center justify-end mt-4\">\n                <PrimaryButton\n                    :class=\"{ 'opacity-25': form.processing }\"\n                    :disabled=\"form.processing\">\n                    Reset Password\n                </PrimaryButton>\n            </div>\n        </form>\n    </AuthenticationCard>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Auth/TwoFactorChallenge.vue",
    "content": "<script setup lang=\"ts\">\nimport { nextTick, ref } from 'vue';\nimport { Head, useForm } from '@inertiajs/vue3';\nimport AuthenticationCard from '@/Components/AuthenticationCard.vue';\nimport AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue';\nimport { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\n\nconst recovery = ref(false);\n\nconst form = useForm({\n    code: '',\n    recovery_code: '',\n});\n\nconst recoveryCodeInput = ref<HTMLInputElement | null>(null);\nconst codeInput = ref<HTMLInputElement | null>(null);\n\nconst toggleRecovery = async () => {\n    recovery.value = !recovery.value;\n\n    await nextTick();\n\n    if (recovery.value) {\n        recoveryCodeInput.value?.focus();\n        form.code = '';\n    } else {\n        codeInput.value?.focus();\n        form.recovery_code = '';\n    }\n};\n\nconst submit = () => {\n    form.post(route('two-factor.login'));\n};\n</script>\n\n<template>\n    <Head title=\"Two-factor Confirmation\" />\n\n    <AuthenticationCard>\n        <template #logo>\n            <AuthenticationCardLogo />\n        </template>\n\n        <div class=\"mb-4 text-sm text-text-secondary\">\n            <template v-if=\"!recovery\">\n                Please confirm access to your account by entering the authentication code provided\n                by your authenticator application.\n            </template>\n\n            <template v-else>\n                Please confirm access to your account by entering one of your emergency recovery\n                codes.\n            </template>\n        </div>\n\n        <form @submit.prevent=\"submit\">\n            <Field v-if=\"!recovery\">\n                <FieldLabel for=\"code\">Code</FieldLabel>\n                <TextInput\n                    id=\"code\"\n                    ref=\"codeInput\"\n                    v-model=\"form.code\"\n                    type=\"text\"\n                    inputmode=\"numeric\"\n                    class=\"block w-full\"\n                    autofocus\n                    autocomplete=\"one-time-code\" />\n                <FieldError v-if=\"form.errors.code\">{{ form.errors.code }}</FieldError>\n            </Field>\n\n            <Field v-else>\n                <FieldLabel for=\"recovery_code\">Recovery Code</FieldLabel>\n                <TextInput\n                    id=\"recovery_code\"\n                    ref=\"recoveryCodeInput\"\n                    v-model=\"form.recovery_code\"\n                    type=\"text\"\n                    class=\"block w-full\"\n                    autocomplete=\"one-time-code\" />\n                <FieldError v-if=\"form.errors.recovery_code\">{{\n                    form.errors.recovery_code\n                }}</FieldError>\n            </Field>\n\n            <div class=\"flex items-center justify-end mt-4\">\n                <button\n                    type=\"button\"\n                    class=\"text-sm text-text-secondary hover:text-text-primary underline cursor-pointer\"\n                    @click.prevent=\"toggleRecovery\">\n                    <template v-if=\"!recovery\"> Use a recovery code</template>\n\n                    <template v-else> Use an authentication code</template>\n                </button>\n\n                <PrimaryButton\n                    class=\"ms-4\"\n                    :class=\"{ 'opacity-25': form.processing }\"\n                    :disabled=\"form.processing\">\n                    Log in\n                </PrimaryButton>\n            </div>\n        </form>\n    </AuthenticationCard>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Auth/VerifyEmail.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue';\nimport { Head, Link, useForm } from '@inertiajs/vue3';\nimport AuthenticationCard from '@/Components/AuthenticationCard.vue';\nimport AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\n\nconst props = defineProps({\n    status: String,\n});\nconst form = useForm({});\n\nconst submit = () => {\n    form.post(route('verification.send'));\n};\n\nconst verificationLinkSent = computed(() => props.status === 'verification-link-sent');\n</script>\n\n<template>\n    <Head title=\"Email Verification\" />\n\n    <AuthenticationCard>\n        <template #logo>\n            <AuthenticationCardLogo />\n        </template>\n\n        <div class=\"mb-4 text-sm text-text-secondary\">\n            Before continuing, could you verify your email address by clicking on the link we just\n            emailed to you? If you didn't receive the email, we will gladly send you another.\n        </div>\n\n        <div v-if=\"verificationLinkSent\" class=\"mb-4 font-medium text-sm text-green-400\">\n            A new verification link has been sent to the email address you provided in your profile\n            settings.\n        </div>\n\n        <form @submit.prevent=\"submit\">\n            <div class=\"mt-4 flex items-center justify-between\">\n                <PrimaryButton\n                    :class=\"{ 'opacity-25': form.processing }\"\n                    :disabled=\"form.processing\">\n                    Resend Verification Email\n                </PrimaryButton>\n\n                <div>\n                    <Link\n                        :href=\"route('logout')\"\n                        method=\"post\"\n                        as=\"button\"\n                        class=\"underline text-sm text-text-secondary hover:text-text-secondary rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800 ms-2\">\n                        Log Out\n                    </Link>\n                </div>\n            </div>\n        </form>\n    </AuthenticationCard>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Calendar.vue",
    "content": "<script setup lang=\"ts\">\nimport AppLayout from '@/Layouts/AppLayout.vue';\nimport { useTimeEntriesCalendarQuery } from '@/utils/useTimeEntriesCalendarQuery';\nimport { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';\nimport { computed, ref } from 'vue';\nimport { useQueryClient } from '@tanstack/vue-query';\nimport {\n    type Client,\n    type CreateClientBody,\n    type CreateProjectBody,\n    type Project,\n} from '@/packages/api/src';\nimport { TimeEntryCalendar } from '@/packages/ui/src';\nimport { isAllowedToPerformPremiumAction } from '@/utils/billing';\nimport { useTagsStore } from '@/utils/useTags';\nimport { useProjectsQuery } from '@/utils/useProjectsQuery';\nimport { useClientsQuery } from '@/utils/useClientsQuery';\nimport { useTasksQuery } from '@/utils/useTasksQuery';\nimport { useTagsQuery } from '@/utils/useTagsQuery';\nimport { useProjectsStore } from '@/utils/useProjects';\nimport { useClientsStore } from '@/utils/useClients';\nimport { getOrganizationCurrencyString } from '@/utils/money';\nimport { canCreateProjects } from '@/utils/permissions';\n\nconst calendarStart = ref<Date | undefined>(undefined);\nconst calendarEnd = ref<Date | undefined>(undefined);\n\nconst { data: timeEntryResponse, isLoading: timeEntriesLoading } = useTimeEntriesCalendarQuery(\n    calendarStart,\n    calendarEnd\n);\n\nconst currentTimeEntries = computed(() => {\n    return timeEntryResponse?.value?.data || [];\n});\n\nconst {\n    createTimeEntry: createTimeEntryMutation,\n    updateTimeEntry: updateTimeEntryMutation,\n    deleteTimeEntry: deleteTimeEntryMutation,\n} = useTimeEntriesMutations();\n\n// Wrap mutations to match expected Promise<void> return type\nasync function createTimeEntry(\n    entry: Omit<import('@/packages/api/src').TimeEntry, 'id' | 'organization_id' | 'user_id'>\n): Promise<void> {\n    await createTimeEntryMutation(entry);\n}\n\nasync function updateTimeEntry(entry: import('@/packages/api/src').TimeEntry): Promise<void> {\n    await updateTimeEntryMutation(entry);\n}\n\nasync function deleteTimeEntry(timeEntryId: string): Promise<void> {\n    await deleteTimeEntryMutation(timeEntryId);\n}\n\nasync function createTag(name: string) {\n    return await useTagsStore().createTag(name);\n}\n\nasync function createProject(project: CreateProjectBody): Promise<Project | undefined> {\n    return await useProjectsStore().createProject(project);\n}\n\nasync function createClient(body: CreateClientBody): Promise<Client | undefined> {\n    return await useClientsStore().createClient(body);\n}\n\nconst { projects } = useProjectsQuery();\nconst { tasks } = useTasksQuery();\nconst { clients } = useClientsQuery();\nconst { tags } = useTagsQuery();\n\nconst queryClient = useQueryClient();\n\nfunction onDatesChange({ start, end }: { start: Date; end: Date }) {\n    calendarStart.value = start;\n    calendarEnd.value = end;\n}\n\nfunction onRefresh() {\n    queryClient.invalidateQueries({\n        queryKey: ['timeEntries', 'calendar'],\n    });\n}\n</script>\n\n<template>\n    <AppLayout title=\"Calendar\" data-testid=\"calendar_view\" main-class=\"p-0\">\n        <TimeEntryCalendar\n            :time-entries=\"currentTimeEntries\"\n            :projects=\"projects\"\n            :tasks=\"tasks\"\n            :clients=\"clients\"\n            :tags=\"tags\"\n            :loading=\"timeEntriesLoading\"\n            :enable-estimated-time=\"isAllowedToPerformPremiumAction()\"\n            :currency=\"getOrganizationCurrencyString()\"\n            :can-create-project=\"canCreateProjects()\"\n            :create-time-entry=\"createTimeEntry\"\n            :update-time-entry=\"updateTimeEntry\"\n            :delete-time-entry=\"deleteTimeEntry\"\n            :create-client=\"createClient\"\n            :create-project=\"createProject\"\n            :create-tag=\"createTag\"\n            @dates-change=\"onDatesChange\"\n            @refresh=\"onRefresh\" />\n    </AppLayout>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Clients.vue",
    "content": "<script setup lang=\"ts\">\nimport MainContainer from '@/packages/ui/src/MainContainer.vue';\nimport AppLayout from '@/Layouts/AppLayout.vue';\nimport { PlusIcon } from '@heroicons/vue/16/solid';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport { UserCircleIcon } from '@heroicons/vue/20/solid';\nimport { computed, ref } from 'vue';\nimport { useClientsQuery } from '@/utils/useClientsQuery';\nimport ClientTable from '@/Components/Common/Client/ClientTable.vue';\nimport ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';\nimport PageTitle from '@/Components/Common/PageTitle.vue';\nimport { canCreateClients } from '@/utils/permissions';\nimport TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';\nimport TabBar from '@/Components/Common/TabBar/TabBar.vue';\nimport { useStorage } from '@vueuse/core';\nimport type { SortColumn, SortDirection } from '@/Components/Common/Client/ClientTable.vue';\n\nconst { clients } = useClientsQuery();\n\nconst activeTab = ref<'active' | 'archived'>('active');\n\nconst createClient = ref(false);\n\ninterface ClientTableState {\n    sortColumn: SortColumn;\n    sortDirection: SortDirection;\n}\n\nconst tableState = useStorage<ClientTableState>(\n    'client-table-state',\n    {\n        sortColumn: 'name',\n        sortDirection: 'asc',\n    },\n    undefined,\n    { mergeDefaults: true }\n);\n\nfunction handleSort(column: SortColumn, direction: SortDirection) {\n    tableState.value.sortColumn = column;\n    tableState.value.sortDirection = direction;\n}\n\nconst shownClients = computed(() => {\n    return clients.value.filter((client) => {\n        if (activeTab.value === 'active') {\n            return !client.is_archived;\n        }\n        return client.is_archived;\n    });\n});\n</script>\n\n<template>\n    <AppLayout title=\"Clients\" data-testid=\"clients_view\">\n        <MainContainer\n            class=\"py-5 border-b border-default-background-separator flex justify-between items-center\">\n            <div class=\"flex items-center space-x-3 sm:space-x-6\">\n                <PageTitle :icon=\"UserCircleIcon\" title=\"Clients\"> </PageTitle>\n                <TabBar v-model=\"activeTab\">\n                    <TabBarItem value=\"active\">Active</TabBarItem>\n                    <TabBarItem value=\"archived\"> Archived </TabBarItem>\n                </TabBar>\n            </div>\n            <SecondaryButton v-if=\"canCreateClients()\" :icon=\"PlusIcon\" @click=\"createClient = true\"\n                >Create Client</SecondaryButton\n            >\n            <ClientCreateModal v-model:show=\"createClient\"></ClientCreateModal>\n        </MainContainer>\n        <ClientTable\n            :clients=\"shownClients\"\n            :sort-column=\"tableState.sortColumn\"\n            :sort-direction=\"tableState.sortDirection\"\n            @sort=\"handleSort\"></ClientTable>\n    </AppLayout>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Dashboard.vue",
    "content": "<script setup lang=\"ts\">\nimport AppLayout from '@/Layouts/AppLayout.vue';\nimport TimeTracker from '@/Components/TimeTracker.vue';\nimport RecentlyTrackedTasksCard from '@/Components/Dashboard/RecentlyTrackedTasksCard.vue';\nimport LastSevenDaysCard from '@/Components/Dashboard/LastSevenDaysCard.vue';\nimport TeamActivityCard from '@/Components/Dashboard/TeamActivityCard.vue';\nimport ThisWeekOverview from '@/Components/Dashboard/ThisWeekOverview.vue';\nimport ActivityGraphCard from '@/Components/Dashboard/ActivityGraphCard.vue';\nimport MainContainer from '@/packages/ui/src/MainContainer.vue';\nimport { canViewMembers } from '@/utils/permissions';\nimport { useQueryClient } from '@tanstack/vue-query';\n\nconst queryClient = useQueryClient();\n\nconst refreshDashboardData = () => {\n    // Invalidate all dashboard queries to trigger refetching\n    queryClient.invalidateQueries({ queryKey: ['latestTasks'] });\n    queryClient.invalidateQueries({ queryKey: ['lastSevenDays'] });\n    queryClient.invalidateQueries({ queryKey: ['dailyTrackedHours'] });\n    queryClient.invalidateQueries({ queryKey: ['latestTeamActivity'] });\n    queryClient.invalidateQueries({ queryKey: ['weeklyProjectOverview'] });\n    queryClient.invalidateQueries({ queryKey: ['totalWeeklyTime'] });\n    queryClient.invalidateQueries({ queryKey: ['totalWeeklyBillableTime'] });\n    queryClient.invalidateQueries({ queryKey: ['totalWeeklyBillableAmount'] });\n    queryClient.invalidateQueries({ queryKey: ['weeklyHistory'] });\n    queryClient.invalidateQueries({ queryKey: ['timeEntries'] });\n};\n</script>\n\n<template>\n    <AppLayout title=\"Dashboard\" data-testid=\"dashboard_view\">\n        <MainContainer\n            class=\"pt-5 sm:pt-8 pb-4 sm:pb-6 border-b border-default-background-separator\">\n            <TimeTracker @change=\"refreshDashboardData\"></TimeTracker>\n        </MainContainer>\n\n        <MainContainer\n            class=\"grid gap-2 sm:gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 pt-3 sm:pt-5 pb-4 sm:pb-6 border-b border-default-background-separator items-stretch\">\n            <RecentlyTrackedTasksCard></RecentlyTrackedTasksCard>\n            <LastSevenDaysCard></LastSevenDaysCard>\n            <ActivityGraphCard></ActivityGraphCard>\n            <TeamActivityCard v-if=\"canViewMembers()\" class=\"flex lg:hidden xl:flex\">\n            </TeamActivityCard>\n        </MainContainer>\n        <MainContainer class=\"py-5\">\n            <ThisWeekOverview></ThisWeekOverview>\n        </MainContainer>\n    </AppLayout>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Import.vue",
    "content": "<script setup lang=\"ts\">\nimport MainContainer from '@/packages/ui/src/MainContainer.vue';\nimport AppLayout from '@/Layouts/AppLayout.vue';\nimport { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';\nimport PageTitle from '@/Components/Common/PageTitle.vue';\nimport ImportData from '@/Pages/Teams/Partials/ImportData.vue';\nimport ExportData from '@/Pages/Teams/Partials/ExportData.vue';\n</script>\n\n<template>\n    <AppLayout title=\"Import / Export\" data-testid=\"import_view\">\n        <MainContainer\n            class=\"py-5 border-b border-default-background-separator flex justify-between items-center\">\n            <div class=\"flex items-center space-x-6\">\n                <PageTitle :icon=\"ArrowsRightLeftIcon\" title=\"Import / Export\"> </PageTitle>\n            </div>\n        </MainContainer>\n        <MainContainer class=\"py-6 space-y-4\">\n            <div class=\"grid lg:grid-cols-2 gap-6\">\n                <ImportData></ImportData>\n                <ExportData></ExportData>\n            </div>\n        </MainContainer>\n    </AppLayout>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Members.vue",
    "content": "<script setup lang=\"ts\">\nimport MainContainer from '@/packages/ui/src/MainContainer.vue';\nimport AppLayout from '@/Layouts/AppLayout.vue';\nimport { PlusIcon } from '@heroicons/vue/16/solid';\nimport { UserGroupIcon } from '@heroicons/vue/20/solid';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport TabBar from '@/Components/Common/TabBar/TabBar.vue';\nimport TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';\nimport { ref } from 'vue';\nimport MemberTable from '@/Components/Common/Member/MemberTable.vue';\nimport MemberInviteModal from '@/Components/Common/Member/MemberInviteModal.vue';\nimport type { Role } from '@/types/jetstream';\nimport PageTitle from '@/Components/Common/PageTitle.vue';\nimport InvitationTable from '@/Components/Common/Invitation/InvitationTable.vue';\nimport { canCreateInvitations } from '@/utils/permissions';\nimport { useStorage } from '@vueuse/core';\nimport type { SortColumn, SortDirection } from '@/Components/Common/Member/MemberTable.vue';\n\nconst inviteMember = ref(false);\n\ndefineProps<{\n    availableRoles: Role[];\n}>();\n\nconst activeTab = ref<'all' | 'invitations'>('all');\n\ninterface MemberTableState {\n    sortColumn: SortColumn;\n    sortDirection: SortDirection;\n}\n\nconst tableState = useStorage<MemberTableState>(\n    'member-table-state',\n    {\n        sortColumn: 'name',\n        sortDirection: 'asc',\n    },\n    undefined,\n    { mergeDefaults: true }\n);\n\nfunction handleSort(column: SortColumn, direction: SortDirection) {\n    tableState.value.sortColumn = column;\n    tableState.value.sortDirection = direction;\n}\n</script>\n\n<template>\n    <AppLayout title=\"Members\" data-testid=\"members_view\">\n        <MainContainer\n            class=\"py-5 border-b border-default-background-separator flex justify-between items-center\">\n            <div class=\"flex items-center space-x-4 sm:space-x-6\">\n                <PageTitle :icon=\"UserGroupIcon\" title=\"Members\"> </PageTitle>\n                <TabBar v-model=\"activeTab\">\n                    <TabBarItem value=\"all\">All</TabBarItem>\n                    <TabBarItem value=\"invitations\">Invitations</TabBarItem>\n                </TabBar>\n            </div>\n            <SecondaryButton\n                v-if=\"canCreateInvitations()\"\n                :icon=\"PlusIcon\"\n                @click=\"inviteMember = true\"\n                >Invite member</SecondaryButton\n            >\n            <MemberInviteModal\n                v-model:show=\"inviteMember\"\n                :available-roles=\"availableRoles\"\n                @close=\"activeTab = 'invitations'\"></MemberInviteModal>\n        </MainContainer>\n        <MemberTable\n            v-if=\"activeTab === 'all'\"\n            :sort-column=\"tableState.sortColumn\"\n            :sort-direction=\"tableState.sortDirection\"\n            @sort=\"handleSort\"></MemberTable>\n        <InvitationTable v-if=\"activeTab === 'invitations'\"></InvitationTable>\n    </AppLayout>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/PrivacyPolicy.vue",
    "content": "<script setup lang=\"ts\">\nimport { Head } from '@inertiajs/vue3';\nimport AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue';\n\ndefineProps({\n    policy: String,\n});\n</script>\n\n<template>\n    <Head title=\"Privacy Policy\" />\n\n    <div class=\"font-sans text-text-secondary antialiased\">\n        <div class=\"pt-4 bg-gray-900\">\n            <div class=\"min-h-screen flex flex-col items-center pt-6 sm:pt-0\">\n                <div>\n                    <AuthenticationCardLogo />\n                </div>\n\n                <div\n                    class=\"w-full sm:max-w-2xl mt-6 p-6 bg-gray-800 shadow-md overflow-hidden sm:rounded-lg prose dark:prose-invert\"\n                    v-html=\"policy\" />\n            </div>\n        </div>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Profile/Partials/ApiTokensForm.vue",
    "content": "<script setup lang=\"ts\">\nimport FormSection from '@/Components/FormSection.vue';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport { computed, ref, inject, type ComputedRef } from 'vue';\nimport { Field, FieldLabel, FieldDescription, FieldError } from '@/packages/ui/src/field';\nimport { api, type ApiToken, type CreateApiTokenBody } from '@/packages/api/src';\nimport SectionBorder from '@/Components/SectionBorder.vue';\nimport DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';\nimport TextInput from '../../../packages/ui/src/Input/TextInput.vue';\nimport SecondaryButton from '../../../packages/ui/src/Buttons/SecondaryButton.vue';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport ActionMessage from '@/Components/ActionMessage.vue';\nimport ConfirmationModal from '@/Components/ConfirmationModal.vue';\nimport ActionSection from '@/Components/ActionSection.vue';\nimport { useForm } from '@inertiajs/vue3';\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';\nimport { useNotificationsStore } from '@/utils/notification';\nimport { useClipboard } from '@vueuse/core';\nimport { formatDateTimeLocalized } from '../../../packages/ui/src/utils/time';\nimport { ClockIcon } from '@heroicons/vue/20/solid';\nimport type { Organization } from '@/packages/api/src';\n\nconst queryClient = useQueryClient();\n\nconst apiTokenBeingDeleted = ref<ApiToken | null>(null);\nconst apiTokenBeingRevoked = ref<ApiToken | null>(null);\n\nconst { handleApiRequestNotifications } = useNotificationsStore();\nconst newToken = ref('');\n\nconst { copy, copied, isSupported } = useClipboard();\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n\nasync function createApiToken() {\n    await handleApiRequestNotifications(\n        () =>\n            createApiTokenMutation.mutateAsync({\n                name: createApiTokenForm.name,\n            }),\n        'API Token successfully created',\n        'There was an error while creating the API Token',\n        (response) => {\n            createApiTokenForm.name = '';\n            displayingToken.value = true;\n            newToken.value = response.data.access_token;\n        }\n    );\n}\n\nconst createApiTokenForm = useForm({\n    name: '',\n});\n\nfunction confirmApiTokenDeletion(token: ApiToken) {\n    apiTokenBeingDeleted.value = token;\n}\n\nfunction confirmApiTokenRevocation(token: ApiToken) {\n    apiTokenBeingRevoked.value = token;\n}\n\nconst displayingToken = ref(false);\n\nasync function deleteApiToken() {\n    if (apiTokenBeingDeleted.value) {\n        await handleApiRequestNotifications(\n            () => deleteApiTokenMutation.mutateAsync(apiTokenBeingDeleted.value!.id),\n            'API Token successfully deleted',\n            'There was an error while deleting the API Token',\n            () => {\n                apiTokenBeingDeleted.value = null;\n            }\n        );\n    }\n}\n\nasync function revokeApiToken() {\n    if (apiTokenBeingRevoked.value) {\n        await handleApiRequestNotifications(\n            () => revokeApiTokenMutation.mutateAsync(apiTokenBeingRevoked.value!.id),\n            'API Token successfully revoked',\n            'There was an error while revoking the API Token',\n            () => {\n                apiTokenBeingRevoked.value = null;\n            }\n        );\n    }\n}\n\nconst { data: sharedReportResponseData } = useQuery({\n    queryKey: ['api-tokens'],\n    queryFn: () => api.getApiTokens(),\n});\n\nconst tokens = computed(() => {\n    return sharedReportResponseData.value?.data ?? [];\n});\n\nconst createApiTokenMutation = useMutation({\n    mutationFn: async (apiToken: CreateApiTokenBody) => {\n        return await api.createApiToken(apiToken);\n    },\n    onSuccess: () => {\n        queryClient.invalidateQueries({ queryKey: ['api-tokens'] });\n    },\n});\n\nconst deleteApiTokenMutation = useMutation({\n    mutationFn: async (apiTokenId: string) => {\n        return await api.deleteApiToken(undefined, {\n            params: {\n                apiToken: apiTokenId,\n            },\n        });\n    },\n    onSuccess: () => {\n        queryClient.invalidateQueries({ queryKey: ['api-tokens'] });\n    },\n});\n\nconst revokeApiTokenMutation = useMutation({\n    mutationFn: async (apiTokenId: string) => {\n        return await api.revokeApiToken(undefined, {\n            params: {\n                apiToken: apiTokenId,\n            },\n        });\n    },\n    onSuccess: () => {\n        queryClient.invalidateQueries({ queryKey: ['api-tokens'] });\n    },\n});\n</script>\n\n<template>\n    <div>\n        <!-- Generate API Token -->\n        <FormSection @submitted=\"createApiToken\">\n            <template #title> Create API Token </template>\n\n            <template #description>\n                API tokens allow third-party services to authenticate with our application on your\n                behalf.\n            </template>\n\n            <template #form>\n                <!-- Token Name -->\n                <Field class=\"col-span-6 sm:col-span-4\">\n                    <FieldLabel for=\"api_key_name\">API Key Name</FieldLabel>\n                    <TextInput\n                        id=\"api_key_name\"\n                        v-model=\"createApiTokenForm.name\"\n                        type=\"text\"\n                        class=\"block w-full\" />\n                    <FieldError v-if=\"createApiTokenForm.errors.name\">{{\n                        createApiTokenForm.errors.name\n                    }}</FieldError>\n                    <FieldDescription>\n                        <span class=\"flex space-x-1.5 items-center text-text-tertiary font-medium\">\n                            <ClockIcon class=\"w-4\"></ClockIcon>\n                            <span> API Tokens are valid for 1 year </span>\n                        </span>\n                    </FieldDescription>\n                </Field>\n            </template>\n\n            <template #actions>\n                <ActionMessage :on=\"createApiTokenForm.recentlySuccessful\" class=\"me-3\">\n                    Created.\n                </ActionMessage>\n\n                <PrimaryButton\n                    :class=\"{ 'opacity-25': createApiTokenForm.processing }\"\n                    :disabled=\"createApiTokenForm.processing\">\n                    Create API Key\n                </PrimaryButton>\n            </template>\n        </FormSection>\n\n        <div v-if=\"tokens.length > 0\">\n            <SectionBorder />\n\n            <!-- Manage API Tokens -->\n            <div class=\"mt-10 sm:mt-0\">\n                <ActionSection>\n                    <template #title> Manage API Tokens </template>\n\n                    <template #description>\n                        You may delete or revoke any of your existing tokens if they are no longer\n                        needed.\n                    </template>\n\n                    <!-- API Token List -->\n                    <template #content>\n                        <div class=\"divide-border-secondary divide-y\">\n                            <div\n                                v-for=\"token in tokens\"\n                                :key=\"token.id\"\n                                class=\"flex items-center py-2.5 justify-between\">\n                                <div class=\"break-all text-text-primary\">\n                                    <div>{{ token.name }}</div>\n                                    <div class=\"text-sm text-text-tertiary space-x-3\">\n                                        <span v-if=\"token.created_at\">\n                                            Created at\n                                            {{\n                                                formatDateTimeLocalized(\n                                                    token.created_at,\n                                                    organization?.date_format,\n                                                    organization?.time_format\n                                                )\n                                            }}\n                                        </span>\n                                        <span v-if=\"token.expires_at\">\n                                            Expires at\n                                            {{\n                                                formatDateTimeLocalized(\n                                                    token.expires_at,\n                                                    organization?.date_format,\n                                                    organization?.time_format\n                                                )\n                                            }}\n                                        </span>\n                                        <span v-if=\"token.revoked\"> Revoked </span>\n                                    </div>\n                                </div>\n\n                                <div class=\"flex items-center ms-2\">\n                                    <div v-if=\"token.last_used_ago\" class=\"text-sm text-gray-400\">\n                                        Last used {{ token.last_used_ago }}\n                                    </div>\n                                    <button\n                                        v-if=\"!token.revoked\"\n                                        class=\"cursor-pointer ms-6 text-sm text-text-secondary\"\n                                        :aria-label=\"'Revoke API Token ' + token.name\"\n                                        @click=\"confirmApiTokenRevocation(token)\">\n                                        Revoke\n                                    </button>\n                                    <button\n                                        class=\"cursor-pointer ms-6 text-sm text-red-500\"\n                                        :aria-label=\"'Delete API Token ' + token.name\"\n                                        @click=\"confirmApiTokenDeletion(token)\">\n                                        Delete\n                                    </button>\n                                </div>\n                            </div>\n                        </div>\n                    </template>\n                </ActionSection>\n            </div>\n        </div>\n\n        <!-- Token Value Modal -->\n        <DialogModal :show=\"displayingToken\" @close=\"displayingToken = false\">\n            <template #title> API Token created successfully </template>\n\n            <template #content>\n                <div>\n                    Please copy your new API token. For your security, it won't be shown again.\n                    <strong>This token is valid for one year</strong> unless you revoke it.\n                </div>\n\n                <div></div>\n\n                <div class=\"flex gap-2 pt-6 w-full\">\n                    <TextInput\n                        v-if=\"newToken\"\n                        disabled\n                        :model-value=\"newToken\"\n                        class=\"flex-1 text-gray-500\"></TextInput>\n                    <PrimaryButton v-if=\"isSupported\" @click=\"copy(newToken)\">{{\n                        copied ? 'Copied!' : 'Copy Token'\n                    }}</PrimaryButton>\n                </div>\n            </template>\n\n            <template #footer>\n                <SecondaryButton @click=\"displayingToken = false\"> Close </SecondaryButton>\n            </template>\n        </DialogModal>\n\n        <!-- Delete Token Confirmation Modal -->\n        <ConfirmationModal\n            :show=\"apiTokenBeingDeleted != null\"\n            @close=\"apiTokenBeingDeleted = null\">\n            <template #title> Delete API Token </template>\n\n            <template #content> Are you sure you would like to delete this API token? </template>\n\n            <template #footer>\n                <SecondaryButton @click=\"apiTokenBeingDeleted = null\"> Cancel </SecondaryButton>\n\n                <DangerButton\n                    class=\"ms-3\"\n                    :class=\"{ 'opacity-25': createApiTokenMutation.isPending.value }\"\n                    :disabled=\"createApiTokenMutation.isPending.value\"\n                    @click=\"deleteApiToken\">\n                    Delete\n                </DangerButton>\n            </template>\n        </ConfirmationModal>\n\n        <ConfirmationModal\n            :show=\"apiTokenBeingRevoked != null\"\n            @close=\"apiTokenBeingRevoked = null\">\n            <template #title> Revoke API Token </template>\n\n            <template #content> Are you sure you would like to revoke this API token? </template>\n\n            <template #footer>\n                <SecondaryButton @click=\"apiTokenBeingRevoked = null\"> Cancel </SecondaryButton>\n\n                <DangerButton\n                    class=\"ms-3\"\n                    :class=\"{ 'opacity-25': revokeApiTokenMutation.isPending.value }\"\n                    :disabled=\"revokeApiTokenMutation.isPending.value\"\n                    @click=\"revokeApiToken\">\n                    Revoke\n                </DangerButton>\n            </template>\n        </ConfirmationModal>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Profile/Partials/DeleteUserForm.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref } from 'vue';\nimport { useForm } from '@inertiajs/vue3';\nimport ActionSection from '@/Components/ActionSection.vue';\nimport DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport { Field, FieldError } from '@/packages/ui/src/field';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\n\nconst confirmingUserDeletion = ref(false);\nconst passwordInput = ref<HTMLElement | null>(null);\n\nconst form = useForm({\n    password: '',\n});\n\nconst confirmUserDeletion = () => {\n    confirmingUserDeletion.value = true;\n\n    setTimeout(() => passwordInput.value?.focus(), 250);\n};\n\nconst deleteUser = () => {\n    form.delete(route('current-user.destroy'), {\n        preserveScroll: true,\n        onSuccess: () => closeModal(),\n        onError: () => passwordInput.value?.focus(),\n        onFinish: () => form.reset(),\n    });\n};\n\nconst closeModal = () => {\n    confirmingUserDeletion.value = false;\n\n    form.reset();\n};\n</script>\n\n<template>\n    <ActionSection>\n        <template #title> Delete Account </template>\n\n        <template #description> Permanently delete your account. </template>\n\n        <template #content>\n            <div class=\"max-w-xl text-sm text-text-secondary\">\n                Once your account is deleted, all of its resources and data will be permanently\n                deleted. Before deleting your account, please download any data or information that\n                you wish to retain.\n            </div>\n\n            <div class=\"mt-5\">\n                <DangerButton @click=\"confirmUserDeletion\"> Delete Account </DangerButton>\n            </div>\n\n            <!-- Delete Account Confirmation Modal -->\n            <DialogModal :show=\"confirmingUserDeletion\" @close=\"closeModal\">\n                <template #title> Delete Account </template>\n\n                <template #content>\n                    Are you sure you want to delete your account? Once your account is deleted, all\n                    of its resources and data will be permanently deleted. Please enter your\n                    password to confirm you would like to permanently delete your account.\n\n                    <Field class=\"mt-4\">\n                        <TextInput\n                            ref=\"passwordInput\"\n                            v-model=\"form.password\"\n                            type=\"password\"\n                            class=\"block w-3/4\"\n                            placeholder=\"Password\"\n                            autocomplete=\"current-password\"\n                            @keyup.enter=\"deleteUser\" />\n\n                        <FieldError v-if=\"form.errors.password\">{{\n                            form.errors.password\n                        }}</FieldError>\n                    </Field>\n                </template>\n\n                <template #footer>\n                    <SecondaryButton @click=\"closeModal\"> Cancel </SecondaryButton>\n\n                    <DangerButton\n                        class=\"ms-3\"\n                        :class=\"{ 'opacity-25': form.processing }\"\n                        :disabled=\"form.processing\"\n                        @click=\"deleteUser\">\n                        Delete Account\n                    </DangerButton>\n                </template>\n            </DialogModal>\n        </template>\n    </ActionSection>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Profile/Partials/LogoutOtherBrowserSessionsForm.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref } from 'vue';\nimport { useForm } from '@inertiajs/vue3';\nimport ActionMessage from '@/Components/ActionMessage.vue';\nimport ActionSection from '@/Components/ActionSection.vue';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport { Field, FieldError } from '@/packages/ui/src/field';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\nimport type { Session } from '@/types/jetstream';\n\ndefineProps<{\n    sessions: Session[];\n}>();\n\nconst confirmingLogout = ref(false);\nconst passwordInput = ref<HTMLElement | null>(null);\n\nconst form = useForm({\n    password: '',\n});\n\nconst confirmLogout = () => {\n    confirmingLogout.value = true;\n\n    setTimeout(() => passwordInput.value?.focus(), 250);\n};\n\nconst logoutOtherBrowserSessions = () => {\n    form.delete(route('other-browser-sessions.destroy'), {\n        preserveScroll: true,\n        onSuccess: () => closeModal(),\n        onError: () => passwordInput.value?.focus(),\n        onFinish: () => form.reset(),\n    });\n};\n\nconst closeModal = () => {\n    confirmingLogout.value = false;\n\n    form.reset();\n};\n</script>\n\n<template>\n    <ActionSection>\n        <template #title> Browser Sessions </template>\n\n        <template #description>\n            Manage and log out your active sessions on other browsers and devices.\n        </template>\n\n        <template #content>\n            <div class=\"max-w-xl text-sm text-text-secondary\">\n                If necessary, you may log out of all of your other browser sessions across all of\n                your devices. Some of your recent sessions are listed below; however, this list may\n                not be exhaustive. If you feel your account has been compromised, you should also\n                update your password.\n            </div>\n\n            <!-- Other Browser Sessions -->\n            <div v-if=\"sessions.length > 0\" class=\"mt-5 space-y-6\">\n                <div v-for=\"(session, i) in sessions\" :key=\"i\" class=\"flex items-center\">\n                    <div>\n                        <svg\n                            v-if=\"session.agent.is_desktop\"\n                            class=\"w-8 h-8 text-text-primary\"\n                            xmlns=\"http://www.w3.org/2000/svg\"\n                            fill=\"none\"\n                            viewBox=\"0 0 24 24\"\n                            stroke-width=\"1.5\"\n                            stroke=\"currentColor\">\n                            <path\n                                stroke-linecap=\"round\"\n                                stroke-linejoin=\"round\"\n                                d=\"M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25\" />\n                        </svg>\n\n                        <svg\n                            v-else\n                            class=\"w-8 h-8 text-gray-500\"\n                            xmlns=\"http://www.w3.org/2000/svg\"\n                            fill=\"none\"\n                            viewBox=\"0 0 24 24\"\n                            stroke-width=\"1.5\"\n                            stroke=\"currentColor\">\n                            <path\n                                stroke-linecap=\"round\"\n                                stroke-linejoin=\"round\"\n                                d=\"M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3\" />\n                        </svg>\n                    </div>\n\n                    <div class=\"ms-3\">\n                        <div class=\"text-sm text-text-primary font-medium\">\n                            {{ session.agent.platform ? session.agent.platform : 'Unknown' }}\n                            -\n                            {{ session.agent.browser ? session.agent.browser : 'Unknown' }}\n                        </div>\n\n                        <div>\n                            <div class=\"text-xs text-text-secondary\">\n                                {{ session.ip_address }},\n\n                                <span\n                                    v-if=\"session.is_current_device\"\n                                    class=\"text-green-500 font-semibold\"\n                                    >This device</span\n                                >\n                                <span v-else>Last active {{ session.last_active }}</span>\n                            </div>\n                        </div>\n                    </div>\n                </div>\n            </div>\n\n            <div class=\"flex items-center mt-5\">\n                <PrimaryButton @click=\"confirmLogout\">\n                    Log Out Other Browser Sessions\n                </PrimaryButton>\n\n                <ActionMessage :on=\"form.recentlySuccessful\" class=\"ms-3\"> Done. </ActionMessage>\n            </div>\n\n            <!-- Log Out Other Devices Confirmation Modal -->\n            <DialogModal :show=\"confirmingLogout\" @close=\"closeModal\">\n                <template #title> Log Out Other Browser Sessions </template>\n\n                <template #content>\n                    Please enter your password to confirm you would like to log out of your other\n                    browser sessions across all of your devices.\n\n                    <Field class=\"mt-4\">\n                        <TextInput\n                            ref=\"passwordInput\"\n                            v-model=\"form.password\"\n                            type=\"password\"\n                            class=\"block w-3/4\"\n                            placeholder=\"Password\"\n                            autocomplete=\"current-password\"\n                            @keyup.enter=\"logoutOtherBrowserSessions\" />\n\n                        <FieldError v-if=\"form.errors.password\">{{\n                            form.errors.password\n                        }}</FieldError>\n                    </Field>\n                </template>\n\n                <template #footer>\n                    <SecondaryButton @click=\"closeModal\"> Cancel </SecondaryButton>\n\n                    <PrimaryButton\n                        class=\"ms-3\"\n                        :class=\"{ 'opacity-25': form.processing }\"\n                        :disabled=\"form.processing\"\n                        @click=\"logoutOtherBrowserSessions\">\n                        Log Out Other Browser Sessions\n                    </PrimaryButton>\n                </template>\n            </DialogModal>\n        </template>\n    </ActionSection>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Profile/Partials/ThemeForm.vue",
    "content": "<script setup lang=\"ts\">\nimport FormSection from '@/Components/FormSection.vue';\nimport { Field, FieldLabel, FieldDescription } from '@/packages/ui/src/field';\nimport {\n    Select,\n    SelectContent,\n    SelectItem,\n    SelectTrigger,\n    SelectValue,\n} from '@/Components/ui/select';\nimport { usePreferredColorScheme } from '@vueuse/core';\nimport { themeSetting } from '@/utils/theme';\n\nconst preferredColor = usePreferredColorScheme();\n</script>\n\n<template>\n    <FormSection>\n        <template #title> Theme</template>\n\n        <template #description> Choose how you want solidtime to look on your device </template>\n\n        <template #form>\n            <Field class=\"col-span-6 sm:col-span-4\">\n                <FieldLabel for=\"theme\">Theme</FieldLabel>\n                <Select id=\"theme\" v-model=\"themeSetting\">\n                    <SelectTrigger>\n                        <SelectValue />\n                    </SelectTrigger>\n                    <SelectContent>\n                        <SelectItem value=\"system\">System</SelectItem>\n                        <SelectItem value=\"light\">Light</SelectItem>\n                        <SelectItem value=\"dark\">Dark</SelectItem>\n                    </SelectContent>\n                </Select>\n                <FieldDescription v-if=\"themeSetting === 'system'\">\n                    System default: {{ preferredColor }}\n                </FieldDescription>\n            </Field>\n        </template>\n    </FormSection>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Profile/Partials/TwoFactorAuthenticationForm.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, computed, watch } from 'vue';\nimport { router, useForm, usePage } from '@inertiajs/vue3';\nimport ActionSection from '@/Components/ActionSection.vue';\nimport ConfirmsPassword from '@/Components/ConfirmsPassword.vue';\nimport DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';\nimport { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\nimport axios from 'axios';\nimport type { JetstreamUser } from '@/types/jetstream';\n\nconst props = defineProps<{\n    requiresConfirmation: boolean;\n}>();\n\nconst page = usePage<{\n    auth: {\n        user: JetstreamUser;\n    };\n}>();\nconst enabling = ref(false);\nconst confirming = ref(false);\nconst disabling = ref(false);\nconst qrCode = ref(null);\nconst setupKey = ref(null);\nconst recoveryCodes = ref([]);\n\nconst confirmationForm = useForm({\n    code: '',\n});\n\nconst twoFactorEnabled = computed(\n    () => !enabling.value && page.props.auth.user?.two_factor_enabled\n);\n\nwatch(twoFactorEnabled, () => {\n    if (!twoFactorEnabled.value) {\n        confirmationForm.reset();\n        confirmationForm.clearErrors();\n    }\n});\n\nconst enableTwoFactorAuthentication = () => {\n    enabling.value = true;\n\n    router.post(\n        route('two-factor.enable'),\n        {},\n        {\n            preserveScroll: true,\n            onSuccess: () => Promise.all([showQrCode(), showSetupKey(), showRecoveryCodes()]),\n            onFinish: () => {\n                enabling.value = false;\n                confirming.value = props.requiresConfirmation;\n            },\n        }\n    );\n};\n\nconst showQrCode = () => {\n    return axios.get(route('two-factor.qr-code')).then((response) => {\n        qrCode.value = response.data.svg;\n    });\n};\n\nconst showSetupKey = () => {\n    return axios.get(route('two-factor.secret-key')).then((response) => {\n        setupKey.value = response.data.secretKey;\n    });\n};\n\nconst showRecoveryCodes = () => {\n    return axios.get(route('two-factor.recovery-codes')).then((response) => {\n        recoveryCodes.value = response.data;\n    });\n};\n\nconst confirmTwoFactorAuthentication = () => {\n    confirmationForm.post(route('two-factor.confirm'), {\n        errorBag: 'confirmTwoFactorAuthentication',\n        preserveScroll: true,\n        preserveState: true,\n        onSuccess: () => {\n            confirming.value = false;\n            qrCode.value = null;\n            setupKey.value = null;\n        },\n    });\n};\n\nconst regenerateRecoveryCodes = () => {\n    axios.post(route('two-factor.recovery-codes')).then(() => showRecoveryCodes());\n};\n\nconst disableTwoFactorAuthentication = () => {\n    disabling.value = true;\n\n    router.delete(route('two-factor.disable'), {\n        preserveScroll: true,\n        onSuccess: () => {\n            disabling.value = false;\n            confirming.value = false;\n        },\n    });\n};\n</script>\n\n<template>\n    <ActionSection>\n        <template #title> Two Factor Authentication </template>\n\n        <template #description>\n            Add additional security to your account using two factor authentication.\n        </template>\n\n        <template #content>\n            <h3\n                v-if=\"twoFactorEnabled && !confirming\"\n                class=\"text-lg font-medium text-text-secondary\">\n                You have enabled two factor authentication.\n            </h3>\n\n            <h3\n                v-else-if=\"twoFactorEnabled && confirming\"\n                class=\"text-lg font-medium text-text-secondary\">\n                Finish enabling two factor authentication.\n            </h3>\n\n            <h3 v-else class=\"text-lg font-medium text-text-primary\">\n                You have not enabled two factor authentication.\n            </h3>\n\n            <div class=\"mt-3 max-w-xl text-sm text-text-secondary\">\n                <p>\n                    When two factor authentication is enabled, you will be prompted for a secure,\n                    random token during authentication. You may retrieve this token from your\n                    phone's Google Authenticator application.\n                </p>\n            </div>\n\n            <div v-if=\"twoFactorEnabled\">\n                <div v-if=\"qrCode\">\n                    <div class=\"mt-4 max-w-xl text-sm text-text-secondary\">\n                        <p v-if=\"confirming\" class=\"font-semibold\">\n                            To finish enabling two factor authentication, scan the following QR code\n                            using your phone's authenticator application or enter the setup key and\n                            provide the generated OTP code.\n                        </p>\n\n                        <p v-else>\n                            Two factor authentication is now enabled. Scan the following QR code\n                            using your phone's authenticator application or enter the setup key.\n                        </p>\n                    </div>\n\n                    <div class=\"mt-4 p-2 inline-block bg-white\" v-html=\"qrCode\" />\n\n                    <div v-if=\"setupKey\" class=\"mt-4 max-w-xl text-sm text-text-secondary\">\n                        <p class=\"font-semibold\">Setup Key: <span v-html=\"setupKey\"></span></p>\n                    </div>\n\n                    <Field v-if=\"confirming\" class=\"mt-4 w-1/2\">\n                        <FieldLabel for=\"code\">Code</FieldLabel>\n\n                        <TextInput\n                            id=\"code\"\n                            v-model=\"confirmationForm.code\"\n                            type=\"text\"\n                            name=\"code\"\n                            class=\"block w-full\"\n                            inputmode=\"numeric\"\n                            autofocus\n                            autocomplete=\"one-time-code\"\n                            @keyup.enter=\"confirmTwoFactorAuthentication\" />\n\n                        <FieldError v-if=\"confirmationForm.errors.code\">{{\n                            confirmationForm.errors.code\n                        }}</FieldError>\n                    </Field>\n                </div>\n\n                <div v-if=\"recoveryCodes.length > 0 && !confirming\">\n                    <div class=\"mt-4 max-w-xl text-sm text-muted\">\n                        <p class=\"font-semibold\">\n                            Store these recovery codes in a secure password manager. They can be\n                            used to recover access to your account if your two factor authentication\n                            device is lost.\n                        </p>\n                    </div>\n\n                    <div\n                        class=\"grid gap-1 max-w-xl mt-4 px-4 py-4 font-mono text-sm bg-input-background text-text-secondary rounded-lg\">\n                        <div v-for=\"code in recoveryCodes\" :key=\"code\">\n                            {{ code }}\n                        </div>\n                    </div>\n                </div>\n            </div>\n\n            <div class=\"mt-5\">\n                <div v-if=\"!twoFactorEnabled\">\n                    <ConfirmsPassword @confirmed=\"enableTwoFactorAuthentication\">\n                        <PrimaryButton\n                            type=\"button\"\n                            :class=\"{ 'opacity-25': enabling }\"\n                            :disabled=\"enabling\">\n                            Enable\n                        </PrimaryButton>\n                    </ConfirmsPassword>\n                </div>\n\n                <div v-else>\n                    <ConfirmsPassword @confirmed=\"confirmTwoFactorAuthentication\">\n                        <PrimaryButton\n                            v-if=\"confirming\"\n                            type=\"button\"\n                            class=\"me-3\"\n                            :class=\"{ 'opacity-25': enabling }\"\n                            :disabled=\"enabling\">\n                            Confirm\n                        </PrimaryButton>\n                    </ConfirmsPassword>\n\n                    <ConfirmsPassword @confirmed=\"regenerateRecoveryCodes\">\n                        <SecondaryButton\n                            v-if=\"recoveryCodes.length > 0 && !confirming\"\n                            class=\"me-3\">\n                            Regenerate Recovery Codes\n                        </SecondaryButton>\n                    </ConfirmsPassword>\n\n                    <ConfirmsPassword @confirmed=\"showRecoveryCodes\">\n                        <SecondaryButton\n                            v-if=\"recoveryCodes.length === 0 && !confirming\"\n                            class=\"me-3\">\n                            Show Recovery Codes\n                        </SecondaryButton>\n                    </ConfirmsPassword>\n\n                    <ConfirmsPassword @confirmed=\"disableTwoFactorAuthentication\">\n                        <SecondaryButton\n                            v-if=\"confirming\"\n                            :class=\"disabling ? 'opacity-25' : ''\"\n                            :disabled=\"disabling\">\n                            Cancel\n                        </SecondaryButton>\n                    </ConfirmsPassword>\n\n                    <ConfirmsPassword @confirmed=\"disableTwoFactorAuthentication\">\n                        <DangerButton\n                            v-if=\"!confirming\"\n                            :class=\"{ 'opacity-25': disabling }\"\n                            :disabled=\"disabling\">\n                            Disable\n                        </DangerButton>\n                    </ConfirmsPassword>\n                </div>\n            </div>\n        </template>\n    </ActionSection>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Profile/Partials/UpdatePasswordForm.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref } from 'vue';\nimport { useForm } from '@inertiajs/vue3';\nimport ActionMessage from '@/Components/ActionMessage.vue';\nimport FormSection from '@/Components/FormSection.vue';\nimport { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\n\nconst passwordInput = ref<HTMLElement | null>(null);\nconst currentPasswordInput = ref<HTMLElement | null>(null);\n\nconst form = useForm({\n    current_password: '',\n    password: '',\n    password_confirmation: '',\n});\n\nconst updatePassword = () => {\n    form.put(route('user-password.update'), {\n        errorBag: 'updatePassword',\n        preserveScroll: true,\n        onSuccess: () => form.reset(),\n        onError: () => {\n            if (form.errors.password) {\n                form.reset('password', 'password_confirmation');\n                passwordInput.value?.focus();\n            }\n\n            if (form.errors.current_password) {\n                form.reset('current_password');\n                currentPasswordInput.value?.focus();\n            }\n        },\n    });\n};\n</script>\n\n<template>\n    <FormSection @submitted=\"updatePassword\">\n        <template #title> Update Password </template>\n\n        <template #description>\n            Ensure your account is using a long, random password to stay secure.\n        </template>\n\n        <template #form>\n            <Field class=\"col-span-6 sm:col-span-4\">\n                <FieldLabel for=\"current_password\">Current Password</FieldLabel>\n                <TextInput\n                    id=\"current_password\"\n                    ref=\"currentPasswordInput\"\n                    v-model=\"form.current_password\"\n                    type=\"password\"\n                    class=\"block w-full\"\n                    autocomplete=\"current-password\" />\n                <FieldError v-if=\"form.errors.current_password\">{{\n                    form.errors.current_password\n                }}</FieldError>\n            </Field>\n\n            <Field class=\"col-span-6 sm:col-span-4\">\n                <FieldLabel for=\"password\">New Password</FieldLabel>\n                <TextInput\n                    id=\"password\"\n                    ref=\"passwordInput\"\n                    v-model=\"form.password\"\n                    type=\"password\"\n                    class=\"block w-full\"\n                    autocomplete=\"new-password\" />\n                <FieldError v-if=\"form.errors.password\">{{ form.errors.password }}</FieldError>\n            </Field>\n\n            <Field class=\"col-span-6 sm:col-span-4\">\n                <FieldLabel for=\"password_confirmation\">Confirm Password</FieldLabel>\n                <TextInput\n                    id=\"password_confirmation\"\n                    v-model=\"form.password_confirmation\"\n                    type=\"password\"\n                    class=\"block w-full\"\n                    autocomplete=\"new-password\" />\n                <FieldError v-if=\"form.errors.password_confirmation\">{{\n                    form.errors.password_confirmation\n                }}</FieldError>\n            </Field>\n        </template>\n\n        <template #actions>\n            <ActionMessage :on=\"form.recentlySuccessful\" class=\"me-3\"> Saved. </ActionMessage>\n\n            <PrimaryButton :class=\"{ 'opacity-25': form.processing }\" :disabled=\"form.processing\">\n                Save\n            </PrimaryButton>\n        </template>\n    </FormSection>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref } from 'vue';\nimport { Link, router, useForm, usePage } from '@inertiajs/vue3';\nimport ActionMessage from '@/Components/ActionMessage.vue';\nimport FormSection from '@/Components/FormSection.vue';\nimport { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\nimport type { User } from '@/types/models';\n\nconst props = defineProps<{\n    user: User;\n}>();\n\nconst form = useForm({\n    _method: 'PUT',\n    name: props.user.name,\n    email: props.user.email,\n    photo: null as File | null,\n    timezone: props.user.timezone,\n    week_start: props.user.week_start,\n});\n\nconst verificationLinkSent = ref<boolean | null>(null);\nconst photoPreview = ref<ArrayBuffer | undefined | string | null>(null);\nconst photoInput = ref<HTMLInputElement | null>(null);\n\nconst updateProfileInformation = () => {\n    if (photoInput.value && photoInput.value.files && photoInput.value.files?.length > 0) {\n        form.photo = photoInput.value?.files[0] ?? null;\n    }\n\n    form.post(route('user-profile-information.update'), {\n        errorBag: 'updateProfileInformation',\n        preserveScroll: true,\n        onSuccess: () => clearPhotoFileInput(),\n    });\n};\n\nconst sendEmailVerification = () => {\n    verificationLinkSent.value = true;\n};\n\nconst selectNewPhoto = () => {\n    photoInput.value?.click();\n};\n\nconst updatePhotoPreview = () => {\n    if (photoInput.value?.files) {\n        const photo = photoInput.value?.files[0];\n        if (!photo) return;\n\n        const reader = new FileReader();\n\n        reader.onload = (e) => {\n            photoPreview.value = e.target?.result;\n        };\n\n        reader.readAsDataURL(photo);\n    }\n};\n\nconst deletePhoto = () => {\n    router.delete(route('current-user-photo.destroy'), {\n        preserveScroll: true,\n        onSuccess: () => {\n            photoPreview.value = null;\n            clearPhotoFileInput();\n        },\n    });\n};\n\nconst clearPhotoFileInput = () => {\n    if (photoInput.value?.value) {\n        photoInput.value.value = '';\n    }\n};\n\nconst page = usePage<{\n    jetstream: {\n        managesProfilePhotos: boolean;\n        hasEmailVerification: boolean;\n    };\n}>();\n</script>\n\n<template>\n    <FormSection @submitted=\"updateProfileInformation\">\n        <template #title> Profile Information</template>\n\n        <template #description>\n            Update your account's profile information and email address.\n        </template>\n\n        <template #form>\n            <!-- Profile Photo -->\n            <div v-if=\"page.props.jetstream.managesProfilePhotos\" class=\"col-span-6 sm:col-span-4\">\n                <!-- Profile Photo File Input -->\n                <input\n                    id=\"photo\"\n                    ref=\"photoInput\"\n                    type=\"file\"\n                    class=\"hidden\"\n                    @change=\"updatePhotoPreview\" />\n\n                <FieldLabel for=\"photo\">Photo</FieldLabel>\n\n                <!-- Current Profile Photo -->\n                <div v-show=\"!photoPreview\" class=\"mt-2\">\n                    <img\n                        :src=\"user.profile_photo_url\"\n                        :alt=\"user.name\"\n                        class=\"rounded-full h-20 w-20 object-cover\" />\n                </div>\n\n                <!-- New Profile Photo Preview -->\n                <div v-show=\"photoPreview\" class=\"mt-2\">\n                    <span\n                        class=\"block rounded-full w-20 h-20 bg-cover bg-no-repeat bg-center\"\n                        :style=\"'background-image: url(\\'' + photoPreview + '\\');'\" />\n                </div>\n\n                <SecondaryButton class=\"mt-2 me-2\" type=\"button\" @click.prevent=\"selectNewPhoto\">\n                    Select A New Photo\n                </SecondaryButton>\n\n                <SecondaryButton\n                    v-if=\"user.profile_photo_path\"\n                    type=\"button\"\n                    class=\"mt-2\"\n                    @click.prevent=\"deletePhoto\">\n                    Remove Photo\n                </SecondaryButton>\n\n                <FieldError v-if=\"form.errors.photo\">{{ form.errors.photo }}</FieldError>\n            </div>\n\n            <!-- Name -->\n            <Field class=\"col-span-6 sm:col-span-4\">\n                <FieldLabel for=\"name\">Name</FieldLabel>\n                <TextInput\n                    id=\"name\"\n                    v-model=\"form.name\"\n                    type=\"text\"\n                    class=\"block w-full\"\n                    required\n                    autocomplete=\"name\" />\n                <FieldError v-if=\"form.errors.name\">{{ form.errors.name }}</FieldError>\n            </Field>\n\n            <!-- Email -->\n            <Field class=\"col-span-6 sm:col-span-4\">\n                <FieldLabel for=\"email\">Email</FieldLabel>\n                <TextInput\n                    id=\"email\"\n                    v-model=\"form.email\"\n                    type=\"email\"\n                    class=\"block w-full\"\n                    required\n                    autocomplete=\"username\" />\n                <FieldError v-if=\"form.errors.email\">{{ form.errors.email }}</FieldError>\n\n                <div\n                    v-if=\"\n                        page.props.jetstream.hasEmailVerification && user.email_verified_at === null\n                    \">\n                    <p class=\"text-sm mt-2 text-text-primary\">\n                        Your email address is unverified.\n\n                        <Link\n                            :href=\"route('verification.send')\"\n                            method=\"post\"\n                            as=\"button\"\n                            class=\"underline text-sm text-text-secondary hover:text-text-secondary rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800\"\n                            @click.prevent=\"sendEmailVerification\">\n                            Click here to re-send the verification email.\n                        </Link>\n                    </p>\n\n                    <div\n                        v-show=\"verificationLinkSent\"\n                        class=\"mt-2 font-medium text-sm text-green-400\">\n                        A new verification link has been sent to your email address.\n                    </div>\n                </div>\n            </Field>\n\n            <!-- Timezone -->\n            <Field class=\"col-span-6 sm:col-span-4\">\n                <FieldLabel for=\"timezone\">Timezone</FieldLabel>\n                <select\n                    id=\"timezone\"\n                    v-model=\"form.timezone\"\n                    name=\"timezone\"\n                    required\n                    class=\"block w-full border-input-border bg-input-background text-text-primary focus:border-input-border-active rounded-md shadow-sm\">\n                    <option value=\"\" disabled>Select a Timezone</option>\n                    <option\n                        v-for=\"(timezoneTranslated, timezoneKey) in $page.props.timezones\"\n                        :key=\"timezoneKey\"\n                        :value=\"timezoneKey\">\n                        {{ timezoneTranslated }}\n                    </option>\n                </select>\n                <FieldError v-if=\"form.errors.timezone\">{{ form.errors.timezone }}</FieldError>\n            </Field>\n\n            <!-- Week start -->\n            <Field class=\"col-span-6 sm:col-span-4\">\n                <FieldLabel for=\"week_start\">Start of the week</FieldLabel>\n                <select\n                    id=\"week_start\"\n                    v-model=\"form.week_start\"\n                    name=\"week_start\"\n                    required\n                    class=\"block w-full border-input-border bg-input-background text-text-primary focus:border-input-border-active rounded-md shadow-sm\">\n                    <option value=\"\" disabled>Select a week day</option>\n                    <option\n                        v-for=\"(weekdayTranslated, weekdayKey) in $page.props.weekdays\"\n                        :key=\"weekdayKey\"\n                        :value=\"weekdayKey\">\n                        {{ weekdayTranslated }}\n                    </option>\n                </select>\n                <FieldError v-if=\"form.errors.week_start\">{{ form.errors.week_start }}</FieldError>\n            </Field>\n        </template>\n\n        <template #actions>\n            <ActionMessage :on=\"form.recentlySuccessful\" class=\"me-3\"> Saved. </ActionMessage>\n\n            <PrimaryButton :class=\"{ 'opacity-25': form.processing }\" :disabled=\"form.processing\">\n                Save\n            </PrimaryButton>\n        </template>\n    </FormSection>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Profile/Show.vue",
    "content": "<script setup lang=\"ts\">\nimport AppLayout from '@/Layouts/AppLayout.vue';\nimport DeleteUserForm from '@/Pages/Profile/Partials/DeleteUserForm.vue';\nimport LogoutOtherBrowserSessionsForm from '@/Pages/Profile/Partials/LogoutOtherBrowserSessionsForm.vue';\nimport SectionBorder from '@/Components/SectionBorder.vue';\nimport TwoFactorAuthenticationForm from '@/Pages/Profile/Partials/TwoFactorAuthenticationForm.vue';\nimport UpdatePasswordForm from '@/Pages/Profile/Partials/UpdatePasswordForm.vue';\nimport UpdateProfileInformationForm from '@/Pages/Profile/Partials/UpdateProfileInformationForm.vue';\nimport { usePage } from '@inertiajs/vue3';\nimport type { User } from '@/types/models';\nimport type { Session } from '@/types/jetstream';\nimport ApiTokensForm from '@/Pages/Profile/Partials/ApiTokensForm.vue';\nimport ThemeForm from '@/Pages/Profile/Partials/ThemeForm.vue';\n\ndefineProps<{\n    confirmsTwoFactorAuthentication: boolean;\n    sessions: Session[];\n}>();\n\nconst page = usePage<{\n    jetstream: {\n        canUpdateProfileInformation: boolean;\n        canUpdatePassword: boolean;\n        canManageTwoFactorAuthentication: boolean;\n        hasAccountDeletionFeatures: boolean;\n    };\n    auth: {\n        user: User;\n    };\n}>();\n</script>\n\n<template>\n    <AppLayout title=\"Profile\">\n        <template #header>\n            <h2 class=\"font-semibold text-xl text-text-primary leading-tight\">Profile</h2>\n        </template>\n\n        <div>\n            <div class=\"max-w-7xl mx-auto py-10 sm:px-6 lg:px-8\">\n                <div v-if=\"page.props.jetstream.canUpdateProfileInformation\">\n                    <UpdateProfileInformationForm :user=\"page.props.auth.user\" />\n\n                    <SectionBorder />\n                </div>\n\n                <div>\n                    <ThemeForm />\n\n                    <SectionBorder />\n                </div>\n\n                <div v-if=\"page.props.jetstream.canUpdatePassword\">\n                    <UpdatePasswordForm class=\"mt-10 sm:mt-0\" />\n\n                    <SectionBorder />\n                </div>\n\n                <div v-if=\"page.props.jetstream.canManageTwoFactorAuthentication\">\n                    <TwoFactorAuthenticationForm\n                        :requires-confirmation=\"confirmsTwoFactorAuthentication\"\n                        class=\"mt-10 sm:mt-0\" />\n\n                    <SectionBorder />\n                </div>\n\n                <LogoutOtherBrowserSessionsForm :sessions=\"sessions\" class=\"mt-10 sm:mt-0\" />\n                <SectionBorder />\n\n                <ApiTokensForm></ApiTokensForm>\n\n                <template v-if=\"page.props.jetstream.hasAccountDeletionFeatures\">\n                    <SectionBorder />\n\n                    <DeleteUserForm class=\"mt-10 sm:mt-0\" />\n                </template>\n            </div>\n        </div>\n    </AppLayout>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/ProjectShow.vue",
    "content": "<script setup lang=\"ts\">\nimport MainContainer from '@/packages/ui/src/MainContainer.vue';\nimport AppLayout from '@/Layouts/AppLayout.vue';\nimport { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport { computed, ref } from 'vue';\nimport { useProjectsQuery } from '@/utils/useProjectsQuery';\nimport {\n    ChevronRightIcon,\n    CheckCircleIcon,\n    UserGroupIcon,\n    PencilSquareIcon,\n} from '@heroicons/vue/20/solid';\n\nimport { Link } from '@inertiajs/vue3';\nimport TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue';\nimport TaskTable from '@/Components/Common/Task/TaskTable.vue';\nimport CardTitle from '@/packages/ui/src/CardTitle.vue';\nimport Card from '@/Components/Common/Card.vue';\nimport ProjectMemberTable from '@/Components/Common/ProjectMember/ProjectMemberTable.vue';\nimport ProjectMemberCreateModal from '@/Components/Common/ProjectMember/ProjectMemberCreateModal.vue';\nimport { useProjectMembersQuery } from '@/utils/useProjectMembersQuery';\nimport { canCreateProjects, canCreateTasks, canViewProjectMembers } from '@/utils/permissions';\nimport TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';\nimport TabBar from '@/Components/Common/TabBar/TabBar.vue';\nimport { useTasksQuery } from '@/utils/useTasksQuery';\nimport ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue';\nimport { Badge } from '@/packages/ui/src';\nimport { formatCents } from '../packages/ui/src/utils/money';\nimport { getOrganizationCurrencyString } from '../utils/money';\nimport { useOrganizationQuery } from '@/utils/useOrganizationQuery';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\n\nconst { projects } = useProjectsQuery();\n\nconst { organization } = useOrganizationQuery(getCurrentOrganizationId()!);\n\nconst project = computed(() => {\n    return projects.value.find((project) => project.id === route().params.project) ?? null;\n});\nconst createTask = ref(false);\nconst createProjectMember = ref(false);\nconst projectId = route()?.params?.project as string;\n\n// TanStack Query automatically fetches project members when component mounts\nconst { projectMembers } = canViewProjectMembers()\n    ? useProjectMembersQuery(projectId)\n    : { projectMembers: computed(() => []) };\n\nconst showEditProjectModal = ref(false);\n\nconst billableRateFormatted = computed(() => {\n    if (project.value?.billable_rate) {\n        return formatCents(\n            project.value.billable_rate,\n            getOrganizationCurrencyString(),\n            organization.value?.currency_format,\n            organization.value?.currency_symbol,\n            organization.value?.number_format\n        );\n    }\n    return null;\n});\n\nconst activeTab = ref<'active' | 'done'>('active');\n\nconst { tasks } = useTasksQuery();\n\nconst shownTasks = computed(() => {\n    return tasks.value.filter((task) => {\n        if (activeTab.value === 'active') {\n            return task.project_id === projectId && !task.is_done;\n        }\n        return task.project_id === projectId && task.is_done;\n    });\n});\n</script>\n\n<template>\n    <AppLayout title=\"Projects\" data-testid=\"projects_view\">\n        <MainContainer\n            class=\"py-5 border-b border-default-background-separator flex justify-between items-center\">\n            <nav class=\"flex\" aria-label=\"Breadcrumb\">\n                <ol role=\"list\" class=\"flex items-center space-x-2\">\n                    <li>\n                        <div class=\"flex items-center space-x-6\">\n                            <Link\n                                :href=\"route('projects')\"\n                                class=\"flex items-center space-x-2 sm:space-x-2.5\">\n                                <FolderIcon class=\"w-5 text-icon-default\"></FolderIcon>\n                                <span class=\"text-sm sm:text-base font-medium\">Projects</span>\n                            </Link>\n                        </div>\n                    </li>\n                    <li>\n                        <div\n                            class=\"flex items-center space-x-3 text-text-primary font-semibold text-base\">\n                            <ChevronRightIcon\n                                class=\"h-5 w-5 flex-shrink-0 text-text-secondary\"\n                                aria-hidden=\"true\" />\n                            <div class=\"flex space-x-3 items-center\">\n                                <div\n                                    :style=\"{\n                                        backgroundColor: project?.color,\n                                        boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project?.color}30`,\n                                    }\"\n                                    class=\"w-3 h-3 rounded-full\"></div>\n                                <span>{{ project?.name }}</span>\n                            </div>\n                        </div>\n                    </li>\n                </ol>\n                <div class=\"px-4\">\n                    <Badge v-if=\"project?.billable_rate\">\n                        {{ billableRateFormatted }}\n                        / h\n                    </Badge>\n                    <Badge v-if=\"project?.is_billable && !project?.billable_rate\">\n                        Default Rate\n                    </Badge>\n                    <Badge v-if=\"!project?.is_billable\"> Non-Billable </Badge>\n                </div>\n            </nav>\n            <div>\n                <SecondaryButton\n                    v-if=\"canCreateProjects()\"\n                    :icon=\"PencilSquareIcon\"\n                    @click=\"showEditProjectModal = true\">\n                    Edit Project\n                </SecondaryButton>\n                <ProjectEditModal\n                    v-if=\"project\"\n                    v-model:show=\"showEditProjectModal\"\n                    :original-project=\"project\"></ProjectEditModal>\n            </div>\n        </MainContainer>\n        <MainContainer>\n            <div class=\"grid lg:grid-cols-2 gap-x-6 pt-6\">\n                <div>\n                    <CardTitle title=\"Tasks\" :icon=\"CheckCircleIcon\">\n                        <template #actions>\n                            <div class=\"w-full items-center flex justify-between\">\n                                <div class=\"pl-6\">\n                                    <TabBar v-model=\"activeTab\">\n                                        <TabBarItem value=\"active\">Active </TabBarItem>\n                                        <TabBarItem value=\"done\">Done </TabBarItem>\n                                    </TabBar>\n                                </div>\n                                <SecondaryButton\n                                    v-if=\"canCreateTasks()\"\n                                    :icon=\"PlusIcon\"\n                                    @click=\"createTask = true\"\n                                    >Create Task\n                                </SecondaryButton>\n                                <TaskCreateModal\n                                    v-model:show=\"createTask\"\n                                    :project-id=\"projectId\"></TaskCreateModal>\n                            </div>\n                        </template>\n                    </CardTitle>\n                    <Card>\n                        <TaskTable :tasks=\"shownTasks\" :project-id=\"projectId\"></TaskTable>\n                    </Card>\n                </div>\n                <div v-if=\"canViewProjectMembers()\">\n                    <CardTitle title=\"Project Members\" :icon=\"UserGroupIcon\">\n                        <template #actions>\n                            <SecondaryButton :icon=\"PlusIcon\" @click=\"createProjectMember = true\">\n                                Add Member\n                            </SecondaryButton>\n                            <ProjectMemberCreateModal\n                                v-model:show=\"createProjectMember\"\n                                :project-id=\"projectId\"\n                                :existing-members=\"projectMembers\"></ProjectMemberCreateModal>\n                        </template>\n                    </CardTitle>\n                    <Card>\n                        <ProjectMemberTable\n                            :project-members=\"projectMembers\"\n                            :project-id=\"projectId\"></ProjectMemberTable>\n                    </Card>\n                </div>\n            </div>\n        </MainContainer>\n    </AppLayout>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Projects.vue",
    "content": "<script setup lang=\"ts\">\nimport MainContainer from '@/packages/ui/src/MainContainer.vue';\nimport AppLayout from '@/Layouts/AppLayout.vue';\nimport { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport ProjectTable from '@/Components/Common/Project/ProjectTable.vue';\nimport { computed } from 'vue';\nimport { useProjectsQuery } from '@/utils/useProjectsQuery';\nimport { useProjectsStore } from '@/utils/useProjects';\nimport ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';\nimport PageTitle from '@/Components/Common/PageTitle.vue';\nimport { canCreateProjects } from '@/utils/permissions';\nimport { useClientsQuery } from '@/utils/useClientsQuery';\nimport { useClientsStore } from '@/utils/useClients';\nimport type { CreateClientBody, Client, CreateProjectBody, Project } from '@/packages/api/src';\nimport { getOrganizationCurrencyString } from '@/utils/money';\nimport { getCurrentOrganizationId, getCurrentRole } from '@/utils/useUser';\nimport { useOrganizationQuery } from '@/utils/useOrganizationQuery';\nimport { isAllowedToPerformPremiumAction } from '@/utils/billing';\nimport { useStorage } from '@vueuse/core';\nimport ProjectsFilterDropdown from '@/Components/Common/Project/ProjectsFilterDropdown.vue';\nimport ProjectStatusFilterBadge from '@/Components/Common/Project/ProjectStatusFilterBadge.vue';\nimport ProjectClientFilterBadge from '@/Components/Common/Project/ProjectClientFilterBadge.vue';\nimport { NO_CLIENT_ID } from '@/Components/Common/Project/constants';\nimport type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue';\n\n// Fetch data using TanStack Query\nconst { projects } = useProjectsQuery();\nconst { clients } = useClientsQuery();\nconst { organization } = useOrganizationQuery(getCurrentOrganizationId()!);\n\n// Table state persisted in localStorage\ninterface ProjectTableState {\n    sortColumn: SortColumn;\n    sortDirection: SortDirection;\n    filters: {\n        clientIds: string[];\n        status: 'active' | 'archived' | 'all';\n    };\n}\n\nconst tableState = useStorage<ProjectTableState>(\n    'project-table-state',\n    {\n        sortColumn: 'name',\n        sortDirection: 'asc',\n        filters: {\n            clientIds: [],\n            status: 'all',\n        },\n    },\n    undefined,\n    { mergeDefaults: true }\n);\n\nfunction handleSort(column: SortColumn, direction: SortDirection) {\n    tableState.value.sortColumn = column;\n    tableState.value.sortDirection = direction;\n}\n\n// Filter projects based on current filters\nconst filteredProjects = computed(() => {\n    return projects.value.filter((project) => {\n        // Status filter\n        if (tableState.value.filters.status === 'active' && project.is_archived) {\n            return false;\n        }\n        if (tableState.value.filters.status === 'archived' && !project.is_archived) {\n            return false;\n        }\n\n        // Client filter\n        const hasClientFilter = tableState.value.filters.clientIds.length > 0;\n        if (hasClientFilter) {\n            const matchesNoClient =\n                tableState.value.filters.clientIds.includes(NO_CLIENT_ID) && !project.client_id;\n            const matchesClientId =\n                project.client_id && tableState.value.filters.clientIds.includes(project.client_id);\n\n            if (!matchesNoClient && !matchesClientId) {\n                return false;\n            }\n        }\n\n        return true;\n    });\n});\n\n// Helper functions for active filters\nfunction removeStatusFilter() {\n    tableState.value.filters.status = 'all';\n}\n\nfunction removeClientFilter() {\n    tableState.value.filters.clientIds = [];\n}\n\nconst showCreateProjectModal = useStorage('project-create-modal-open', false);\n\nasync function createProject(project: CreateProjectBody): Promise<Project | undefined> {\n    return await useProjectsStore().createProject(project);\n}\n\nasync function createClient(client: CreateClientBody): Promise<Client | undefined> {\n    return await useClientsStore().createClient(client);\n}\n\nconst showBillableRate = computed(() => {\n    return !!(\n        getCurrentRole() !== 'employee' || organization.value?.employees_can_see_billable_rates\n    );\n});\n</script>\n\n<template>\n    <AppLayout title=\"Projects\" data-testid=\"projects_view\">\n        <MainContainer\n            class=\"py-3 sm:pt-5 border-b border-default-background-separator flex justify-between items-center\">\n            <div class=\"flex items-center space-x-3 sm:space-x-6\">\n                <PageTitle :icon=\"FolderIcon\" title=\"Projects\"></PageTitle>\n            </div>\n            <SecondaryButton\n                v-if=\"canCreateProjects()\"\n                :icon=\"PlusIcon\"\n                @click=\"showCreateProjectModal = true\"\n                >Create Project\n            </SecondaryButton>\n            <ProjectCreateModal\n                v-model:show=\"showCreateProjectModal\"\n                :create-project\n                :enable-estimated-time=\"isAllowedToPerformPremiumAction()\"\n                :create-client\n                :currency=\"getOrganizationCurrencyString()\"\n                :clients=\"clients\"\n                @submit=\"createProject\"></ProjectCreateModal>\n        </MainContainer>\n        <MainContainer>\n            <div class=\"flex items-center gap-2 py-1\">\n                <ProjectsFilterDropdown\n                    :filters=\"tableState.filters\"\n                    :clients=\"clients\"\n                    @update:filters=\"tableState.filters = $event\" />\n\n                <!-- Active Filters -->\n                <ProjectStatusFilterBadge\n                    v-if=\"tableState.filters.status !== 'all'\"\n                    data-testid=\"status-filter-badge\"\n                    :value=\"tableState.filters.status\"\n                    @remove=\"removeStatusFilter\"\n                    @update:value=\"\n                        tableState.filters.status = $event as 'active' | 'archived' | 'all'\n                    \" />\n\n                <ProjectClientFilterBadge\n                    v-if=\"tableState.filters.clientIds.length > 0\"\n                    data-testid=\"client-filter-badge\"\n                    :value=\"tableState.filters.clientIds\"\n                    :clients=\"clients\"\n                    @remove=\"removeClientFilter\"\n                    @update:value=\"tableState.filters.clientIds = $event as string[]\" />\n            </div>\n        </MainContainer>\n\n        <ProjectTable\n            :show-billable-rate=\"showBillableRate\"\n            :projects=\"filteredProjects\"\n            :sort-column=\"tableState.sortColumn\"\n            :sort-direction=\"tableState.sortDirection\"\n            @sort=\"handleSort\"></ProjectTable>\n    </AppLayout>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Reporting.vue",
    "content": "<script setup lang=\"ts\">\nimport AppLayout from '@/Layouts/AppLayout.vue';\nimport ReportingOverview from '@/Components/Common/Reporting/ReportingOverview.vue';\n</script>\n\n<template>\n    <AppLayout title=\"Reporting\" data-testid=\"reporting_view\" class=\"overflow-hidden\">\n        <ReportingOverview></ReportingOverview>\n    </AppLayout>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/ReportingDetailed.vue",
    "content": "<script setup lang=\"ts\">\nimport MainContainer from '@/packages/ui/src/MainContainer.vue';\nimport AppLayout from '@/Layouts/AppLayout.vue';\nimport PageTitle from '@/Components/Common/PageTitle.vue';\nimport {\n    ChartBarIcon,\n    ChevronLeftIcon,\n    ChevronDoubleLeftIcon,\n    ChevronRightIcon,\n    ChevronDoubleRightIcon,\n    ClockIcon,\n    EllipsisVerticalIcon,\n    ArrowDownTrayIcon,\n    LockClosedIcon,\n} from '@heroicons/vue/20/solid';\nimport {\n    DropdownMenu,\n    DropdownMenuContent,\n    DropdownMenuItem,\n    DropdownMenuTrigger,\n} from '@/Components/ui/dropdown-menu';\nimport { SecondaryButton } from '@/packages/ui/src';\nimport { computed, onMounted, ref, watch } from 'vue';\nimport { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';\nimport { storeToRefs } from 'pinia';\nimport {\n    api,\n    type Client,\n    type CreateClientBody,\n    type CreateProjectBody,\n    type Project,\n    type TimeEntry,\n} from '@/packages/api/src';\nimport { useTagsQuery } from '@/utils/useTagsQuery';\nimport { useTagsStore } from '@/utils/useTags';\nimport { useSessionStorage } from '@vueuse/core';\nimport TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue';\nimport { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';\nimport { useProjectsQuery } from '@/utils/useProjectsQuery';\nimport { useProjectsStore } from '@/utils/useProjects';\nimport { useTasksQuery } from '@/utils/useTasksQuery';\nimport { useClientsQuery } from '@/utils/useClientsQuery';\nimport { useClientsStore } from '@/utils/useClients';\nimport { getOrganizationCurrencyString } from '@/utils/money';\nimport { useMembersQuery } from '@/utils/useMembersQuery';\nimport {\n    PaginationEllipsis,\n    PaginationFirst,\n    PaginationLast,\n    PaginationList,\n    PaginationListItem,\n    PaginationNext,\n    PaginationPrev,\n    PaginationRoot,\n} from 'radix-vue';\nimport { useQueryClient } from '@tanstack/vue-query';\nimport { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';\nimport ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';\nimport UpgradeModal from '@/Components/Common/UpgradeModal.vue';\nimport type { ExportFormat } from '@/types/reporting';\nimport { useNotificationsStore } from '@/utils/notification';\nimport TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';\nimport { isAllowedToPerformPremiumAction } from '@/utils/billing';\nimport { canCreateProjects, canViewAllTimeEntries } from '@/utils/permissions';\nimport ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';\nimport ReportingFilterBar from '@/Components/Common/Reporting/ReportingFilterBar.vue';\nimport { useTimeEntriesReportQuery } from '@/utils/useTimeEntriesReportQuery';\nimport { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';\n\n// TimeEntryRoundingType is now defined in ReportingRoundingControls component\ntype TimeEntryRoundingType = 'up' | 'down' | 'nearest';\n\nconst startDate = useSessionStorage<string>(\n    'reporting-start-date',\n    getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()\n);\nconst endDate = useSessionStorage<string>(\n    'reporting-end-date',\n    getLocalizedDayJs(getDayJsInstance()().format()).format()\n);\nconst selectedTags = ref<string[]>([]);\nconst selectedProjects = ref<string[]>([]);\nconst selectedMembers = ref<string[]>([]);\nconst selectedTasks = ref<string[]>([]);\nconst selectedClients = ref<string[]>([]);\nconst billable = ref<'true' | 'false' | null>(null);\nconst roundingEnabled = ref<boolean>(false);\nconst roundingType = ref<TimeEntryRoundingType>('nearest');\nconst roundingMinutes = ref<number>(15);\n\nconst { members } = useMembersQuery();\nconst pageLimit = 15;\n\n// Watch rounding enabled state to trigger updates\nwatch(roundingEnabled, () => {\n    updateFilteredTimeEntries();\n});\nconst currentPage = ref(1);\n\nfunction getFilterAttributes() {\n    const defaultParams = {\n        start: getLocalizedDayJs(startDate.value).startOf('day').utc().format(),\n        end: getLocalizedDayJs(endDate.value).endOf('day').utc().format(),\n        active: 'false' as 'true' | 'false',\n        limit: pageLimit,\n        offset: currentPage.value * pageLimit - pageLimit,\n    };\n    const params = {\n        ...defaultParams,\n        member_id: !canViewAllTimeEntries() ? getCurrentMembershipId() : undefined,\n        member_ids: selectedMembers.value.length > 0 ? selectedMembers.value : undefined,\n        project_ids: selectedProjects.value.length > 0 ? selectedProjects.value : undefined,\n        task_ids: selectedTasks.value.length > 0 ? selectedTasks.value : undefined,\n        client_ids: selectedClients.value.length > 0 ? selectedClients.value : undefined,\n        tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,\n        billable: billable.value !== null ? billable.value : undefined,\n        rounding_type: roundingEnabled.value ? roundingType.value : undefined,\n        rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,\n    };\n    return params;\n}\n\nconst currentTimeEntryStore = useCurrentTimeEntryStore();\nconst { currentTimeEntry } = storeToRefs(currentTimeEntryStore);\nconst { setActiveState, startLiveTimer } = currentTimeEntryStore;\nconst { handleApiRequestNotifications } = useNotificationsStore();\n\nconst {\n    createTimeEntry,\n    updateTimeEntry,\n    updateTimeEntries: updateTimeEntriesMutation,\n    deleteTimeEntries: deleteTimeEntriesMutation,\n} = useTimeEntriesMutations();\n\nasync function updateTimeEntries(\n    ids: string[],\n    changes: Parameters<typeof updateTimeEntriesMutation>[0]['changes']\n) {\n    await updateTimeEntriesMutation({ ids, changes });\n}\n\nconst { tags } = useTagsQuery();\n\nconst filterParams = computed(() => getFilterAttributes());\nconst { data: timeEntryResponse } = useTimeEntriesReportQuery(filterParams);\n\nconst totalPages = computed(() => {\n    return timeEntryResponse?.value?.meta?.total ?? 1;\n});\n\nasync function deleteTimeEntries(timeEntries: TimeEntry[]) {\n    await deleteTimeEntriesMutation(timeEntries);\n    selectedTimeEntries.value = [];\n    await updateFilteredTimeEntries();\n}\n\nconst timeEntries = computed(() => {\n    return timeEntryResponse?.value?.data || [];\n});\n\nonMounted(async () => {\n    await updateFilteredTimeEntries();\n});\n\nconst { projects } = useProjectsQuery();\nconst { tasks } = useTasksQuery();\nconst { clients } = useClientsQuery();\n\nconst selectedTimeEntries = ref<TimeEntry[]>([]);\n\nconst showExportModal = ref(false);\nconst exportUrl = ref<string | null>(null);\nconst showPremiumModal = ref(false);\nconst exportLoading = ref(false);\n\nfunction triggerExport(format: ExportFormat) {\n    if (format === 'pdf' && !isAllowedToPerformPremiumAction()) {\n        showPremiumModal.value = true;\n        return;\n    }\n    exportLoading.value = true;\n    downloadExport(format).finally(() => {\n        exportLoading.value = false;\n    });\n}\n\nasync function createTag(name: string) {\n    return await useTagsStore().createTag(name);\n}\n\nasync function createProject(project: CreateProjectBody): Promise<Project | undefined> {\n    return await useProjectsStore().createProject(project);\n}\n\nasync function createClient(body: CreateClientBody): Promise<Client | undefined> {\n    return await useClientsStore().createClient(body);\n}\n\nasync function startTimeEntryFromExisting(entry: TimeEntry) {\n    if (currentTimeEntry.value.id) {\n        await setActiveState(false);\n    }\n    await createTimeEntry({\n        project_id: entry.project_id,\n        task_id: entry.task_id,\n        start: getDayJsInstance().utc().format(),\n        end: null,\n        billable: entry.billable,\n        description: entry.description,\n    });\n    startLiveTimer();\n    updateFilteredTimeEntries();\n    useCurrentTimeEntryStore().fetchCurrentTimeEntry();\n}\nconst queryClient = useQueryClient();\nasync function updateFilteredTimeEntries() {\n    await queryClient.invalidateQueries({\n        queryKey: ['timeEntries', 'detailed-report'],\n    });\n}\nwatch(currentPage, () => {\n    updateFilteredTimeEntries();\n});\nfunction deleteSelected() {\n    deleteTimeEntries(selectedTimeEntries.value);\n}\n\nasync function clearSelectionAndState() {\n    selectedTimeEntries.value = [];\n    await updateFilteredTimeEntries();\n}\nasync function downloadExport(format: ExportFormat) {\n    const organizationId = getCurrentOrganizationId();\n    if (organizationId) {\n        const response = await handleApiRequestNotifications(\n            () =>\n                api.exportTimeEntries({\n                    params: {\n                        organization: organizationId,\n                    },\n                    queries: {\n                        ...getFilterAttributes(),\n                        format: format,\n                    },\n                }),\n            'Export successful',\n            'Export failed'\n        );\n        if (response?.download_url) {\n            showExportModal.value = true;\n            exportUrl.value = response.download_url as string;\n        }\n    }\n}\n</script>\n\n<template>\n    <AppLayout title=\"Reporting\" data-testid=\"reporting_view\" class=\"overflow-hidden\">\n        <ReportingExportModal\n            v-model:show=\"showExportModal\"\n            :export-url=\"exportUrl\"></ReportingExportModal>\n        <UpgradeModal v-model:show=\"showPremiumModal\">\n            <strong>PDF Reports</strong> are only available in solidtime Professional.\n        </UpgradeModal>\n        <MainContainer\n            class=\"h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center\">\n            <div class=\"flex items-center space-x-3 sm:space-x-6\">\n                <PageTitle :icon=\"ChartBarIcon\" title=\"Reporting\"></PageTitle>\n                <ReportingTabNavbar active=\"detailed\" class=\"hidden sm:flex\"></ReportingTabNavbar>\n            </div>\n            <div class=\"hidden sm:block\">\n                <DropdownMenu>\n                    <DropdownMenuTrigger as-child>\n                        <SecondaryButton :icon=\"ArrowDownTrayIcon\" :loading=\"exportLoading\">\n                            Export\n                        </SecondaryButton>\n                    </DropdownMenuTrigger>\n                    <DropdownMenuContent align=\"end\">\n                        <DropdownMenuItem @click=\"triggerExport('pdf')\">\n                            <div class=\"flex items-center space-x-2\">\n                                <span>Export as PDF</span>\n                                <LockClosedIcon\n                                    v-if=\"!isAllowedToPerformPremiumAction()\"\n                                    class=\"w-3.5 text-text-tertiary\" />\n                            </div>\n                        </DropdownMenuItem>\n                        <DropdownMenuItem @click=\"triggerExport('xlsx')\">\n                            Export as Excel\n                        </DropdownMenuItem>\n                        <DropdownMenuItem @click=\"triggerExport('csv')\">\n                            Export as CSV\n                        </DropdownMenuItem>\n                        <DropdownMenuItem @click=\"triggerExport('ods')\">\n                            Export as ODS\n                        </DropdownMenuItem>\n                    </DropdownMenuContent>\n                </DropdownMenu>\n            </div>\n            <DropdownMenu>\n                <DropdownMenuTrigger as-child class=\"sm:hidden\">\n                    <button\n                        class=\"p-1.5 rounded-lg border border-border-tertiary text-text-secondary hover:text-text-primary hover:bg-secondary transition\"\n                        aria-label=\"More options\">\n                        <EllipsisVerticalIcon class=\"w-5 h-5\" />\n                    </button>\n                </DropdownMenuTrigger>\n                <DropdownMenuContent align=\"end\">\n                    <DropdownMenuItem @click=\"triggerExport('pdf')\">\n                        <div class=\"flex items-center space-x-2\">\n                            <span>Export as PDF</span>\n                            <LockClosedIcon\n                                v-if=\"!isAllowedToPerformPremiumAction()\"\n                                class=\"w-3.5 text-text-tertiary\" />\n                        </div>\n                    </DropdownMenuItem>\n                    <DropdownMenuItem @click=\"triggerExport('xlsx')\">\n                        Export as Excel\n                    </DropdownMenuItem>\n                    <DropdownMenuItem @click=\"triggerExport('csv')\">\n                        Export as CSV\n                    </DropdownMenuItem>\n                    <DropdownMenuItem @click=\"triggerExport('ods')\">\n                        Export as ODS\n                    </DropdownMenuItem>\n                </DropdownMenuContent>\n            </DropdownMenu>\n        </MainContainer>\n        <MainContainer class=\"sm:hidden py-2 border-b border-default-background-separator\">\n            <ReportingTabNavbar active=\"detailed\"></ReportingTabNavbar>\n        </MainContainer>\n\n        <ReportingFilterBar\n            v-model:selected-members=\"selectedMembers\"\n            v-model:selected-projects=\"selectedProjects\"\n            v-model:selected-tasks=\"selectedTasks\"\n            v-model:selected-clients=\"selectedClients\"\n            v-model:selected-tags=\"selectedTags\"\n            v-model:billable=\"billable\"\n            v-model:rounding-enabled=\"roundingEnabled\"\n            v-model:rounding-type=\"roundingType\"\n            v-model:rounding-minutes=\"roundingMinutes\"\n            v-model:start-date=\"startDate\"\n            v-model:end-date=\"endDate\"\n            @submit=\"updateFilteredTimeEntries\" />\n        <TimeEntryMassActionRow\n            :selected-time-entries=\"selectedTimeEntries\"\n            :can-create-project=\"canCreateProjects()\"\n            :enable-estimated-time=\"isAllowedToPerformPremiumAction()\"\n            :delete-selected=\"deleteSelected\"\n            :all-selected=\"selectedTimeEntries.length === timeEntries.length\"\n            :projects=\"projects\"\n            :tasks=\"tasks\"\n            :tags=\"tags\"\n            :currency=\"getOrganizationCurrencyString()\"\n            :clients=\"clients\"\n            class=\"border-b border-default-background-separator\"\n            :update-time-entries=\"\n                (args) =>\n                    updateTimeEntries(\n                        selectedTimeEntries.map((timeEntry) => timeEntry.id),\n                        args\n                    )\n            \"\n            :create-project=\"createProject\"\n            :create-client=\"createClient\"\n            :create-tag=\"createTag\"\n            @submit=\"clearSelectionAndState\"\n            @select-all=\"selectedTimeEntries = [...timeEntries]\"\n            @unselect-all=\"selectedTimeEntries = []\"></TimeEntryMassActionRow>\n        <div class=\"w-full relative @container\">\n            <div v-for=\"entry in timeEntries\" :key=\"entry.id\">\n                <TimeEntryRow\n                    :selected=\"selectedTimeEntries.some((item) => item.id === entry.id)\"\n                    :can-create-project=\"canCreateProjects()\"\n                    :create-client\n                    :create-project\n                    :enable-estimated-time=\"isAllowedToPerformPremiumAction()\"\n                    :projects=\"projects\"\n                    :tasks=\"tasks\"\n                    :tags=\"tags\"\n                    :clients\n                    :create-tag\n                    :update-time-entry\n                    :on-start-stop-click=\"() => startTimeEntryFromExisting(entry)\"\n                    :delete-time-entry=\"() => deleteTimeEntries([entry])\"\n                    :currency=\"getOrganizationCurrencyString()\"\n                    :duplicate-time-entry=\"() => createTimeEntry(entry)\"\n                    :members=\"members\"\n                    show-date\n                    show-member\n                    :time-entry=\"entry\"\n                    @selected=\"selectedTimeEntries.push(entry)\"\n                    @unselected=\"\n                        selectedTimeEntries = selectedTimeEntries.filter(\n                            (item) => item.id !== entry.id\n                        )\n                    \"></TimeEntryRow>\n            </div>\n            <div v-if=\"timeEntries.length === 0\">\n                <div class=\"text-center pt-12\">\n                    <ClockIcon class=\"w-8 text-icon-default inline pb-2\"></ClockIcon>\n                    <h3 class=\"text-text-primary font-semibold\">No time entries found</h3>\n                    <p class=\"pb-5\">Adjust the filters to see more time entries!</p>\n                </div>\n            </div>\n        </div>\n\n        <PaginationRoot\n            v-model:page=\"currentPage\"\n            :total=\"totalPages\"\n            :items-per-page=\"pageLimit\"\n            class=\"flex justify-center items-center py-8\"\n            :sibling-count=\"1\"\n            show-edges>\n            <PaginationList v-slot=\"{ items }\" class=\"flex items-center space-x-1 relative\">\n                <div class=\"pr-2 flex items-center space-x-1 border-r border-border-primary mr-1\">\n                    <PaginationFirst class=\"navigation-item\">\n                        <ChevronDoubleLeftIcon class=\"w-4\"> </ChevronDoubleLeftIcon>\n                    </PaginationFirst>\n                    <PaginationPrev class=\"mr-4 navigation-item\">\n                        <ChevronLeftIcon class=\"w-4 text-text-tertiary hover:text-text-primary\">\n                        </ChevronLeftIcon>\n                    </PaginationPrev>\n                </div>\n                <template v-for=\"(page, index) in items\">\n                    <PaginationListItem\n                        v-if=\"page.type === 'page'\"\n                        :key=\"index\"\n                        class=\"pagination-item\"\n                        :value=\"page.value\">\n                        {{ page.value }}\n                    </PaginationListItem>\n                    <PaginationEllipsis\n                        v-else\n                        :key=\"page.type\"\n                        :index=\"index\"\n                        class=\"PaginationEllipsis\">\n                        <div class=\"px-2\">&#8230;</div>\n                    </PaginationEllipsis>\n                </template>\n                <div class=\"!ml-2 pl-2 flex items-center space-x-1 border-l border-border-primary\">\n                    <PaginationNext class=\"navigation-item\">\n                        <ChevronRightIcon\n                            class=\"w-4 text-text-tertiary hover:text-text-primary\"></ChevronRightIcon>\n                    </PaginationNext>\n                    <PaginationLast class=\"navigation-item\">\n                        <ChevronDoubleRightIcon\n                            class=\"w-4 text-text-tertiary hover:text-text-primary\"></ChevronDoubleRightIcon>\n                    </PaginationLast>\n                </div>\n            </PaginationList>\n        </PaginationRoot>\n    </AppLayout>\n</template>\n<style lang=\"postcss\">\n.navigation-item {\n    @apply bg-quaternary h-8 w-8 flex items-center justify-center rounded border border-border-primary text-text-tertiary hover:text-text-primary transition cursor-pointer hover:border-border-secondary hover:bg-secondary focus-visible:text-text-primary focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-ring;\n}\n\n.pagination-item {\n    @apply bg-secondary h-8 w-8 flex items-center justify-center rounded border border-border-tertiary text-text-secondary hover:text-text-primary transition cursor-pointer hover:border-border-secondary hover:bg-secondary focus-visible:text-text-primary focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-ring;\n}\n.pagination-item[data-selected] {\n    @apply text-text-primary bg-accent-300/10 border border-accent-300/20 rounded-md font-medium hover:bg-accent-300/20 active:bg-accent-300/20 outline-0 focus-visible:ring-2 focus:ring-ring transition ease-in-out duration-150;\n}\n</style>\n"
  },
  {
    "path": "resources/js/Pages/ReportingShared.vue",
    "content": "<script setup lang=\"ts\">\nimport MainContainer from '@/packages/ui/src/MainContainer.vue';\nimport AppLayout from '@/Layouts/AppLayout.vue';\nimport PageTitle from '@/Components/Common/PageTitle.vue';\nimport { ChartBarIcon, CreditCardIcon, UserGroupIcon } from '@heroicons/vue/20/solid';\nimport { computed } from 'vue';\n\nimport { useQuery } from '@tanstack/vue-query';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';\nimport ReportTable from '@/Components/Common/Report/ReportTable.vue';\nimport { isAllowedToPerformPremiumAction, isBillingActivated } from '@/utils/billing';\nimport { canManageBilling, canUpdateOrganization } from '@/utils/permissions';\nimport PrimaryButton from '../packages/ui/src/Buttons/PrimaryButton.vue';\nimport { Link } from '@inertiajs/vue3';\nimport { fetchAllReports } from '@/utils/useReportsQuery';\n\nconst { data: reportsData } = useQuery({\n    queryKey: computed(() => ['reports', getCurrentOrganizationId()]),\n    enabled: !!getCurrentOrganizationId(),\n    queryFn: async () => {\n        const organizationId = getCurrentOrganizationId();\n        if (!organizationId) throw new Error('No organization');\n        const data = await fetchAllReports(organizationId);\n        return { data };\n    },\n    staleTime: 1000 * 30,\n});\n\nconst reports = computed(() => {\n    return reportsData.value?.data ?? [];\n});\n</script>\n\n<template>\n    <AppLayout title=\"Reporting\" data-testid=\"reporting_view\" class=\"overflow-hidden\">\n        <MainContainer\n            class=\"h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center\">\n            <div class=\"flex items-center space-x-3 sm:space-x-6\">\n                <PageTitle :icon=\"ChartBarIcon\" title=\"Reporting\"></PageTitle>\n                <ReportingTabNavbar active=\"shared\" class=\"hidden sm:flex\"></ReportingTabNavbar>\n            </div>\n        </MainContainer>\n        <MainContainer class=\"sm:hidden py-2 border-b border-default-background-separator\">\n            <ReportingTabNavbar active=\"shared\"></ReportingTabNavbar>\n        </MainContainer>\n\n        <div v-if=\"!isAllowedToPerformPremiumAction()\">\n            <div class=\"py-12\">\n                <div\n                    class=\"rounded-full flex items-center justify-center w-20 h-20 mx-auto border border-border-tertiary bg-secondary\">\n                    <UserGroupIcon class=\"w-12\"></UserGroupIcon>\n                </div>\n                <div class=\"max-w-sm text-center mx-auto py-4 text-base\">\n                    <p class=\"py-1\">\n                        <slot></slot>\n                    </p>\n                    <p class=\"py-1\">\n                        If you want to use <strong>sharable reports</strong> ,\n                        <strong>please upgrade to a paid plan</strong>.\n                    </p>\n\n                    <Link v-if=\"isBillingActivated() && canManageBilling()\" href=\"/billing\">\n                        <PrimaryButton\n                            v-if=\"isBillingActivated() && canUpdateOrganization()\"\n                            type=\"button\"\n                            class=\"mt-6\"\n                            :icon=\"CreditCardIcon\">\n                            Go to Billing\n                        </PrimaryButton>\n                    </Link>\n                </div>\n            </div>\n        </div>\n\n        <ReportTable\n            v-if=\"reports.length > 0 || isAllowedToPerformPremiumAction()\"\n            :reports=\"reports\"></ReportTable>\n    </AppLayout>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/SharedReport.vue",
    "content": "<script setup lang=\"ts\">\nimport MainContainer from '@/packages/ui/src/MainContainer.vue';\nimport PageTitle from '@/Components/Common/PageTitle.vue';\nimport { ChartBarIcon } from '@heroicons/vue/20/solid';\nimport ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';\nimport { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';\nimport ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';\nimport ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';\nimport { formatCents } from '@/packages/ui/src/utils/money';\nimport type { CurrencyFormat } from '@/packages/ui/src/utils/money';\nimport { computed, onMounted, provide, ref } from 'vue';\nimport { useQuery } from '@tanstack/vue-query';\nimport { api } from '@/packages/api/src';\nimport { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';\nimport { useReportingStore } from '@/utils/useReporting';\nimport { Head } from '@inertiajs/vue3';\nimport { useTheme } from '@/utils/theme';\n\nconst sharedSecret = ref<string | null>(null);\n\nconst hasSharedSecret = computed(() => {\n    return sharedSecret.value !== null;\n});\n\nconst { data: sharedReportResponseData } = useQuery({\n    enabled: hasSharedSecret,\n    queryKey: ['reporting', sharedSecret],\n    queryFn: () =>\n        api.getPublicReport({\n            headers: {\n                'X-Api-Key': sharedSecret.value,\n            },\n        }),\n});\n\nonMounted(() => {\n    const currentUrl = window.location.href;\n    // check if # exists exactly once in the URL\n    if (currentUrl.split('#').length === 2) {\n        sharedSecret.value = currentUrl.split('#')[1] ?? null;\n    }\n});\n\nconst reportCurrency = computed(() => {\n    if (sharedReportResponseData.value) {\n        return sharedReportResponseData.value?.currency;\n    }\n    return 'EUR';\n});\n\nconst reportIntervalFormat = computed(() => {\n    return sharedReportResponseData.value?.interval_format;\n});\n\nconst reportNumberFormat = computed(() => {\n    return sharedReportResponseData.value?.number_format;\n});\n\nconst reportCurrencyFormat = computed(() => {\n    return (sharedReportResponseData.value?.currency_format ?? 'symbol-before') as CurrencyFormat;\n});\n\nconst reportDateFormat = computed(() => {\n    return sharedReportResponseData.value?.date_format;\n});\n\nconst reportCurrencySymbol = computed(() => {\n    return sharedReportResponseData.value?.currency_symbol;\n});\n\nprovide(\n    'organization',\n    computed(() => ({\n        'number_format': reportNumberFormat.value,\n        'interval_format': reportIntervalFormat.value,\n        'currency_format': reportCurrencyFormat.value,\n        'currency_symbol': reportCurrencySymbol.value,\n        'date_format': reportDateFormat.value,\n    }))\n);\n\nconst aggregatedTableTimeEntries = computed(() => {\n    if (sharedReportResponseData.value) {\n        return sharedReportResponseData.value?.data;\n    }\n    return {\n        grouped_data: [],\n        grouped_type: 'project',\n        seconds: 0,\n        cost: 0,\n    };\n});\nconst aggregatedGraphTimeEntries = computed(() => {\n    if (sharedReportResponseData.value) {\n        return sharedReportResponseData.value?.history_data;\n    }\n    // Placeholder Data\n    return {\n        grouped_data: [],\n        grouped_type: 'project',\n        seconds: 0,\n        cost: 0,\n    };\n});\n\nconst group = computed(() => {\n    if (sharedReportResponseData.value) {\n        return sharedReportResponseData.value?.properties.group;\n    }\n    return 'billable';\n});\n\nconst subGroup = computed(() => {\n    if (sharedReportResponseData.value) {\n        return sharedReportResponseData.value?.properties.sub_group;\n    }\n    return 'project';\n});\nconst { emptyPlaceholder } = useReportingStore();\n\nconst groupedPieChartData = computed(() => {\n    return (\n        aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {\n            if (entry.description === null) {\n                return {\n                    value: entry.seconds,\n                    name:\n                        emptyPlaceholder[\n                            aggregatedTableTimeEntries.value?.grouped_type ?? 'project'\n                        ] ?? '',\n                    color: '#CCCCCC',\n                };\n            }\n            return {\n                value: entry.seconds,\n                name: entry.description,\n                color: entry.color ?? getRandomColorWithSeed(entry.description ?? 'none'),\n            };\n        }) ?? []\n    );\n});\n\nconst tableData = computed(() => {\n    return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {\n        return {\n            seconds: entry.seconds,\n            cost: entry.cost,\n            description:\n                entry.description ??\n                emptyPlaceholder[aggregatedTableTimeEntries.value?.grouped_type ?? 'project'] ??\n                '',\n            grouped_data:\n                entry.grouped_data?.map((el) => {\n                    return {\n                        seconds: el.seconds,\n                        cost: el.cost,\n                        description:\n                            el.description ??\n                            emptyPlaceholder[entry.grouped_type ?? 'project'] ??\n                            '',\n                    };\n                }) ?? [],\n        };\n    });\n});\n\nconst { groupByOptions } = useReportingStore();\n\nfunction getGroupLabel(key: string) {\n    return groupByOptions.find((option) => {\n        return option.value === key;\n    })?.label;\n}\n\nonMounted(async () => {\n    useTheme();\n});\n</script>\n\n<template>\n    <Head :title=\"sharedReportResponseData?.name\" />\n\n    <div class=\"text-text-secondary\">\n        <MainContainer\n            class=\"py-3 sm:py-5 border-b border-default-background-separator flex justify-between items-center\">\n            <div class=\"flex items-center space-x-3 sm:space-x-6\">\n                <PageTitle :icon=\"ChartBarIcon\" title=\"Reporting\"></PageTitle>\n            </div>\n        </MainContainer>\n        <MainContainer>\n            <div class=\"pt-10 w-full px-3 relative\">\n                <ReportingChart\n                    :grouped-type=\"aggregatedGraphTimeEntries?.grouped_type\"\n                    :grouped-data=\"aggregatedGraphTimeEntries?.grouped_data\"></ReportingChart>\n            </div>\n        </MainContainer>\n        <MainContainer>\n            <div class=\"sm:grid grid-cols-4 pt-6 items-start\">\n                <div\n                    class=\"col-span-3 bg-card-background rounded-lg border border-card-border pt-3\">\n                    <div\n                        class=\"text-sm flex text-text-primary items-center font-medium px-6 border-b border-card-background-separator pb-3\">\n                        Group by\n                        <strong class=\"px-2\">{{ getGroupLabel(group) }}</strong>\n                        and\n                        <strong class=\"px-2\">{{ getGroupLabel(subGroup) }}</strong>\n                    </div>\n                    <div class=\"grid items-center\" style=\"grid-template-columns: 1fr 100px 150px\">\n                        <div\n                            class=\"contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:bg-tertiary [&>*]:pb-1.5 [&>*]:pt-1 text-text-secondary text-sm\">\n                            <div class=\"pl-6\">Name</div>\n                            <div class=\"text-right\">Duration</div>\n                            <div class=\"text-right pr-6\">Cost</div>\n                        </div>\n                        <template\n                            v-if=\"\n                                aggregatedTableTimeEntries?.grouped_data &&\n                                aggregatedTableTimeEntries.grouped_data?.length > 0\n                            \">\n                            <ReportingRow\n                                v-for=\"entry in tableData\"\n                                :key=\"entry.description ?? 'none'\"\n                                :currency=\"reportCurrency\"\n                                :currency-format=\"reportCurrencyFormat\"\n                                :show-cost=\"true\"\n                                :entry=\"entry\"></ReportingRow>\n                            <div\n                                class=\"contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]\">\n                                <div class=\"flex items-center pl-6 font-medium\">\n                                    <span>Total</span>\n                                </div>\n                                <div class=\"justify-end flex items-center font-medium\">\n                                    {{\n                                        formatHumanReadableDuration(\n                                            aggregatedTableTimeEntries.seconds,\n                                            reportIntervalFormat,\n                                            reportNumberFormat\n                                        )\n                                    }}\n                                </div>\n                                <div class=\"justify-end pr-6 flex items-center font-medium\">\n                                    {{\n                                        aggregatedTableTimeEntries.cost\n                                            ? formatCents(\n                                                  aggregatedTableTimeEntries.cost,\n                                                  reportCurrency,\n                                                  reportCurrencyFormat,\n                                                  reportCurrencySymbol,\n                                                  reportNumberFormat\n                                              )\n                                            : '--'\n                                    }}\n                                </div>\n                            </div>\n                        </template>\n                        <div\n                            v-else\n                            class=\"chart flex flex-col items-center justify-center py-12 col-span-3\">\n                            <p class=\"text-lg text-text-primary font-semibold\">\n                                No time entries found\n                            </p>\n                            <p>Try to change the filters and time range</p>\n                        </div>\n                    </div>\n                </div>\n                <div class=\"px-2 lg:px-4\">\n                    <ReportingPieChart :data=\"groupedPieChartData\"></ReportingPieChart>\n                </div>\n            </div>\n        </MainContainer>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Tags.vue",
    "content": "<script setup lang=\"ts\">\nimport MainContainer from '@/packages/ui/src/MainContainer.vue';\nimport AppLayout from '@/Layouts/AppLayout.vue';\nimport { TagIcon, PlusIcon } from '@heroicons/vue/16/solid';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport { ref } from 'vue';\nimport TagTable from '@/Components/Common/Tag/TagTable.vue';\nimport TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';\nimport PageTitle from '@/Components/Common/PageTitle.vue';\nimport { canCreateTags } from '@/utils/permissions';\nimport { useTagsStore } from '@/utils/useTags';\nimport { useStorage } from '@vueuse/core';\nimport type { SortColumn, SortDirection } from '@/Components/Common/Tag/TagTable.vue';\n\nconst showCreateTagModal = ref(false);\n\ninterface TagTableState {\n    sortColumn: SortColumn;\n    sortDirection: SortDirection;\n}\n\nconst tableState = useStorage<TagTableState>(\n    'tag-table-state',\n    {\n        sortColumn: 'name',\n        sortDirection: 'asc',\n    },\n    undefined,\n    { mergeDefaults: true }\n);\n\nfunction handleSort(column: SortColumn, direction: SortDirection) {\n    tableState.value.sortColumn = column;\n    tableState.value.sortDirection = direction;\n}\n\nasync function createTag(tag: string) {\n    return await useTagsStore().createTag(tag);\n}\n</script>\n\n<template>\n    <AppLayout title=\"Tags\" data-testid=\"tags_view\">\n        <MainContainer\n            class=\"py-5 border-b border-default-background-separator flex justify-between items-center\">\n            <div class=\"flex items-center space-x-6\">\n                <PageTitle :icon=\"TagIcon\" title=\"Tags\"></PageTitle>\n            </div>\n            <SecondaryButton\n                v-if=\"canCreateTags()\"\n                :icon=\"PlusIcon\"\n                @click=\"showCreateTagModal = true\"\n                >Create Tag\n            </SecondaryButton>\n            <TagCreateModal\n                v-model:show=\"showCreateTagModal\"\n                :create-tag=\"createTag\"></TagCreateModal>\n        </MainContainer>\n        <TagTable\n            :create-tag=\"createTag\"\n            :sort-column=\"tableState.sortColumn\"\n            :sort-direction=\"tableState.sortDirection\"\n            @sort=\"handleSort\"></TagTable>\n    </AppLayout>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Teams/Create.vue",
    "content": "<script setup lang=\"ts\">\nimport AppLayout from '@/Layouts/AppLayout.vue';\nimport CreateTeamForm from '@/Pages/Teams/Partials/CreateTeamForm.vue';\n</script>\n\n<template>\n    <AppLayout title=\"Create Organization\">\n        <template #header>\n            <h2 class=\"font-semibold text-xl text-text-primary leading-tight\">\n                Create Organization\n            </h2>\n        </template>\n\n        <div>\n            <div class=\"max-w-7xl mx-auto py-10 sm:px-6 lg:px-8\">\n                <CreateTeamForm />\n            </div>\n        </div>\n    </AppLayout>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Teams/Partials/CreateTeamForm.vue",
    "content": "<script setup lang=\"ts\">\nimport { useForm, usePage } from '@inertiajs/vue3';\nimport FormSection from '@/Components/FormSection.vue';\nimport { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\nimport type { User } from '@/types/models';\nimport { initializeStores } from '@/utils/init';\n\nconst form = useForm({\n    name: '',\n});\n\nconst createTeam = () => {\n    form.post(route('teams.store'), {\n        errorBag: 'createTeam',\n        preserveScroll: true,\n        onSuccess: () => {\n            initializeStores();\n        },\n    });\n};\nconst page = usePage<{\n    auth: {\n        user: User;\n    };\n}>();\n</script>\n\n<template>\n    <FormSection @submitted=\"createTeam\">\n        <template #title> Organization Details</template>\n\n        <template #description>\n            Create a new organization to collaborate with others on projects.\n        </template>\n\n        <template #form>\n            <div class=\"col-span-6\">\n                <FieldLabel>Organization Owner</FieldLabel>\n\n                <div class=\"flex items-center mt-2\">\n                    <img\n                        class=\"object-cover w-12 h-12 rounded-full\"\n                        :src=\"page.props.auth.user.profile_photo_url\"\n                        :alt=\"page.props.auth.user.name\" />\n\n                    <div class=\"ms-4 leading-tight\">\n                        <div class=\"text-text-primary\">\n                            {{ page.props.auth.user.name }}\n                        </div>\n                        <div class=\"text-sm text-text-secondary\">\n                            {{ page.props.auth.user.email }}\n                        </div>\n                    </div>\n                </div>\n            </div>\n\n            <Field class=\"col-span-6 sm:col-span-4\">\n                <FieldLabel for=\"name\">Organization Name</FieldLabel>\n                <TextInput\n                    id=\"name\"\n                    v-model=\"form.name\"\n                    type=\"text\"\n                    class=\"block w-full\"\n                    autofocus />\n                <FieldError v-if=\"form.errors.name\">{{ form.errors.name }}</FieldError>\n            </Field>\n        </template>\n\n        <template #actions>\n            <PrimaryButton :class=\"{ 'opacity-25': form.processing }\" :disabled=\"form.processing\">\n                Create\n            </PrimaryButton>\n        </template>\n    </FormSection>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Teams/Partials/DeleteTeamForm.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref } from 'vue';\nimport { useForm } from '@inertiajs/vue3';\nimport ActionSection from '@/Components/ActionSection.vue';\nimport ConfirmationModal from '@/Components/ConfirmationModal.vue';\nimport DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\n\nconst props = defineProps({\n    team: Object,\n});\n\nconst confirmingTeamDeletion = ref(false);\nconst form = useForm({});\n\nconst confirmTeamDeletion = () => {\n    confirmingTeamDeletion.value = true;\n};\n\nconst deleteTeam = () => {\n    form.delete(route('teams.destroy', props.team), {\n        errorBag: 'deleteTeam',\n    });\n};\n</script>\n\n<template>\n    <ActionSection>\n        <template #title> Delete Organization </template>\n\n        <template #description> Permanently delete this organization. </template>\n\n        <template #content>\n            <div class=\"max-w-xl text-sm text-text-secondary\">\n                Once a organization is deleted, all of its resources and data will be permanently\n                deleted. Before deleting this organization, please download any data or information\n                regarding this organization that you wish to retain.\n            </div>\n\n            <div class=\"mt-5\">\n                <DangerButton @click=\"confirmTeamDeletion\"> Delete Organization </DangerButton>\n            </div>\n\n            <!-- Delete Organization Confirmation Modal -->\n            <ConfirmationModal\n                :show=\"confirmingTeamDeletion\"\n                @close=\"confirmingTeamDeletion = false\">\n                <template #title> Delete Organization </template>\n\n                <template #content>\n                    Are you sure you want to delete this organization? Once a organization is\n                    deleted, all of its resources and data will be permanently deleted.\n                </template>\n\n                <template #footer>\n                    <SecondaryButton @click=\"confirmingTeamDeletion = false\">\n                        Cancel\n                    </SecondaryButton>\n\n                    <DangerButton\n                        class=\"ms-3\"\n                        :class=\"{ 'opacity-25': form.processing }\"\n                        :disabled=\"form.processing\"\n                        @click=\"deleteTeam\">\n                        Delete Organization\n                    </DangerButton>\n                </template>\n            </ConfirmationModal>\n        </template>\n    </ActionSection>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Teams/Partials/ExportData.vue",
    "content": "<script setup lang=\"ts\">\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport { ref } from 'vue';\nimport { useNotificationsStore } from '@/utils/notification';\nimport { api, type OrganizationExportResponse } from '@/packages/api/src';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport { ArrowUpOnSquareIcon, InformationCircleIcon } from '@heroicons/vue/24/outline';\nimport { CardTitle } from '@/packages/ui/src';\nimport Card from '@/Components/Common/Card.vue';\nimport { useOrganizationStore } from '@/utils/useOrganization';\n\nconst showResultModal = ref(false);\nconst loading = ref(false);\nconst exportResponse = ref<OrganizationExportResponse | null>(null);\n\nconst { organization } = useOrganizationStore();\nconst { handleApiRequestNotifications } = useNotificationsStore();\n\nasync function exportData() {\n    loading.value = true;\n    const organizationId = getCurrentOrganizationId();\n    if (organizationId) {\n        try {\n            const response = await handleApiRequestNotifications(\n                () =>\n                    api.exportOrganization(undefined, {\n                        params: {\n                            organization: organizationId,\n                        },\n                    }),\n                'Organization data exported successfully.',\n                'Exporting organization data failed.'\n            );\n            if (response) {\n                showResultModal.value = true;\n                exportResponse.value = response;\n                window.open(response.download_url, '_self')?.focus();\n            }\n        } finally {\n            loading.value = false;\n        }\n    }\n}\n</script>\n\n<template>\n    <DialogModal closeable :show=\"showResultModal\" @close=\"showResultModal = false\">\n        <template #title>The export was successful!</template>\n        <template #content>\n            <div class=\"pb-6\">\n                The download should start automatically. If it does not\n                <a\n                    class=\"font-semibold text-accent-200 hover:text-accent-300\"\n                    target=\"_self\"\n                    :href=\"exportResponse?.download_url\"\n                    >click here</a\n                >\n            </div>\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"showResultModal = false\"> Close </SecondaryButton>\n        </template>\n    </DialogModal>\n    <div>\n        <CardTitle title=\"Export Data\" :icon=\"ArrowUpOnSquareIcon\"></CardTitle>\n        <Card class=\"mb-3\">\n            <div class=\"py-2 px-3 sm:px-4 text-sm flex items-center space-x-3\">\n                <InformationCircleIcon class=\"h-5 min-w-0 w-5 text-bg-tertiary\" />\n                <p class=\"flex-1\">\n                    Export your solidtime organization data. This will include all clients,\n                    projects, tasks, and time entries. You will receive a zip file with json files\n                    for each entity.\n                </p>\n            </div>\n        </Card>\n        <Card>\n            <div class=\"py-6 flex-col items-center flex space-y-5 text-center text-sm\">\n                <div>\n                    The following organization will be exported: <br />\n                    <strong class=\"font-semibold\">{{ organization?.name }}</strong>\n                </div>\n                <PrimaryButton :loading @click=\"exportData\"\n                    >Export Organization Data\n                </PrimaryButton>\n            </div>\n        </Card>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Teams/Partials/ImportData.vue",
    "content": "<script setup lang=\"ts\">\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport { computed, onMounted, ref } from 'vue';\nimport { useNotificationsStore } from '@/utils/notification';\nimport { api } from '@/packages/api/src';\nimport { Field, FieldLabel } from '@/packages/ui/src/field';\nimport { DocumentIcon } from '@heroicons/vue/24/solid';\nimport { ArrowDownOnSquareIcon, InformationCircleIcon } from '@heroicons/vue/24/outline';\n\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport type { ImportReport, ImportType } from '@/packages/api/src';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport { initializeStores } from '@/utils/init';\nimport { CardTitle } from '@/packages/ui/src';\nimport Card from '@/Components/Common/Card.vue';\n\nconst importTypeOptions = ref<ImportType[]>([]);\n\nconst { addNotification } = useNotificationsStore();\n\nconst loading = ref(false);\n\nonMounted(async () => {\n    const organizationId = getCurrentOrganizationId();\n    if (organizationId) {\n        importTypeOptions.value = (\n            await api.getImporters({\n                params: {\n                    organization: organizationId,\n                },\n            })\n        ).data;\n    }\n});\n\nconst reportResult = ref<ImportReport | null>();\nconst files = ref<FileList | null>(null);\n\nasync function importData() {\n    if (importType.value === null) {\n        addNotification('error', 'Please select the import type');\n        return;\n    }\n    if (files.value?.length !== 1) {\n        addNotification('error', 'Please select the CSV or ZIP file that you want to import');\n        return;\n    }\n    const rawBase64String = await toBase64(files.value[0]!);\n    const base64String = rawBase64String.split(';')[1]!.replace('base64,', '') as string;\n    const organizationId = getCurrentOrganizationId();\n    if (organizationId !== null) {\n        const { handleApiRequestNotifications } = useNotificationsStore();\n        loading.value = true;\n        try {\n            reportResult.value = await handleApiRequestNotifications(() => {\n                if (importType.value) {\n                    return api.importData(\n                        {\n                            type: importType.value.key,\n                            data: base64String,\n                        },\n                        {\n                            params: {\n                                organization: organizationId,\n                            },\n                        }\n                    );\n                }\n                return new Promise((resolve, reject) => {\n                    reject('Import type is null');\n                });\n            });\n            initializeStores();\n            if (reportResult.value) {\n                showResultModal.value = true;\n            }\n        } finally {\n            loading.value = false;\n        }\n    }\n}\n\nconst importFile = ref<HTMLInputElement | null>();\n\nfunction toBase64(file: File): Promise<string> {\n    return new Promise((resolve, reject) => {\n        const reader = new FileReader();\n        reader.readAsDataURL(file);\n        reader.onload = () => {\n            if (reader.result instanceof ArrayBuffer) {\n                const decoder = new TextDecoder();\n                const str = decoder.decode(reader.result);\n                return reject(str);\n            } else if (reader.result) {\n                resolve(reader.result);\n            }\n        };\n        reader.onerror = reject;\n    });\n}\n\nfunction updateFiles() {\n    files.value = importFile.value?.files ?? null;\n}\n\nconst currentImporterDescription = computed(() => {\n    if (importType.value === null) {\n        return '';\n    }\n    return importType.value.description;\n});\n\nconst filenames = computed(() => {\n    return files.value?.item(0)?.name ?? 'Import File selected';\n});\n\nconst importType = ref<ImportType | null>(null);\n\nconst showResultModal = ref(false);\n</script>\n\n<template>\n    <DialogModal closeable :show=\"showResultModal\" @close=\"showResultModal = false\">\n        <template #title>Import Result</template>\n        <template #content>\n            <div class=\"pb-6\">\n                The import was successful! Here is an overview of the imported data:\n            </div>\n\n            <div class=\"py-2.5 px-3 border-t border-t-card-background-separator\">\n                <span class=\"text-text-primary font-semibold\">Clients created:</span>\n                {{ reportResult?.report.clients.created }}\n            </div>\n            <div class=\"py-2.5 px-3 border-t border-t-card-background-separator\">\n                <span class=\"text-text-primary font-semibold\">Projects created:</span>\n                {{ reportResult?.report.projects.created }}\n            </div>\n            <div class=\"py-2.5 px-3 border-t border-t-card-background-separator\">\n                <span class=\"text-text-primary font-semibold\">Tasks created:</span>\n                {{ reportResult?.report.tasks.created }}\n            </div>\n            <div class=\"py-2.5 px-3 border-t border-t-card-background-separator\">\n                <span class=\"text-text-primary font-semibold\">Time entries created:</span>\n                {{ reportResult?.report.time_entries.created }}\n            </div>\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"showResultModal = false\"> Close </SecondaryButton>\n        </template>\n    </DialogModal>\n    <div>\n        <CardTitle title=\"Import Data\" :icon=\"ArrowDownOnSquareIcon\"></CardTitle>\n        <Card class=\"mb-3\">\n            <div class=\"py-2 px-3 sm:px-4 text-sm flex items-center space-x-3\">\n                <InformationCircleIcon class=\"h-5 min-w-0 w-5 text-bg-tertiary\" />\n                <p class=\"flex-1\">\n                    Import existing data from Toggl, Clockify or a different solidtime instance.\n                    Please select the type of data you want to import and follow the instructions.\n                </p>\n            </div>\n        </Card>\n\n        <Card>\n            <div class=\"px-4 py-5 sm:px-5\">\n                <Field>\n                    <FieldLabel for=\"importType\">Import Type</FieldLabel>\n                    <select\n                        id=\"importType\"\n                        v-model=\"importType\"\n                        name=\"importType\"\n                        class=\"block w-full border-input-border bg-input-background text-text-primary focus:border-input-border-active rounded-md shadow-sm\">\n                        <option :value=\"null\" selected disabled>\n                            Select an import type to get instructions...\n                        </option>\n                        <option\n                            v-for=\"importTypeOption in importTypeOptions\"\n                            :key=\"importTypeOption.key\"\n                            :value=\"importTypeOption\">\n                            {{ importTypeOption.name }}\n                        </option>\n                    </select>\n                    <div v-if=\"currentImporterDescription\" class=\"py-3 text-text-primary\">\n                        <div class=\"font-semibold text-text-secondary py-1\">Instructions:</div>\n                        <div class=\"max-w-2xl\" v-html=\"currentImporterDescription\"></div>\n                    </div>\n                </Field>\n\n                <div\n                    class=\"mt-2 flex justify-center rounded-lg border border-dashed border-border-primary px-6 py-10\">\n                    <div class=\"text-center\">\n                        <DocumentIcon\n                            class=\"mx-auto h-8 w-8 text-text-secondary\"\n                            aria-hidden=\"true\" />\n\n                        <div class=\"mt-4 flex text-sm leading-6 text-text-secondary\">\n                            <label\n                                for=\"file-upload\"\n                                class=\"relative cursor-pointer rounded-md font-semibold text-text-primary focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 focus-within:ring-offset-gray-900 hover:text-indigo-500\">\n                                <span v-if=\"files\">{{ filenames }}</span>\n                                <span v-else>Upload a Toggl/Clockify Export</span>\n                                <input\n                                    id=\"file-upload\"\n                                    ref=\"importFile\"\n                                    name=\"file-upload\"\n                                    type=\"file\"\n                                    class=\"sr-only\"\n                                    @change=\"updateFiles\" />\n                            </label>\n                        </div>\n                        <p class=\"text-xs leading-5 text-text-secondary\">\n                            CSV and ZIP are supported\n                        </p>\n                    </div>\n                </div>\n            </div>\n\n            <div\n                class=\"flex items-center justify-end px-4 py-3 bg-card-background border-t border-card-background-separator text-end sm:px-6 shadow sm:rounded-bl-md sm:rounded-br-md\">\n                <PrimaryButton :loading @click=\"importData\">Import Data </PrimaryButton>\n            </div>\n        </Card>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Teams/Partials/OrganizationBillableRate.vue",
    "content": "<script setup lang=\"ts\">\nimport FormSection from '@/Components/FormSection.vue';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport { onMounted, ref } from 'vue';\nimport { Field, FieldLabel } from '@/packages/ui/src/field';\nimport type { UpdateOrganizationBody } from '@/packages/api/src';\nimport BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';\nimport { useOrganizationStore } from '@/utils/useOrganization';\nimport { storeToRefs } from 'pinia';\nimport OrganizationBillableRateModal from '@/Components/Common/Organization/OrganizationBillableRateModal.vue';\nimport { getOrganizationCurrencyString } from '@/utils/money';\nimport { Checkbox } from '@/packages/ui/src';\n\nconst store = useOrganizationStore();\nconst { fetchOrganization, updateOrganization } = store;\nconst { organization } = storeToRefs(store);\nconst saving = ref(false);\nconst organizationBody = ref<UpdateOrganizationBody>({\n    name: '',\n    billable_rate: null as number | null,\n    employees_can_see_billable_rates: false,\n});\n\nonMounted(async () => {\n    await fetchOrganization();\n    organizationBody.value = {\n        name: organization.value?.name ?? '',\n        billable_rate: organization.value?.billable_rate,\n        employees_can_see_billable_rates:\n            organization.value?.employees_can_see_billable_rates ?? false,\n    };\n});\nconst showConfirmationModal = ref(false);\n\nasync function submit() {\n    saving.value = true;\n    await updateOrganization(organizationBody.value);\n    saving.value = false;\n    showConfirmationModal.value = false;\n}\n\nfunction checkForConfirmationModal() {\n    if (organizationBody.value.billable_rate === organization.value?.billable_rate) {\n        submit();\n    } else {\n        showConfirmationModal.value = true;\n    }\n}\n</script>\n\n<template>\n    <FormSection>\n        <template #title> Billable Rate</template>\n\n        <template #description>\n            Configure the default billable rate for the organization.\n        </template>\n\n        <template #form>\n            <OrganizationBillableRateModal\n                v-model:show=\"showConfirmationModal\"\n                :new-billable-rate=\"organizationBody.billable_rate\"\n                @submit=\"submit\"></OrganizationBillableRateModal>\n            <Field class=\"col-span-6 sm:col-span-4\">\n                <FieldLabel for=\"organizationBillableRate\">Organization Billable Rate</FieldLabel>\n                <BillableRateInput\n                    v-if=\"organization\"\n                    v-model=\"organizationBody.billable_rate\"\n                    :currency=\"getOrganizationCurrencyString()\"\n                    name=\"organizationBillableRate\"></BillableRateInput>\n            </Field>\n\n            <div class=\"col-span-6 sm:col-span-4\">\n                <Field orientation=\"horizontal\">\n                    <Checkbox\n                        v-if=\"organization\"\n                        id=\"organizationShowBillableRatesToEmployees\"\n                        v-model:checked=\"\n                            organizationBody.employees_can_see_billable_rates\n                        \"></Checkbox>\n                    <FieldLabel for=\"organizationShowBillableRatesToEmployees\"\n                        >Show Billable Rates to Employees</FieldLabel\n                    >\n                </Field>\n            </div>\n        </template>\n        <template #actions>\n            <PrimaryButton @click=\"checkForConfirmationModal\">Save</PrimaryButton>\n        </template>\n    </FormSection>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Teams/Partials/OrganizationFormatSettings.vue",
    "content": "<script setup lang=\"ts\">\nimport FormSection from '@/Components/FormSection.vue';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport { onMounted, ref } from 'vue';\nimport { Field, FieldLabel } from '@/packages/ui/src/field';\nimport type { UpdateOrganizationBody } from '@/packages/api/src';\nimport { useOrganizationStore } from '@/utils/useOrganization';\nimport { storeToRefs } from 'pinia';\nimport {\n    Select,\n    SelectContent,\n    SelectItem,\n    SelectTrigger,\n    SelectValue,\n} from '@/Components/ui/select';\nimport { useMutation, useQueryClient } from '@tanstack/vue-query';\nimport type { DateFormat, TimeFormat, IntervalFormat } from '@/packages/ui/src/utils/time';\nimport type { CurrencyFormat } from '@/packages/ui/src/utils/money';\nimport type { NumberFormat } from '@/packages/ui/src/utils/number';\n\ninterface FormValues {\n    number_format: NumberFormat | undefined;\n    currency_format: CurrencyFormat | undefined;\n    date_format: DateFormat | undefined;\n    time_format: TimeFormat | undefined;\n    interval_format: IntervalFormat | undefined;\n}\n\nconst store = useOrganizationStore();\nconst { updateOrganization } = store;\nconst { organization } = storeToRefs(store);\nconst queryClient = useQueryClient();\n\nconst form = ref<FormValues>({\n    number_format: undefined,\n    currency_format: undefined,\n    date_format: undefined,\n    time_format: undefined,\n    interval_format: undefined,\n});\n\nconst mutation = useMutation({\n    mutationFn: (values: FormValues) => updateOrganization(values as UpdateOrganizationBody),\n    onSuccess: () => {\n        queryClient.invalidateQueries({ queryKey: ['organization'] });\n    },\n});\n\nonMounted(async () => {\n    if (organization.value) {\n        form.value = {\n            number_format: organization.value.number_format as NumberFormat,\n            currency_format: organization.value.currency_format as CurrencyFormat,\n            date_format: organization.value.date_format as DateFormat,\n            time_format: organization.value.time_format as TimeFormat,\n            interval_format: organization?.value.interval_format as IntervalFormat,\n        };\n    }\n});\n\nasync function submit() {\n    mutation.mutate(form.value);\n}\n</script>\n\n<template>\n    <FormSection>\n        <template #title>Format Settings</template>\n\n        <template #description>\n            Configure the default format settings for the organization.\n        </template>\n\n        <template #form>\n            <!-- Number Format -->\n            <Field class=\"col-span-6 sm:col-span-4\">\n                <FieldLabel for=\"numberFormat\">Number Format</FieldLabel>\n                <Select v-model=\"form.number_format\">\n                    <SelectTrigger id=\"numberFormat\">\n                        <SelectValue placeholder=\"Select number format\" />\n                    </SelectTrigger>\n                    <SelectContent>\n                        <SelectItem value=\"point-comma\">1.111,11</SelectItem>\n                        <SelectItem value=\"comma-point\">1,111.11</SelectItem>\n                        <SelectItem value=\"space-comma\">1 111,11</SelectItem>\n                        <SelectItem value=\"space-point\">1 111.11</SelectItem>\n                        <SelectItem value=\"apostrophe-point\">1'111.11</SelectItem>\n                    </SelectContent>\n                </Select>\n            </Field>\n\n            <!-- Currency Format -->\n            <Field class=\"col-span-6 sm:col-span-4\">\n                <FieldLabel for=\"currencyFormat\">Currency Format</FieldLabel>\n                <Select v-model=\"form.currency_format\">\n                    <SelectTrigger id=\"currencyFormat\">\n                        <SelectValue placeholder=\"Select currency format\" />\n                    </SelectTrigger>\n                    <SelectContent>\n                        <SelectItem value=\"iso-code-before-with-space\">EUR 111</SelectItem>\n                        <SelectItem value=\"iso-code-after-with-space\">111 EUR</SelectItem>\n                        <SelectItem value=\"symbol-before\">€111</SelectItem>\n                        <SelectItem value=\"symbol-after\">111€</SelectItem>\n                        <SelectItem value=\"symbol-before-with-space\">€ 111</SelectItem>\n                        <SelectItem value=\"symbol-after-with-space\">111 €</SelectItem>\n                    </SelectContent>\n                </Select>\n            </Field>\n\n            <!-- Date Format -->\n            <Field class=\"col-span-6 sm:col-span-4\">\n                <FieldLabel for=\"dateFormat\">Date Format</FieldLabel>\n                <Select v-model=\"form.date_format\">\n                    <SelectTrigger id=\"dateFormat\">\n                        <SelectValue placeholder=\"Select date format\" />\n                    </SelectTrigger>\n                    <SelectContent>\n                        <SelectItem value=\"point-separated-d-m-yyyy\">D.M.YYYY</SelectItem>\n                        <SelectItem value=\"slash-separated-mm-dd-yyyy\">MM/DD/YYYY</SelectItem>\n                        <SelectItem value=\"slash-separated-dd-mm-yyyy\">DD/MM/YYYY</SelectItem>\n                        <SelectItem value=\"hyphen-separated-dd-mm-yyyy\">DD-MM-YYYY</SelectItem>\n                        <SelectItem value=\"hyphen-separated-mm-dd-yyyy\">MM-DD-YYYY</SelectItem>\n                        <SelectItem value=\"hyphen-separated-yyyy-mm-dd\">YYYY-MM-DD</SelectItem>\n                    </SelectContent>\n                </Select>\n            </Field>\n\n            <!-- Time Format -->\n            <Field class=\"col-span-6 sm:col-span-4\">\n                <FieldLabel for=\"timeFormat\">Time Format</FieldLabel>\n                <Select v-model=\"form.time_format\">\n                    <SelectTrigger id=\"timeFormat\">\n                        <SelectValue placeholder=\"Select time format\" />\n                    </SelectTrigger>\n                    <SelectContent>\n                        <SelectItem value=\"12-hours\">12-hour clock</SelectItem>\n                        <SelectItem value=\"24-hours\">24-hour clock</SelectItem>\n                    </SelectContent>\n                </Select>\n            </Field>\n\n            <!-- Interval Format -->\n            <Field class=\"col-span-6 sm:col-span-4\">\n                <FieldLabel for=\"intervalFormat\">Time Duration Format</FieldLabel>\n                <Select v-model=\"form.interval_format\">\n                    <SelectTrigger id=\"intervalFormat\">\n                        <SelectValue placeholder=\"Select interval format\" />\n                    </SelectTrigger>\n                    <SelectContent>\n                        <SelectItem value=\"decimal\">Decimal</SelectItem>\n                        <SelectItem value=\"hours-minutes\">12h 3m</SelectItem>\n                        <SelectItem value=\"hours-minutes-colon-separated\">12:03</SelectItem>\n                        <SelectItem value=\"hours-minutes-seconds-colon-separated\"\n                            >12:03:45</SelectItem\n                        >\n                    </SelectContent>\n                </Select>\n            </Field>\n        </template>\n\n        <template #actions>\n            <PrimaryButton :disabled=\"mutation.isPending.value\" @click=\"submit\">\n                {{ mutation.isPending.value ? 'Saving...' : 'Save' }}\n            </PrimaryButton>\n        </template>\n    </FormSection>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue",
    "content": "<script setup lang=\"ts\">\nimport FormSection from '@/Components/FormSection.vue';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport { onMounted, ref } from 'vue';\nimport { Field, FieldLabel } from '@/packages/ui/src/field';\nimport { Checkbox } from '@/packages/ui/src';\nimport type { UpdateOrganizationBody } from '@/packages/api/src';\nimport { useOrganizationStore } from '@/utils/useOrganization';\nimport { storeToRefs } from 'pinia';\nimport { useMutation, useQueryClient } from '@tanstack/vue-query';\n\nconst store = useOrganizationStore();\nconst { updateOrganization } = store;\nconst { organization } = storeToRefs(store);\nconst queryClient = useQueryClient();\n\nconst form = ref<{\n    prevent_overlapping_time_entries: boolean;\n    employees_can_manage_tasks: boolean;\n}>({\n    prevent_overlapping_time_entries: false,\n    employees_can_manage_tasks: false,\n});\n\nonMounted(async () => {\n    form.value.prevent_overlapping_time_entries =\n        organization.value?.prevent_overlapping_time_entries ?? false;\n    form.value.employees_can_manage_tasks = organization.value?.employees_can_manage_tasks ?? false;\n});\n\nconst mutation = useMutation({\n    mutationFn: (values: Partial<UpdateOrganizationBody>) => updateOrganization(values),\n    onSuccess: () => {\n        queryClient.invalidateQueries({ queryKey: ['organization'] });\n    },\n});\n\nasync function submit() {\n    await mutation.mutateAsync({\n        prevent_overlapping_time_entries: form.value.prevent_overlapping_time_entries,\n        employees_can_manage_tasks: form.value.employees_can_manage_tasks,\n    });\n}\n</script>\n\n<template>\n    <FormSection>\n        <template #title>Organization Settings</template>\n        <template #description>\n            Configure various settings for your organization, including time entry and task\n            management permissions.\n        </template>\n\n        <template #form>\n            <div class=\"col-span-6 sm:col-span-4 space-y-4\">\n                <Field orientation=\"horizontal\">\n                    <Checkbox\n                        id=\"preventOverlappingTimeEntries\"\n                        v-model:checked=\"form.prevent_overlapping_time_entries\" />\n                    <FieldLabel for=\"preventOverlappingTimeEntries\"\n                        >Prevent overlapping time entries (new entries only)</FieldLabel\n                    >\n                </Field>\n                <Field orientation=\"horizontal\">\n                    <Checkbox\n                        id=\"employeesCanManageTasks\"\n                        v-model:checked=\"form.employees_can_manage_tasks\" />\n                    <FieldLabel for=\"employeesCanManageTasks\"\n                        >Allow Employees to manage tasks</FieldLabel\n                    >\n                </Field>\n            </div>\n        </template>\n\n        <template #actions>\n            <PrimaryButton :disabled=\"mutation.isPending.value\" @click=\"submit\">Save</PrimaryButton>\n        </template>\n    </FormSection>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Teams/Partials/TeamMemberManager.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, ref } from 'vue';\nimport { router, useForm, usePage } from '@inertiajs/vue3';\nimport ActionMessage from '@/Components/ActionMessage.vue';\nimport ActionSection from '@/Components/ActionSection.vue';\nimport ConfirmationModal from '@/Components/ConfirmationModal.vue';\nimport DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport FormSection from '@/Components/FormSection.vue';\nimport { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';\n\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport SectionBorder from '@/Components/SectionBorder.vue';\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\nimport type { Organization, OrganizationInvitation, User } from '@/types/models';\nimport type { Membership, Permissions, Role } from '@/types/jetstream';\nimport { filterRoles } from '@/utils/roles';\n\ntype UserWithMembership = User & { membership: Membership };\n\nconst props = defineProps<{\n    team: Organization;\n    availableRoles: Role[];\n    userPermissions: Permissions;\n}>();\n\nconst users = computed(() => {\n    return props.team.users as Array<UserWithMembership>;\n});\n\nconst page = usePage<{\n    auth: {\n        user: User;\n    };\n}>();\n\nconst addTeamMemberForm = useForm({\n    email: '',\n    role: null as string | null,\n});\n\nconst updateRoleForm = useForm({\n    role: null as string | null,\n});\n\nconst leaveTeamForm = useForm({});\nconst removeTeamMemberForm = useForm({});\n\nconst currentlyManagingRole = ref(false);\nconst managingRoleFor = ref<User | null>(null);\nconst confirmingLeavingTeam = ref(false);\nconst teamMemberBeingRemoved = ref<User | null>(null);\n\nconst addTeamMember = () => {\n    addTeamMemberForm.post(route('team-members.store', props.team.id), {\n        errorBag: 'addTeamMember',\n        preserveScroll: true,\n        onSuccess: () => addTeamMemberForm.reset(),\n    });\n};\n\nconst cancelTeamInvitation = (invitation: OrganizationInvitation) => {\n    router.delete(route('team-invitations.destroy', invitation.id), {\n        preserveScroll: true,\n    });\n};\n\nconst manageRole = (teamMember: User & { membership: Membership }) => {\n    managingRoleFor.value = teamMember;\n    updateRoleForm.role = teamMember.membership.role;\n    currentlyManagingRole.value = true;\n};\n\nconst updateRole = () => {\n    updateRoleForm.put(\n        route('team-members.update', {\n            team: props.team.id,\n            user: managingRoleFor.value?.id,\n        }),\n        {\n            preserveScroll: true,\n            onSuccess: () => (currentlyManagingRole.value = false),\n        }\n    );\n};\n\nconst confirmLeavingTeam = () => {\n    confirmingLeavingTeam.value = true;\n};\n\nconst leaveTeam = () => {\n    leaveTeamForm.delete(route('team-members.destroy', [props.team.id, page.props.auth.user.id]));\n};\n\nconst confirmTeamMemberRemoval = (teamMember: User) => {\n    teamMemberBeingRemoved.value = teamMember;\n};\n\nconst removeTeamMember = () => {\n    removeTeamMemberForm.delete(\n        route('team-members.destroy', {\n            team: props.team.id,\n            user: teamMemberBeingRemoved.value?.id,\n        }),\n        {\n            errorBag: 'removeTeamMember',\n            preserveScroll: true,\n            preserveState: true,\n            onSuccess: () => (teamMemberBeingRemoved.value = null),\n        }\n    );\n};\n\nconst displayableRole = (role: string) => {\n    return props.availableRoles.find((r) => r.key === role)?.name;\n};\n</script>\n\n<template>\n    <div>\n        <div v-if=\"userPermissions.canAddTeamMembers\">\n            <SectionBorder />\n\n            <!-- Add Organization Member -->\n            <FormSection @submitted=\"addTeamMember\">\n                <template #title> Add Organization Member</template>\n\n                <template #description>\n                    Add a new member to your organization, allowing them to collaborate with you.\n                </template>\n\n                <template #form>\n                    <div class=\"col-span-6\">\n                        <div class=\"max-w-xl text-sm text-muted\">\n                            Please provide the email address of the person you would like to add to\n                            this organization.\n                        </div>\n                    </div>\n\n                    <!-- Member Email -->\n                    <Field class=\"col-span-6 sm:col-span-4\">\n                        <FieldLabel for=\"email\">Email</FieldLabel>\n                        <TextInput\n                            id=\"email\"\n                            v-model=\"addTeamMemberForm.email\"\n                            type=\"email\"\n                            class=\"block w-full\" />\n                        <FieldError v-if=\"addTeamMemberForm.errors.email\">{{\n                            addTeamMemberForm.errors.email\n                        }}</FieldError>\n                    </Field>\n\n                    <!-- Role -->\n                    <div v-if=\"availableRoles.length > 0\" class=\"col-span-6 lg:col-span-4\">\n                        <FieldLabel for=\"roles\">Role</FieldLabel>\n                        <FieldError v-if=\"addTeamMemberForm.errors.role\">{{\n                            addTeamMemberForm.errors.role\n                        }}</FieldError>\n\n                        <div\n                            class=\"relative z-0 mt-1 border border-card-border rounded-lg cursor-pointer\">\n                            <button\n                                v-for=\"(role, i) in filterRoles(availableRoles)\"\n                                :key=\"role.key\"\n                                type=\"button\"\n                                class=\"relative px-4 py-3 inline-flex w-full rounded-lg focus:z-10 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500\"\n                                :class=\"{\n                                    'border-t border-card-border focus:border-none rounded-t-none':\n                                        i > 0,\n                                    'rounded-b-none': i != Object.keys(availableRoles).length - 1,\n                                }\"\n                                @click=\"addTeamMemberForm.role = role.key\">\n                                <div\n                                    :class=\"{\n                                        'opacity-50':\n                                            addTeamMemberForm.role &&\n                                            addTeamMemberForm.role != role.key,\n                                    }\">\n                                    <!-- Role Name -->\n                                    <div class=\"flex items-center\">\n                                        <div\n                                            class=\"text-sm text-text-primary\"\n                                            :class=\"{\n                                                'font-semibold': addTeamMemberForm.role == role.key,\n                                            }\">\n                                            {{ role.name }}\n                                        </div>\n\n                                        <svg\n                                            v-if=\"addTeamMemberForm.role == role.key\"\n                                            class=\"ms-2 h-5 w-5 text-green-400\"\n                                            xmlns=\"http://www.w3.org/2000/svg\"\n                                            fill=\"none\"\n                                            viewBox=\"0 0 24 24\"\n                                            stroke-width=\"1.5\"\n                                            stroke=\"currentColor\">\n                                            <path\n                                                stroke-linecap=\"round\"\n                                                stroke-linejoin=\"round\"\n                                                d=\"M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n                                        </svg>\n                                    </div>\n\n                                    <!-- Role Description -->\n                                    <div class=\"mt-2 text-xs text-muted text-start\">\n                                        {{ role.description }}\n                                    </div>\n                                </div>\n                            </button>\n                        </div>\n                    </div>\n                </template>\n\n                <template #actions>\n                    <ActionMessage :on=\"addTeamMemberForm.recentlySuccessful\" class=\"me-3\">\n                        Added.\n                    </ActionMessage>\n\n                    <PrimaryButton\n                        :class=\"{ 'opacity-25': addTeamMemberForm.processing }\"\n                        :disabled=\"addTeamMemberForm.processing\">\n                        Add\n                    </PrimaryButton>\n                </template>\n            </FormSection>\n        </div>\n\n        <div v-if=\"team.team_invitations.length > 0 && userPermissions.canAddTeamMembers\">\n            <SectionBorder />\n\n            <!-- Organization Member Invitations -->\n            <ActionSection class=\"mt-10 sm:mt-0\">\n                <template #title> Pending Organization Invitations</template>\n\n                <template #description>\n                    These people have been invited to your organization and have been sent an\n                    invitation email. They may join the organization by accepting the email\n                    invitation.\n                </template>\n\n                <!-- Pending Organization Member Invitation List -->\n                <template #content>\n                    <div class=\"space-y-6\">\n                        <div\n                            v-for=\"invitation in team.team_invitations\"\n                            :key=\"invitation.id\"\n                            class=\"flex items-center justify-between\">\n                            <div class=\"text-muted\">\n                                {{ invitation.email }}\n                            </div>\n\n                            <div class=\"flex items-center\">\n                                <!-- Cancel Organization Invitation -->\n                                <button\n                                    v-if=\"userPermissions.canRemoveTeamMembers\"\n                                    class=\"cursor-pointer ms-6 text-sm text-red-500 focus:outline-none\"\n                                    @click=\"cancelTeamInvitation(invitation)\">\n                                    Cancel\n                                </button>\n                            </div>\n                        </div>\n                    </div>\n                </template>\n            </ActionSection>\n        </div>\n\n        <div v-if=\"users.length > 0\">\n            <SectionBorder />\n\n            <!-- Manage Organization Members -->\n            <ActionSection class=\"mt-10 sm:mt-0\">\n                <template #title> Organization Members</template>\n\n                <template #description>\n                    All of the people that are part of this organization.\n                </template>\n\n                <!-- Organization Member List -->\n                <template #content>\n                    <div class=\"space-y-6\">\n                        <div\n                            v-for=\"user in users\"\n                            :key=\"user.id\"\n                            class=\"flex items-center justify-between\">\n                            <div class=\"flex items-center\">\n                                <img\n                                    class=\"w-8 h-8 rounded-full object-cover\"\n                                    :src=\"user.profile_photo_url\"\n                                    :alt=\"user.name\" />\n                                <div class=\"ms-4 text-text-primary\">\n                                    {{ user.name }}\n                                </div>\n                            </div>\n\n                            <div class=\"flex items-center\">\n                                <!-- Manage Organization Member Role -->\n                                <button\n                                    v-if=\"\n                                        userPermissions.canUpdateTeamMembers &&\n                                        availableRoles.length\n                                    \"\n                                    class=\"ms-2 text-sm text-gray-400 underline\"\n                                    @click=\"manageRole(user)\">\n                                    {{ displayableRole(user.membership.role) }}\n                                </button>\n\n                                <div\n                                    v-else-if=\"availableRoles.length\"\n                                    class=\"ms-2 text-sm text-gray-400\">\n                                    {{ displayableRole(user.membership.role) }}\n                                </div>\n\n                                <!-- Leave Organization -->\n                                <button\n                                    v-if=\"page.props.auth.user.id === user.id\"\n                                    class=\"cursor-pointer ms-6 text-sm text-red-500\"\n                                    @click=\"confirmLeavingTeam\">\n                                    Leave\n                                </button>\n\n                                <!-- Remove Organization Member -->\n                                <button\n                                    v-else-if=\"userPermissions.canRemoveTeamMembers\"\n                                    class=\"cursor-pointer ms-6 text-sm text-red-500\"\n                                    @click=\"confirmTeamMemberRemoval(user)\">\n                                    Remove\n                                </button>\n                            </div>\n                        </div>\n                    </div>\n                </template>\n            </ActionSection>\n        </div>\n\n        <!-- Role Management Modal -->\n        <DialogModal :show=\"currentlyManagingRole\" @close=\"currentlyManagingRole = false\">\n            <template #title> Manage Role</template>\n\n            <template #content>\n                <div v-if=\"managingRoleFor\">\n                    <div\n                        class=\"relative z-0 mt-1 border border-card-border rounded-lg cursor-pointer\">\n                        <button\n                            v-for=\"(role, i) in availableRoles\"\n                            :key=\"role.key\"\n                            type=\"button\"\n                            class=\"relative px-4 py-3 inline-flex w-full rounded-lg focus:z-10 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500\"\n                            :class=\"{\n                                'border-t border-card-border focus:border-none rounded-t-none':\n                                    i > 0,\n                                'rounded-b-none': i !== Object.keys(availableRoles).length - 1,\n                            }\"\n                            @click=\"updateRoleForm.role = role.key\">\n                            <div\n                                :class=\"{\n                                    'opacity-50':\n                                        updateRoleForm.role && updateRoleForm.role !== role.key,\n                                }\">\n                                <!-- Role Name -->\n                                <div class=\"flex items-center\">\n                                    <div\n                                        class=\"text-sm text-muted\"\n                                        :class=\"{\n                                            'font-semibold': updateRoleForm.role === role.key,\n                                        }\">\n                                        {{ role.name }}\n                                    </div>\n\n                                    <svg\n                                        v-if=\"updateRoleForm.role == role.key\"\n                                        class=\"ms-2 h-5 w-5 text-green-400\"\n                                        xmlns=\"http://www.w3.org/2000/svg\"\n                                        fill=\"none\"\n                                        viewBox=\"0 0 24 24\"\n                                        stroke-width=\"1.5\"\n                                        stroke=\"currentColor\">\n                                        <path\n                                            stroke-linecap=\"round\"\n                                            stroke-linejoin=\"round\"\n                                            d=\"M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n                                    </svg>\n                                </div>\n\n                                <!-- Role Description -->\n                                <div class=\"mt-2 text-xs text-muted\">\n                                    {{ role.description }}\n                                </div>\n                            </div>\n                        </button>\n                    </div>\n                </div>\n            </template>\n\n            <template #footer>\n                <SecondaryButton @click=\"currentlyManagingRole = false\"> Cancel </SecondaryButton>\n\n                <PrimaryButton\n                    class=\"ms-3\"\n                    :class=\"{ 'opacity-25': updateRoleForm.processing }\"\n                    :disabled=\"updateRoleForm.processing\"\n                    @click=\"updateRole\">\n                    Save\n                </PrimaryButton>\n            </template>\n        </DialogModal>\n\n        <!-- Leave Organization Confirmation Modal -->\n        <ConfirmationModal :show=\"confirmingLeavingTeam\" @close=\"confirmingLeavingTeam = false\">\n            <template #title> Leave Organization</template>\n\n            <template #content> Are you sure you would like to leave this organization? </template>\n\n            <template #footer>\n                <SecondaryButton @click=\"confirmingLeavingTeam = false\"> Cancel </SecondaryButton>\n\n                <DangerButton\n                    class=\"ms-3\"\n                    :class=\"{ 'opacity-25': leaveTeamForm.processing }\"\n                    :disabled=\"leaveTeamForm.processing\"\n                    @click=\"leaveTeam\">\n                    Leave\n                </DangerButton>\n            </template>\n        </ConfirmationModal>\n\n        <!-- Remove Organization Member Confirmation Modal -->\n        <ConfirmationModal :show=\"!!teamMemberBeingRemoved\" @close=\"teamMemberBeingRemoved = null\">\n            <template #title> Remove Organization Member</template>\n\n            <template #content>\n                Are you sure you would like to remove this person from the organization?\n            </template>\n\n            <template #footer>\n                <SecondaryButton @click=\"teamMemberBeingRemoved = null\"> Cancel </SecondaryButton>\n\n                <DangerButton\n                    class=\"ms-3\"\n                    :class=\"{ 'opacity-25': removeTeamMemberForm.processing }\"\n                    :disabled=\"removeTeamMemberForm.processing\"\n                    @click=\"removeTeamMember\">\n                    Remove\n                </DangerButton>\n            </template>\n        </ConfirmationModal>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Teams/Partials/UpdateTeamNameForm.vue",
    "content": "<script setup lang=\"ts\">\nimport { Link, useForm } from '@inertiajs/vue3';\nimport ActionMessage from '@/Components/ActionMessage.vue';\nimport FormSection from '@/Components/FormSection.vue';\nimport { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\nimport type { Organization } from '@/types/models';\nimport type { Permissions } from '@/types/jetstream';\nimport { CreditCardIcon } from '@heroicons/vue/20/solid';\nimport { isBillingActivated } from '@/utils/billing';\nimport { canManageBilling } from '@/utils/permissions';\n\nconst props = defineProps<{\n    team: Organization;\n    permissions: Permissions;\n}>();\n\nconst form = useForm({\n    name: props.team.name,\n    currency: props.team.currency,\n});\n\nconst updateTeamName = () => {\n    form.put(route('teams.update', props.team.id), {\n        errorBag: 'updateTeamName',\n        preserveScroll: true,\n    });\n};\n</script>\n\n<template>\n    <FormSection @submitted=\"updateTeamName\">\n        <template #title> Organization Name</template>\n\n        <template #description> The organization's name and owner information. </template>\n\n        <template #form>\n            <!-- Organization Owner Information -->\n            <div class=\"col-span-6 flex items-center justify-between\">\n                <div class=\"\">\n                    <FieldLabel>Organization Owner</FieldLabel>\n\n                    <div class=\"flex items-center mt-2\">\n                        <img\n                            class=\"w-12 h-12 rounded-full object-cover\"\n                            :src=\"team.owner.profile_photo_url\"\n                            :alt=\"team.owner.name\" />\n\n                        <div class=\"ms-4 leading-tight\">\n                            <div class=\"text-text-primary\">\n                                {{ team.owner.name }}\n                            </div>\n                            <div class=\"text-text-secondary text-sm\">\n                                {{ team.owner.email }}\n                            </div>\n                        </div>\n                    </div>\n                </div>\n                <div>\n                    <Link v-if=\"isBillingActivated() && canManageBilling()\" href=\"/billing\">\n                        <PrimaryButton :icon=\"CreditCardIcon\" type=\"button\">\n                            Go to Billing\n                        </PrimaryButton>\n                    </Link>\n                </div>\n            </div>\n\n            <!-- Organization Name -->\n            <Field class=\"col-span-6 sm:col-span-4\">\n                <FieldLabel for=\"name\">Organization Name</FieldLabel>\n\n                <TextInput\n                    id=\"name\"\n                    v-model=\"form.name\"\n                    type=\"text\"\n                    class=\"block w-full\"\n                    :disabled=\"!permissions.canUpdateTeam\" />\n\n                <FieldError v-if=\"form.errors.name\">{{ form.errors.name }}</FieldError>\n            </Field>\n\n            <!-- Currency -->\n            <Field class=\"col-span-6 sm:col-span-4\">\n                <FieldLabel for=\"currency\">Currency</FieldLabel>\n                <select\n                    id=\"currency\"\n                    v-model=\"form.currency\"\n                    name=\"currency\"\n                    :disabled=\"!permissions.canUpdateTeam\"\n                    class=\"block w-full border-input-border bg-input-background text-text-primary focus:border-input-border-active rounded-md shadow-sm\">\n                    <option value=\"\" disabled>Select a currency</option>\n                    <option\n                        v-for=\"(currencyTranslated, currencyKey) in $page.props.currencies\"\n                        :key=\"currencyKey\"\n                        :value=\"currencyKey\">\n                        {{ currencyKey }} - {{ currencyTranslated }}\n                    </option>\n                </select>\n                <FieldError v-if=\"form.errors.currency\">{{ form.errors.currency }}</FieldError>\n            </Field>\n        </template>\n\n        <template v-if=\"permissions.canUpdateTeam\" #actions>\n            <ActionMessage :on=\"form.recentlySuccessful\" class=\"me-3\"> Saved. </ActionMessage>\n\n            <PrimaryButton :class=\"{ 'opacity-25': form.processing }\" :disabled=\"form.processing\">\n                Save\n            </PrimaryButton>\n        </template>\n    </FormSection>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Teams/Show.vue",
    "content": "<script setup lang=\"ts\">\nimport AppLayout from '@/Layouts/AppLayout.vue';\nimport DeleteTeamForm from '@/Pages/Teams/Partials/DeleteTeamForm.vue';\nimport SectionBorder from '@/Components/SectionBorder.vue';\nimport UpdateTeamNameForm from '@/Pages/Teams/Partials/UpdateTeamNameForm.vue';\nimport type { Organization } from '@/types/models';\nimport type { Permissions, Role } from '@/types/jetstream';\nimport OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue';\nimport OrganizationFormatSettings from '@/Pages/Teams/Partials/OrganizationFormatSettings.vue';\nimport OrganizationTimeEntrySettings from '@/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue';\nimport { onMounted, ref } from 'vue';\nimport { useOrganizationStore } from '@/utils/useOrganization';\nimport { storeToRefs } from 'pinia';\n\ndefineProps<{\n    team: Organization;\n    availableRoles: Role[];\n    permissions: Permissions;\n}>();\n\nconst loading = ref(true);\nconst orgStore = useOrganizationStore();\nconst { organization } = storeToRefs(orgStore);\n\nonMounted(async () => {\n    await orgStore.fetchOrganization();\n    loading.value = false;\n});\n</script>\n\n<template>\n    <AppLayout title=\"Organization Settings\">\n        <template #header>\n            <h2 class=\"font-semibold text-xl text-text-primary leading-tight\">\n                Organization Settings\n            </h2>\n        </template>\n\n        <div>\n            <div class=\"max-w-7xl mx-auto py-10 sm:px-6 lg:px-8\">\n                <div v-if=\"loading || !organization\" class=\"py-16 text-center text-text-secondary\">\n                    Loading organization settings...\n                </div>\n                <template v-else>\n                    <UpdateTeamNameForm :team=\"team\" :permissions=\"permissions\" />\n\n                    <SectionBorder />\n                    <OrganizationBillableRate v-if=\"permissions.canUpdateTeam\" :team=\"team\" />\n                    <SectionBorder />\n\n                    <OrganizationFormatSettings v-if=\"permissions.canUpdateTeam\" :team=\"team\" />\n                    <SectionBorder />\n\n                    <OrganizationTimeEntrySettings v-if=\"permissions.canUpdateTeam\" />\n                    <SectionBorder />\n\n                    <template v-if=\"permissions.canDeleteTeam && !team.personal_team\">\n                        <DeleteTeamForm class=\"mt-10 sm:mt-0\" :team=\"team\" />\n                    </template>\n                </template>\n            </div>\n        </div>\n    </AppLayout>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/TermsOfService.vue",
    "content": "<script setup lang=\"ts\">\nimport { Head } from '@inertiajs/vue3';\nimport AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue';\n\ndefineProps({\n    terms: String,\n});\n</script>\n\n<template>\n    <Head title=\"Terms of Service\" />\n\n    <div class=\"font-sans text-text-secondary antialiased\">\n        <div class=\"pt-4 bg-gray-900\">\n            <div class=\"min-h-screen flex flex-col items-center pt-6 sm:pt-0\">\n                <div>\n                    <AuthenticationCardLogo />\n                </div>\n\n                <div\n                    class=\"w-full sm:max-w-2xl mt-6 p-6 bg-gray-800 shadow-md overflow-hidden sm:rounded-lg prose dark:prose-invert\"\n                    v-html=\"terms\" />\n            </div>\n        </div>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Time.vue",
    "content": "<script setup lang=\"ts\">\nimport AppLayout from '@/Layouts/AppLayout.vue';\nimport TimeTracker from '@/Components/TimeTracker.vue';\nimport { computed, ref, watch } from 'vue';\nimport MainContainer from '@/packages/ui/src/MainContainer.vue';\nimport { storeToRefs } from 'pinia';\nimport type {\n    CreateClientBody,\n    CreateProjectBody,\n    CreateTimeEntryBody,\n    Project,\n    TimeEntry,\n    Client,\n} from '@/packages/api/src';\nimport { useElementVisibility } from '@vueuse/core';\nimport { ClockIcon } from '@heroicons/vue/20/solid';\nimport LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';\nimport { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';\nimport { useTasksQuery } from '@/utils/useTasksQuery';\nimport { useProjectsQuery } from '@/utils/useProjectsQuery';\nimport TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue';\nimport { useTagsQuery } from '@/utils/useTagsQuery';\nimport { useClientsQuery } from '@/utils/useClientsQuery';\nimport { getOrganizationCurrencyString } from '@/utils/money';\nimport TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';\nimport type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';\nimport { isAllowedToPerformPremiumAction } from '@/utils/billing';\nimport { canCreateProjects } from '@/utils/permissions';\nimport { useTagsStore } from '@/utils/useTags';\nimport { useProjectsStore } from '@/utils/useProjects';\nimport { useClientsStore } from '@/utils/useClients';\nimport { useTimeEntriesInfiniteQuery } from '@/utils/useTimeEntriesInfiniteQuery';\nimport { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';\n\nconst { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } =\n    useTimeEntriesInfiniteQuery();\nconst {\n    createTimeEntry: createTimeEntryMutation,\n    updateTimeEntry,\n    updateTimeEntries: updateTimeEntriesMutation,\n    deleteTimeEntries: deleteTimeEntriesMutation,\n} = useTimeEntriesMutations();\n\nconst timeEntries = computed(() => data.value?.pages.flatMap((page) => page.data) || []);\n\nasync function updateTimeEntries(ids: string[], changes: UpdateMultipleTimeEntriesChangeset) {\n    await updateTimeEntriesMutation({ ids, changes });\n}\n\nconst loadMoreContainer = ref<HTMLDivElement | null>(null);\nconst isLoadMoreVisible = useElementVisibility(loadMoreContainer);\nconst currentTimeEntryStore = useCurrentTimeEntryStore();\nconst { currentTimeEntry } = storeToRefs(currentTimeEntryStore);\nconst { setActiveState } = currentTimeEntryStore;\n\nasync function startTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) {\n    if (currentTimeEntry.value.id) {\n        await setActiveState(false);\n    }\n    await createTimeEntryMutation(timeEntry);\n    useCurrentTimeEntryStore().fetchCurrentTimeEntry();\n}\n\nasync function deleteTimeEntries(timeEntries: TimeEntry[]) {\n    await deleteTimeEntriesMutation(timeEntries);\n}\n\nwatch(isLoadMoreVisible, async (isVisible) => {\n    if (isVisible && hasNextPage.value) {\n        await fetchNextPage();\n    }\n});\n\nconst { projects } = useProjectsQuery();\nconst { tasks } = useTasksQuery();\nconst { clients } = useClientsQuery();\n\nconst { tags } = useTagsQuery();\n\nasync function createTag(name: string) {\n    return await useTagsStore().createTag(name);\n}\nasync function createProject(project: CreateProjectBody): Promise<Project | undefined> {\n    return await useProjectsStore().createProject(project);\n}\nasync function createClient(body: CreateClientBody): Promise<Client | undefined> {\n    return await useClientsStore().createClient(body);\n}\n\nconst selectedTimeEntries = ref([] as TimeEntry[]);\n\nasync function clearSelectionAndState() {\n    selectedTimeEntries.value = [];\n}\n\nfunction deleteSelected() {\n    deleteTimeEntries(selectedTimeEntries.value);\n    selectedTimeEntries.value = [];\n}\n</script>\n\n<template>\n    <AppLayout title=\"Dashboard\" data-testid=\"time_view\">\n        <MainContainer class=\"pt-5 lg:pt-8 pb-4 lg:pb-6\">\n            <TimeTracker></TimeTracker>\n        </MainContainer>\n        <TimeEntryMassActionRow\n            :selected-time-entries=\"selectedTimeEntries\"\n            :enable-estimated-time=\"isAllowedToPerformPremiumAction()\"\n            :can-create-project=\"canCreateProjects()\"\n            :all-selected=\"selectedTimeEntries.length === timeEntries.length\"\n            :delete-selected=\"deleteSelected\"\n            :projects=\"projects\"\n            :tasks=\"tasks\"\n            :tags=\"tags\"\n            :currency=\"getOrganizationCurrencyString()\"\n            :clients=\"clients\"\n            class=\"border-t border-default-background-separator hidden sm:block\"\n            :update-time-entries=\"\n                (args) =>\n                    updateTimeEntries(\n                        selectedTimeEntries.map((timeEntry) => timeEntry.id),\n                        args\n                    )\n            \"\n            :create-project=\"createProject\"\n            :create-client=\"createClient\"\n            :create-tag=\"createTag\"\n            @submit=\"clearSelectionAndState\"\n            @select-all=\"selectedTimeEntries = [...timeEntries]\"\n            @unselect-all=\"selectedTimeEntries = []\"></TimeEntryMassActionRow>\n        <TimeEntryGroupedTable\n            v-model:selected=\"selectedTimeEntries\"\n            :create-project\n            :enable-estimated-time=\"isAllowedToPerformPremiumAction()\"\n            :can-create-project=\"canCreateProjects()\"\n            :clients\n            :create-client\n            :update-time-entry\n            :update-time-entries\n            :delete-time-entries\n            :create-time-entry=\"startTimeEntry\"\n            :create-tag\n            :projects=\"projects\"\n            :tasks=\"tasks\"\n            :currency=\"getOrganizationCurrencyString()\"\n            :time-entries=\"timeEntries\"\n            :tags=\"tags\"></TimeEntryGroupedTable>\n        <div v-if=\"isPending\" class=\"flex justify-center items-center py-12\">\n            <LoadingSpinner></LoadingSpinner>\n        </div>\n        <div v-else-if=\"timeEntries.length === 0\" class=\"text-center pt-12\">\n            <ClockIcon class=\"w-8 text-icon-default inline pb-2\"></ClockIcon>\n            <h3 class=\"text-text-primary font-semibold\">No time entries found</h3>\n            <p class=\"pb-5\">Create your first time entry now!</p>\n        </div>\n        <div ref=\"loadMoreContainer\">\n            <div\n                v-if=\"isFetchingNextPage\"\n                class=\"flex justify-center items-center py-5 text-text-primary font-medium\">\n                <LoadingSpinner></LoadingSpinner>\n                <span> Loading more time entries... </span>\n            </div>\n            <div\n                v-else-if=\"!hasNextPage && timeEntries.length > 0\"\n                class=\"flex justify-center items-center py-5 text-sm text-text-tertiary\">\n                All time entries are loaded!\n            </div>\n        </div>\n    </AppLayout>\n</template>\n"
  },
  {
    "path": "resources/js/Pages/Welcome.vue",
    "content": "<script setup lang=\"ts\">\nimport { Head, Link, usePage } from '@inertiajs/vue3';\nimport { computed } from 'vue';\nimport type { User } from '@/types/models';\n\ndefineProps({\n    canLogin: Boolean,\n    canRegister: Boolean,\n    laravelVersion: String,\n    phpVersion: String,\n});\n\nconst page = usePage<{\n    auth: {\n        user: User;\n    };\n}>();\n\nconst user = computed(() => page?.props?.auth?.user);\n</script>\n\n<template>\n    <Head title=\"Welcome\" />\n\n    <div\n        class=\"relative sm:flex sm:justify-center sm:items-center min-h-screen bg-dots-darker bg-center bg-gray-100 dark:bg-dots-lighter dark:bg-gray-900 selection:bg-red-500 selection:text-text-primary\">\n        <div v-if=\"canLogin\" class=\"sm:fixed sm:top-0 sm:end-0 p-6 text-end z-10\">\n            <Link\n                v-if=\"user\"\n                :href=\"route('dashboard')\"\n                class=\"font-semibold text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-text-primary focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500\"\n                >Dashboard</Link\n            >\n\n            <template v-else>\n                <Link\n                    :href=\"route('login')\"\n                    class=\"font-semibold text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-text-primary focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500\"\n                    >Log in</Link\n                >\n\n                <Link\n                    v-if=\"canRegister\"\n                    :href=\"route('register')\"\n                    class=\"ms-4 font-semibold text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500\"\n                    >Register</Link\n                >\n            </template>\n        </div>\n\n        <div class=\"max-w-7xl mx-auto p-6 lg:p-8\">\n            <div class=\"flex justify-center\">\n                <svg\n                    viewBox=\"0 0 62 65\"\n                    fill=\"none\"\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                    class=\"h-16 w-auto bg-gray-100 dark:bg-gray-900\">\n                    <path\n                        d=\"M61.8548 14.6253C61.8778 14.7102 61.8895 14.7978 61.8897 14.8858V28.5615C61.8898 28.737 61.8434 28.9095 61.7554 29.0614C61.6675 29.2132 61.5409 29.3392 61.3887 29.4265L49.9104 36.0351V49.1337C49.9104 49.4902 49.7209 49.8192 49.4118 49.9987L25.4519 63.7916C25.3971 63.8227 25.3372 63.8427 25.2774 63.8639C25.255 63.8714 25.2338 63.8851 25.2101 63.8913C25.0426 63.9354 24.8666 63.9354 24.6991 63.8913C24.6716 63.8838 24.6467 63.8689 24.6205 63.8589C24.5657 63.8389 24.5084 63.8215 24.456 63.7916L0.501061 49.9987C0.348882 49.9113 0.222437 49.7853 0.134469 49.6334C0.0465019 49.4816 0.000120578 49.3092 0 49.1337L0 8.10652C0 8.01678 0.0124642 7.92953 0.0348998 7.84477C0.0423783 7.8161 0.0598282 7.78993 0.0697995 7.76126C0.0884958 7.70891 0.105946 7.65531 0.133367 7.6067C0.152063 7.5743 0.179485 7.54812 0.20192 7.51821C0.230588 7.47832 0.256763 7.43719 0.290416 7.40229C0.319084 7.37362 0.356476 7.35243 0.388883 7.32751C0.425029 7.29759 0.457436 7.26518 0.498568 7.2415L12.4779 0.345059C12.6296 0.257786 12.8015 0.211853 12.9765 0.211853C13.1515 0.211853 13.3234 0.257786 13.475 0.345059L25.4531 7.2415H25.4556C25.4955 7.26643 25.5292 7.29759 25.5653 7.32626C25.5977 7.35119 25.6339 7.37362 25.6625 7.40104C25.6974 7.43719 25.7224 7.47832 25.7523 7.51821C25.7735 7.54812 25.8021 7.5743 25.8196 7.6067C25.8483 7.65656 25.8645 7.70891 25.8844 7.76126C25.8944 7.78993 25.9118 7.8161 25.9193 7.84602C25.9423 7.93096 25.954 8.01853 25.9542 8.10652V33.7317L35.9355 27.9844V14.8846C35.9355 14.7973 35.948 14.7088 35.9704 14.6253C35.9792 14.5954 35.9954 14.5692 36.0053 14.5405C36.0253 14.4882 36.0427 14.4346 36.0702 14.386C36.0888 14.3536 36.1163 14.3274 36.1375 14.2975C36.1674 14.2576 36.1923 14.2165 36.2272 14.1816C36.2559 14.1529 36.292 14.1317 36.3244 14.1068C36.3618 14.0769 36.3942 14.0445 36.4341 14.0208L48.4147 7.12434C48.5663 7.03694 48.7383 6.99094 48.9133 6.99094C49.0883 6.99094 49.2602 7.03694 49.4118 7.12434L61.3899 14.0208C61.4323 14.0457 61.4647 14.0769 61.5021 14.1055C61.5333 14.1305 61.5694 14.1529 61.5981 14.1803C61.633 14.2165 61.6579 14.2576 61.6878 14.2975C61.7103 14.3274 61.7377 14.3536 61.7551 14.386C61.7838 14.4346 61.8 14.4882 61.8199 14.5405C61.8312 14.5692 61.8474 14.5954 61.8548 14.6253ZM59.893 27.9844V16.6121L55.7013 19.0252L49.9104 22.3593V33.7317L59.8942 27.9844H59.893ZM47.9149 48.5566V37.1768L42.2187 40.4299L25.953 49.7133V61.2003L47.9149 48.5566ZM1.99677 9.83281V48.5566L23.9562 61.199V49.7145L12.4841 43.2219L12.4804 43.2194L12.4754 43.2169C12.4368 43.1945 12.4044 43.1621 12.3682 43.1347C12.3371 43.1097 12.3009 43.0898 12.2735 43.0624L12.271 43.0586C12.2386 43.0275 12.2162 42.9888 12.1887 42.9539C12.1638 42.9203 12.1339 42.8916 12.114 42.8567L12.1127 42.853C12.0903 42.8156 12.0766 42.7707 12.0604 42.7283C12.0442 42.6909 12.023 42.656 12.013 42.6161C12.0005 42.5688 11.998 42.5177 11.9931 42.4691C11.9881 42.4317 11.9781 42.3943 11.9781 42.3569V15.5801L6.18848 12.2446L1.99677 9.83281ZM12.9777 2.36177L2.99764 8.10652L12.9752 13.8513L22.9541 8.10527L12.9752 2.36177H12.9777ZM18.1678 38.2138L23.9574 34.8809V9.83281L19.7657 12.2459L13.9749 15.5801V40.6281L18.1678 38.2138ZM48.9133 9.14105L38.9344 14.8858L48.9133 20.6305L58.8909 14.8846L48.9133 9.14105ZM47.9149 22.3593L42.124 19.0252L37.9323 16.6121V27.9844L43.7219 31.3174L47.9149 33.7317V22.3593ZM24.9533 47.987L39.59 39.631L46.9065 35.4555L36.9352 29.7145L25.4544 36.3242L14.9907 42.3482L24.9533 47.987Z\"\n                        fill=\"#FF2D20\" />\n                </svg>\n            </div>\n\n            <div class=\"mt-16\">\n                <div class=\"grid grid-cols-1 md:grid-cols-2 gap-6 lg:gap-8\">\n                    <a\n                        href=\"https://laravel.com/docs\"\n                        class=\"scale-100 p-6 bg-white dark:bg-gray-800/50 dark:bg-gradient-to-bl from-gray-700/50 via-transparent dark:ring-1 dark:ring-inset dark:ring-white/5 rounded-lg shadow-2xl shadow-gray-500/20 dark:shadow-none flex motion-safe:hover:scale-[1.01] transition-all duration-250 focus:outline focus:outline-2 focus:outline-red-500\">\n                        <div>\n                            <div\n                                class=\"h-16 w-16 bg-red-50 dark:bg-red-800/20 flex items-center justify-center rounded-full\">\n                                <svg\n                                    xmlns=\"http://www.w3.org/2000/svg\"\n                                    fill=\"none\"\n                                    viewBox=\"0 0 24 24\"\n                                    stroke-width=\"1.5\"\n                                    class=\"w-7 h-7 stroke-red-500\">\n                                    <path\n                                        stroke-linecap=\"round\"\n                                        stroke-linejoin=\"round\"\n                                        d=\"M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25\" />\n                                </svg>\n                            </div>\n\n                            <h2 class=\"mt-6 text-xl font-semibold text-gray-900 dark:text-white\">\n                                Documentation\n                            </h2>\n\n                            <p\n                                class=\"mt-4 text-gray-500 dark:text-gray-400 text-sm leading-relaxed\">\n                                Laravel has wonderful documentation covering every aspect of the\n                                framework. Whether you are a newcomer or have prior experience with\n                                Laravel, we recommend reading our documentation from beginning to\n                                end.\n                            </p>\n                        </div>\n\n                        <svg\n                            xmlns=\"http://www.w3.org/2000/svg\"\n                            fill=\"none\"\n                            viewBox=\"0 0 24 24\"\n                            stroke-width=\"1.5\"\n                            class=\"self-center shrink-0 stroke-red-500 w-6 h-6 mx-6\">\n                            <path\n                                stroke-linecap=\"round\"\n                                stroke-linejoin=\"round\"\n                                d=\"M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75\" />\n                        </svg>\n                    </a>\n\n                    <a\n                        href=\"https://laracasts.com\"\n                        class=\"scale-100 p-6 bg-white dark:bg-gray-800/50 dark:bg-gradient-to-bl from-gray-700/50 via-transparent dark:ring-1 dark:ring-inset dark:ring-white/5 rounded-lg shadow-2xl shadow-gray-500/20 dark:shadow-none flex motion-safe:hover:scale-[1.01] transition-all duration-250 focus:outline focus:outline-2 focus:outline-red-500\">\n                        <div>\n                            <div\n                                class=\"h-16 w-16 bg-red-50 dark:bg-red-800/20 flex items-center justify-center rounded-full\">\n                                <svg\n                                    xmlns=\"http://www.w3.org/2000/svg\"\n                                    fill=\"none\"\n                                    viewBox=\"0 0 24 24\"\n                                    stroke-width=\"1.5\"\n                                    class=\"w-7 h-7 stroke-red-500\">\n                                    <path\n                                        stroke-linecap=\"round\"\n                                        d=\"M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z\" />\n                                </svg>\n                            </div>\n\n                            <h2 class=\"mt-6 text-xl font-semibold text-gray-900 dark:text-white\">\n                                Laracasts\n                            </h2>\n\n                            <p\n                                class=\"mt-4 text-gray-500 dark:text-gray-400 text-sm leading-relaxed\">\n                                Laracasts offers thousands of video tutorials on Laravel, PHP, and\n                                JavaScript development. Check them out, see for yourself, and\n                                massively level up your development skills in the process.\n                            </p>\n                        </div>\n\n                        <svg\n                            xmlns=\"http://www.w3.org/2000/svg\"\n                            fill=\"none\"\n                            viewBox=\"0 0 24 24\"\n                            stroke-width=\"1.5\"\n                            class=\"self-center shrink-0 stroke-red-500 w-6 h-6 mx-6\">\n                            <path\n                                stroke-linecap=\"round\"\n                                stroke-linejoin=\"round\"\n                                d=\"M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75\" />\n                        </svg>\n                    </a>\n\n                    <a\n                        href=\"https://laravel-news.com\"\n                        class=\"scale-100 p-6 bg-white dark:bg-gray-800/50 dark:bg-gradient-to-bl from-gray-700/50 via-transparent dark:ring-1 dark:ring-inset dark:ring-white/5 rounded-lg shadow-2xl shadow-gray-500/20 dark:shadow-none flex motion-safe:hover:scale-[1.01] transition-all duration-250 focus:outline focus:outline-2 focus:outline-red-500\">\n                        <div>\n                            <div\n                                class=\"h-16 w-16 bg-red-50 dark:bg-red-800/20 flex items-center justify-center rounded-full\">\n                                <svg\n                                    xmlns=\"http://www.w3.org/2000/svg\"\n                                    fill=\"none\"\n                                    viewBox=\"0 0 24 24\"\n                                    stroke-width=\"1.5\"\n                                    class=\"w-7 h-7 stroke-red-500\">\n                                    <path\n                                        stroke-linecap=\"round\"\n                                        stroke-linejoin=\"round\"\n                                        d=\"M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 01-2.25 2.25M16.5 7.5V18a2.25 2.25 0 002.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 002.25 2.25h13.5M6 7.5h3v3H6v-3z\" />\n                                </svg>\n                            </div>\n\n                            <h2 class=\"mt-6 text-xl font-semibold text-gray-900 dark:text-white\">\n                                Laravel News\n                            </h2>\n\n                            <p\n                                class=\"mt-4 text-gray-500 dark:text-gray-400 text-sm leading-relaxed\">\n                                Laravel News is a community driven portal and newsletter aggregating\n                                all of the latest and most important news in the Laravel ecosystem,\n                                including new package releases and tutorials.\n                            </p>\n                        </div>\n\n                        <svg\n                            xmlns=\"http://www.w3.org/2000/svg\"\n                            fill=\"none\"\n                            viewBox=\"0 0 24 24\"\n                            stroke-width=\"1.5\"\n                            class=\"self-center shrink-0 stroke-red-500 w-6 h-6 mx-6\">\n                            <path\n                                stroke-linecap=\"round\"\n                                stroke-linejoin=\"round\"\n                                d=\"M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75\" />\n                        </svg>\n                    </a>\n\n                    <div\n                        class=\"scale-100 p-6 bg-white dark:bg-gray-800/50 dark:bg-gradient-to-bl from-gray-700/50 via-transparent dark:ring-1 dark:ring-inset dark:ring-white/5 rounded-lg shadow-2xl shadow-gray-500/20 dark:shadow-none flex motion-safe:hover:scale-[1.01] transition-all duration-250 focus:outline focus:outline-2 focus:outline-red-500\">\n                        <div>\n                            <div\n                                class=\"h-16 w-16 bg-red-50 dark:bg-red-800/20 flex items-center justify-center rounded-full\">\n                                <svg\n                                    xmlns=\"http://www.w3.org/2000/svg\"\n                                    fill=\"none\"\n                                    viewBox=\"0 0 24 24\"\n                                    stroke-width=\"1.5\"\n                                    class=\"w-7 h-7 stroke-red-500\">\n                                    <path\n                                        stroke-linecap=\"round\"\n                                        stroke-linejoin=\"round\"\n                                        d=\"M6.115 5.19l.319 1.913A6 6 0 008.11 10.36L9.75 12l-.387.775c-.217.433-.132.956.21 1.298l1.348 1.348c.21.21.329.497.329.795v1.089c0 .426.24.815.622 1.006l.153.076c.433.217.956.132 1.298-.21l.723-.723a8.7 8.7 0 002.288-4.042 1.087 1.087 0 00-.358-1.099l-1.33-1.108c-.251-.21-.582-.299-.905-.245l-1.17.195a1.125 1.125 0 01-.98-.314l-.295-.295a1.125 1.125 0 010-1.591l.13-.132a1.125 1.125 0 011.3-.21l.603.302a.809.809 0 001.086-1.086L14.25 7.5l1.256-.837a4.5 4.5 0 001.528-1.732l.146-.292M6.115 5.19A9 9 0 1017.18 4.64M6.115 5.19A8.965 8.965 0 0112 3c1.929 0 3.716.607 5.18 1.64\" />\n                                </svg>\n                            </div>\n\n                            <h2 class=\"mt-6 text-xl font-semibold text-gray-900 dark:text-white\">\n                                Vibrant Ecosystem\n                            </h2>\n\n                            <p\n                                class=\"mt-4 text-gray-500 dark:text-gray-400 text-sm leading-relaxed\">\n                                Laravel's robust library of first-party tools and libraries, such as\n                                <a\n                                    href=\"https://forge.laravel.com\"\n                                    class=\"underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500\"\n                                    >Forge</a\n                                >,\n                                <a\n                                    href=\"https://vapor.laravel.com\"\n                                    class=\"underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500\"\n                                    >Vapor</a\n                                >,\n                                <a\n                                    href=\"https://nova.laravel.com\"\n                                    class=\"underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500\"\n                                    >Nova</a\n                                >, and\n                                <a\n                                    href=\"https://envoyer.io\"\n                                    class=\"underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500\"\n                                    >Envoyer</a\n                                >\n                                help you take your projects to the next level. Pair them with\n                                powerful open source libraries like\n                                <a\n                                    href=\"https://laravel.com/docs/billing\"\n                                    class=\"underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500\"\n                                    >Cashier</a\n                                >,\n                                <a\n                                    href=\"https://laravel.com/docs/dusk\"\n                                    class=\"underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500\"\n                                    >Dusk</a\n                                >,\n                                <a\n                                    href=\"https://laravel.com/docs/broadcasting\"\n                                    class=\"underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500\"\n                                    >Echo</a\n                                >,\n                                <a\n                                    href=\"https://laravel.com/docs/horizon\"\n                                    class=\"underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500\"\n                                    >Horizon</a\n                                >,\n                                <a\n                                    href=\"https://laravel.com/docs/sanctum\"\n                                    class=\"underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500\"\n                                    >Sanctum</a\n                                >,\n                                <a\n                                    href=\"https://laravel.com/docs/telescope\"\n                                    class=\"underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500\"\n                                    >Telescope</a\n                                >, and more.\n                            </p>\n                        </div>\n                    </div>\n                </div>\n            </div>\n\n            <div class=\"flex justify-center mt-16 px-6 sm:items-center sm:justify-between\">\n                <div class=\"text-center text-sm text-gray-500 dark:text-gray-400 sm:text-start\">\n                    <div class=\"flex items-center gap-4\">\n                        <a\n                            href=\"https://github.com/sponsors/taylorotwell\"\n                            class=\"group inline-flex items-center hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500\">\n                            <svg\n                                xmlns=\"http://www.w3.org/2000/svg\"\n                                fill=\"none\"\n                                viewBox=\"0 0 24 24\"\n                                stroke-width=\"1.5\"\n                                class=\"-mt-px me-1 w-5 h-5 stroke-gray-400 dark:stroke-gray-600 group-hover:stroke-gray-600 dark:group-hover:stroke-gray-400\">\n                                <path\n                                    stroke-linecap=\"round\"\n                                    stroke-linejoin=\"round\"\n                                    d=\"M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z\" />\n                            </svg>\n                            Sponsor\n                        </a>\n                    </div>\n                </div>\n\n                <div\n                    class=\"ms-4 text-center text-sm text-gray-500 dark:text-gray-400 sm:text-end sm:ms-0\">\n                    Laravel v{{ laravelVersion }} (PHP v{{ phpVersion }})\n                </div>\n            </div>\n        </div>\n    </div>\n</template>\n\n<style>\n.bg-dots-darker {\n    background-image: url(\"data:image/svg+xml,%3Csvg width='30' height='30' viewBox='0 0 30 30' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1.22676 0C1.91374 0 2.45351 0.539773 2.45351 1.22676C2.45351 1.91374 1.91374 2.45351 1.22676 2.45351C0.539773 2.45351 0 1.91374 0 1.22676C0 0.539773 0.539773 0 1.22676 0Z' fill='rgba(0,0,0,0.07)'/%3E%3C/svg%3E\");\n}\n@media (prefers-color-scheme: dark) {\n    .dark\\:bg-dots-lighter {\n        background-image: url(\"data:image/svg+xml,%3Csvg width='30' height='30' viewBox='0 0 30 30' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1.22676 0C1.91374 0 2.45351 0.539773 2.45351 1.22676C2.45351 1.91374 1.91374 2.45351 1.22676 2.45351C0.539773 2.45351 0 1.91374 0 1.22676C0 0.539773 0.539773 0 1.22676 0Z' fill='rgba(255,255,255,0.07)'/%3E%3C/svg%3E\");\n    }\n}\n</style>\n"
  },
  {
    "path": "resources/js/app.ts",
    "content": "import './bootstrap';\nimport '../css/app.css';\nimport { createApp, h } from 'vue';\nimport { createInertiaApp, usePage } from '@inertiajs/vue3';\nimport { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';\nimport { ZiggyVue } from '../../vendor/tightenco/ziggy';\nimport { createPinia } from 'pinia';\nimport type { User } from '@/types/models';\nimport { QueryClient, VueQueryPlugin } from '@tanstack/vue-query';\nimport { type DefineComponent } from 'vue';\nimport { setupPrefetching } from '@/utils/prefetch';\n\nconst appName = import.meta.env.VITE_APP_NAME || 'Laravel';\nconst pinia = createPinia();\nconst queryClient = new QueryClient();\n\ncreateInertiaApp({\n    title: (title) => `${title} - ${appName}`,\n    resolve: (name) => {\n        if (name.includes('Invoicing::')) {\n            const [module, page] = name.split('::');\n\n            const pagePath = module\n                ? `../../extensions/${module}/resources/js/Pages/${page}.vue`\n                : `./Pages/${page}.vue`;\n\n            // BillingPortal is a Vue 2 Component and therefore should not be imported\n            const pages = module\n                ? import.meta.glob<DefineComponent>([\n                      '../../extensions/**/resources/js/Pages/*.vue',\n                      '!**/BillingPortal.vue',\n                  ])\n                : import.meta.glob<DefineComponent>('./Pages/**/*.vue');\n\n            return resolvePageComponent(pagePath, pages);\n        } else {\n            return resolvePageComponent(\n                `./Pages/${name}.vue`,\n                import.meta.glob<DefineComponent>('./Pages/**/*.vue')\n            );\n        }\n    },\n    setup({ el, App, props, plugin }) {\n        const app = createApp({ render: () => h(App, props) });\n\n        // currently only one vue app setup hook is supported\n        if (window.vueAppSetupHook) {\n            window.vueAppSetupHook(app);\n        }\n        window.getWeekStartSetting = function () {\n            const page = usePage<{\n                auth: {\n                    user: User;\n                };\n            }>();\n            return page.props.auth.user.week_start ?? 'monday';\n        };\n        window.getTimezoneSetting = function () {\n            const page = usePage<{\n                auth: {\n                    user: User;\n                };\n            }>();\n            return page.props.auth.user.timezone;\n        };\n\n        app.use(plugin).use(pinia).use(ZiggyVue).use(VueQueryPlugin, { queryClient }).mount(el);\n\n        // Setup Inertia prefetching to warm TanStack Query cache\n        setupPrefetching(queryClient);\n    },\n\n    progress: {\n        color: '#4B5563',\n    },\n});\n"
  },
  {
    "path": "resources/js/bootstrap.js",
    "content": "/**\n * We'll load the axios HTTP library which allows us to easily issue requests\n * to our Laravel back-end. This library automatically handles sending the\n * CSRF token as a header based on the value of the \"XSRF\" token cookie.\n */\n\nimport axios from 'axios';\nwindow.axios = axios;\n\nwindow.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';\n\n/**\n * Echo exposes an expressive API for subscribing to channels and listening\n * for events that are broadcast by Laravel. Echo and event broadcasting\n * allows your team to easily build robust real-time web applications.\n */\n\n// import Echo from 'laravel-echo';\n\n// import Pusher from 'pusher-js';\n// window.Pusher = Pusher;\n\n// window.Echo = new Echo({\n//     broadcaster: 'pusher',\n//     key: import.meta.env.VITE_PUSHER_APP_KEY,\n//     cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER ?? 'mt1',\n//     wsHost: import.meta.env.VITE_PUSHER_HOST ? import.meta.env.VITE_PUSHER_HOST : `ws-${import.meta.env.VITE_PUSHER_APP_CLUSTER}.pusher.com`,\n//     wsPort: import.meta.env.VITE_PUSHER_PORT ?? 80,\n//     wssPort: import.meta.env.VITE_PUSHER_PORT ?? 443,\n//     forceTLS: (import.meta.env.VITE_PUSHER_SCHEME ?? 'https') === 'https',\n//     enabledTransports: ['ws', 'wss'],\n// });\n"
  },
  {
    "path": "resources/js/lib/utils.ts",
    "content": "import { type ClassValue, clsx } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n    return twMerge(clsx(inputs.filter(Boolean)));\n}\n"
  },
  {
    "path": "resources/js/packages/api/.gitignore",
    "content": "node_modules\ndist\nout\n.DS_Store\n*.log*\n"
  },
  {
    "path": "resources/js/packages/api/package.json",
    "content": "{\n    \"name\": \"@solidtime/api\",\n    \"version\": \"0.0.6\",\n    \"description\": \"Package containing the solidtime api client and type declarations\",\n    \"main\": \"./dist/solidtime-api.umd.cjs\",\n    \"module\": \"./dist/solidtime-api.js\",\n    \"types\": \"./dist/index.d.ts\",\n    \"repository\": {\n        \"type\": \"git\",\n        \"url\": \"git+https://github.com/solidtime-io/solidtime.git\",\n        \"directory\": \"resources/js/packages/api\"\n    },\n    \"scripts\": {\n        \"build\": \"vite build\",\n        \"test\": \"echo \\\"Error: no test specified\\\" && exit 1\"\n    },\n    \"files\": [\n        \"dist\"\n    ],\n    \"keywords\": [\n        \"solidtime\",\n        \"timetracker\",\n        \"timetracking\",\n        \"api\",\n        \"client\"\n    ],\n    \"type\": \"module\",\n    \"author\": \"solidtime\",\n    \"license\": \"AGPL-3.0\",\n    \"devDependencies\": {\n        \"vite-plugin-dts\": \"^4.0.3\"\n    },\n    \"peerDependencies\": {\n        \"@zodios/core\": \"^10.9.6\",\n        \"axios\": \"^1.6.4\",\n        \"typescript\": \"^5.5.4\",\n        \"vite\": \"^5.4.1 || ^6.0.0 || ^7.0.0\",\n        \"zod\": \"^3.23.8\"\n    }\n}\n"
  },
  {
    "path": "resources/js/packages/api/src/index.ts",
    "content": "import type {\n    ApiOf,\n    ZodiosResponseByAlias,\n    ZodiosBodyByAlias,\n    ZodiosQueryParamsByAlias,\n} from '@zodios/core';\nimport { createApiClient } from './openapi.json.client';\n\nexport type SolidTimeApi = ApiOf<typeof api>;\n\nexport type InvitationsIndexResponse = ZodiosResponseByAlias<SolidTimeApi, 'getInvitations'>;\n\nexport type CreateInvitationBody = ZodiosBodyByAlias<SolidTimeApi, 'invite'>;\n\nexport type Invitation = InvitationsIndexResponse['data'][0];\n\nexport type TimeEntryResponse = ZodiosResponseByAlias<SolidTimeApi, 'getTimeEntries'>;\nexport type TimeEntry = TimeEntryResponse['data'][0];\n\nexport type CreateTimeEntryBody = ZodiosBodyByAlias<SolidTimeApi, 'createTimeEntry'>;\n\nexport type UpdateMultipleTimeEntriesBody = ZodiosBodyByAlias<\n    SolidTimeApi,\n    'updateMultipleTimeEntries'\n>;\n\nexport type UpdateMultipleTimeEntriesChangeset = UpdateMultipleTimeEntriesBody['changes'];\n\nexport type ProjectResponse = ZodiosResponseByAlias<SolidTimeApi, 'getProjects'>;\nexport type Project = ProjectResponse['data'][0];\n\nexport type CreateProjectBody = ZodiosBodyByAlias<SolidTimeApi, 'createProject'>;\n\nexport type UpdateProjectBody = ZodiosBodyByAlias<SolidTimeApi, 'updateProject'>;\n\nexport type ProjectMemberResponse = ZodiosResponseByAlias<SolidTimeApi, 'getProjectMembers'>;\n\nexport type CreateProjectMemberBody = ZodiosBodyByAlias<SolidTimeApi, 'createProjectMember'>;\n\nexport type UpdateProjectMemberBody = ZodiosBodyByAlias<SolidTimeApi, 'updateProjectMember'>;\n\nexport type ProjectMember = ProjectMemberResponse['data'][0];\n\nexport type CreateTaskBody = ZodiosBodyByAlias<SolidTimeApi, 'createTask'>;\n\nexport type CreateClientBody = ZodiosBodyByAlias<SolidTimeApi, 'createClient'>;\nexport type UpdateClientBody = ZodiosBodyByAlias<SolidTimeApi, 'updateClient'>;\n\nexport type TagIndexResponse = ZodiosResponseByAlias<SolidTimeApi, 'getTags'>;\nexport type Tag = TagIndexResponse['data'][0];\n\nexport type TaskIndexResponse = ZodiosResponseByAlias<SolidTimeApi, 'getTasks'>;\nexport type Task = TaskIndexResponse['data'][0];\n\nexport type UpdateTaskBody = ZodiosBodyByAlias<SolidTimeApi, 'updateTask'>;\n\nexport type ClientIndexResponse = ZodiosResponseByAlias<SolidTimeApi, 'getClients'>;\nexport type Client = ClientIndexResponse['data'][0];\n\nexport type MemberIndexResponse = ZodiosResponseByAlias<SolidTimeApi, 'getMembers'>;\nexport type Member = MemberIndexResponse['data'][0];\n\nexport type UpdateMemberBody = ZodiosBodyByAlias<SolidTimeApi, 'updateMember'>;\n\nexport type InviteMemberBody = ZodiosBodyByAlias<SolidTimeApi, 'invite'>;\nexport type MemberRole = InviteMemberBody['role'];\n\nexport type CreateTagBody = ZodiosBodyByAlias<SolidTimeApi, 'createTag'>;\nexport type UpdateTagBody = ZodiosBodyByAlias<SolidTimeApi, 'updateTag'>;\n\nexport type ImportType = ZodiosResponseByAlias<SolidTimeApi, 'getImporters'>['data'][0];\nexport type ImportReport = ZodiosResponseByAlias<SolidTimeApi, 'importData'>;\n\nexport type ReportingResponse = ZodiosResponseByAlias<SolidTimeApi, 'getAggregatedTimeEntries'>;\n\nexport type AggregatedTimeEntries = ReportingResponse['data'];\nexport type GroupedDataEntries = ReportingResponse['data']['grouped_data'];\n\nexport type TimeEntriesQueryParams = ZodiosQueryParamsByAlias<SolidTimeApi, 'getTimeEntries'>;\n\nexport type AggregatedTimeEntriesQueryParams = ZodiosQueryParamsByAlias<\n    SolidTimeApi,\n    'getAggregatedTimeEntries'\n> & {\n    start: string;\n    end: string;\n    rounding_type?: string;\n    rounding_minutes?: number;\n};\n\nexport type OrganizationResponse = ZodiosResponseByAlias<SolidTimeApi, 'getOrganization'>;\n\nexport type Organization = ZodiosResponseByAlias<SolidTimeApi, 'getOrganization'>['data'];\n\nexport type UpdateOrganizationBody = ZodiosBodyByAlias<SolidTimeApi, 'updateOrganization'>;\n\nexport type MyMemberships = ZodiosResponseByAlias<SolidTimeApi, 'getMyMemberships'>['data'];\n\nexport type MyMembership = MyMemberships[0];\n\nexport type OrganizationExportResponse = ZodiosResponseByAlias<SolidTimeApi, 'exportOrganization'>;\n\nexport type ReportIndexResponse = ZodiosResponseByAlias<SolidTimeApi, 'getReports'>;\n\nexport type CreateReportBody = ZodiosBodyByAlias<SolidTimeApi, 'createReport'>;\nexport type UpdateReportBody = ZodiosBodyByAlias<SolidTimeApi, 'updateReport'>;\nexport type CreateReportBodyProperties = CreateReportBody['properties'];\nexport type Report = ReportIndexResponse['data'][0];\n\nexport type ApiTokenIndexResponse = ZodiosResponseByAlias<SolidTimeApi, 'getApiTokens'>;\n\nexport type CreateApiTokenBody = ZodiosBodyByAlias<SolidTimeApi, 'createApiToken'>;\nexport type ApiToken = ApiTokenIndexResponse['data'][0];\n\nexport type DetailedInvoiceResponse = ZodiosResponseByAlias<SolidTimeApi, 'getInvoice'>;\n\nexport type InvoiceIndexEntry = ZodiosResponseByAlias<SolidTimeApi, 'getInvoices'>['data'][0];\n\nexport type UpdateInvoiceSettings = ZodiosBodyByAlias<SolidTimeApi, 'updateInvoiceSettings'>;\n\nexport type CreateInvoiceBody = ZodiosBodyByAlias<SolidTimeApi, 'createInvoice'>;\n\nexport type UpdateInvoiceBody = ZodiosBodyByAlias<SolidTimeApi, 'updateInvoice'>;\n\nconst api = createApiClient('/api', { validate: 'none' });\n\nexport { createApiClient, api };\n"
  },
  {
    "path": "resources/js/packages/api/src/openapi.json.client.ts",
    "content": "import { makeApi, Zodios, type ZodiosOptions } from '@zodios/core';\nimport { z } from 'zod';\n\nconst ApiTokenResource = z\n    .object({\n        id: z.string(),\n        name: z.string(),\n        revoked: z.boolean(),\n        scopes: z.array(z.string()),\n        created_at: z.string(),\n        expires_at: z.union([z.string(), z.null()]),\n    })\n    .passthrough();\nconst ApiTokenCollection = z.array(ApiTokenResource);\nconst ApiTokenStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();\nconst ApiTokenWithAccessTokenResource = z\n    .object({\n        id: z.string(),\n        name: z.string(),\n        revoked: z.boolean(),\n        scopes: z.array(z.string()),\n        created_at: z.string(),\n        expires_at: z.union([z.string(), z.null()]),\n        access_token: z.string(),\n    })\n    .passthrough();\nconst ClientResource = z\n    .object({\n        id: z.string(),\n        name: z.string(),\n        is_archived: z.boolean(),\n        created_at: z.string(),\n        updated_at: z.string(),\n    })\n    .passthrough();\nconst ClientStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();\nconst ClientUpdateRequest = z\n    .object({ name: z.string().min(1).max(255), is_archived: z.boolean().optional() })\n    .passthrough();\nconst ImportRequest = z.object({ type: z.string(), data: z.string() }).passthrough();\nconst InvitationResource = z\n    .object({ id: z.string(), email: z.string(), role: z.string() })\n    .passthrough();\nconst InvitationStoreRequest = z\n    .object({ email: z.string().email(), role: z.enum(['admin', 'manager', 'employee']) })\n    .passthrough();\nconst InvoiceResource = z\n    .object({\n        id: z.string(),\n        organization_id: z.string(),\n        reference: z.string(),\n        seller_name: z.string(),\n        buyer_name: z.string(),\n        status: z.string(),\n        date: z.string(),\n        due_at: z.string(),\n        paid_date: z.string(),\n        created_at: z.union([z.string(), z.null()]),\n        updated_at: z.union([z.string(), z.null()]),\n    })\n    .passthrough();\nconst InvoiceCollection = z.array(InvoiceResource);\nconst InvoiceDiscountType = z.enum(['percentage', 'fixed']);\nconst InvoiceStoreRequest = z\n    .object({\n        due_at: z.union([z.string(), z.null()]).optional(),\n        paid_date: z.union([z.string(), z.null()]).optional(),\n        seller_name: z.string(),\n        seller_vatin: z.union([z.string(), z.null()]).optional(),\n        seller_address_line_1: z.union([z.string(), z.null()]).optional(),\n        seller_address_line_2: z.union([z.string(), z.null()]).optional(),\n        seller_address_line_3: z.union([z.string(), z.null()]).optional(),\n        seller_address_post_code: z.union([z.string(), z.null()]).optional(),\n        seller_address_city: z.union([z.string(), z.null()]).optional(),\n        seller_address_country: z.union([z.string(), z.null()]).optional(),\n        seller_phone: z.union([z.string(), z.null()]).optional(),\n        seller_email: z.union([z.string(), z.null()]).optional(),\n        buyer_name: z.string(),\n        buyer_vatin: z.union([z.string(), z.null()]).optional(),\n        buyer_address_line_1: z.union([z.string(), z.null()]).optional(),\n        buyer_address_line_2: z.union([z.string(), z.null()]).optional(),\n        buyer_address_line_3: z.union([z.string(), z.null()]).optional(),\n        buyer_address_post_code: z.union([z.string(), z.null()]).optional(),\n        buyer_address_city: z.union([z.string(), z.null()]).optional(),\n        buyer_address_country: z.union([z.string(), z.null()]).optional(),\n        buyer_phone: z.union([z.string(), z.null()]).optional(),\n        buyer_email: z.union([z.string(), z.null()]).optional(),\n        date: z.string(),\n        billing_period_start: z.union([z.string(), z.null()]).optional(),\n        billing_period_end: z.union([z.string(), z.null()]).optional(),\n        reference: z.string(),\n        currency: z.string(),\n        payment_iban: z.union([z.string(), z.null()]).optional(),\n        tax_rate: z.number().int().gte(0).lte(2147483647).optional(),\n        discount_amount: z.number().int().gte(0).lte(9223372036854776000).optional(),\n        discount_type: InvoiceDiscountType.optional(),\n        footer: z.union([z.string(), z.null()]).optional(),\n        notes: z.union([z.string(), z.null()]).optional(),\n        payment_terms: z.union([z.string(), z.null()]).optional(),\n        is_eu_reverse_charge: z.boolean().optional(),\n        entries: z\n            .array(\n                z\n                    .object({\n                        name: z.string(),\n                        description: z.union([z.string(), z.null()]).optional(),\n                        unit_price: z.number().int().gte(0).lte(9223372036854776000),\n                        quantity: z.number().gte(0).lte(99999999),\n                    })\n                    .passthrough()\n            )\n            .optional(),\n    })\n    .passthrough();\nconst InvoiceEntryResource = z\n    .object({\n        id: z.string(),\n        invoice_id: z.string(),\n        name: z.string(),\n        description: z.union([z.string(), z.null()]),\n        unit_price: z.number().int(),\n        quantity: z.number(),\n        order_index: z.number().int(),\n        created_at: z.union([z.string(), z.null()]),\n        updated_at: z.union([z.string(), z.null()]),\n    })\n    .passthrough();\nconst DetailedInvoiceResource = z\n    .object({\n        id: z.string(),\n        organization_id: z.string(),\n        reference: z.string(),\n        seller_name: z.string(),\n        seller_vatin: z.string(),\n        seller_address_line_1: z.string(),\n        seller_address_line_2: z.string(),\n        seller_address_line_3: z.string(),\n        seller_address_post_code: z.string(),\n        seller_address_city: z.string(),\n        seller_address_country: z.string(),\n        seller_phone: z.string(),\n        seller_email: z.string(),\n        buyer_name: z.string(),\n        buyer_vatin: z.string(),\n        buyer_address_line_1: z.string(),\n        buyer_address_line_2: z.string(),\n        buyer_address_line_3: z.string(),\n        buyer_address_post_code: z.string(),\n        buyer_address_city: z.string(),\n        buyer_address_country: z.string(),\n        buyer_phone: z.string(),\n        buyer_email: z.string(),\n        paid_date: z.string(),\n        due_at: z.string(),\n        discount_type: z.string(),\n        discount_amount: z.number().int(),\n        tax_rate: z.number().int(),\n        payment_iban: z.string(),\n        status: z.string(),\n        currency: z.string(),\n        date: z.string(),\n        footer: z.string(),\n        notes: z.string(),\n        payment_terms: z.string(),\n        is_eu_reverse_charge: z.string(),\n        billing_period_start: z.string(),\n        billing_period_end: z.string(),\n        created_at: z.union([z.string(), z.null()]),\n        updated_at: z.union([z.string(), z.null()]),\n        entries: z.array(InvoiceEntryResource),\n    })\n    .passthrough();\nconst InvoiceStatus = z.enum(['draft', 'sent', 'cancelled']);\nconst InvoiceUpdateRequest = z\n    .object({\n        status: InvoiceStatus,\n        due_at: z.union([z.string(), z.null()]),\n        paid_date: z.union([z.string(), z.null()]),\n        seller_name: z.string(),\n        seller_vatin: z.union([z.string(), z.null()]),\n        seller_address_line_1: z.union([z.string(), z.null()]),\n        seller_address_line_2: z.union([z.string(), z.null()]),\n        seller_address_line_3: z.union([z.string(), z.null()]),\n        seller_address_post_code: z.union([z.string(), z.null()]),\n        seller_address_city: z.union([z.string(), z.null()]),\n        seller_address_country: z.union([z.string(), z.null()]),\n        seller_phone: z.union([z.string(), z.null()]),\n        seller_email: z.union([z.string(), z.null()]),\n        buyer_name: z.string(),\n        buyer_vatin: z.union([z.string(), z.null()]),\n        buyer_address_line_1: z.union([z.string(), z.null()]),\n        buyer_address_line_2: z.union([z.string(), z.null()]),\n        buyer_address_line_3: z.union([z.string(), z.null()]),\n        buyer_address_post_code: z.union([z.string(), z.null()]),\n        buyer_address_city: z.union([z.string(), z.null()]),\n        buyer_address_country: z.union([z.string(), z.null()]),\n        buyer_phone: z.union([z.string(), z.null()]),\n        buyer_email: z.union([z.string(), z.null()]),\n        date: z.string(),\n        billing_period_start: z.union([z.string(), z.null()]),\n        billing_period_end: z.union([z.string(), z.null()]),\n        reference: z.string(),\n        currency: z.string(),\n        payment_iban: z.union([z.string(), z.null()]),\n        tax_rate: z.number().int().gte(0).lte(2147483647),\n        discount_amount: z.number().int().gte(0).lte(9223372036854776000),\n        discount_type: InvoiceDiscountType,\n        footer: z.union([z.string(), z.null()]),\n        notes: z.union([z.string(), z.null()]),\n        payment_terms: z.union([z.string(), z.null()]),\n        is_eu_reverse_charge: z.boolean(),\n        entries: z.array(\n            z\n                .object({\n                    id: z.union([z.string(), z.null()]).optional(),\n                    name: z.string(),\n                    description: z.union([z.string(), z.null()]).optional(),\n                    unit_price: z.number().int().gte(0).lte(9223372036854776000),\n                    quantity: z.number().gte(0).lte(99999999),\n                })\n                .passthrough()\n        ),\n    })\n    .partial()\n    .passthrough();\nconst InvoiceDownloadRequest = z.object({ with_e_invoice: z.boolean() }).passthrough();\nconst InvoiceSettingResource = z\n    .object({\n        seller_name: z.union([z.string(), z.null()]),\n        seller_vatin: z.union([z.string(), z.null()]),\n        seller_address_line_1: z.union([z.string(), z.null()]),\n        seller_address_line_2: z.union([z.string(), z.null()]),\n        seller_address_line_3: z.union([z.string(), z.null()]),\n        seller_address_post_code: z.union([z.string(), z.null()]),\n        seller_address_city: z.union([z.string(), z.null()]),\n        seller_address_country: z.union([z.string(), z.null()]),\n        seller_phone: z.union([z.string(), z.null()]),\n        seller_email: z.union([z.string(), z.null()]),\n        footer_default: z.union([z.string(), z.null()]),\n        notes_default: z.union([z.string(), z.null()]),\n        tax_rate_default: z.union([z.number(), z.null()]),\n        e_invoicing_enabled: z.boolean(),\n        organization_id: z.string(),\n    })\n    .passthrough();\nconst InvoiceSettingUpdateRequest = z\n    .object({\n        seller_name: z.union([z.string(), z.null()]),\n        seller_vatin: z.union([z.string(), z.null()]),\n        seller_address_line_1: z.union([z.string(), z.null()]),\n        seller_address_line_2: z.union([z.string(), z.null()]),\n        seller_address_line_3: z.union([z.string(), z.null()]),\n        seller_address_post_code: z.union([z.string(), z.null()]),\n        seller_address_city: z.union([z.string(), z.null()]),\n        seller_address_country: z.union([z.string(), z.null()]),\n        seller_phone: z.union([z.string(), z.null()]),\n        seller_email: z.union([z.string(), z.null()]),\n        footer_default: z.union([z.string(), z.null()]),\n        notes_default: z.union([z.string(), z.null()]),\n        tax_rate_default: z.union([z.number(), z.null()]),\n        e_invoicing_enabled: z.boolean(),\n    })\n    .partial()\n    .passthrough();\nconst MemberResource = z\n    .object({\n        id: z.string(),\n        user_id: z.string(),\n        name: z.string(),\n        email: z.string(),\n        role: z.string(),\n        is_placeholder: z.boolean(),\n        billable_rate: z.union([z.number(), z.null()]),\n    })\n    .passthrough();\nconst Role = z.enum(['owner', 'admin', 'manager', 'employee', 'placeholder']);\nconst MemberUpdateRequest = z\n    .object({ role: Role, billable_rate: z.union([z.number(), z.null()]) })\n    .partial()\n    .passthrough();\nconst MemberMergeIntoRequest = z.object({ member_id: z.string() }).partial().passthrough();\nconst NumberFormat = z.enum([\n    'point-comma',\n    'comma-point',\n    'space-comma',\n    'space-point',\n    'apostrophe-point',\n]);\nconst CurrencyFormat = z.enum([\n    'iso-code-before-with-space',\n    'iso-code-after-with-space',\n    'symbol-before',\n    'symbol-after',\n    'symbol-before-with-space',\n    'symbol-after-with-space',\n]);\nconst DateFormat = z.enum([\n    'point-separated-d-m-yyyy',\n    'slash-separated-mm-dd-yyyy',\n    'slash-separated-dd-mm-yyyy',\n    'hyphen-separated-dd-mm-yyyy',\n    'hyphen-separated-mm-dd-yyyy',\n    'hyphen-separated-yyyy-mm-dd',\n]);\nconst IntervalFormat = z.enum([\n    'decimal',\n    'hours-minutes',\n    'hours-minutes-colon-separated',\n    'hours-minutes-seconds-colon-separated',\n]);\nconst TimeFormat = z.enum(['12-hours', '24-hours']);\nconst OrganizationResource = z\n    .object({\n        id: z.string(),\n        name: z.string(),\n        is_personal: z.boolean(),\n        billable_rate: z.union([z.number(), z.null()]),\n        employees_can_see_billable_rates: z.boolean(),\n        employees_can_manage_tasks: z.boolean(),\n        prevent_overlapping_time_entries: z.boolean(),\n        currency: z.string(),\n        currency_symbol: z.string(),\n        number_format: NumberFormat,\n        currency_format: CurrencyFormat,\n        date_format: DateFormat,\n        interval_format: IntervalFormat,\n        time_format: TimeFormat,\n    })\n    .passthrough();\nconst OrganizationUpdateRequest = z\n    .object({\n        name: z.string().max(255),\n        billable_rate: z.union([z.number(), z.null()]),\n        employees_can_see_billable_rates: z.boolean(),\n        employees_can_manage_tasks: z.boolean(),\n        prevent_overlapping_time_entries: z.boolean(),\n        number_format: NumberFormat,\n        currency_format: CurrencyFormat,\n        date_format: DateFormat,\n        interval_format: IntervalFormat,\n        time_format: TimeFormat,\n    })\n    .partial()\n    .passthrough();\nconst ProjectResource = z\n    .object({\n        id: z.string(),\n        name: z.string(),\n        color: z.string(),\n        client_id: z.union([z.string(), z.null()]),\n        is_archived: z.boolean(),\n        billable_rate: z.union([z.number(), z.null()]),\n        is_billable: z.boolean(),\n        estimated_time: z.union([z.number(), z.null()]),\n        spent_time: z.number().int(),\n        is_public: z.boolean(),\n    })\n    .passthrough();\nconst ProjectStoreRequest = z\n    .object({\n        name: z.string().min(1).max(255),\n        color: z.string().max(255),\n        is_billable: z.boolean(),\n        billable_rate: z.union([z.number(), z.null()]).optional(),\n        client_id: z.union([z.string(), z.null()]).optional(),\n        estimated_time: z.union([z.number(), z.null()]).optional(),\n        is_public: z.boolean().optional(),\n    })\n    .passthrough();\nconst ProjectUpdateRequest = z\n    .object({\n        name: z.string().max(255),\n        color: z.string().max(255),\n        is_billable: z.boolean(),\n        is_archived: z.boolean().optional(),\n        is_public: z.boolean().optional(),\n        client_id: z.union([z.string(), z.null()]).optional(),\n        billable_rate: z.union([z.number(), z.null()]).optional(),\n        estimated_time: z.union([z.number(), z.null()]).optional(),\n    })\n    .passthrough();\nconst ProjectMemberResource = z\n    .object({\n        id: z.string(),\n        billable_rate: z.union([z.number(), z.null()]),\n        member_id: z.string(),\n        project_id: z.string(),\n    })\n    .passthrough();\nconst ProjectMemberStoreRequest = z\n    .object({ member_id: z.string(), billable_rate: z.union([z.number(), z.null()]).optional() })\n    .passthrough();\nconst ProjectMemberUpdateRequest = z\n    .object({ billable_rate: z.union([z.number(), z.null()]) })\n    .partial()\n    .passthrough();\nconst ReportResource = z\n    .object({\n        id: z.string(),\n        name: z.string(),\n        description: z.union([z.string(), z.null()]),\n        is_public: z.boolean(),\n        public_until: z.union([z.string(), z.null()]),\n        shareable_link: z.union([z.string(), z.null()]),\n        created_at: z.string(),\n        updated_at: z.string(),\n    })\n    .passthrough();\nconst TimeEntryAggregationType = z.enum([\n    'day',\n    'week',\n    'month',\n    'year',\n    'user',\n    'project',\n    'task',\n    'client',\n    'billable',\n    'description',\n    'tag',\n]);\nconst TimeEntryAggregationTypeInterval = z.enum(['day', 'week', 'month', 'year']);\nconst Weekday = z.enum([\n    'monday',\n    'tuesday',\n    'wednesday',\n    'thursday',\n    'friday',\n    'saturday',\n    'sunday',\n]);\nconst TimeEntryRoundingType = z.enum(['up', 'down', 'nearest']);\nconst ReportStoreRequest = z\n    .object({\n        name: z.string().max(255),\n        description: z.union([z.string(), z.null()]).optional(),\n        is_public: z.boolean(),\n        public_until: z.union([z.string(), z.null()]).optional(),\n        properties: z\n            .object({\n                start: z.string(),\n                end: z.string(),\n                active: z.union([z.boolean(), z.null()]).optional(),\n                member_ids: z.union([z.array(z.string().uuid()), z.null()]).optional(),\n                billable: z.union([z.boolean(), z.null()]).optional(),\n                client_ids: z.union([z.array(z.string()), z.null()]).optional(),\n                project_ids: z.union([z.array(z.string()), z.null()]).optional(),\n                tag_ids: z.union([z.array(z.string()), z.null()]).optional(),\n                task_ids: z.union([z.array(z.string()), z.null()]).optional(),\n                group: TimeEntryAggregationType,\n                sub_group: TimeEntryAggregationType,\n                history_group: TimeEntryAggregationTypeInterval,\n                week_start: Weekday.optional(),\n                timezone: z.union([z.string(), z.null()]).optional(),\n                rounding_type: TimeEntryRoundingType.optional(),\n                rounding_minutes: z.union([z.number(), z.null()]).optional(),\n            })\n            .passthrough(),\n    })\n    .passthrough();\nconst DetailedReportResource = z\n    .object({\n        id: z.string(),\n        name: z.string(),\n        description: z.union([z.string(), z.null()]),\n        is_public: z.boolean(),\n        public_until: z.union([z.string(), z.null()]),\n        shareable_link: z.union([z.string(), z.null()]),\n        properties: z\n            .object({\n                group: z.string(),\n                sub_group: z.string(),\n                history_group: z.string(),\n                start: z.string(),\n                end: z.string(),\n                active: z.union([z.boolean(), z.null()]),\n                member_ids: z.union([z.array(z.string()), z.null()]),\n                billable: z.union([z.boolean(), z.null()]),\n                client_ids: z.union([z.array(z.string()), z.null()]),\n                project_ids: z.union([z.array(z.string()), z.null()]),\n                tag_ids: z.union([z.array(z.string()), z.null()]),\n                task_ids: z.union([z.array(z.string()), z.null()]),\n                rounding_type: z.union([z.string(), z.null()]),\n                rounding_minutes: z.union([z.number(), z.null()]),\n            })\n            .passthrough(),\n        created_at: z.string(),\n        updated_at: z.string(),\n    })\n    .passthrough();\nconst ReportUpdateRequest = z\n    .object({\n        name: z.string().max(255),\n        description: z.union([z.string(), z.null()]),\n        is_public: z.boolean(),\n        public_until: z.union([z.string(), z.null()]),\n    })\n    .partial()\n    .passthrough();\nconst DetailedWithDataReportResource = z\n    .object({\n        name: z.string(),\n        description: z.union([z.string(), z.null()]),\n        public_until: z.union([z.string(), z.null()]),\n        currency: z.string(),\n        number_format: NumberFormat,\n        currency_format: CurrencyFormat,\n        currency_symbol: z.string(),\n        date_format: DateFormat,\n        interval_format: IntervalFormat,\n        time_format: TimeFormat,\n        properties: z\n            .object({\n                group: z.string(),\n                sub_group: z.string(),\n                history_group: z.string(),\n                start: z.string(),\n                end: z.string(),\n            })\n            .passthrough(),\n        data: z\n            .object({\n                grouped_type: z.union([z.string(), z.null()]),\n                grouped_data: z.union([\n                    z.array(\n                        z\n                            .object({\n                                key: z.union([z.string(), z.null()]),\n                                description: z.union([z.string(), z.null()]),\n                                color: z.union([z.string(), z.null()]),\n                                seconds: z.number().int(),\n                                cost: z.number().int(),\n                                grouped_type: z.union([z.string(), z.null()]),\n                                grouped_data: z.union([\n                                    z.array(\n                                        z\n                                            .object({\n                                                key: z.union([z.string(), z.null()]),\n                                                description: z.union([z.string(), z.null()]),\n                                                color: z.union([z.string(), z.null()]),\n                                                seconds: z.number().int(),\n                                                cost: z.number().int(),\n                                                grouped_type: z.null(),\n                                                grouped_data: z.null(),\n                                            })\n                                            .passthrough()\n                                    ),\n                                    z.null(),\n                                ]),\n                            })\n                            .passthrough()\n                    ),\n                    z.null(),\n                ]),\n                seconds: z.number().int(),\n                cost: z.number().int(),\n            })\n            .passthrough(),\n        history_data: z\n            .object({\n                grouped_type: z.union([z.string(), z.null()]),\n                grouped_data: z.union([\n                    z.array(\n                        z\n                            .object({\n                                key: z.union([z.string(), z.null()]),\n                                description: z.union([z.string(), z.null()]),\n                                seconds: z.number().int(),\n                                cost: z.number().int(),\n                                grouped_type: z.union([z.string(), z.null()]),\n                                grouped_data: z.union([\n                                    z.array(\n                                        z\n                                            .object({\n                                                key: z.union([z.string(), z.null()]),\n                                                description: z.union([z.string(), z.null()]),\n                                                seconds: z.number().int(),\n                                                cost: z.number().int(),\n                                                grouped_type: z.null(),\n                                                grouped_data: z.null(),\n                                            })\n                                            .passthrough()\n                                    ),\n                                    z.null(),\n                                ]),\n                            })\n                            .passthrough()\n                    ),\n                    z.null(),\n                ]),\n                seconds: z.number().int(),\n                cost: z.number().int(),\n            })\n            .passthrough(),\n    })\n    .passthrough();\nconst TagResource = z\n    .object({ id: z.string(), name: z.string(), created_at: z.string(), updated_at: z.string() })\n    .passthrough();\nconst TagStoreRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();\nconst TagUpdateRequest = z.object({ name: z.string().min(1).max(255) }).passthrough();\nconst TaskResource = z\n    .object({\n        id: z.string(),\n        name: z.string(),\n        is_done: z.boolean(),\n        project_id: z.string(),\n        estimated_time: z.union([z.number(), z.null()]),\n        spent_time: z.number().int(),\n        created_at: z.string(),\n        updated_at: z.string(),\n    })\n    .passthrough();\nconst TaskStoreRequest = z\n    .object({\n        name: z.string().min(1).max(255),\n        project_id: z.string(),\n        estimated_time: z.union([z.number(), z.null()]).optional(),\n    })\n    .passthrough();\nconst TaskUpdateRequest = z\n    .object({\n        name: z.string().min(1).max(255),\n        is_done: z.boolean().optional(),\n        estimated_time: z.union([z.number(), z.null()]).optional(),\n    })\n    .passthrough();\nconst start = z.union([z.string(), z.null()]).optional();\nconst rounding_minutes = z.union([z.number(), z.null()]).optional();\nconst TimeEntryResource = z\n    .object({\n        id: z.string(),\n        start: z.string(),\n        end: z.union([z.string(), z.null()]),\n        duration: z.union([z.number(), z.null()]),\n        description: z.union([z.string(), z.null()]),\n        task_id: z.union([z.string(), z.null()]),\n        project_id: z.union([z.string(), z.null()]),\n        organization_id: z.string(),\n        user_id: z.string(),\n        tags: z.array(z.string()),\n        billable: z.boolean(),\n    })\n    .passthrough();\nconst TimeEntryStoreRequest = z\n    .object({\n        member_id: z.string(),\n        project_id: z.union([z.string(), z.null()]).optional(),\n        task_id: z.union([z.string(), z.null()]).optional(),\n        start: z.string(),\n        end: z.union([z.string(), z.null()]).optional(),\n        billable: z.boolean(),\n        description: z.union([z.string(), z.null()]).optional(),\n        tags: z.union([z.array(z.string()), z.null()]).optional(),\n    })\n    .passthrough();\nconst TimeEntryUpdateMultipleRequest = z\n    .object({\n        ids: z.array(z.string().uuid()),\n        changes: z\n            .object({\n                member_id: z.string(),\n                project_id: z.union([z.string(), z.null()]),\n                task_id: z.union([z.string(), z.null()]),\n                billable: z.boolean(),\n                description: z.union([z.string(), z.null()]),\n                tags: z.union([z.array(z.string()), z.null()]),\n            })\n            .partial()\n            .passthrough(),\n    })\n    .passthrough();\nconst TimeEntryUpdateRequest = z\n    .object({\n        member_id: z.string(),\n        project_id: z.union([z.string(), z.null()]),\n        task_id: z.union([z.string(), z.null()]),\n        start: z.string(),\n        end: z.union([z.string(), z.null()]),\n        billable: z.boolean(),\n        description: z.union([z.string(), z.null()]),\n        tags: z.union([z.array(z.string()), z.null()]),\n    })\n    .partial()\n    .passthrough();\nconst UserResource = z\n    .object({\n        id: z.string(),\n        name: z.string(),\n        email: z.string(),\n        profile_photo_url: z.string(),\n        timezone: z.string(),\n        week_start: Weekday,\n    })\n    .passthrough();\nconst PersonalMembershipResource = z\n    .object({\n        id: z.string(),\n        organization: z\n            .object({ id: z.string(), name: z.string(), currency: z.string() })\n            .passthrough(),\n        role: z.string(),\n    })\n    .passthrough();\n\nexport const schemas = {\n    ApiTokenResource,\n    ApiTokenCollection,\n    ApiTokenStoreRequest,\n    ApiTokenWithAccessTokenResource,\n    ClientResource,\n    ClientStoreRequest,\n    ClientUpdateRequest,\n    ImportRequest,\n    InvitationResource,\n    InvitationStoreRequest,\n    InvoiceResource,\n    InvoiceCollection,\n    InvoiceDiscountType,\n    InvoiceStoreRequest,\n    InvoiceEntryResource,\n    DetailedInvoiceResource,\n    InvoiceStatus,\n    InvoiceUpdateRequest,\n    InvoiceDownloadRequest,\n    InvoiceSettingResource,\n    InvoiceSettingUpdateRequest,\n    MemberResource,\n    Role,\n    MemberUpdateRequest,\n    MemberMergeIntoRequest,\n    NumberFormat,\n    CurrencyFormat,\n    DateFormat,\n    IntervalFormat,\n    TimeFormat,\n    OrganizationResource,\n    OrganizationUpdateRequest,\n    ProjectResource,\n    ProjectStoreRequest,\n    ProjectUpdateRequest,\n    ProjectMemberResource,\n    ProjectMemberStoreRequest,\n    ProjectMemberUpdateRequest,\n    ReportResource,\n    TimeEntryAggregationType,\n    TimeEntryAggregationTypeInterval,\n    Weekday,\n    TimeEntryRoundingType,\n    ReportStoreRequest,\n    DetailedReportResource,\n    ReportUpdateRequest,\n    DetailedWithDataReportResource,\n    TagResource,\n    TagStoreRequest,\n    TagUpdateRequest,\n    TaskResource,\n    TaskStoreRequest,\n    TaskUpdateRequest,\n    start,\n    rounding_minutes,\n    TimeEntryResource,\n    TimeEntryStoreRequest,\n    TimeEntryUpdateMultipleRequest,\n    TimeEntryUpdateRequest,\n    UserResource,\n    PersonalMembershipResource,\n};\n\nconst endpoints = makeApi([\n    {\n        method: 'get',\n        path: '/v1/countries',\n        alias: 'getCountries',\n        requestFormat: 'json',\n        response: z.array(z.object({ code: z.string(), name: z.string() }).passthrough()),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/currencies',\n        alias: 'getCurrencies',\n        requestFormat: 'json',\n        response: z.array(\n            z.object({ code: z.string(), name: z.string(), symbol: z.string() }).passthrough()\n        ),\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization',\n        alias: 'getOrganization',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ data: OrganizationResource }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'put',\n        path: '/v1/organizations/:organization',\n        alias: 'updateOrganization',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'body',\n                type: 'Body',\n                schema: OrganizationUpdateRequest,\n            },\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ data: OrganizationResource }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/charts/daily-tracked-hours',\n        alias: 'dailyTrackedHours',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.array(z.object({ date: z.string(), duration: z.number().int() }).passthrough()),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/charts/last-seven-days',\n        alias: 'lastSevenDays',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.array(\n            z\n                .object({\n                    date: z.string(),\n                    duration: z.number().int(),\n                    history: z.array(z.number().int()),\n                })\n                .passthrough()\n        ),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/charts/latest-tasks',\n        alias: 'latestTasks',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.array(\n            z\n                .object({\n                    task_id: z.string(),\n                    name: z.string(),\n                    description: z.union([z.string(), z.null()]),\n                    status: z.boolean(),\n                    time_entry_id: z.union([z.string(), z.null()]),\n                })\n                .passthrough()\n        ),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/charts/latest-team-activity',\n        alias: 'latestTeamActivity',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.array(\n            z\n                .object({\n                    member_id: z.string(),\n                    name: z.string(),\n                    description: z.union([z.string(), z.null()]),\n                    time_entry_id: z.string(),\n                    task_id: z.union([z.string(), z.null()]),\n                    status: z.boolean(),\n                })\n                .passthrough()\n        ),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/charts/total-weekly-billable-amount',\n        alias: 'totalWeeklyBillableAmount',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ value: z.number().int(), currency: z.string() }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/charts/total-weekly-billable-time',\n        alias: 'totalWeeklyBillableTime',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.number().int(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/charts/total-weekly-time',\n        alias: 'totalWeeklyTime',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.number().int(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/charts/weekly-history',\n        alias: 'weeklyHistory',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.array(z.object({ date: z.string(), duration: z.number().int() }).passthrough()),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/charts/weekly-project-overview',\n        alias: 'weeklyProjectOverview',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.array(\n            z.object({ value: z.number().int(), name: z.string(), color: z.string() }).passthrough()\n        ),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/clients',\n        alias: 'getClients',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'page',\n                type: 'Query',\n                schema: z.number().int().gte(1).lte(2147483647).optional(),\n            },\n            {\n                name: 'archived',\n                type: 'Query',\n                schema: z.enum(['true', 'false', 'all']).optional(),\n            },\n        ],\n        response: z\n            .object({\n                data: z.array(ClientResource),\n                links: z\n                    .object({\n                        first: z.union([z.string(), z.null()]),\n                        last: z.union([z.string(), z.null()]),\n                        prev: z.union([z.string(), z.null()]),\n                        next: z.union([z.string(), z.null()]),\n                    })\n                    .passthrough(),\n                meta: z\n                    .object({\n                        current_page: z.number().int(),\n                        from: z.union([z.number(), z.null()]),\n                        last_page: z.number().int(),\n                        links: z.array(\n                            z\n                                .object({\n                                    url: z.union([z.string(), z.null()]),\n                                    label: z.string(),\n                                    active: z.boolean(),\n                                })\n                                .passthrough()\n                        ),\n                        path: z.union([z.string(), z.null()]),\n                        per_page: z.number().int(),\n                        to: z.union([z.number(), z.null()]),\n                        total: z.number().int(),\n                    })\n                    .passthrough(),\n            })\n            .passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'post',\n        path: '/v1/organizations/:organization/clients',\n        alias: 'createClient',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'body',\n                type: 'Body',\n                schema: z.object({ name: z.string().min(1).max(255) }).passthrough(),\n            },\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ data: ClientResource }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'put',\n        path: '/v1/organizations/:organization/clients/:client',\n        alias: 'updateClient',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'body',\n                type: 'Body',\n                schema: ClientUpdateRequest,\n            },\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'client',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ data: ClientResource }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'delete',\n        path: '/v1/organizations/:organization/clients/:client',\n        alias: 'deleteClient',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'client',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.void(),\n        errors: [\n            {\n                status: 400,\n                description: `API exception`,\n                schema: z\n                    .object({ error: z.boolean(), key: z.string(), message: z.string() })\n                    .passthrough(),\n            },\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'post',\n        path: '/v1/organizations/:organization/export',\n        alias: 'exportOrganization',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ success: z.boolean(), download_url: z.string() }).passthrough(),\n        errors: [\n            {\n                status: 400,\n                description: `API exception`,\n                schema: z\n                    .object({ error: z.boolean(), key: z.string(), message: z.string() })\n                    .passthrough(),\n            },\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'post',\n        path: '/v1/organizations/:organization/import',\n        alias: 'importData',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'body',\n                type: 'Body',\n                schema: ImportRequest,\n            },\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z\n            .object({\n                report: z\n                    .object({\n                        clients: z.object({ created: z.number().int() }).passthrough(),\n                        projects: z.object({ created: z.number().int() }).passthrough(),\n                        tasks: z.object({ created: z.number().int() }).passthrough(),\n                        time_entries: z.object({ created: z.number().int() }).passthrough(),\n                        tags: z.object({ created: z.number().int() }).passthrough(),\n                        users: z.object({ created: z.number().int() }).passthrough(),\n                    })\n                    .passthrough(),\n            })\n            .passthrough(),\n        errors: [\n            {\n                status: 400,\n                schema: z.union([\n                    z.object({ message: z.string() }).passthrough(),\n                    z.object({ message: z.literal('Invalid base64 encoded data') }).passthrough(),\n                ]),\n            },\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/importers',\n        alias: 'getImporters',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z\n            .object({\n                data: z.array(\n                    z\n                        .object({ key: z.string(), name: z.string(), description: z.string() })\n                        .passthrough()\n                ),\n            })\n            .passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/invitations',\n        alias: 'getInvitations',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'page',\n                type: 'Query',\n                schema: z.number().int().gte(1).lte(2147483647).optional(),\n            },\n        ],\n        response: z\n            .object({\n                data: z.array(InvitationResource),\n                links: z\n                    .object({\n                        first: z.union([z.string(), z.null()]),\n                        last: z.union([z.string(), z.null()]),\n                        prev: z.union([z.string(), z.null()]),\n                        next: z.union([z.string(), z.null()]),\n                    })\n                    .passthrough(),\n                meta: z\n                    .object({\n                        current_page: z.number().int(),\n                        from: z.union([z.number(), z.null()]),\n                        last_page: z.number().int(),\n                        links: z.array(\n                            z\n                                .object({\n                                    url: z.union([z.string(), z.null()]),\n                                    label: z.string(),\n                                    active: z.boolean(),\n                                })\n                                .passthrough()\n                        ),\n                        path: z.union([z.string(), z.null()]),\n                        per_page: z.number().int(),\n                        to: z.union([z.number(), z.null()]),\n                        total: z.number().int(),\n                    })\n                    .passthrough(),\n            })\n            .passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'post',\n        path: '/v1/organizations/:organization/invitations',\n        alias: 'invite',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'body',\n                type: 'Body',\n                schema: InvitationStoreRequest,\n            },\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.void(),\n        errors: [\n            {\n                status: 400,\n                description: `API exception`,\n                schema: z\n                    .object({ error: z.boolean(), key: z.string(), message: z.string() })\n                    .passthrough(),\n            },\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'delete',\n        path: '/v1/organizations/:organization/invitations/:invitation',\n        alias: 'removeInvitation',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'invitation',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.void(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'post',\n        path: '/v1/organizations/:organization/invitations/:invitation/resend',\n        alias: 'resendInvitationEmail',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'invitation',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.void(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/invoice-settings',\n        alias: 'getInvoiceSettings',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ data: InvoiceSettingResource }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'put',\n        path: '/v1/organizations/:organization/invoice-settings',\n        alias: 'updateInvoiceSettings',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'body',\n                type: 'Body',\n                schema: InvoiceSettingUpdateRequest,\n            },\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ data: InvoiceSettingResource }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/invoices',\n        alias: 'getInvoices',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'page',\n                type: 'Query',\n                schema: z.number().int().gte(1).lte(2147483647).optional(),\n            },\n        ],\n        response: z.object({ data: InvoiceCollection }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'post',\n        path: '/v1/organizations/:organization/invoices',\n        alias: 'createInvoice',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'body',\n                type: 'Body',\n                schema: InvoiceStoreRequest,\n            },\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ data: DetailedInvoiceResource }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/invoices/:invoice',\n        alias: 'getInvoice',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'invoice',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ data: DetailedInvoiceResource }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'put',\n        path: '/v1/organizations/:organization/invoices/:invoice',\n        alias: 'updateInvoice',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'body',\n                type: 'Body',\n                schema: InvoiceUpdateRequest,\n            },\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'invoice',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ data: DetailedInvoiceResource }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'delete',\n        path: '/v1/organizations/:organization/invoices/:invoice',\n        alias: 'deleteInvoice',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'invoice',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.void(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'post',\n        path: '/v1/organizations/:organization/invoices/:invoice/download',\n        alias: 'downloadInvoice',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'body',\n                type: 'Body',\n                schema: z.object({ with_e_invoice: z.boolean() }).passthrough(),\n            },\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'invoice',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ download_link: z.string() }).passthrough(),\n        errors: [\n            {\n                status: 400,\n                description: `API exception`,\n                schema: z\n                    .object({ error: z.boolean(), key: z.string(), message: z.string() })\n                    .passthrough(),\n            },\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'post',\n        path: '/v1/organizations/:organization/invoices/:invoice/download-e-invoice',\n        alias: 'downloadEInvoice',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'invoice',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ download_link: z.string() }).passthrough(),\n        errors: [\n            {\n                status: 400,\n                description: `API exception`,\n                schema: z\n                    .object({ error: z.boolean(), key: z.string(), message: z.string() })\n                    .passthrough(),\n            },\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'post',\n        path: '/v1/organizations/:organization/member/:member/merge-into',\n        alias: 'mergeMember',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'body',\n                type: 'Body',\n                schema: z.object({ member_id: z.string() }).partial().passthrough(),\n            },\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'member',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.void(),\n        errors: [\n            {\n                status: 400,\n                description: `API exception`,\n                schema: z\n                    .object({ error: z.boolean(), key: z.string(), message: z.string() })\n                    .passthrough(),\n            },\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/members',\n        alias: 'getMembers',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'page',\n                type: 'Query',\n                schema: z.number().int().gte(1).lte(2147483647).optional(),\n            },\n        ],\n        response: z\n            .object({\n                data: z.array(MemberResource),\n                links: z\n                    .object({\n                        first: z.union([z.string(), z.null()]),\n                        last: z.union([z.string(), z.null()]),\n                        prev: z.union([z.string(), z.null()]),\n                        next: z.union([z.string(), z.null()]),\n                    })\n                    .passthrough(),\n                meta: z\n                    .object({\n                        current_page: z.number().int(),\n                        from: z.union([z.number(), z.null()]),\n                        last_page: z.number().int(),\n                        links: z.array(\n                            z\n                                .object({\n                                    url: z.union([z.string(), z.null()]),\n                                    label: z.string(),\n                                    active: z.boolean(),\n                                })\n                                .passthrough()\n                        ),\n                        path: z.union([z.string(), z.null()]),\n                        per_page: z.number().int(),\n                        to: z.union([z.number(), z.null()]),\n                        total: z.number().int(),\n                    })\n                    .passthrough(),\n            })\n            .passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'put',\n        path: '/v1/organizations/:organization/members/:member',\n        alias: 'updateMember',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'body',\n                type: 'Body',\n                schema: MemberUpdateRequest,\n            },\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'member',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ data: MemberResource }).passthrough(),\n        errors: [\n            {\n                status: 400,\n                description: `API exception`,\n                schema: z\n                    .object({ error: z.boolean(), key: z.string(), message: z.string() })\n                    .passthrough(),\n            },\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'delete',\n        path: '/v1/organizations/:organization/members/:member',\n        alias: 'removeMember',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'member',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'delete_related',\n                type: 'Query',\n                schema: z.enum(['true', 'false']).optional(),\n            },\n        ],\n        response: z.void(),\n        errors: [\n            {\n                status: 400,\n                description: `API exception`,\n                schema: z\n                    .object({ error: z.boolean(), key: z.string(), message: z.string() })\n                    .passthrough(),\n            },\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'post',\n        path: '/v1/organizations/:organization/members/:member/invite-placeholder',\n        alias: 'invitePlaceholder',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'member',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.void(),\n        errors: [\n            {\n                status: 400,\n                description: `API exception`,\n                schema: z\n                    .object({ error: z.boolean(), key: z.string(), message: z.string() })\n                    .passthrough(),\n            },\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'post',\n        path: '/v1/organizations/:organization/members/:member/make-placeholder',\n        alias: 'makePlaceholder',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'member',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.void(),\n        errors: [\n            {\n                status: 400,\n                description: `API exception`,\n                schema: z\n                    .object({ error: z.boolean(), key: z.string(), message: z.string() })\n                    .passthrough(),\n            },\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'put',\n        path: '/v1/organizations/:organization/project-members/:projectMember',\n        alias: 'updateProjectMember',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'body',\n                type: 'Body',\n                schema: ProjectMemberUpdateRequest,\n            },\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'projectMember',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ data: ProjectMemberResource }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'delete',\n        path: '/v1/organizations/:organization/project-members/:projectMember',\n        alias: 'deleteProjectMember',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'projectMember',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.void(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/projects',\n        alias: 'getProjects',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'page',\n                type: 'Query',\n                schema: z.number().int().gte(1).lte(2147483647).optional(),\n            },\n            {\n                name: 'archived',\n                type: 'Query',\n                schema: z.enum(['true', 'false', 'all']).optional(),\n            },\n        ],\n        response: z\n            .object({\n                data: z.array(ProjectResource),\n                links: z\n                    .object({\n                        first: z.union([z.string(), z.null()]),\n                        last: z.union([z.string(), z.null()]),\n                        prev: z.union([z.string(), z.null()]),\n                        next: z.union([z.string(), z.null()]),\n                    })\n                    .passthrough(),\n                meta: z\n                    .object({\n                        current_page: z.number().int(),\n                        from: z.union([z.number(), z.null()]),\n                        last_page: z.number().int(),\n                        links: z.array(\n                            z\n                                .object({\n                                    url: z.union([z.string(), z.null()]),\n                                    label: z.string(),\n                                    active: z.boolean(),\n                                })\n                                .passthrough()\n                        ),\n                        path: z.union([z.string(), z.null()]),\n                        per_page: z.number().int(),\n                        to: z.union([z.number(), z.null()]),\n                        total: z.number().int(),\n                    })\n                    .passthrough(),\n            })\n            .passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'post',\n        path: '/v1/organizations/:organization/projects',\n        alias: 'createProject',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'body',\n                type: 'Body',\n                schema: ProjectStoreRequest,\n            },\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ data: ProjectResource }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/projects/:project',\n        alias: 'getProject',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'project',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ data: ProjectResource }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'put',\n        path: '/v1/organizations/:organization/projects/:project',\n        alias: 'updateProject',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'body',\n                type: 'Body',\n                schema: ProjectUpdateRequest,\n            },\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'project',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ data: ProjectResource }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'delete',\n        path: '/v1/organizations/:organization/projects/:project',\n        alias: 'deleteProject',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'project',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.void(),\n        errors: [\n            {\n                status: 400,\n                description: `API exception`,\n                schema: z\n                    .object({ error: z.boolean(), key: z.string(), message: z.string() })\n                    .passthrough(),\n            },\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/projects/:project/project-members',\n        alias: 'getProjectMembers',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'project',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'page',\n                type: 'Query',\n                schema: z.number().int().gte(1).lte(2147483647).optional(),\n            },\n        ],\n        response: z\n            .object({\n                data: z.array(ProjectMemberResource),\n                links: z\n                    .object({\n                        first: z.union([z.string(), z.null()]),\n                        last: z.union([z.string(), z.null()]),\n                        prev: z.union([z.string(), z.null()]),\n                        next: z.union([z.string(), z.null()]),\n                    })\n                    .passthrough(),\n                meta: z\n                    .object({\n                        current_page: z.number().int(),\n                        from: z.union([z.number(), z.null()]),\n                        last_page: z.number().int(),\n                        links: z.array(\n                            z\n                                .object({\n                                    url: z.union([z.string(), z.null()]),\n                                    label: z.string(),\n                                    active: z.boolean(),\n                                })\n                                .passthrough()\n                        ),\n                        path: z.union([z.string(), z.null()]),\n                        per_page: z.number().int(),\n                        to: z.union([z.number(), z.null()]),\n                        total: z.number().int(),\n                    })\n                    .passthrough(),\n            })\n            .passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'post',\n        path: '/v1/organizations/:organization/projects/:project/project-members',\n        alias: 'createProjectMember',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'body',\n                type: 'Body',\n                schema: ProjectMemberStoreRequest,\n            },\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'project',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ data: ProjectMemberResource }).passthrough(),\n        errors: [\n            {\n                status: 400,\n                description: `API exception`,\n                schema: z\n                    .object({ error: z.boolean(), key: z.string(), message: z.string() })\n                    .passthrough(),\n            },\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/reports',\n        alias: 'getReports',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'page',\n                type: 'Query',\n                schema: z.number().int().gte(1).lte(2147483647).optional(),\n            },\n        ],\n        response: z\n            .object({\n                data: z.array(ReportResource),\n                links: z\n                    .object({\n                        first: z.union([z.string(), z.null()]),\n                        last: z.union([z.string(), z.null()]),\n                        prev: z.union([z.string(), z.null()]),\n                        next: z.union([z.string(), z.null()]),\n                    })\n                    .passthrough(),\n                meta: z\n                    .object({\n                        current_page: z.number().int(),\n                        from: z.union([z.number(), z.null()]),\n                        last_page: z.number().int(),\n                        links: z.array(\n                            z\n                                .object({\n                                    url: z.union([z.string(), z.null()]),\n                                    label: z.string(),\n                                    active: z.boolean(),\n                                })\n                                .passthrough()\n                        ),\n                        path: z.union([z.string(), z.null()]),\n                        per_page: z.number().int(),\n                        to: z.union([z.number(), z.null()]),\n                        total: z.number().int(),\n                    })\n                    .passthrough(),\n            })\n            .passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'post',\n        path: '/v1/organizations/:organization/reports',\n        alias: 'createReport',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'body',\n                type: 'Body',\n                schema: ReportStoreRequest,\n            },\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ data: DetailedReportResource }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/reports/:report',\n        alias: 'getReport',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'report',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ data: DetailedReportResource }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'put',\n        path: '/v1/organizations/:organization/reports/:report',\n        alias: 'updateReport',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'body',\n                type: 'Body',\n                schema: ReportUpdateRequest,\n            },\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'report',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ data: DetailedReportResource }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'delete',\n        path: '/v1/organizations/:organization/reports/:report',\n        alias: 'deleteReport',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'report',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.void(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/tags',\n        alias: 'getTags',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'page',\n                type: 'Query',\n                schema: z.number().int().gte(1).lte(2147483647).optional(),\n            },\n        ],\n        response: z\n            .object({\n                data: z.array(TagResource),\n                links: z\n                    .object({\n                        first: z.union([z.string(), z.null()]),\n                        last: z.union([z.string(), z.null()]),\n                        prev: z.union([z.string(), z.null()]),\n                        next: z.union([z.string(), z.null()]),\n                    })\n                    .passthrough(),\n                meta: z\n                    .object({\n                        current_page: z.number().int(),\n                        from: z.union([z.number(), z.null()]),\n                        last_page: z.number().int(),\n                        links: z.array(\n                            z\n                                .object({\n                                    url: z.union([z.string(), z.null()]),\n                                    label: z.string(),\n                                    active: z.boolean(),\n                                })\n                                .passthrough()\n                        ),\n                        path: z.union([z.string(), z.null()]),\n                        per_page: z.number().int(),\n                        to: z.union([z.number(), z.null()]),\n                        total: z.number().int(),\n                    })\n                    .passthrough(),\n            })\n            .passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'post',\n        path: '/v1/organizations/:organization/tags',\n        alias: 'createTag',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'body',\n                type: 'Body',\n                schema: z.object({ name: z.string().min(1).max(255) }).passthrough(),\n            },\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ data: TagResource }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'put',\n        path: '/v1/organizations/:organization/tags/:tag',\n        alias: 'updateTag',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'body',\n                type: 'Body',\n                schema: z.object({ name: z.string().min(1).max(255) }).passthrough(),\n            },\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'tag',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ data: TagResource }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'delete',\n        path: '/v1/organizations/:organization/tags/:tag',\n        alias: 'deleteTag',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'tag',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.void(),\n        errors: [\n            {\n                status: 400,\n                description: `API exception`,\n                schema: z\n                    .object({ error: z.boolean(), key: z.string(), message: z.string() })\n                    .passthrough(),\n            },\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/tasks',\n        alias: 'getTasks',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'page',\n                type: 'Query',\n                schema: z.number().int().gte(1).lte(2147483647).optional(),\n            },\n            {\n                name: 'project_id',\n                type: 'Query',\n                schema: z.string().optional(),\n            },\n            {\n                name: 'done',\n                type: 'Query',\n                schema: z.enum(['true', 'false', 'all']).optional(),\n            },\n        ],\n        response: z\n            .object({\n                data: z.array(TaskResource),\n                links: z\n                    .object({\n                        first: z.union([z.string(), z.null()]),\n                        last: z.union([z.string(), z.null()]),\n                        prev: z.union([z.string(), z.null()]),\n                        next: z.union([z.string(), z.null()]),\n                    })\n                    .passthrough(),\n                meta: z\n                    .object({\n                        current_page: z.number().int(),\n                        from: z.union([z.number(), z.null()]),\n                        last_page: z.number().int(),\n                        links: z.array(\n                            z\n                                .object({\n                                    url: z.union([z.string(), z.null()]),\n                                    label: z.string(),\n                                    active: z.boolean(),\n                                })\n                                .passthrough()\n                        ),\n                        path: z.union([z.string(), z.null()]),\n                        per_page: z.number().int(),\n                        to: z.union([z.number(), z.null()]),\n                        total: z.number().int(),\n                    })\n                    .passthrough(),\n            })\n            .passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'post',\n        path: '/v1/organizations/:organization/tasks',\n        alias: 'createTask',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'body',\n                type: 'Body',\n                schema: TaskStoreRequest,\n            },\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ data: TaskResource }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'put',\n        path: '/v1/organizations/:organization/tasks/:task',\n        alias: 'updateTask',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'body',\n                type: 'Body',\n                schema: TaskUpdateRequest,\n            },\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'task',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ data: TaskResource }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'delete',\n        path: '/v1/organizations/:organization/tasks/:task',\n        alias: 'deleteTask',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'task',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.void(),\n        errors: [\n            {\n                status: 400,\n                description: `API exception`,\n                schema: z\n                    .object({ error: z.boolean(), key: z.string(), message: z.string() })\n                    .passthrough(),\n            },\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/time-entries',\n        alias: 'getTimeEntries',\n        description: `If you only need time entries for a specific user, you can filter by &#x60;user_id&#x60;.\nUsers with the permission &#x60;time-entries:view:own&#x60; can only use this endpoint with their own user ID in the user_id filter.`,\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'member_id',\n                type: 'Query',\n                schema: z.string().optional(),\n            },\n            {\n                name: 'start',\n                type: 'Query',\n                schema: start,\n            },\n            {\n                name: 'end',\n                type: 'Query',\n                schema: start,\n            },\n            {\n                name: 'active',\n                type: 'Query',\n                schema: z.enum(['true', 'false']).optional(),\n            },\n            {\n                name: 'billable',\n                type: 'Query',\n                schema: z.enum(['true', 'false']).optional(),\n            },\n            {\n                name: 'limit',\n                type: 'Query',\n                schema: z.number().int().gte(1).lte(500).optional(),\n            },\n            {\n                name: 'offset',\n                type: 'Query',\n                schema: z.number().int().gte(0).lte(2147483647).optional(),\n            },\n            {\n                name: 'only_full_dates',\n                type: 'Query',\n                schema: z.enum(['true', 'false']).optional(),\n            },\n            {\n                name: 'rounding_type',\n                type: 'Query',\n                schema: z.enum(['up', 'down', 'nearest']).optional(),\n            },\n            {\n                name: 'rounding_minutes',\n                type: 'Query',\n                schema: rounding_minutes,\n            },\n            {\n                name: 'user_id',\n                type: 'Query',\n                schema: z.string().optional(),\n            },\n            {\n                name: 'member_ids',\n                type: 'Query',\n                schema: z.array(z.string()).min(1).optional(),\n            },\n            {\n                name: 'client_ids',\n                type: 'Query',\n                schema: z.array(z.string()).min(1).optional(),\n            },\n            {\n                name: 'project_ids',\n                type: 'Query',\n                schema: z.array(z.string()).min(1).optional(),\n            },\n            {\n                name: 'tag_ids',\n                type: 'Query',\n                schema: z.array(z.string()).min(1).optional(),\n            },\n            {\n                name: 'task_ids',\n                type: 'Query',\n                schema: z.array(z.string()).min(1).optional(),\n            },\n        ],\n        response: z\n            .object({\n                data: z.array(TimeEntryResource),\n                meta: z.object({ total: z.number().int() }).passthrough(),\n            })\n            .passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'post',\n        path: '/v1/organizations/:organization/time-entries',\n        alias: 'createTimeEntry',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'body',\n                type: 'Body',\n                schema: TimeEntryStoreRequest,\n            },\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ data: TimeEntryResource }).passthrough(),\n        errors: [\n            {\n                status: 400,\n                description: `API exception`,\n                schema: z\n                    .object({ error: z.boolean(), key: z.string(), message: z.string() })\n                    .passthrough(),\n            },\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'patch',\n        path: '/v1/organizations/:organization/time-entries',\n        alias: 'updateMultipleTimeEntries',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'body',\n                type: 'Body',\n                schema: TimeEntryUpdateMultipleRequest,\n            },\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ success: z.string(), error: z.string() }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'delete',\n        path: '/v1/organizations/:organization/time-entries',\n        alias: 'deleteTimeEntries',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'ids',\n                type: 'Query',\n                schema: z.array(z.string().uuid()),\n            },\n        ],\n        response: z.object({ success: z.string(), error: z.string() }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'put',\n        path: '/v1/organizations/:organization/time-entries/:timeEntry',\n        alias: 'updateTimeEntry',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'body',\n                type: 'Body',\n                schema: TimeEntryUpdateRequest,\n            },\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'timeEntry',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.object({ data: TimeEntryResource }).passthrough(),\n        errors: [\n            {\n                status: 400,\n                description: `API exception`,\n                schema: z\n                    .object({ error: z.boolean(), key: z.string(), message: z.string() })\n                    .passthrough(),\n            },\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'delete',\n        path: '/v1/organizations/:organization/time-entries/:timeEntry',\n        alias: 'deleteTimeEntry',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'timeEntry',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.void(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/time-entries/aggregate',\n        alias: 'getAggregatedTimeEntries',\n        description: `This endpoint allows you to filter time entries and aggregate them by different criteria.\nThe parameters &#x60;group&#x60; and &#x60;sub_group&#x60; allow you to group the time entries by different criteria.\nIf the group parameters are all set to &#x60;null&#x60; or are all missing, the endpoint will aggregate all filtered time entries.`,\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'group',\n                type: 'Query',\n                schema: z\n                    .enum([\n                        'day',\n                        'week',\n                        'month',\n                        'year',\n                        'user',\n                        'project',\n                        'task',\n                        'client',\n                        'billable',\n                        'description',\n                        'tag',\n                    ])\n                    .optional(),\n            },\n            {\n                name: 'sub_group',\n                type: 'Query',\n                schema: z\n                    .enum([\n                        'day',\n                        'week',\n                        'month',\n                        'year',\n                        'user',\n                        'project',\n                        'task',\n                        'client',\n                        'billable',\n                        'description',\n                        'tag',\n                    ])\n                    .optional(),\n            },\n            {\n                name: 'member_id',\n                type: 'Query',\n                schema: z.string().optional(),\n            },\n            {\n                name: 'user_id',\n                type: 'Query',\n                schema: z.string().optional(),\n            },\n            {\n                name: 'start',\n                type: 'Query',\n                schema: start,\n            },\n            {\n                name: 'end',\n                type: 'Query',\n                schema: start,\n            },\n            {\n                name: 'active',\n                type: 'Query',\n                schema: z.enum(['true', 'false']).optional(),\n            },\n            {\n                name: 'billable',\n                type: 'Query',\n                schema: z.enum(['true', 'false']).optional(),\n            },\n            {\n                name: 'fill_gaps_in_time_groups',\n                type: 'Query',\n                schema: z.enum(['true', 'false']).optional(),\n            },\n            {\n                name: 'rounding_type',\n                type: 'Query',\n                schema: z.enum(['up', 'down', 'nearest']).optional(),\n            },\n            {\n                name: 'rounding_minutes',\n                type: 'Query',\n                schema: rounding_minutes,\n            },\n            {\n                name: 'member_ids',\n                type: 'Query',\n                schema: z.array(z.string()).min(1).optional(),\n            },\n            {\n                name: 'project_ids',\n                type: 'Query',\n                schema: z.array(z.string()).min(1).optional(),\n            },\n            {\n                name: 'client_ids',\n                type: 'Query',\n                schema: z.array(z.string()).min(1).optional(),\n            },\n            {\n                name: 'tag_ids',\n                type: 'Query',\n                schema: z.array(z.string()).min(1).optional(),\n            },\n            {\n                name: 'task_ids',\n                type: 'Query',\n                schema: z.array(z.string()).min(1).optional(),\n            },\n        ],\n        response: z\n            .object({\n                data: z\n                    .object({\n                        grouped_type: z.union([z.string(), z.null()]),\n                        grouped_data: z.union([\n                            z.array(\n                                z\n                                    .object({\n                                        key: z.union([z.string(), z.null()]),\n                                        seconds: z.number().int(),\n                                        cost: z.union([z.number(), z.null()]),\n                                        grouped_type: z.union([z.string(), z.null()]),\n                                        grouped_data: z.union([\n                                            z.array(\n                                                z\n                                                    .object({\n                                                        key: z.union([z.string(), z.null()]),\n                                                        seconds: z.number().int(),\n                                                        cost: z.union([z.number(), z.null()]),\n                                                        grouped_type: z.null(),\n                                                        grouped_data: z.null(),\n                                                    })\n                                                    .passthrough()\n                                            ),\n                                            z.null(),\n                                        ]),\n                                    })\n                                    .passthrough()\n                            ),\n                            z.null(),\n                        ]),\n                        seconds: z.number().int(),\n                        cost: z.union([z.number(), z.null()]),\n                    })\n                    .passthrough(),\n            })\n            .passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/time-entries/aggregate/export',\n        alias: 'exportAggregatedTimeEntries',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'format',\n                type: 'Query',\n                schema: z.enum(['csv', 'pdf', 'xlsx', 'ods']),\n            },\n            {\n                name: 'group',\n                type: 'Query',\n                schema: z.enum([\n                    'day',\n                    'week',\n                    'month',\n                    'year',\n                    'user',\n                    'project',\n                    'task',\n                    'client',\n                    'billable',\n                    'description',\n                    'tag',\n                ]),\n            },\n            {\n                name: 'sub_group',\n                type: 'Query',\n                schema: z.enum([\n                    'day',\n                    'week',\n                    'month',\n                    'year',\n                    'user',\n                    'project',\n                    'task',\n                    'client',\n                    'billable',\n                    'description',\n                    'tag',\n                ]),\n            },\n            {\n                name: 'history_group',\n                type: 'Query',\n                schema: z.enum(['day', 'week', 'month', 'year']),\n            },\n            {\n                name: 'member_id',\n                type: 'Query',\n                schema: z.string().optional(),\n            },\n            {\n                name: 'user_id',\n                type: 'Query',\n                schema: z.string().optional(),\n            },\n            {\n                name: 'start',\n                type: 'Query',\n                schema: z.string(),\n            },\n            {\n                name: 'end',\n                type: 'Query',\n                schema: z.string(),\n            },\n            {\n                name: 'active',\n                type: 'Query',\n                schema: z.enum(['true', 'false']).optional(),\n            },\n            {\n                name: 'billable',\n                type: 'Query',\n                schema: z.enum(['true', 'false']).optional(),\n            },\n            {\n                name: 'fill_gaps_in_time_groups',\n                type: 'Query',\n                schema: z.enum(['true', 'false']).optional(),\n            },\n            {\n                name: 'debug',\n                type: 'Query',\n                schema: z.enum(['true', 'false']).optional(),\n            },\n            {\n                name: 'rounding_type',\n                type: 'Query',\n                schema: z.enum(['up', 'down', 'nearest']).optional(),\n            },\n            {\n                name: 'rounding_minutes',\n                type: 'Query',\n                schema: rounding_minutes,\n            },\n            {\n                name: 'member_ids',\n                type: 'Query',\n                schema: z.array(z.string()).min(1).optional(),\n            },\n            {\n                name: 'project_ids',\n                type: 'Query',\n                schema: z.array(z.string()).min(1).optional(),\n            },\n            {\n                name: 'client_ids',\n                type: 'Query',\n                schema: z.array(z.string()).min(1).optional(),\n            },\n            {\n                name: 'tag_ids',\n                type: 'Query',\n                schema: z.array(z.string()).min(1).optional(),\n            },\n            {\n                name: 'task_ids',\n                type: 'Query',\n                schema: z.array(z.string()).min(1).optional(),\n            },\n        ],\n        response: z.union([\n            z.object({ download_url: z.string() }).passthrough(),\n            z.object({ html: z.string(), footer_html: z.string() }).passthrough(),\n        ]),\n        errors: [\n            {\n                status: 400,\n                description: `API exception`,\n                schema: z\n                    .object({ error: z.boolean(), key: z.string(), message: z.string() })\n                    .passthrough(),\n            },\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/organizations/:organization/time-entries/export',\n        alias: 'exportTimeEntries',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'organization',\n                type: 'Path',\n                schema: z.string(),\n            },\n            {\n                name: 'format',\n                type: 'Query',\n                schema: z.enum(['csv', 'pdf', 'xlsx', 'ods']),\n            },\n            {\n                name: 'member_id',\n                type: 'Query',\n                schema: z.string().uuid().optional(),\n            },\n            {\n                name: 'start',\n                type: 'Query',\n                schema: z.string(),\n            },\n            {\n                name: 'end',\n                type: 'Query',\n                schema: z.string(),\n            },\n            {\n                name: 'active',\n                type: 'Query',\n                schema: z.enum(['true', 'false']).optional(),\n            },\n            {\n                name: 'billable',\n                type: 'Query',\n                schema: z.enum(['true', 'false']).optional(),\n            },\n            {\n                name: 'limit',\n                type: 'Query',\n                schema: z.number().int().gte(1).lte(500).optional(),\n            },\n            {\n                name: 'only_full_dates',\n                type: 'Query',\n                schema: z.enum(['true', 'false']).optional(),\n            },\n            {\n                name: 'debug',\n                type: 'Query',\n                schema: z.enum(['true', 'false']).optional(),\n            },\n            {\n                name: 'rounding_type',\n                type: 'Query',\n                schema: z.enum(['up', 'down', 'nearest']).optional(),\n            },\n            {\n                name: 'rounding_minutes',\n                type: 'Query',\n                schema: rounding_minutes,\n            },\n            {\n                name: 'member_ids',\n                type: 'Query',\n                schema: z.array(z.string().uuid()).min(1).optional(),\n            },\n            {\n                name: 'client_ids',\n                type: 'Query',\n                schema: z.array(z.string()).min(1).optional(),\n            },\n            {\n                name: 'project_ids',\n                type: 'Query',\n                schema: z.array(z.string()).min(1).optional(),\n            },\n            {\n                name: 'tag_ids',\n                type: 'Query',\n                schema: z.array(z.string()).min(1).optional(),\n            },\n            {\n                name: 'task_ids',\n                type: 'Query',\n                schema: z.array(z.string()).min(1).optional(),\n            },\n        ],\n        response: z.union([\n            z.object({ download_url: z.string() }).passthrough(),\n            z.object({ html: z.string(), footer_html: z.string() }).passthrough(),\n        ]),\n        errors: [\n            {\n                status: 400,\n                description: `API exception`,\n                schema: z\n                    .object({ error: z.boolean(), key: z.string(), message: z.string() })\n                    .passthrough(),\n            },\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/public/reports',\n        alias: 'getPublicReport',\n        description: `This endpoint is public and does not require authentication. The report must be public and not expired.\nThe report is considered expired if the &#x60;public_until&#x60; field is set and the date is in the past.\nThe report is considered public if the &#x60;is_public&#x60; field is set to &#x60;true&#x60;.`,\n        requestFormat: 'json',\n        response: DetailedWithDataReportResource,\n        errors: [\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/users/me',\n        alias: 'getMe',\n        description: `This endpoint is independent of organization.`,\n        requestFormat: 'json',\n        response: z.object({ data: UserResource }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/users/me/api-tokens',\n        alias: 'getApiTokens',\n        description: `This endpoint is independent of organization.`,\n        requestFormat: 'json',\n        response: z.object({ data: ApiTokenCollection }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'post',\n        path: '/v1/users/me/api-tokens',\n        alias: 'createApiToken',\n        description: `The response will contain the access token that can be used to send authenticated API requests.\nPlease note that the access token is only shown in this response and cannot be retrieved later.`,\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'body',\n                type: 'Body',\n                schema: z.object({ name: z.string().min(1).max(255) }).passthrough(),\n            },\n        ],\n        response: z.object({ data: ApiTokenWithAccessTokenResource }).passthrough(),\n        errors: [\n            {\n                status: 400,\n                description: `API exception`,\n                schema: z\n                    .object({ error: z.boolean(), key: z.string(), message: z.string() })\n                    .passthrough(),\n            },\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 422,\n                description: `Validation error`,\n                schema: z\n                    .object({ message: z.string(), errors: z.record(z.array(z.string())) })\n                    .passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'delete',\n        path: '/v1/users/me/api-tokens/:apiToken',\n        alias: 'deleteApiToken',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'apiToken',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.void(),\n        errors: [\n            {\n                status: 400,\n                description: `API exception`,\n                schema: z\n                    .object({ error: z.boolean(), key: z.string(), message: z.string() })\n                    .passthrough(),\n            },\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'post',\n        path: '/v1/users/me/api-tokens/:apiToken/revoke',\n        alias: 'revokeApiToken',\n        requestFormat: 'json',\n        parameters: [\n            {\n                name: 'apiToken',\n                type: 'Path',\n                schema: z.string(),\n            },\n        ],\n        response: z.void(),\n        errors: [\n            {\n                status: 400,\n                description: `API exception`,\n                schema: z\n                    .object({ error: z.boolean(), key: z.string(), message: z.string() })\n                    .passthrough(),\n            },\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/users/me/memberships',\n        alias: 'getMyMemberships',\n        description: `This endpoint is independent of organization.`,\n        requestFormat: 'json',\n        response: z.object({ data: z.array(PersonalMembershipResource) }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n    {\n        method: 'get',\n        path: '/v1/users/me/time-entries/active',\n        alias: 'getMyActiveTimeEntry',\n        description: `This endpoint is independent of organization.`,\n        requestFormat: 'json',\n        response: z.object({ data: TimeEntryResource }).passthrough(),\n        errors: [\n            {\n                status: 401,\n                description: `Unauthenticated`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 403,\n                description: `Authorization error`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n            {\n                status: 404,\n                description: `Not found`,\n                schema: z.object({ message: z.string() }).passthrough(),\n            },\n        ],\n    },\n]);\n\nexport const api = new Zodios('/api', endpoints);\n\nexport function createApiClient(baseUrl: string, options?: ZodiosOptions) {\n    return new Zodios(baseUrl, endpoints, options);\n}\n"
  },
  {
    "path": "resources/js/packages/api/tsconfig.json",
    "content": "{\n    \"compilerOptions\": {\n        \"noEmit\": true,\n        \"module\": \"ESNext\",\n        \"moduleResolution\": \"Node\",\n        \"resolveJsonModule\": true,\n        \"noImplicitThis\": true,\n        \"strict\": true,\n        \"verbatimModuleSyntax\": true,\n        \"target\": \"ESNext\",\n        \"useDefineForClassFields\": true,\n        \"esModuleInterop\": true,\n        \"forceConsistentCasingInFileNames\": true,\n        \"skipLibCheck\": true,\n        \"outDir\": \"./dist\",\n        \"rootDir\": \"./src\",\n        \"declaration\": true\n    },\n    \"include\": [\n        \"src/**/*.ts\"\n    ],\n    \"exclude\": [\n        \"./dist\",\n        \"./node_modules\",\n        \"./__tests__\",\n        \"./coverage\"\n    ],\n}\n"
  },
  {
    "path": "resources/js/packages/api/vite.config.js",
    "content": "import { resolve } from 'path';\nimport { defineConfig } from 'vite';\nimport dts from 'vite-plugin-dts';\n\nexport default defineConfig({\n    plugins: [dts()],\n    build: {\n        lib: {\n            // Could also be a dictionary or array of multiple entry points\n            entry: resolve(__dirname, 'src/index.ts'),\n            name: 'SolidtimeApi',\n            // the proper extensions will be added\n            fileName: 'solidtime-api',\n        },\n    },\n});\n"
  },
  {
    "path": "resources/js/packages/ui/.gitignore",
    "content": "node_modules\ndist\nout\n.DS_Store\n*.log*\n"
  },
  {
    "path": "resources/js/packages/ui/package.json",
    "content": "{\n    \"name\": \"@solidtime/ui\",\n    \"version\": \"0.0.16\",\n    \"description\": \"Package containing the solidtime ui components\",\n    \"main\": \"./dist/solidtime-ui-lib.umd.cjs\",\n    \"module\": \"./dist/solidtime-ui-lib.js\",\n    \"repository\": {\n        \"type\": \"git\",\n        \"url\": \"git+https://github.com/solidtime-io/solidtime.git\",\n        \"directory\": \"resources/js/packages/ui\"\n    },\n    \"types\": \"./dist/packages/ui/src/index.d.ts\",\n    \"exports\": {\n        \".\": {\n            \"import\": {\n                \"types\": \"./dist/packages/ui/src/index.d.ts\",\n                \"default\": \"./dist/solidtime-ui-lib.js\"\n            },\n            \"require\": {\n                \"types\": \"./dist/packages/ui/src/index.d.ts\",\n                \"default\": \"./dist/solidtime-ui-lib.umd.cjs\"\n            }\n        },\n        \"./style.css\": \"./dist/style.css\",\n        \"./styles.css\": \"./styles.css\",\n        \"./tailwind.theme.js\": \"./tailwind.theme.js\"\n    },\n    \"scripts\": {\n        \"dev\": \"vite\",\n        \"build\": \"vite build && vue-tsc --emitDeclarationOnly\",\n        \"watch\": \"vite build --watch\",\n        \"types\": \"vue-tsc \",\n        \"preview\": \"vite preview\"\n    },\n    \"files\": [\n        \"dist\",\n        \"styles.css\",\n        \"tailwind.theme.js\"\n    ],\n    \"keywords\": [\n        \"solidtime\",\n        \"timetracker\",\n        \"timetracking\",\n        \"api\",\n        \"client\"\n    ],\n    \"type\": \"module\",\n    \"author\": \"solidtime\",\n    \"license\": \"AGPL-3.0\",\n    \"devDependencies\": {\n        \"vite-plugin-dts\": \"^4.0.3\"\n    },\n    \"peerDependencies\": {\n        \"@floating-ui/vue\": \"^1.1.4\",\n        \"@heroicons/vue\": \"^2.1.5\",\n        \"@vitejs/plugin-vue\": \"^5.1.2 || ^6.0.0\",\n        \"@vueuse/core\": \"^12.5.0 || ^14.0.0\",\n        \"class-variance-authority\": \"^0.7.1\",\n        \"clsx\": \"^2.1.1\",\n        \"dayjs\": \"^1.11.13\",\n        \"parse-duration\": \"^2.0.1\",\n        \"reka-ui\": \"^2.2.0\",\n        \"tailwind-merge\": \"^2.5.2\",\n        \"tailwindcss\": \"^3.1.0\",\n        \"typescript\": \"^5.5.4\",\n        \"vite\": \"^5.4.1 || ^6.0.0 || ^7.0.0\",\n        \"vue\": \"^3.5.0\",\n        \"vue-tsc\": \"^2.2.0 || ^3.0.0\"\n    }\n}\n"
  },
  {
    "path": "resources/js/packages/ui/src/Badge.vue",
    "content": "<script setup lang=\"ts\">\nimport { twMerge } from 'tailwind-merge';\nimport { computed } from 'vue';\n\nconst props = withDefaults(\n    defineProps<{\n        size?: 'base' | 'large' | 'xlarge';\n        tag?: string;\n        class?: string;\n        color?: string;\n        border?: boolean;\n    }>(),\n    {\n        size: 'base',\n        tag: 'div',\n        color: 'var(--theme-color-icon-default)',\n        border: true,\n    }\n);\n\nconst badgeClasses = {\n    base: 'py-1 px-2 space-x-1.5 text-xs',\n    large: 'py-1 sm:py-1.5 px-2 sm:px-3 space-x-1.5 sm:space-x-2 text-xs sm:text-sm text-text-secondary',\n    xlarge: 'py-2 sm:py-2.5 px-3 sm:px-3.5 space-x-2 sm:space-x-3 text-sm sm:text-sm text-text-secondary',\n};\n\nconst borderClasses = computed(() => {\n    if (props.border) {\n        return 'border-input-border border';\n    }\n    return '';\n});\n\nconst tagClasses = computed(() => {\n    if (props.tag === 'button') {\n        return 'hover:bg-tertiary';\n    }\n    return '';\n});\n</script>\n\n<template>\n    <component\n        :is=\"tag\"\n        :class=\"\n            twMerge(\n                tagClasses,\n                badgeClasses[size],\n                borderClasses,\n                'rounded transition inline-flex items-center font-medium text-text-primary disabled:text-text-quaternary outline-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring min-w-0 overflow-hidden',\n                props.class\n            )\n        \">\n        <slot></slot>\n    </component>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/BillableRateModal.vue",
    "content": "<script setup lang=\"ts\">\nimport PrimaryButton from './Buttons/PrimaryButton.vue';\nimport DialogModal from './DialogModal.vue';\nimport SecondaryButton from './Buttons/SecondaryButton.vue';\nimport { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';\n\nconst show = defineModel('show', { default: false });\nconst saving = defineModel('saving', { default: false });\n\nconst emit = defineEmits<{\n    submit: [];\n}>();\n\ndefineProps<{\n    title: string;\n}>();\n</script>\n\n<template>\n    <DialogModal closeable :show=\"show\" @close=\"show = false\">\n        <template #title>\n            <div class=\"flex justify-center\">\n                <span> {{ title }} </span>\n            </div>\n        </template>\n        <template #content>\n            <div class=\"flex items-center space-x-4\">\n                <div class=\"col-span-6 sm:col-span-4 flex-1\">\n                    <slot></slot>\n                    <div class=\"space-x-3 pt-5 pb-2 flex justify-center\">\n                        <PrimaryButton\n                            :class=\"{ 'opacity-25': saving }\"\n                            :disabled=\"saving\"\n                            @click=\"emit('submit')\">\n                            Yes, update existing time entries\n                        </PrimaryButton>\n                    </div>\n                    <p class=\"text-center pt-3 pb-1\">\n                        Learn more about the\n                        <a\n                            target=\"_blank\"\n                            href=\"https://docs.solidtime.io/user-guide/billable-rates\"\n                            class=\"text-blue-400 hover:text-blue-500 transition\"\n                            >billable rate logic\n                            <ArrowTopRightOnSquareIcon\n                                class=\"w-4 -mt-0.5 inline-block\"></ArrowTopRightOnSquareIcon\n                        ></a>\n                    </p>\n                </div>\n            </div>\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"show = false\"> Cancel </SecondaryButton>\n        </template>\n    </DialogModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Buttons/Button.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport { cn } from '../utils/cn';\nimport { Primitive, type PrimitiveProps } from 'reka-ui';\nimport { type ButtonVariants, buttonVariants } from '.';\n\ninterface Props extends PrimitiveProps {\n    variant?: ButtonVariants['variant'];\n    size?: ButtonVariants['size'];\n    class?: HTMLAttributes['class'];\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n    as: 'button',\n});\n</script>\n\n<template>\n    <Primitive\n        :as=\"as\"\n        :as-child=\"asChild\"\n        :class=\"cn(buttonVariants({ variant, size }), props.class)\">\n        <slot />\n    </Primitive>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Buttons/DangerButton.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HtmlButtonType } from '@/types/dom';\n\nwithDefaults(\n    defineProps<{\n        type?: HtmlButtonType;\n    }>(),\n    {\n        type: 'button',\n    }\n);\n</script>\n<template>\n    <button\n        :type=\"type\"\n        class=\"inline-flex items-center justify-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-500 active:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition ease-in-out duration-150\">\n        <slot />\n    </button>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Buttons/PrimaryButton.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HtmlButtonType } from '@/types/dom';\nimport LoadingSpinner from '../LoadingSpinner.vue';\nimport type { Component } from 'vue';\nimport { twMerge } from 'tailwind-merge';\n\nconst props = withDefaults(\n    defineProps<{\n        type?: HtmlButtonType;\n        icon?: Component;\n        loading?: boolean;\n    }>(),\n    {\n        type: 'submit',\n        loading: false,\n    }\n);\n</script>\n\n<template>\n    <button\n        :type=\"type\"\n        :disabled=\"loading\"\n        class=\"inline-flex items-center h-9 px-3 text-sm bg-button-primary-background border border-button-primary-border rounded-md font-medium text-button-primary-text hover:bg-button-primary-background-hover active:bg-button-primary-background-hover focus:outline-none focus-visible:ring-2 focus-visible:border-transparent focus-visible:ring-ring transition ease-in-out duration-150\">\n        <span :class=\"twMerge('flex items-center ', props.icon ? 'space-x-1.5' : '')\">\n            <LoadingSpinner v-if=\"loading\"></LoadingSpinner>\n            <component\n                :is=\"props.icon\"\n                v-if=\"props.icon && !loading\"\n                class=\"w-4 -ml-0.5 mr-1\"></component>\n            <span>\n                <slot />\n            </span>\n        </span>\n    </button>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Buttons/SecondaryButton.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HtmlButtonType } from '@/types/dom';\nimport { twMerge } from 'tailwind-merge';\nimport { type Component } from 'vue';\nimport LoadingSpinner from '../LoadingSpinner.vue';\n\nconst props = withDefaults(\n    defineProps<{\n        type?: HtmlButtonType;\n        icon?: Component;\n        size?: 'small' | 'base';\n        loading?: boolean;\n        // Accept any valid Vue class binding shape (string | object | array)\n        class?: Parameters<typeof twMerge>[0];\n    }>(),\n    {\n        type: 'button',\n        size: 'base',\n        loading: false,\n    }\n);\n\nconst sizeClasses = {\n    small: 'text-xs px-2.5 py-1.5',\n    base: 'h-9 px-3 text-sm',\n};\n</script>\n\n<template>\n    <button\n        :type=\"type\"\n        :disabled=\"loading\"\n        :class=\"\n            twMerge(\n                'bg-button-secondary-background border border-button-secondary-border hover:bg-button-secondary-background-hover shadow-sm transition text-text-primary rounded-lg font-semibold inline-flex items-center space-x-1.5 focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring focus:border-transparent disabled:opacity-25 ease-in-out',\n                sizeClasses[props.size],\n                props.class\n            )\n        \">\n        <span :class=\"twMerge('flex items-center ', props.icon ? 'space-x-1.5' : '')\">\n            <LoadingSpinner v-if=\"loading\"></LoadingSpinner>\n            <component\n                :is=\"props.icon\"\n                v-if=\"props.icon && !loading\"\n                class=\"text-text-tertiary w-4 -ml-0.5 mr-1\"></component>\n            <span>\n                <slot />\n            </span>\n        </span>\n    </button>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Buttons/index.ts",
    "content": "import { cva, type VariantProps } from 'class-variance-authority';\n\nexport { default as Button } from './Button.vue';\n\nexport const buttonVariants = cva(\n    'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\n    {\n        variants: {\n            variant: {\n                default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',\n                destructive:\n                    'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',\n                outline:\n                    'border shadow-xs hover:text-text-primary bg-card-background dark:bg-transparent border-input dark:border-input hover:bg-white/5',\n                secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',\n                ghost: 'hover:bg-white/5',\n                link: 'text-primary underline-offset-4 hover:underline',\n                input: 'border-input-border border bg-input-background text-text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent shadow-sm',\n            },\n            size: {\n                default: 'h-9 px-3 text-sm',\n                xs: 'h-7 rounded px-2 text-xs',\n                sm: 'h-8 rounded-md px-3 text-xs',\n                lg: 'h-10 rounded-md px-4',\n                icon: 'h-9 w-9',\n            },\n        },\n        defaultVariants: {\n            variant: 'default',\n            size: 'default',\n        },\n    }\n);\n\nexport type ButtonVariants = VariantProps<typeof buttonVariants>;\n"
  },
  {
    "path": "resources/js/packages/ui/src/CardTitle.vue",
    "content": "<script setup lang=\"ts\">\nimport type { Component } from 'vue';\n\ndefineProps<{\n    title: string;\n    icon?: Component;\n}>();\n</script>\n\n<template>\n    <div class=\"flex w-full items-center justify-between py-1.5\">\n        <h3 class=\"text-text-primary font-medium text-sm flex items-center space-x-2\">\n            <component :is=\"icon\" v-if=\"icon\" class=\"w-4 lg:w-4 text-text-tertiary\"></component>\n            <span>\n                {{ title }}\n            </span>\n        </h3>\n        <div class=\"flex-1 flex justify-end items-center\">\n            <slot name=\"actions\"></slot>\n        </div>\n    </div>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Client/ClientDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, nextTick, ref, watch } from 'vue';\nimport type { CreateClientBody, Client } from '@/packages/api/src';\nimport {\n    ComboboxAnchor,\n    ComboboxContent,\n    ComboboxInput,\n    ComboboxItem,\n    ComboboxRoot,\n    ComboboxViewport,\n} from 'radix-vue';\nimport { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';\nimport Dropdown from '@/packages/ui/src/Input/Dropdown.vue';\nimport { Check, Plus } from 'lucide-vue-next';\n\nconst model = defineModel<string | null>({\n    default: null,\n});\n\nconst props = defineProps<{\n    clients: Client[];\n    createClient: (client: CreateClientBody) => Promise<Client | undefined>;\n}>();\n\nconst searchInput = ref<HTMLElement | null>(null);\nconst open = ref(false);\nconst searchValue = ref('');\n\nfunction isClientSelected(id: string) {\n    return model.value === id;\n}\n\nwatch(open, (isOpen) => {\n    if (isOpen) {\n        nextTick(() => {\n            // @ts-expect-error We need to access the actual HTML Element to focus as radix-vue does not support any other way right now\n            searchInput.value?.$el?.focus();\n        });\n    }\n});\n\nconst filteredClients = computed(() => {\n    return props.clients.filter((client) => {\n        return client.name.toLowerCase().includes(searchValue.value?.toLowerCase()?.trim() || '');\n    });\n});\n\nasync function addClientIfNoneExists() {\n    if (searchValue.value.length > 0 && filteredClients.value.length === 0) {\n        const newClient = await props.createClient({\n            name: searchValue.value,\n        });\n        if (newClient) {\n            model.value = newClient.id;\n            searchValue.value = '';\n            open.value = false;\n        }\n    }\n}\n\nconst currentClient = computed(() => {\n    return (\n        props.clients.find((client) => client.id === model.value) ?? {\n            id: null,\n            name: 'No Client',\n        }\n    );\n});\n\nconst emit = defineEmits(['update:modelValue', 'changed']);\n\nfunction updateValue(client: { id: string | null; name: string }) {\n    model.value = client.id;\n    emit('changed');\n}\n</script>\n\n<template>\n    <Dropdown v-model=\"open\" align=\"start\">\n        <template #trigger>\n            <slot name=\"trigger\"></slot>\n        </template>\n        <template #content>\n            <UseFocusTrap v-if=\"open\" :options=\"{ immediate: true, allowOutsideClick: true }\">\n                <ComboboxRoot\n                    v-model:search-term=\"searchValue\"\n                    v-model:open=\"open\"\n                    :model-value=\"currentClient\"\n                    class=\"relative\"\n                    @update:model-value=\"updateValue\">\n                    <ComboboxAnchor>\n                        <ComboboxInput\n                            ref=\"searchInput\"\n                            class=\"bg-transparent border-0 placeholder-muted-foreground text-sm text-popover-foreground py-2 px-3 focus:ring-0 border-b border-popover-border focus:border-popover-border w-full\"\n                            placeholder=\"Search for a client...\" />\n                    </ComboboxAnchor>\n                    <ComboboxContent>\n                        <ComboboxViewport\n                            class=\"w-[--reka-popper-anchor-width] max-h-60 overflow-y-scroll p-1\">\n                            <ComboboxItem\n                                :value=\"{ id: null, name: 'No Client' }\"\n                                class=\"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground\">\n                                <span>No Client</span>\n                                <span\n                                    v-if=\"model === null\"\n                                    class=\"absolute right-2 flex h-3.5 w-3.5 items-center justify-center\">\n                                    <Check class=\"h-4 w-4\" />\n                                </span>\n                            </ComboboxItem>\n                            <ComboboxItem\n                                v-for=\"client in filteredClients\"\n                                :key=\"client.id\"\n                                :value=\"client\"\n                                class=\"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground\"\n                                :data-client-id=\"client.id\">\n                                <span>{{ client.name }}</span>\n                                <span\n                                    v-if=\"isClientSelected(client.id)\"\n                                    class=\"absolute right-2 flex h-3.5 w-3.5 items-center justify-center\">\n                                    <Check class=\"h-4 w-4\" />\n                                </span>\n                            </ComboboxItem>\n                            <div\n                                v-if=\"searchValue.length > 0 && filteredClients.length === 0\"\n                                class=\"flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground\"\n                                @click=\"addClientIfNoneExists\">\n                                <Plus class=\"h-4 w-4 shrink-0\" />\n                                <span>Add \"{{ searchValue }}\" as a new Client</span>\n                            </div>\n                        </ComboboxViewport>\n                    </ComboboxContent>\n                </ComboboxRoot>\n            </UseFocusTrap>\n        </template>\n    </Dropdown>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Client/ClientDropdownItem.vue",
    "content": "<script setup lang=\"ts\">\nimport { CheckCircleIcon } from '@heroicons/vue/20/solid';\nimport { computed } from 'vue';\nimport { twMerge } from 'tailwind-merge';\n\nconst props = defineProps<{\n    name: string;\n    selected: boolean;\n}>();\n\nconst iconClasses = computed(() => {\n    if (props.selected) {\n        return 'text-accent-200';\n    } else {\n        return 'text-card-border';\n    }\n});\n</script>\n\n<template>\n    <div\n        class=\"flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out\">\n        <CheckCircleIcon :class=\"twMerge(iconClasses, 'w-5')\"></CheckCircleIcon>\n        <span>{{ name }}</span>\n    </div>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/CommandPalette/CommandPalette.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, watch } from 'vue';\nimport { DialogRoot, DialogPortal, DialogOverlay, DialogContent } from 'reka-ui';\nimport {\n    Command as CommandRoot,\n    CommandGroup,\n    CommandInput,\n    CommandItem,\n    CommandList,\n    CommandSeparator,\n    CommandShortcut,\n} from '../command';\nimport { cn } from '../utils/cn';\nimport type {\n    CommandPaletteCommand,\n    CommandPaletteGroup,\n    EntitySearchResult,\n} from './CommandPaletteTypes';\n\nconst open = defineModel<boolean>('open', { required: true });\nconst searchTerm = defineModel<string>('searchTerm', { default: '' });\n\nconst props = withDefaults(\n    defineProps<{\n        groups: CommandPaletteGroup[];\n        entityResults?: EntitySearchResult[];\n        placeholder?: string;\n    }>(),\n    {\n        entityResults: () => [],\n        placeholder: 'Type a command or search...',\n    }\n);\n\nconst emit = defineEmits<{\n    select: [command: CommandPaletteCommand | EntitySearchResult];\n}>();\n\n// Non-empty groups for rendering\nconst nonEmptyGroups = computed(() => props.groups.filter((g) => g.commands.length > 0));\n\nconst hasEntityResults = computed(() => (props.entityResults?.length ?? 0) > 0);\n\nconst hasAnyGroups = computed(() => nonEmptyGroups.value.length > 0);\n\n// Handle command selection\nasync function handleSelect(cmd: CommandPaletteCommand | EntitySearchResult) {\n    emit('select', cmd);\n    await cmd.action();\n}\n\n// Reset search when dialog closes\nwatch(open, (isOpen) => {\n    if (!isOpen) {\n        searchTerm.value = '';\n    }\n});\n</script>\n\n<template>\n    <DialogRoot v-model:open=\"open\">\n        <DialogPortal>\n            <DialogOverlay\n                class=\"fixed inset-0 z-50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\">\n                <div class=\"absolute inset-0 bg-default-background opacity-30\" />\n            </DialogOverlay>\n            <div\n                :class=\"\n                    cn(\n                        'fixed top-0 left-0 z-50 pointer-events-none w-screen h-screen flex items-start pt-6 md:pt-20 xl:pt-32 justify-center overflow-auto'\n                    )\n                \">\n                <DialogContent\n                    class=\"pointer-events-auto bg-default-background w-full max-w-lg border border-border-tertiary shadow-lg sm:rounded-lg outline-none overflow-hidden p-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95\">\n                    <CommandRoot\n                        v-model:search-term=\"searchTerm\"\n                        class=\"[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5\">\n                        <CommandInput :placeholder=\"placeholder\" />\n                        <CommandList>\n                            <!-- Empty state -->\n                            <div\n                                v-if=\"searchTerm.length > 0 && !hasEntityResults && !hasAnyGroups\"\n                                class=\"py-6 text-center text-sm text-muted-foreground\">\n                                No results found.\n                            </div>\n\n                            <!-- Command Groups -->\n                            <template v-for=\"(group, index) in nonEmptyGroups\" :key=\"group.id\">\n                                <CommandSeparator v-if=\"index > 0\" />\n                                <CommandGroup :heading=\"group.heading\">\n                                    <CommandItem\n                                        v-for=\"cmd in group.commands\"\n                                        :key=\"cmd.id\"\n                                        :value=\"cmd.id\"\n                                        class=\"cursor-pointer\"\n                                        @select=\"handleSelect(cmd)\">\n                                        <component :is=\"cmd.icon\" v-if=\"cmd.icon\" />\n                                        <span>{{ cmd.label }}</span>\n                                        <span class=\"sr-only\" aria-hidden=\"true\">{{\n                                            cmd.keywords.join(' ')\n                                        }}</span>\n                                        <CommandShortcut v-if=\"cmd.shortcut\">\n                                            {{ cmd.shortcut }}\n                                        </CommandShortcut>\n                                    </CommandItem>\n                                </CommandGroup>\n                            </template>\n\n                            <!-- Entity Search Results -->\n                            <template v-if=\"hasEntityResults\">\n                                <CommandSeparator v-if=\"hasAnyGroups\" />\n                                <CommandGroup heading=\"Search Results\">\n                                    <CommandItem\n                                        v-for=\"cmd in entityResults\"\n                                        :key=\"cmd.id\"\n                                        :value=\"cmd.id\"\n                                        class=\"cursor-pointer\"\n                                        @select=\"handleSelect(cmd)\">\n                                        <component :is=\"cmd.icon\" v-if=\"cmd.icon\" />\n                                        <span class=\"flex-1\">{{ cmd.label }}</span>\n                                        <span class=\"sr-only\" aria-hidden=\"true\">{{\n                                            cmd.keywords.join(' ')\n                                        }}</span>\n                                        <span\n                                            v-if=\"cmd.badgeClass\"\n                                            class=\"ml-2 rounded px-1.5 py-0.5 text-xs font-medium\"\n                                            :class=\"cmd.badgeClass\">\n                                            {{ cmd.entityType }}\n                                        </span>\n                                    </CommandItem>\n                                </CommandGroup>\n                            </template>\n                        </CommandList>\n                    </CommandRoot>\n                </DialogContent>\n            </div>\n        </DialogPortal>\n    </DialogRoot>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/CommandPalette/CommandPaletteTypes.ts",
    "content": "// Use `object` instead of Vue's `Component` to avoid type incompatibility\n// between root and UI package Vue runtime-core copies in the monorepo.\n// Vue's `<component :is=\"...\">` accepts any object at runtime.\nexport interface CommandPaletteCommand {\n    id: string;\n    label: string;\n    icon?: object;\n    keywords: string[];\n    action: () => void | Promise<void>;\n    shortcut?: string;\n}\n\nexport interface CommandPaletteGroup {\n    id: string;\n    heading: string;\n    commands: CommandPaletteCommand[];\n}\n\nexport interface EntitySearchResult extends CommandPaletteCommand {\n    entityType: string;\n    color?: string;\n    badgeClass?: string;\n}\n"
  },
  {
    "path": "resources/js/packages/ui/src/CommandPalette/index.ts",
    "content": "export { default as CommandPalette } from './CommandPalette.vue';\nexport type {\n    CommandPaletteCommand,\n    CommandPaletteGroup,\n    EntitySearchResult,\n} from './CommandPaletteTypes';\n"
  },
  {
    "path": "resources/js/packages/ui/src/DialogModal.vue",
    "content": "<script setup lang=\"ts\">\nimport Modal from './Modal.vue';\n\nconst emit = defineEmits(['close']);\n\ndefineProps({\n    show: {\n        type: Boolean,\n        default: false,\n    },\n    maxWidth: {\n        type: String,\n        default: '2xl',\n    },\n    closeable: {\n        type: Boolean,\n        default: true,\n    },\n});\n\nconst close = () => {\n    emit('close');\n};\n</script>\n\n<template>\n    <Modal :show=\"show\" :max-width=\"maxWidth\" :closeable=\"closeable\" @close=\"close\">\n        <div class=\"px-4 lg:px-6 py-4\">\n            <div class=\"text-lg font-medium text-text-primary\" role=\"heading\">\n                <slot name=\"title\" />\n            </div>\n\n            <div class=\"mt-4 text-sm text-text-secondary\">\n                <slot name=\"content\" />\n            </div>\n        </div>\n\n        <div\n            class=\"flex flex-row justify-end px-6 py-4 border-t border-card-background-separator bg-default-background rounded-b-2xl text-end\">\n            <slot name=\"footer\" />\n        </div>\n    </Modal>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/EstimatedTimeProgress.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue';\n\nconst props = defineProps<{ estimated: number; current: number }>();\nfunction formatHours(seconds: number) {\n    return Math.round(seconds / 60 / 60) + 'h';\n}\n\nconst isOverEstimate = computed(() => props.current > props.estimated);\nconst progressBarPercentage = computed(() => {\n    return (props.current / props.estimated) * 100;\n});\nconst formattedProgressBarPercentage = computed(() => {\n    return progressBarPercentage.value.toFixed(1) + '%';\n});\n</script>\n<template>\n    <div class=\"w-full\">\n        <div class=\"bg-tertiary h-1 rounded relative overflow-hidden w-full\">\n            <div\n                class=\"h-full\"\n                :class=\"{\n                    'bg-accent-200': !isOverEstimate,\n                    'bg-red-500': isOverEstimate,\n                }\"\n                :style=\"{ width: progressBarPercentage + '%' }\"></div>\n        </div>\n        <div class=\"text-xs font-semibold pt-1.5\">\n            {{ formattedProgressBarPercentage }} of\n            {{ formatHours(estimated) }}\n        </div>\n    </div>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/EstimatedTimeSection.vue",
    "content": "<script setup lang=\"ts\">\nimport EstimatedTimeInput from '@/packages/ui/src/Input/EstimatedTimeInput.vue';\nimport { ClockIcon } from '@heroicons/vue/20/solid';\nimport { Field, FieldDescription, FieldLabel } from './field';\n\nconst model = defineModel<number | null>();\nconst emit = defineEmits(['submit']);\n</script>\n\n<template>\n    <Field>\n        <FieldLabel for=\"time_estimated\" :icon=\"ClockIcon\">Time Estimated</FieldLabel>\n        <EstimatedTimeInput\n            id=\"time_estimated\"\n            v-model=\"model\"\n            @submit=\"emit('submit')\"></EstimatedTimeInput>\n        <FieldDescription>\n            You can type natural language like\n            <span class=\"font-semibold\">2h 30m</span>\n        </FieldDescription>\n    </Field>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/FullCalendar/CalendarSettingsPopover.vue",
    "content": "<script setup lang=\"ts\">\nimport { Popover, PopoverContent, PopoverTrigger, Button } from '..';\nimport {\n    Select,\n    SelectContent,\n    SelectItem,\n    SelectTrigger,\n    SelectValue,\n} from '@/Components/ui/select';\nimport { Field, FieldLabel } from '../field';\nimport { Settings } from 'lucide-vue-next';\nimport { ref, watch } from 'vue';\nimport type { CalendarSettings } from './calendarSettings';\n\nexport type { CalendarSettings };\n\nconst props = defineProps<{\n    settings: CalendarSettings;\n}>();\n\nconst emit = defineEmits<{\n    'update:settings': [value: CalendarSettings];\n}>();\n\nconst snapMinutes = ref(String(props.settings.snapMinutes));\nconst startHour = ref(String(props.settings.startHour));\nconst endHour = ref(String(props.settings.endHour));\nconst slotMinutes = ref(String(props.settings.slotMinutes));\n\nwatch(\n    () => props.settings,\n    (s) => {\n        snapMinutes.value = String(s.snapMinutes);\n        startHour.value = String(s.startHour);\n        endHour.value = String(s.endHour);\n        slotMinutes.value = String(s.slotMinutes);\n    }\n);\n\nfunction emitUpdate(partial: Partial<CalendarSettings>) {\n    emit('update:settings', { ...props.settings, ...partial });\n}\n\nfunction onSnapChange(value: string) {\n    snapMinutes.value = value;\n    emitUpdate({ snapMinutes: parseInt(value) });\n}\n\nfunction onStartHourChange(value: string) {\n    const newStart = parseInt(value);\n    // Ensure start < end\n    if (newStart >= parseInt(endHour.value)) {\n        startHour.value = String(props.settings.startHour);\n        return;\n    }\n    startHour.value = value;\n    emitUpdate({ startHour: newStart });\n}\n\nfunction onEndHourChange(value: string) {\n    const newEnd = parseInt(value);\n    // Ensure end > start\n    if (newEnd <= parseInt(startHour.value)) {\n        endHour.value = String(props.settings.endHour);\n        return;\n    }\n    endHour.value = value;\n    emitUpdate({ endHour: newEnd });\n}\n\nfunction onSlotChange(value: string) {\n    slotMinutes.value = value;\n    emitUpdate({ slotMinutes: parseInt(value) });\n}\n\nconst snapOptions = [\n    { value: '1', label: '1 min' },\n    { value: '5', label: '5 min' },\n    { value: '10', label: '10 min' },\n    { value: '15', label: '15 min' },\n    { value: '30', label: '30 min' },\n    { value: '60', label: '1 hour' },\n];\n\nconst slotOptions = [\n    { value: '5', label: '5 min' },\n    { value: '10', label: '10 min' },\n    { value: '15', label: '15 min' },\n    { value: '30', label: '30 min' },\n    { value: '60', label: '1 hour' },\n];\n\n// Generate hour options 0-24\nconst hourOptions = Array.from({ length: 25 }, (_, i) => ({\n    value: String(i),\n    label:\n        i === 0\n            ? '12:00 AM'\n            : i === 12\n              ? '12:00 PM'\n              : i === 24\n                ? '12:00 AM (next)'\n                : i < 12\n                  ? `${i}:00 AM`\n                  : `${i - 12}:00 PM`,\n}));\n</script>\n\n<template>\n    <Popover>\n        <PopoverTrigger as-child>\n            <Button variant=\"outline\" size=\"sm\" aria-label=\"Calendar settings\" class=\"h-8 w-8 p-0\">\n                <Settings class=\"h-4 w-4 text-muted-foreground\" />\n            </Button>\n        </PopoverTrigger>\n        <PopoverContent align=\"end\" class=\"w-72 p-4\">\n            <div class=\"space-y-4\">\n                <div class=\"text-sm font-semibold\">Calendar Settings</div>\n\n                <Field>\n                    <FieldLabel for=\"calendar-snap\">Snap Interval</FieldLabel>\n                    <Select\n                        :model-value=\"snapMinutes\"\n                        @update:model-value=\"(v) => onSnapChange(v as string)\">\n                        <SelectTrigger id=\"calendar-snap\" size=\"sm\" class=\"w-full\">\n                            <SelectValue placeholder=\"Snap interval\" />\n                        </SelectTrigger>\n                        <SelectContent>\n                            <SelectItem\n                                v-for=\"opt in snapOptions\"\n                                :key=\"opt.value\"\n                                :value=\"opt.value\">\n                                {{ opt.label }}\n                            </SelectItem>\n                        </SelectContent>\n                    </Select>\n                </Field>\n\n                <Field>\n                    <FieldLabel for=\"calendar-start-hour\">Start Time</FieldLabel>\n                    <Select\n                        :model-value=\"startHour\"\n                        @update:model-value=\"(v) => onStartHourChange(v as string)\">\n                        <SelectTrigger id=\"calendar-start-hour\" size=\"sm\" class=\"w-full\">\n                            <SelectValue placeholder=\"Start time\" />\n                        </SelectTrigger>\n                        <SelectContent>\n                            <SelectItem\n                                v-for=\"opt in hourOptions.slice(0, -1)\"\n                                :key=\"opt.value\"\n                                :value=\"opt.value\">\n                                {{ opt.label }}\n                            </SelectItem>\n                        </SelectContent>\n                    </Select>\n                </Field>\n\n                <Field>\n                    <FieldLabel for=\"calendar-end-hour\">End Time</FieldLabel>\n                    <Select\n                        :model-value=\"endHour\"\n                        @update:model-value=\"(v) => onEndHourChange(v as string)\">\n                        <SelectTrigger id=\"calendar-end-hour\" size=\"sm\" class=\"w-full\">\n                            <SelectValue placeholder=\"End time\" />\n                        </SelectTrigger>\n                        <SelectContent>\n                            <SelectItem\n                                v-for=\"opt in hourOptions.slice(1)\"\n                                :key=\"opt.value\"\n                                :value=\"opt.value\">\n                                {{ opt.label }}\n                            </SelectItem>\n                        </SelectContent>\n                    </Select>\n                </Field>\n\n                <Field>\n                    <FieldLabel for=\"calendar-scale\">Grid Scale</FieldLabel>\n                    <Select\n                        :model-value=\"slotMinutes\"\n                        @update:model-value=\"(v) => onSlotChange(v as string)\">\n                        <SelectTrigger id=\"calendar-scale\" size=\"sm\" class=\"w-full\">\n                            <SelectValue placeholder=\"Grid scale\" />\n                        </SelectTrigger>\n                        <SelectContent>\n                            <SelectItem\n                                v-for=\"opt in slotOptions\"\n                                :key=\"opt.value\"\n                                :value=\"opt.value\">\n                                {{ opt.label }}\n                            </SelectItem>\n                        </SelectContent>\n                    </Select>\n                </Field>\n            </div>\n        </PopoverContent>\n    </Popover>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/FullCalendar/FullCalendarDayHeader.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, inject, type ComputedRef } from 'vue';\nimport { formatDate, formatHumanReadableDuration } from '../utils/time';\nimport type { Organization } from '@/packages/api/src';\nimport type { Dayjs } from 'dayjs';\n\nconst props = defineProps<{\n    date: Dayjs;\n    totalSeconds?: number;\n}>();\n\nconst totalSecondsValue = computed(() => props.totalSeconds ?? 0);\n\n// Injected organization for formatting settings\nconst organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;\nconst intervalFormat = computed(() => organization?.value?.interval_format);\nconst numberFormat = computed(() => organization?.value?.number_format);\nconst dateFormat = computed(() => organization?.value?.date_format);\n</script>\n\n<template>\n    <div class=\"fc-day-header-custom\">\n        <div class=\"text-xs text-muted-foreground font-medium\">\n            {{ date.format('ddd') }}\n        </div>\n        <span class=\"text-xs\">{{ formatDate(date.toISOString(), dateFormat) }}</span>\n        <span class=\"block text-xs text-muted-foreground font-medium mt-1\">\n            {{ formatHumanReadableDuration(totalSecondsValue, intervalFormat, numberFormat) }}\n        </span>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/FullCalendar/FullCalendarEventContent.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, inject, type ComputedRef } from 'vue';\nimport { formatHumanReadableDuration, getDayJsInstance } from '../utils/time';\nimport type { Organization } from '@/packages/api/src';\n\nconst props = defineProps<{\n    title: string;\n    projectName?: string | null;\n    taskName?: string | null;\n    clientName?: string | null;\n    durationSeconds?: number;\n    start?: string | Date | null;\n    end?: string | Date | null;\n}>();\n\nconst effectiveDurationSeconds = computed(() => {\n    if (typeof props.durationSeconds === 'number') {\n        return props.durationSeconds;\n    }\n    if (props.start && props.end) {\n        const end = getDayJsInstance()(props.end as unknown as string | Date);\n        const start = getDayJsInstance()(props.start as unknown as string | Date);\n        const minutes = end.diff(start, 'minutes');\n        return minutes * 60;\n    }\n    return 0;\n});\n\nconst organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;\nconst intervalFormat = computed(() => organization?.value?.interval_format);\nconst numberFormat = computed(() => organization?.value?.number_format);\n\nconst formattedDuration = computed(() =>\n    formatHumanReadableDuration(\n        effectiveDurationSeconds.value,\n        intervalFormat.value,\n        numberFormat.value\n    )\n);\n</script>\n\n<template>\n    <div class=\"text-2xs leading-tight px-0.5 py-1.5\">\n        <div class=\"font-semibold\">{{ title }}</div>\n        <div v-if=\"projectName\" class=\"font-medium opacity-90\">\n            {{ projectName }}\n        </div>\n        <div v-if=\"taskName\" class=\"font-medium\">\n            {{ taskName }}\n        </div>\n        <div v-if=\"clientName\" class=\"opacity-85\">\n            {{ clientName }}\n        </div>\n        <div class=\"opacity-90\">\n            {{ formattedDuration }}\n        </div>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue",
    "content": "<script setup lang=\"ts\">\nimport FullCalendar from '@fullcalendar/vue3';\nimport dayGridPlugin from '@fullcalendar/daygrid';\nimport timeGridPlugin from '@fullcalendar/timegrid';\nimport interactionPlugin from '@fullcalendar/interaction';\nimport type { DatesSetArg, EventClickArg, EventDropArg, EventChangeArg } from '@fullcalendar/core';\nimport {\n    computed,\n    ref,\n    watch,\n    inject,\n    type ComputedRef,\n    nextTick,\n    onMounted,\n    onActivated,\n    onUnmounted,\n} from 'vue';\nimport { useLocalStorage } from '@vueuse/core';\nimport chroma from 'chroma-js';\nimport { useCssVariable } from '@/utils/useCssVariable';\nimport {\n    getDayJsInstance,\n    getLocalizedDayJs,\n    formatHumanReadableDuration,\n    formatDuration,\n} from '../utils/time';\nimport { getUserTimezone, getWeekStart } from '../utils/settings';\nimport { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';\nimport FullCalendarEventContent from './FullCalendarEventContent.vue';\nimport FullCalendarDayHeader from './FullCalendarDayHeader.vue';\nimport CalendarSettingsPopover from './CalendarSettingsPopover.vue';\nimport type { CalendarSettings } from './calendarSettings';\nimport { useVisualSnap } from './useVisualSnap';\nimport activityStatusPlugin, {\n    type ActivityPeriod,\n    renderActivityStatusBoxes,\n} from './idleStatusPlugin';\nimport type {\n    TimeEntry,\n    Project,\n    Client,\n    Task,\n    CreateProjectBody,\n    CreateClientBody,\n    Tag,\n    Organization,\n} from '@/packages/api/src';\nimport type { Dayjs } from 'dayjs';\n\ntype CalendarExtendedProps = { timeEntry: TimeEntry; isRunning?: boolean } & Record<\n    string,\n    unknown\n>;\n\nconst emit = defineEmits<{\n    (e: 'dates-change', payload: { start: Date; end: Date }): void;\n    (e: 'refresh'): void;\n}>();\n\nconst props = defineProps<{\n    timeEntries: TimeEntry[];\n    projects: Project[];\n    tasks: Task[];\n    clients: Client[];\n    tags: Tag[];\n    activityPeriods?: ActivityPeriod[];\n    loading?: boolean;\n\n    // Permissions / feature flags\n    enableEstimatedTime: boolean;\n    currency: string;\n    canCreateProject: boolean;\n\n    createTimeEntry: (\n        entry: Omit<TimeEntry, 'id' | 'organization_id' | 'user_id'>\n    ) => Promise<void>;\n    updateTimeEntry: (entry: TimeEntry) => Promise<void>;\n    deleteTimeEntry: (timeEntryId: string) => Promise<void>;\n    createProject: (project: CreateProjectBody) => Promise<Project | undefined>;\n    createClient: (client: CreateClientBody) => Promise<Client | undefined>;\n    createTag: (name: string) => Promise<Tag | undefined>;\n}>();\n\n// Local component state\nconst newEventStart = ref<Dayjs | null>(null);\nconst newEventEnd = ref<Dayjs | null>(null);\nconst showCreateTimeEntryModal = ref<boolean>(false);\nconst showEditTimeEntryModal = ref<boolean>(false);\nconst selectedTimeEntry = ref<TimeEntry | null>(null);\n\nconst calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);\n\n// Calendar settings with localStorage persistence via VueUse\nconst calendarSettings = useLocalStorage<CalendarSettings>(\n    'solidtime:calendar-settings',\n    {\n        snapMinutes: 15,\n        startHour: 0,\n        endHour: 24,\n        slotMinutes: 15,\n    },\n    { mergeDefaults: true }\n);\n\nfunction onSettingsUpdate(newSettings: CalendarSettings) {\n    calendarSettings.value = newSettings;\n}\n\n// Reactive \"now\" for running time entry - updates every minute\nconst currentTime = ref(getDayJsInstance()());\nlet currentTimeInterval: ReturnType<typeof setInterval> | null = null;\n\n// Inject organization data for settings\nconst organization = inject<ComputedRef<Organization>>('organization');\n\n// Helper function to convert week start to FullCalendar firstDay value\nconst getFirstDay = () => {\n    const weekStart = getWeekStart();\n    const weekStartMap: Record<string, number> = {\n        'sunday': 0,\n        'monday': 1,\n        'tuesday': 2,\n        'wednesday': 3,\n        'thursday': 4,\n        'friday': 5,\n        'saturday': 6,\n    };\n    return weekStartMap[weekStart] ?? 1; // Default to Monday if not found\n};\n\n// Helper function to get time format for slot labels\nconst getSlotLabelFormat = () => {\n    const timeFormat = organization?.value?.time_format || '24-hours';\n    if (timeFormat === '12-hours') {\n        return {\n            hour: 'numeric' as const,\n            hour12: true,\n        };\n    } else {\n        return {\n            hour: '2-digit' as const,\n            minute: '2-digit' as const,\n            hour12: false,\n        };\n    }\n};\n\nconst cssBackground = useCssVariable('--color-bg-background');\n\nconst events = computed(() => {\n    const themeBackground = (() => {\n        return cssBackground.value?.trim();\n    })();\n    return props.timeEntries?.map((timeEntry) => {\n        const isRunning = timeEntry.end === null;\n        const project = props.projects.find((p) => p.id === timeEntry.project_id);\n        const client = props.clients.find((c) => c.id === project?.client_id);\n        const task = props.tasks.find((t) => t.id === timeEntry.task_id);\n\n        // For running entries, use current time as end\n        const effectiveEnd = isRunning ? currentTime.value : getDayJsInstance()(timeEntry.end!);\n        const duration = effectiveEnd.diff(getDayJsInstance()(timeEntry.start), 'minutes');\n\n        const title = timeEntry.description || 'No description';\n\n        const baseColor = project?.color || '#6B7280';\n        const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex();\n        const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex();\n\n        // For 0-duration events, display them with minimum visual duration but preserve actual duration\n        const startTime = getLocalizedDayJs(timeEntry.start);\n        const endTime =\n            duration === 0\n                ? startTime.add(1, 'second') // Show as 1 second for minimal visibility\n                : isRunning\n                  ? getLocalizedDayJs(currentTime.value.toISOString())\n                  : getLocalizedDayJs(timeEntry.end!);\n\n        return {\n            id: timeEntry.id,\n            start: startTime.format(),\n            end: endTime.format(),\n            title,\n            backgroundColor,\n            borderColor,\n            textColor: 'var(--foreground)',\n            // For running entries: disable dragging and resizing\n            startEditable: !isRunning,\n            classNames: isRunning ? ['running-entry'] : [],\n            extendedProps: {\n                timeEntry,\n                project,\n                client,\n                task,\n                duration,\n                isRunning,\n            },\n        };\n    });\n});\n\n// Daily totals used in day header\nconst dailyTotals = computed(() => {\n    const totals: Record<string, number> = {};\n    props.timeEntries.forEach((entry) => {\n        const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD');\n        let durationSeconds: number;\n\n        if (entry.end !== null) {\n            // Completed entry\n            durationSeconds = getDayJsInstance()(entry.end).diff(\n                getDayJsInstance()(entry.start),\n                'seconds'\n            );\n        } else {\n            // Running entry - use current time\n            durationSeconds = currentTime.value.diff(getDayJsInstance()(entry.start), 'seconds');\n        }\n\n        totals[date] = (totals[date] || 0) + durationSeconds;\n    });\n    return totals;\n});\n\nfunction emitDatesChange(arg: DatesSetArg) {\n    emit('dates-change', { start: arg.start, end: arg.end });\n    // Render activity boxes after calendar view has been rendered\n    renderActivityBoxes();\n}\n\nfunction handleDateSelect(arg: { start: Date; end: Date }) {\n    stopVisualSnap();\n    const snap = calendarSettings.value.snapMinutes;\n    const startLocal = getDayJsInstance()(arg.start.toISOString())\n        .utc()\n        .tz(getUserTimezone(), true);\n    const endLocal = getDayJsInstance()(arg.end.toISOString()).utc().tz(getUserTimezone(), true);\n    const snappedStart = snapStartToGrid(startLocal, snap);\n    let snappedEnd = snapEndToGrid(endLocal, snap);\n    if (!snappedEnd.isAfter(snappedStart)) {\n        snappedEnd = snappedStart.add(snap, 'minute');\n    }\n    newEventStart.value = snappedStart.utc();\n    newEventEnd.value = snappedEnd.utc();\n    showCreateTimeEntryModal.value = true;\n}\n\nfunction handleEventClick(arg: EventClickArg) {\n    const ext = arg.event.extendedProps as CalendarExtendedProps;\n    // Don't open edit modal for running time entries\n    if (ext.isRunning) {\n        return;\n    }\n    selectedTimeEntry.value = ext.timeEntry;\n    showEditTimeEntryModal.value = true;\n}\n\n// Snap a dayjs time down to the previous snap boundary (for start times)\nfunction snapStartToGrid(time: Dayjs, snapMinutes: number): Dayjs {\n    const minutes = time.hour() * 60 + time.minute();\n    const snapped = Math.floor(minutes / snapMinutes) * snapMinutes;\n    return time.startOf('day').add(snapped, 'minute');\n}\n\n// Snap a dayjs time up to the next snap boundary (for end times)\nfunction snapEndToGrid(time: Dayjs, snapMinutes: number): Dayjs {\n    const minutes = time.hour() * 60 + time.minute();\n    const snapped = Math.ceil(minutes / snapMinutes) * snapMinutes;\n    return time.startOf('day').add(snapped, 'minute');\n}\n\n// --- Visual snap (composable) ---\nconst {\n    startDragSnap: startVisualDragSnap,\n    startResizeSnap: startVisualResizeSnap,\n    stop: stopVisualSnap,\n} = useVisualSnap({\n    calendarRef,\n    snapMinutes: () => calendarSettings.value.snapMinutes,\n    slotMinutes: () => calendarSettings.value.slotMinutes,\n    formatDuration: (seconds) =>\n        formatHumanReadableDuration(\n            seconds,\n            organization?.value?.interval_format,\n            organization?.value?.number_format\n        ),\n});\n\nasync function handleEventDrop(arg: EventDropArg) {\n    stopVisualSnap();\n    const ext = arg.event.extendedProps as CalendarExtendedProps;\n    const timeEntry = ext.timeEntry;\n    if (!arg.event.start || !arg.event.end) return;\n    // Running entries have no end time — can't compute duration for drop\n    if (!timeEntry.end) return;\n    const snap = calendarSettings.value.snapMinutes;\n    const startLocal = getDayJsInstance()(arg.event.start.toISOString())\n        .utc()\n        .tz(getUserTimezone(), true)\n        .second(0);\n    const snappedStart = snapStartToGrid(startLocal, snap);\n    const durationMs = getLocalizedDayJs(timeEntry.end).diff(getLocalizedDayJs(timeEntry.start));\n    const snappedEnd = snappedStart.add(durationMs, 'millisecond');\n    // Set FC event to snapped position immediately to avoid flash\n    arg.event.setDates(snappedStart.utc(true).toDate(), snappedEnd.utc(true).toDate());\n    const updatedTimeEntry = {\n        ...timeEntry,\n        start: snappedStart.utc().format(),\n        end: snappedEnd.utc().format(),\n    } as TimeEntry;\n    await props.updateTimeEntry(updatedTimeEntry);\n    emit('refresh');\n}\n\nasync function handleEventResize(arg: EventChangeArg) {\n    stopVisualSnap();\n    const ext = arg.event.extendedProps as CalendarExtendedProps;\n    const timeEntry = ext.timeEntry;\n    if (!arg.event.start || !arg.event.end) return;\n    const snap = calendarSettings.value.snapMinutes;\n\n    const newStartLocal = getDayJsInstance()(arg.event.start.toISOString())\n        .utc()\n        .tz(getUserTimezone(), true)\n        .second(0);\n    const newEndLocal = getDayJsInstance()(arg.event.end.toISOString())\n        .utc()\n        .tz(getUserTimezone(), true)\n        .second(0);\n    const origStartLocal = getLocalizedDayJs(timeEntry.start).second(0);\n\n    const startChanged = !newStartLocal.isSame(origStartLocal, 'minute');\n\n    // Snap only the changed edge once, reuse for both setDates and API update\n    const snappedStart = startChanged ? snapStartToGrid(newStartLocal, snap) : null;\n    const snappedEnd = !startChanged && !ext.isRunning ? snapEndToGrid(newEndLocal, snap) : null;\n\n    // Set FC event to snapped position immediately to avoid flash.\n    // Use the original event date for the edge that wasn't resized.\n    if (snappedStart) {\n        arg.event.setDates(snappedStart.utc(true).toDate(), arg.oldEvent.end!);\n    } else if (snappedEnd) {\n        arg.event.setDates(arg.oldEvent.start!, snappedEnd.utc(true).toDate());\n    }\n    const updatedTimeEntry = {\n        ...timeEntry,\n        start: snappedStart ? snappedStart.utc().format() : timeEntry.start,\n        end: ext.isRunning ? null : snappedEnd ? snappedEnd.utc().format() : timeEntry.end,\n    } as TimeEntry;\n    await props.updateTimeEntry(updatedTimeEntry);\n    emit('refresh');\n}\n\nconst calendarOptions = computed(() => {\n    const s = calendarSettings.value;\n\n    return {\n        plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin],\n        initialView: 'timeGridWeek',\n        headerToolbar: {\n            left: 'prev,next today',\n            center: 'title',\n            right: 'timeGridWeek,timeGridDay',\n        },\n        height: 'parent',\n        slotMinTime: formatDuration(s.startHour * 3600),\n        slotMaxTime: formatDuration(s.endHour * 3600),\n        slotDuration: formatDuration(s.slotMinutes * 60),\n        slotLabelInterval: '01:00:00',\n        slotLabelFormat: getSlotLabelFormat(),\n        snapDuration: '00:01:00',\n        firstDay: getFirstDay(),\n        allDaySlot: false,\n        nowIndicator: true,\n        eventMinHeight: 1,\n        selectable: true,\n        selectMirror: true,\n        editable: true,\n        eventResizableFromStart: true,\n        eventDurationEditable: true,\n        timeZone: getUserTimezone(),\n        eventStartEditable: true,\n        select: handleDateSelect,\n        eventClick: handleEventClick,\n        eventDragStart: startVisualDragSnap,\n        eventDrop: handleEventDrop,\n        eventResizeStart: startVisualResizeSnap,\n        eventResize: handleEventResize,\n        datesSet: emitDatesChange,\n\n        events: events.value,\n        activityPeriods: props.activityPeriods || [],\n    };\n});\n\nwatch(showCreateTimeEntryModal, (value) => {\n    if (!value) {\n        newEventStart.value = null;\n        newEventEnd.value = null;\n        // Ensure FullCalendar clears the selection mirror when modal closes\n        calendarRef.value?.getApi().unselect();\n        emit('refresh');\n    }\n});\n\nwatch(showEditTimeEntryModal, (value) => {\n    if (!value) {\n        selectedTimeEntry.value = null;\n        emit('refresh');\n    }\n});\n\n// Render activity status boxes after FullCalendar has rendered\nconst renderActivityBoxes = () => {\n    if (!calendarRef.value || !props.activityPeriods) return;\n\n    const calendarEl = calendarRef.value.$el as HTMLElement;\n    if (calendarEl && props.activityPeriods.length > 0) {\n        renderActivityStatusBoxes(calendarEl, props.activityPeriods);\n    }\n};\n\n// Watch for activity periods changes - re-render when data changes\nwatch(\n    () => props.activityPeriods,\n    () => {\n        renderActivityBoxes();\n    }\n);\n\nconst scrollToCurrentTime = () => {\n    nextTick(() => {\n        if (calendarRef.value) {\n            const now = getDayJsInstance()();\n            const oneHourBefore = now.subtract(1, 'hour');\n\n            // If subtracting 1 hour keeps us on the same day, scroll to 1 hour before\n            const scrollTime = now.isSame(oneHourBefore, 'day')\n                ? oneHourBefore.format('HH:mm:ss')\n                : now.format('HH:mm:ss');\n\n            calendarRef.value.getApi().scrollToTime(scrollTime);\n        }\n    });\n};\n\nonMounted(() => {\n    scrollToCurrentTime();\n    // Start interval to update running time entry\n    currentTimeInterval = setInterval(() => {\n        currentTime.value = getDayJsInstance()();\n    }, 60000); // Update every minute\n});\n\nonActivated(() => {\n    scrollToCurrentTime();\n});\n\nonUnmounted(() => {\n    if (currentTimeInterval) {\n        clearInterval(currentTimeInterval);\n        currentTimeInterval = null;\n    }\n});\n</script>\n\n<template>\n    <div class=\"w-full relative h-full flex-1\">\n        <div v-if=\"loading\" class=\"flex items-center justify-center h-full\">\n            <div class=\"flex flex-col items-center space-y-4\">\n                <LoadingSpinner class=\"h-8 w-8\" />\n                <p class=\"text-muted-foreground\">Loading calendar data...</p>\n            </div>\n        </div>\n\n        <TimeEntryCreateModal\n            v-model:show=\"showCreateTimeEntryModal\"\n            :enable-estimated-time=\"enableEstimatedTime\"\n            :create-time-entry=\"createTimeEntry\"\n            :create-client=\"createClient\"\n            :create-project=\"createProject\"\n            :create-tag=\"createTag\"\n            :currency=\"currency\"\n            :can-create-project=\"canCreateProject\"\n            :tags=\"tags as any\"\n            :projects=\"projects\"\n            :tasks=\"tasks\"\n            :clients=\"clients\"\n            :start=\"newEventStart ? newEventStart.toISOString() : undefined\"\n            :end=\"newEventEnd ? newEventEnd.toISOString() : undefined\" />\n\n        <TimeEntryEditModal\n            v-model:show=\"showEditTimeEntryModal\"\n            :time-entry=\"selectedTimeEntry as any\"\n            :enable-estimated-time=\"enableEstimatedTime\"\n            :update-time-entry=\"updateTimeEntry\"\n            :delete-time-entry=\"deleteTimeEntry\"\n            :create-client=\"createClient\"\n            :create-project=\"createProject\"\n            :create-tag=\"createTag\"\n            :tags=\"tags as any\"\n            :projects=\"projects\"\n            :tasks=\"tasks\"\n            :clients=\"clients\"\n            :currency=\"currency\"\n            :can-create-project=\"canCreateProject\" />\n        <div class=\"calendar-settings-trigger\">\n            <CalendarSettingsPopover\n                :settings=\"calendarSettings\"\n                @update:settings=\"onSettingsUpdate\" />\n        </div>\n        <FullCalendar ref=\"calendarRef\" class=\"fullcalendar\" :options=\"calendarOptions\">\n            <template #eventContent=\"arg\">\n                <FullCalendarEventContent\n                    :title=\"arg.event.title\"\n                    :project-name=\"(arg.event.extendedProps as any).project?.name\"\n                    :task-name=\"(arg.event.extendedProps as any).task?.name\"\n                    :client-name=\"(arg.event.extendedProps as any).client?.name\"\n                    :duration-seconds=\"\n                        ((arg.event.extendedProps as any).duration ?? undefined)\n                            ? (arg.event.extendedProps as any).duration * 60\n                            : undefined\n                    \"\n                    :start=\"arg.event.start as any\"\n                    :end=\"arg.event.end as any\" />\n            </template>\n            <template #dayHeaderContent=\"arg\">\n                <FullCalendarDayHeader\n                    :date=\"\n                        getDayJsInstance()(arg.date.toISOString()).utc().tz(getUserTimezone(), true)\n                    \"\n                    :total-seconds=\"\n                        dailyTotals[\n                            getDayJsInstance()(arg.date)\n                                .utc()\n                                .tz(getUserTimezone(), true)\n                                .format('YYYY-MM-DD')\n                        ] || 0\n                    \" />\n            </template>\n        </FullCalendar>\n    </div>\n</template>\n\n<style scoped>\n.calendar-settings-trigger {\n    position: absolute;\n    top: 0.5rem;\n    right: 0.5rem;\n    z-index: 20;\n}\n\n.fullcalendar {\n    height: 100%;\n    --fc-border-color: var(--border);\n}\n\n/* FullCalendar theme customization */\n.fullcalendar :deep(.fc) {\n    background-color: var(--theme-color-default-background);\n    color: var(--foreground);\n    font-family: inherit;\n}\n\n.fullcalendar :deep(.fc-timegrid-slot) {\n    height: 25px;\n    transition: height 0.2s ease;\n}\n\n.fullcalendar :deep(.fc-timegrid-slot-label) {\n    background-color: var(--background);\n}\n\n.fullcalendar :deep(.fc-toolbar) {\n    background-color: var(--background);\n    padding: 0.5rem;\n    padding-right: 2.75rem;\n    margin-bottom: 0;\n}\n\n.fullcalendar :deep(.fc-toolbar-title) {\n    color: var(--foreground);\n    font-size: 1rem;\n    font-weight: 600;\n}\n\n.fullcalendar :deep(.fc-button) {\n    background-color: var(--secondary);\n    border: 1px solid var(--border);\n    color: var(--foreground);\n    font-weight: 500;\n    font-size: 14px !important;\n}\n\n.fullcalendar :deep(.fc-button:hover) {\n    background-color: var(--muted);\n    border-color: var(--border);\n}\n\n.fullcalendar :deep(.fc-button:focus) {\n    box-shadow: 0 0 0 2px var(--ring);\n}\n\n.fullcalendar :deep(.fc-button-active) {\n    background-color: var(--primary);\n    border-color: var(--primary);\n    color: var(--primary-foreground);\n}\n\n.fullcalendar :deep(.fc-col-header) {\n    border-bottom: 1px solid var(--border);\n}\n\n.fullcalendar :deep(.fc-col-header-cell) {\n    border-right: 1px solid var(--border);\n    border-bottom: 1px solid var(--border);\n    padding: 0.75rem 0.5rem;\n    background-color: var(--theme-color-default-background);\n}\n\n.fullcalendar :deep(.fc-timegrid-axis) {\n    background-color: var(--theme-color-default-background) !important;\n}\n\n.fullcalendar :deep(.fc-col-header-cell .fc-col-header-cell-cushion) {\n    padding: 0;\n}\n\n.fullcalendar :deep(.fc-timegrid-axis) {\n    background-color: var(--theme-color-default-background);\n    border-right: 1px solid var(--border);\n}\n\n/* Quarter-hour slots - transparent borders */\n.fullcalendar :deep(.fc-timegrid-slot-minor.fc-timegrid-slot-label) {\n    border-top: 1px solid transparent;\n}\n\n.fullcalendar :deep(.fc-timegrid-slot-minor.fc-timegrid-slot-lane) {\n    --tw-border-opacity: 0;\n}\n\n.fullcalendar :deep(.fc-day-today.fc-col-header-cell) {\n    background-color: var(--color-bg-secondary);\n}\n\n.fullcalendar :deep(.fc-day-today) {\n    background-color: var(--theme-color-default-background);\n}\n\n.fullcalendar :deep(.fc-now-indicator) {\n    border-color: var(--primary);\n    border-width: 2px;\n}\n\n.fullcalendar :deep(.fc-event) {\n    border-radius: calc(var(--radius) - 4px);\n    padding: 0;\n    font-size: 0.75rem;\n    cursor: pointer;\n    box-shadow: var(--theme-shadow-card);\n    opacity: 0.9;\n    overflow: hidden;\n}\n\n.fullcalendar :deep(.fc-v-event) {\n    background-color: var(--muted);\n    border-color: var(--muted);\n}\n\n.fullcalendar :deep(.fc-event-title) {\n    font-weight: 500;\n    line-height: 1.2;\n}\n\n/* Resize handle hit areas */\n.fullcalendar :deep(.fc-event-resizer) {\n    position: absolute;\n    z-index: 99;\n    width: 100%;\n    height: 12px;\n    left: 0;\n    cursor: row-resize;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    opacity: 0;\n    transition: opacity 0.15s ease;\n}\n\n.fullcalendar :deep(.fc-event-resizer-start) {\n    top: -2px;\n}\n\n.fullcalendar :deep(.fc-event-resizer-end) {\n    bottom: -2px;\n}\n\n/* Visual grip indicator */\n.fullcalendar :deep(.fc-event-resizer::after) {\n    content: '';\n    width: 24px;\n    height: 3px;\n    border-radius: 1.5px;\n    background: rgba(255, 255, 255, 0.6);\n    transition: background 0.15s ease;\n}\n\n.fullcalendar :deep(.fc-event:hover .fc-event-resizer) {\n    opacity: 1;\n}\n\n.fullcalendar :deep(.fc-event-resizer:hover::after) {\n    background: rgba(255, 255, 255, 0.9);\n}\n\n/* Keep resize cursor during active resize */\n.fullcalendar :deep(.fc-event-resizing),\n.fullcalendar :deep(.fc-event-resizing .fc-event-resizer) {\n    cursor: row-resize !important;\n}\n\n/* Keep event in hover state while resizing */\n.fullcalendar :deep(.fc-event-resizing) {\n    opacity: 1;\n    box-shadow: var(--theme-shadow-dropdown);\n}\n\n.fullcalendar :deep(.fc-event-resizing .fc-event-resizer) {\n    opacity: 1;\n}\n\n.fullcalendar :deep(.fc-event-resizing .fc-event-resizer::after) {\n    background: rgba(255, 255, 255, 0.9);\n}\n\n/* Update the earlier hover rule to include the shadow */\n.fullcalendar :deep(.fc-event:hover) {\n    opacity: 1;\n    transition: all 0.2s ease;\n    box-shadow: var(--theme-shadow-dropdown);\n}\n\n.fullcalendar :deep(.fc-timegrid-event-harness) {\n    margin: 0 1px;\n}\n\n.fullcalendar :deep(.fc-highlight) {\n    background-color: var(--primary);\n}\n\n.fullcalendar :deep(.fc-select-mirror) {\n    background-color: var(--accent);\n    border: 1px solid var(--primary);\n}\n\n.fullcalendar :deep(.fc-event-mirror) {\n    pointer-events: none;\n}\n\n.fullcalendar :deep(.fc-scrollgrid) {\n    border: 1px solid var(--border);\n    border-left: 1px solid transparent;\n}\n\n.fullcalendar :deep(.fc-scrollgrid-section > td) {\n    border-right: 1px solid var(--border);\n}\n\n.fullcalendar :deep(.fc-timegrid-body) {\n    background-color: var(--background);\n}\n\n.fullcalendar :deep(.fc-timegrid-col) {\n    border-right: 1px solid var(--border);\n}\n\n.fullcalendar :deep(.fc-timegrid-axis-cushion) {\n    color: var(--theme-text-secondary);\n    font-size: 0.75rem;\n    font-weight: 500;\n}\n\n.fullcalendar :deep(.fc-timegrid-slot-label-cushion) {\n    font-size: 0.8125rem;\n    color: var(--muted-foreground);\n}\n\n.fullcalendar :deep(.fc-col-header-cell-cushion) {\n    color: var(--foreground);\n    font-size: 0.875rem;\n    font-weight: 600;\n}\n\n/* Daily totals styling */\n.fullcalendar :deep(.fc-col-header-cell .text-muted-foreground) {\n    color: var(--muted-foreground);\n    margin-top: 0.125rem;\n}\n\n/* Reduce visibility of time slot dividers */\n.fullcalendar :deep(.fc-timegrid-divider) {\n    display: none;\n}\n\n/* Make scrollbars gray */\n.fullcalendar :deep(.fc-scroller) {\n    scrollbar-width: thin;\n    scrollbar-color: var(--muted-foreground) transparent;\n}\n\n.fullcalendar :deep(.fc-scroller::-webkit-scrollbar) {\n    width: 8px;\n}\n\n.fullcalendar :deep(.fc-scroller::-webkit-scrollbar-track) {\n    background: transparent;\n}\n\n.fullcalendar :deep(.fc-scroller::-webkit-scrollbar-thumb) {\n    background-color: var(--muted-foreground);\n    border-radius: 4px;\n}\n\n.fullcalendar :deep(.fc-scroller::-webkit-scrollbar-thumb:hover) {\n    background-color: var(--foreground);\n}\n\n/* Improve time axis styling */\n.fullcalendar :deep(.fc-timegrid-axis-chunk) {\n    background-color: var(--theme-color-default-background);\n}\n\n/* Simple event main styling */\n.fullcalendar :deep(.fc-event-main) {\n    padding: 0.125rem 0.25rem;\n}\n\n/* Activity status plugin styles */\n.fullcalendar :deep(.activity-status-box) {\n    position: absolute;\n    width: 10px;\n    left: 0px;\n    z-index: 10;\n    cursor: default;\n}\n\n.fullcalendar :deep(.activity-status-box::before) {\n    content: '';\n    position: absolute;\n    top: 0;\n    bottom: 0;\n    width: 5px;\n    transition: opacity 0.2s ease;\n}\n\n.fullcalendar :deep(.activity-status-box.idle::before) {\n    background-color: rgba(156, 163, 175, 0.1);\n}\n\n.fullcalendar :deep(.activity-status-box.idle):hover::before {\n    background-color: rgba(156, 163, 175, 0.5);\n}\n\n.fullcalendar :deep(.activity-status-box.active::before) {\n    background-color: rgba(34, 197, 94, 0.3);\n}\n\n.fullcalendar :deep(.activity-status-box.active):hover::before {\n    background-color: rgba(34, 197, 94, 1);\n}\n\n/* Add left margin to events only on days with activity status data */\n.fullcalendar :deep(.has-activity-status .fc-timegrid-event-harness) {\n    margin-left: 8px !important;\n}\n\n.fullcalendar :deep(.fc-timegrid-event) {\n    margin-left: 0 !important;\n}\n\n/* Hide end resizer for running time entries */\n.fullcalendar :deep(.running-entry .fc-event-resizer-end) {\n    display: none;\n}\n\n.fullcalendar :deep(.running-entry) {\n    border-bottom-left-radius: 0px;\n    border-bottom-right-radius: 0px;\n}\n</style>\n\n<style>\n/* Global cursor override during resize — must be unscoped to affect body */\nbody.fc-resizing-active,\nbody.fc-resizing-active * {\n    cursor: row-resize !important;\n}\n</style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/FullCalendar/calendarSettings.ts",
    "content": "export interface CalendarSettings {\n    snapMinutes: number;\n    startHour: number;\n    endHour: number;\n    slotMinutes: number;\n}\n"
  },
  {
    "path": "resources/js/packages/ui/src/FullCalendar/idleStatusPlugin.ts",
    "content": "import { createPlugin, type PluginDef } from '@fullcalendar/core';\nimport { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';\n\nexport interface WindowActivityInPeriod {\n    appName: string;\n    url: string | null;\n    count: number;\n    icon?: string | null;\n}\n\nexport interface ActivityPeriod {\n    start: string;\n    end: string;\n    isIdle: boolean;\n    windowActivities?: WindowActivityInPeriod[];\n}\n\nexport interface ActivityStatusPluginOptions {\n    activityPeriods?: ActivityPeriod[];\n}\n\n// Tooltip state management - single instance per module\nlet tooltipInstance: HTMLElement | null = null;\nlet cleanupAutoUpdate: (() => void) | null = null;\n\n/**\n * Creates and manages a tooltip element for activity status boxes\n */\nfunction getOrCreateTooltip(): HTMLElement {\n    if (!tooltipInstance) {\n        tooltipInstance = document.createElement('div');\n        tooltipInstance.className =\n            'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground';\n        tooltipInstance.style.position = 'fixed';\n        tooltipInstance.style.pointerEvents = 'none';\n        tooltipInstance.style.opacity = '0';\n        tooltipInstance.style.whiteSpace = 'nowrap';\n        tooltipInstance.style.transform = 'scale(0.95)';\n        tooltipInstance.style.transition = 'opacity 150ms, transform 150ms';\n        document.body.appendChild(tooltipInstance);\n    }\n    return tooltipInstance;\n}\n\n/**\n * Shows tooltip for an activity status box using Floating UI's autoUpdate\n */\nfunction showTooltip(box: HTMLElement, tooltip: HTMLElement, content: string | HTMLElement) {\n    // Clear previous content\n    tooltip.innerHTML = '';\n\n    if (typeof content === 'string') {\n        tooltip.textContent = content;\n    } else {\n        tooltip.appendChild(content);\n    }\n\n    tooltip.style.opacity = '1';\n    tooltip.style.transform = 'scale(1)';\n\n    // Clean up previous autoUpdate if it exists\n    if (cleanupAutoUpdate) {\n        cleanupAutoUpdate();\n    }\n\n    // Use autoUpdate to automatically update position\n    cleanupAutoUpdate = autoUpdate(box, tooltip, () => {\n        computePosition(box, tooltip, {\n            placement: 'right',\n            middleware: [offset(8), flip(), shift({ padding: 5 })],\n        }).then(({ x, y }) => {\n            tooltip.style.left = `${x}px`;\n            tooltip.style.top = `${y}px`;\n        });\n    });\n}\n\n/**\n * Hides the tooltip immediately\n */\nfunction hideTooltip(tooltip: HTMLElement) {\n    tooltip.style.opacity = '0';\n    tooltip.style.transform = 'scale(0.95)';\n\n    // Clean up autoUpdate when tooltip is hidden\n    if (cleanupAutoUpdate) {\n        cleanupAutoUpdate();\n        cleanupAutoUpdate = null;\n    }\n}\n\n/**\n * Formats duration in minutes to human readable format\n */\nfunction formatDuration(durationMinutes: number): string {\n    const hours = Math.floor(durationMinutes / 60);\n    const minutes = durationMinutes % 60;\n    return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;\n}\n\n/**\n * Creates tooltip content for an activity period\n */\nfunction createTooltipContent(\n    status: string,\n    durationText: string,\n    windowActivities?: WindowActivityInPeriod[]\n): string | HTMLElement {\n    if (!windowActivities || windowActivities.length === 0) {\n        return `${status} (${durationText})`;\n    }\n\n    const container = document.createElement('div');\n    container.style.maxWidth = '300px';\n\n    // Header with status and duration\n    const header = document.createElement('div');\n    header.style.fontWeight = '600';\n    header.style.marginBottom = '8px';\n    header.textContent = `${status} (${durationText})`;\n    container.appendChild(header);\n\n    // Window activities list\n    const totalActivities = windowActivities.reduce((sum, act) => sum + act.count, 0);\n\n    // Show top 5 activities\n    const topActivities = windowActivities.slice(0, 5);\n\n    topActivities.forEach((activity) => {\n        const activityDiv = document.createElement('div');\n        activityDiv.style.marginTop = '4px';\n        activityDiv.style.fontSize = '11px';\n        activityDiv.style.opacity = '0.9';\n        activityDiv.style.display = 'flex';\n        activityDiv.style.alignItems = 'center';\n        activityDiv.style.gap = '6px';\n\n        // Add icon if available\n        if (activity.icon) {\n            const icon = document.createElement('img');\n            icon.src = activity.icon;\n            icon.alt = activity.appName;\n            icon.style.width = '16px';\n            icon.style.height = '16px';\n            icon.style.borderRadius = '2px';\n            icon.style.flexShrink = '0';\n            activityDiv.appendChild(icon);\n        } else {\n            // Placeholder for no icon\n            const placeholder = document.createElement('div');\n            placeholder.style.width = '16px';\n            placeholder.style.height = '16px';\n            placeholder.style.borderRadius = '2px';\n            placeholder.style.backgroundColor = 'rgba(255, 255, 255, 0.1)';\n            placeholder.style.display = 'flex';\n            placeholder.style.alignItems = 'center';\n            placeholder.style.justifyContent = 'center';\n            placeholder.style.fontSize = '8px';\n            placeholder.style.flexShrink = '0';\n            placeholder.textContent = activity.appName.charAt(0).toUpperCase();\n            activityDiv.appendChild(placeholder);\n        }\n\n        const textSpan = document.createElement('span');\n        textSpan.style.flex = '1';\n        textSpan.style.overflow = 'hidden';\n        textSpan.style.textOverflow = 'ellipsis';\n        textSpan.style.whiteSpace = 'nowrap';\n\n        const percentage = ((activity.count / totalActivities) * 100).toFixed(0);\n        const activityText = activity.url\n            ? `${activity.appName} - ${activity.url}`\n            : activity.appName;\n\n        textSpan.textContent = `${percentage}% ${activityText}`;\n        activityDiv.appendChild(textSpan);\n\n        container.appendChild(activityDiv);\n    });\n\n    // Show \"and X more\" if there are more activities\n    if (windowActivities.length > 5) {\n        const moreDiv = document.createElement('div');\n        moreDiv.style.marginTop = '4px';\n        moreDiv.style.fontSize = '11px';\n        moreDiv.style.opacity = '0.7';\n        moreDiv.style.fontStyle = 'italic';\n        moreDiv.textContent = `...and ${windowActivities.length - 5} more`;\n        container.appendChild(moreDiv);\n    }\n\n    return container;\n}\n\n/**\n * Renders activity status boxes in the calendar time grid\n */\nexport function renderActivityStatusBoxes(\n    calendarEl: HTMLElement,\n    activityPeriods: ActivityPeriod[]\n) {\n    if (!calendarEl) return;\n\n    // Clean up existing activity boxes\n    const existingBoxes = calendarEl.querySelectorAll('.activity-status-box');\n    existingBoxes.forEach((box) => box.remove());\n\n    // Remove has-activity-status class from all lanes\n    const allLanes = calendarEl.querySelectorAll('.fc-timegrid-col');\n    allLanes.forEach((lane) => lane.classList.remove('has-activity-status'));\n\n    const timeGrid = calendarEl.querySelector('.fc-timegrid-body');\n    if (!timeGrid) return;\n\n    const lanes = timeGrid.querySelectorAll('.fc-timegrid-col');\n    if (lanes.length === 0) return;\n\n    // Get or reuse the single tooltip instance\n    const tooltip = getOrCreateTooltip();\n\n    // Get slot duration from calendar (fallback to 15 minutes)\n    const slotDurationMinutes = getSlotDuration(calendarEl);\n\n    lanes.forEach((lane: Element) => {\n        // Get the date for this lane from the data attribute\n        const laneEl = lane as HTMLElement;\n        const dateStr = laneEl.getAttribute('data-date');\n\n        if (!dateStr) return;\n\n        const laneDate = new Date(dateStr);\n        const laneDateStart = new Date(laneDate);\n        laneDateStart.setHours(0, 0, 0, 0);\n        const laneDateEnd = new Date(laneDate);\n        laneDateEnd.setHours(23, 59, 59, 999);\n\n        let hasActivityStatusForThisDay = false;\n\n        activityPeriods.forEach((period) => {\n            const periodStart = new Date(period.start);\n            const periodEnd = new Date(period.end);\n\n            // Check if period overlaps with this day\n            if (periodEnd < laneDateStart || periodStart > laneDateEnd) {\n                return;\n            }\n\n            // Calculate actual start and end times for this day\n            const actualStart = periodStart > laneDateStart ? periodStart : laneDateStart;\n            const actualEnd = periodEnd < laneDateEnd ? periodEnd : laneDateEnd;\n\n            // Calculate the position and height of the activity box\n            const { top, height } = calculateBoxPosition(\n                calendarEl,\n                actualStart,\n                actualEnd,\n                slotDurationMinutes\n            );\n\n            if (height <= 0) return;\n\n            hasActivityStatusForThisDay = true;\n\n            // Calculate duration in minutes\n            const durationMs = actualEnd.getTime() - actualStart.getTime();\n            const durationMinutes = Math.round(durationMs / 60000);\n            const durationText = formatDuration(durationMinutes);\n\n            // Add tooltip text based on status\n            const status = period.isIdle ? 'Idling' : 'Active';\n\n            // Create and append the activity status box\n            const box = document.createElement('div');\n            box.className = `activity-status-box ${period.isIdle ? 'idle' : 'active'}`;\n            box.style.top = `${top}px`;\n            box.style.height = `${height}px`;\n\n            // Store tooltip content generator in data attribute for event delegation\n            const tooltipContent = createTooltipContent(\n                status,\n                durationText,\n                period.windowActivities\n            );\n\n            // Add hover event listeners for tooltip\n            box.addEventListener('mouseenter', () => {\n                showTooltip(box, tooltip, tooltipContent);\n            });\n\n            box.addEventListener('mouseleave', () => {\n                hideTooltip(tooltip);\n            });\n\n            // Position relative to the lane\n            const laneFrame = lane.querySelector('.fc-timegrid-col-frame');\n            if (laneFrame) {\n                laneFrame.appendChild(box);\n            }\n        });\n\n        // Mark this lane as having activity status if any periods were rendered\n        if (hasActivityStatusForThisDay) {\n            laneEl.classList.add('has-activity-status');\n        }\n    });\n}\n\n/**\n * Gets the slot duration from the calendar configuration\n */\nfunction getSlotDuration(calendarEl: HTMLElement): number {\n    const slotsEl = calendarEl.querySelectorAll('.fc-timegrid-slot');\n    if (slotsEl.length < 2) return 15; // Default to 15 minutes\n\n    // Try to calculate from the time difference between slots\n    const firstSlot = slotsEl[0] as HTMLElement;\n    const secondSlot = slotsEl[1] as HTMLElement;\n\n    const firstTime = firstSlot.getAttribute('data-time');\n    const secondTime = secondSlot.getAttribute('data-time');\n\n    if (firstTime && secondTime) {\n        const [h1 = 0, m1 = 0] = firstTime.split(':').map(Number);\n        const [h2 = 0, m2 = 0] = secondTime.split(':').map(Number);\n        const diff = h2 * 60 + m2 - (h1 * 60 + m1);\n        if (diff > 0) return diff;\n    }\n\n    // Fallback to 15 minutes\n    return 15;\n}\n\n/**\n * Calculates the pixel position and height for an activity status box\n */\nfunction calculateBoxPosition(\n    calendarEl: HTMLElement,\n    startTime: Date,\n    endTime: Date,\n    slotDurationMinutes: number\n): { top: number; height: number } {\n    // Get the slot duration and slot height\n    const slotsEl = calendarEl.querySelectorAll('.fc-timegrid-slot');\n    if (slotsEl.length === 0) {\n        return { top: 0, height: 0 };\n    }\n\n    // Calculate slot height (assuming all slots are equal height)\n    const firstSlot = slotsEl[0] as HTMLElement;\n    const slotHeight = firstSlot.offsetHeight;\n\n    const pixelsPerMinute = slotHeight / slotDurationMinutes;\n\n    // Calculate start position (minutes from midnight)\n    const startMinutes = startTime.getHours() * 60 + startTime.getMinutes();\n    const endMinutes = endTime.getHours() * 60 + endTime.getMinutes();\n\n    // Calculate pixel positions\n    const top = startMinutes * pixelsPerMinute;\n    const height = (endMinutes - startMinutes) * pixelsPerMinute;\n\n    return { top, height };\n}\n\n/**\n * Cleanup function to remove tooltip from DOM\n */\nexport function cleanupActivityStatusPlugin() {\n    if (tooltipInstance) {\n        tooltipInstance.remove();\n        tooltipInstance = null;\n    }\n    if (cleanupAutoUpdate) {\n        cleanupAutoUpdate();\n        cleanupAutoUpdate = null;\n    }\n}\n\n/**\n * FullCalendar plugin to display idle/active status boxes in the time grid\n */\nconst activityStatusPlugin: PluginDef = createPlugin({\n    name: '@solidtime/activity-status',\n\n    optionRefiners: {\n        activityPeriods: (rawVal: unknown): ActivityPeriod[] => {\n            if (!Array.isArray(rawVal)) return [];\n            return rawVal as ActivityPeriod[];\n        },\n    },\n});\n\nexport default activityStatusPlugin;\n"
  },
  {
    "path": "resources/js/packages/ui/src/FullCalendar/useVisualSnap.ts",
    "content": "import { onActivated, onDeactivated, onMounted, onUnmounted, type Ref } from 'vue';\nimport type FullCalendar from '@fullcalendar/vue3';\n\ninterface VisualSnapOptions {\n    calendarRef: Ref<InstanceType<typeof FullCalendar> | null>;\n    snapMinutes: () => number;\n    slotMinutes: () => number;\n    formatDuration: (durationSeconds: number) => string;\n}\n\nexport function useVisualSnap({\n    calendarRef,\n    snapMinutes,\n    slotMinutes,\n    formatDuration,\n}: VisualSnapOptions) {\n    let rafId: number | null = null;\n\n    function getCalendarEl(): HTMLElement | null {\n        return (calendarRef.value?.$el as HTMLElement) ?? null;\n    }\n\n    function getSnapPixels(): number {\n        const calendarEl = getCalendarEl();\n        if (!calendarEl) return 25;\n        const slot = calendarEl.querySelector('.fc-timegrid-slot-lane') as HTMLElement;\n        if (!slot) return 25;\n        const slotHeightPx = slot.getBoundingClientRect().height;\n        return (snapMinutes() / slotMinutes()) * slotHeightPx;\n    }\n\n    function findMirrorHarness(calendarEl: HTMLElement) {\n        const mirror = calendarEl.querySelector('.fc-event-mirror') as HTMLElement | null;\n        const harness = mirror?.closest('.fc-timegrid-event-harness') as HTMLElement | null;\n        if (harness) {\n            harness.style.pointerEvents = 'none';\n        }\n        return { mirror, harness };\n    }\n\n    function updateMirrorDurationLabel(\n        mirror: HTMLElement,\n        snappedTop: number,\n        snappedEnd: number,\n        snapPx: number\n    ) {\n        const snappedDurationMin = Math.round((snappedEnd - snappedTop) / snapPx) * snapMinutes();\n        const durationText = formatDuration(snappedDurationMin * 60);\n        const durationEl = mirror.querySelector('.fc-event-main')?.querySelector('div:last-child');\n        if (durationEl) {\n            durationEl.textContent = durationText;\n        }\n    }\n\n    function startLoop(onFrame: (calendarEl: HTMLElement, snapPx: number) => void) {\n        const calendarEl = getCalendarEl();\n        if (!calendarEl) return;\n        const snapPx = getSnapPixels();\n        if (snapPx <= 0) return;\n\n        const loop = () => {\n            onFrame(calendarEl, snapPx);\n            rafId = requestAnimationFrame(loop);\n        };\n        rafId = requestAnimationFrame(loop);\n    }\n\n    function stop() {\n        document.body.classList.remove('fc-resizing-active');\n        if (rafId !== null) {\n            cancelAnimationFrame(rafId);\n            rafId = null;\n        }\n    }\n\n    // --- Public snap starters ---\n\n    function startSelectSnap() {\n        // Don't start if another snap loop is already running\n        if (rafId !== null) return;\n        startLoop((calendarEl, snapPx) => {\n            const { mirror, harness } = findMirrorHarness(calendarEl);\n            if (!harness || !mirror) return;\n\n            const top = parseFloat(harness.style.top) || 0;\n            const endPos = -(parseFloat(harness.style.bottom) || 0);\n            const snappedTop = Math.floor(top / snapPx) * snapPx;\n            const snappedEnd = Math.ceil(endPos / snapPx) * snapPx;\n            const clampedEnd = Math.max(snappedTop + snapPx, snappedEnd);\n            harness.style.top = snappedTop + 'px';\n            harness.style.bottom = -clampedEnd + 'px';\n            updateMirrorDurationLabel(mirror, snappedTop, clampedEnd, snapPx);\n        });\n    }\n\n    function startDragSnap() {\n        stop();\n        startLoop((calendarEl, snapPx) => {\n            const { harness } = findMirrorHarness(calendarEl);\n            if (!harness) return;\n\n            const top = parseFloat(harness.style.top) || 0;\n            const endPos = -(parseFloat(harness.style.bottom) || 0);\n            const height = endPos - top;\n            const snappedTop = Math.floor(top / snapPx) * snapPx;\n            harness.style.top = snappedTop + 'px';\n            harness.style.bottom = -(snappedTop + height) + 'px';\n        });\n    }\n\n    function startResizeSnap() {\n        stop();\n        document.body.classList.add('fc-resizing-active');\n\n        let initialTop: number | null = null;\n        let initialEnd: number | null = null;\n        let resizeEdge: 'top' | 'bottom' | null = null;\n\n        startLoop((calendarEl, snapPx) => {\n            const { mirror, harness } = findMirrorHarness(calendarEl);\n            if (!harness) return;\n\n            const top = parseFloat(harness.style.top) || 0;\n            const endPos = -(parseFloat(harness.style.bottom) || 0);\n\n            // Detect which edge is being resized\n            if (initialTop === null) {\n                initialTop = top;\n                initialEnd = endPos;\n            } else if (resizeEdge === null) {\n                const topDelta = Math.abs(top - initialTop);\n                const endDelta = Math.abs(endPos - initialEnd!);\n                if (topDelta > 0.5) {\n                    resizeEdge = 'top';\n                } else if (endDelta > 0.5) {\n                    resizeEdge = 'bottom';\n                }\n            }\n\n            if (resizeEdge === 'bottom') {\n                const snappedEnd = Math.ceil(endPos / snapPx) * snapPx;\n                const clampedEnd = Math.max(top + snapPx, snappedEnd);\n                harness.style.bottom = -clampedEnd + 'px';\n                if (mirror) updateMirrorDurationLabel(mirror, top, clampedEnd, snapPx);\n            } else if (resizeEdge === 'top') {\n                const snappedTop = Math.floor(top / snapPx) * snapPx;\n                const clampedTop = Math.min(endPos - snapPx, snappedTop);\n                harness.style.top = clampedTop + 'px';\n                if (mirror) updateMirrorDurationLabel(mirror, clampedTop, endPos, snapPx);\n            }\n        });\n    }\n\n    // Pointerdown handler for starting select snap on timegrid background\n    function handleTimegridPointerDown(e: PointerEvent) {\n        const target = e.target as HTMLElement;\n        if (target.closest('.fc-event')) return;\n        startSelectSnap();\n    }\n\n    // Lifecycle: attach/detach pointerdown listener\n    function attachListener() {\n        const calendarEl = getCalendarEl();\n        calendarEl?.addEventListener('pointerdown', handleTimegridPointerDown);\n    }\n\n    function detachListener() {\n        const calendarEl = getCalendarEl();\n        calendarEl?.removeEventListener('pointerdown', handleTimegridPointerDown);\n    }\n\n    onMounted(attachListener);\n    onActivated(attachListener);\n    onDeactivated(() => {\n        stop();\n        detachListener();\n    });\n    onUnmounted(() => {\n        stop();\n        detachListener();\n    });\n\n    return {\n        startSelectSnap,\n        startDragSnap,\n        startResizeSnap,\n        stop,\n    };\n}\n"
  },
  {
    "path": "resources/js/packages/ui/src/GroupedItemsCountButton.vue",
    "content": "<script setup lang=\"ts\">\nimport { twMerge } from 'tailwind-merge';\nimport { computed } from 'vue';\n\nconst props = withDefaults(\n    defineProps<{\n        expanded?: boolean;\n        size?: string;\n    }>(),\n    {\n        expanded: false,\n        size: 'w-7 h-7',\n    }\n);\n\nconst expandedStatusClasses = computed(() => {\n    if (props.expanded) {\n        return 'border-card-border border bg-quaternary text-text-primary';\n    }\n    return 'border-card-border bg-secondary border hover:bg-tertiary hover:text-text-primary transition text-text-secondary';\n});\n</script>\n\n<template>\n    <button\n        :class=\"\n            twMerge(\n                'font-medium rounded flex items-center transition justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent',\n                expandedStatusClasses,\n                props.size\n            )\n        \">\n        <slot></slot>\n    </button>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Icons/BillableIcon.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, inject, type ComputedRef } from 'vue';\nimport type { Organization } from '@/packages/api/src';\nimport DollarIcon from './DollarIcon.vue';\nimport EuroIcon from './EuroIcon.vue';\n\nconst organization = inject<ComputedRef<Organization>>('organization');\nconst icon = computed(() => (organization?.value?.currency === 'EUR' ? EuroIcon : DollarIcon));\n</script>\n\n<template>\n    <component :is=\"icon\" />\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Icons/DollarIcon.vue",
    "content": "<template>\n    <svg viewBox=\"0 0 8 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            d=\"M4 1V13M1 10.182L1.879 10.841C3.05 11.72 4.949 11.72 6.121 10.841C7.293 9.962 7.293 8.538 6.121 7.659C5.536 7.219 4.768 7 4 7C3.275 7 2.55 6.78 1.997 6.341C0.891 5.462 0.891 4.038 1.997 3.159C3.103 2.28 4.897 2.28 6.003 3.159L6.418 3.489\"\n            stroke=\"currentColor\"\n            stroke-width=\"1.5\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Icons/EuroIcon.vue",
    "content": "<template>\n    <svg viewBox=\"0 0 12 12\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            d=\"M9.02045 3.12728C8.30227 2.26547 7.22499 1.69092 5.96818 1.69092C3.99319 1.69092 2.37728 3.63001 2.37728 6C2.37728 8.37 3.99319 10.3091 5.96818 10.3091C7.22499 10.3091 8.30227 9.73453 9.02045 8.87272M1.47955 4.92274H6.50682M1.47955 7.07727H6.50682\"\n            stroke=\"currentColor\"\n            stroke-width=\"1.5\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Icons/ListFilterIcon.vue",
    "content": "<script setup lang=\"ts\"></script>\n\n<template>\n    <svg\n        xmlns=\"http://www.w3.org/2000/svg\"\n        width=\"24\"\n        height=\"24\"\n        viewBox=\"0 0 24 24\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        stroke-width=\"2\"\n        stroke-linecap=\"round\"\n        stroke-linejoin=\"round\">\n        <path d=\"M2 5h20\" />\n        <path d=\"M6 12h12\" />\n        <path d=\"M9 19h6\" />\n    </svg>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Input/BillableRateInput.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref } from 'vue';\nimport { useFocus } from '@vueuse/core';\nimport {\n    NumberField,\n    NumberFieldContent,\n    NumberFieldDecrement,\n    NumberFieldIncrement,\n    NumberFieldInput,\n} from '@/Components/ui/number-field';\n\nconst props = defineProps<{\n    name: string;\n    focus?: boolean;\n    currency: string;\n    disabled?: boolean;\n}>();\n\nconst model = defineModel<number | null>({\n    default: null,\n});\n\nconst billableRateInput = ref<HTMLInputElement | null>(null);\nuseFocus(billableRateInput, { initialValue: props.focus });\n\nfunction formatValue(modelValue: number | null) {\n    return modelValue ? modelValue / 100 : 0;\n}\n</script>\n\n<template>\n    <div class=\"relative\">\n        <NumberField\n            :id=\"name\"\n            ref=\"billableRateInput\"\n            :model-value=\"formatValue(model)\"\n            :disabled=\"disabled\"\n            :step-snapping=\"false\"\n            class=\"block w-full\"\n            :format-options=\"{\n                style: 'currency',\n                currency: currency,\n                currencyDisplay: 'code',\n                currencySign: 'accounting',\n            }\"\n            @update:model-value=\"(value) => (model = value * 100)\">\n            <NumberFieldContent>\n                <NumberFieldDecrement />\n                <NumberFieldInput placeholder=\"Billable Rate\" />\n                <NumberFieldIncrement />\n            </NumberFieldContent>\n        </NumberField>\n    </div>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Input/BillableToggleButton.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue';\nimport { twMerge } from 'tailwind-merge';\nimport BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';\nimport {\n    Tooltip,\n    TooltipContent,\n    TooltipProvider,\n    TooltipTrigger,\n} from '@/packages/ui/src/tooltip';\nconst active = defineModel({ default: false });\nconst emit = defineEmits(['changed']);\nfunction toggleBillable() {\n    active.value = !active.value;\n    emit('changed', active.value);\n}\n\nconst props = withDefaults(\n    defineProps<{\n        size?: 'small' | 'base';\n        faded?: boolean;\n    }>(),\n    {\n        size: 'base',\n        faded: false,\n    }\n);\n\nconst tooltipLabel = computed(() => (active.value ? 'Billable' : 'Non Billable'));\n\nconst iconColorClasses = computed(() => {\n    if (active.value) {\n        return 'text-input-select-active focus:text-input-select-active-hover hover:text-input-select-active-hover';\n    } else {\n        return 'text-icon-default focus:text-icon-active hover:text-icon-active';\n    }\n});\n\nconst iconSizeClasses = computed(() => {\n    if (props.size === 'small') {\n        return 'w-5 h-5';\n    } else {\n        return 'w-5 lg:w-6 h-5 lg:h-6';\n    }\n});\n\nconst iconSizeWrapperClasses = props.size === 'small' ? 'w-6 sm:w-8 h-6 sm:h-8' : 'w-10 h-10';\n</script>\n\n<template>\n    <TooltipProvider>\n        <Tooltip disable-closing-trigger>\n            <TooltipTrigger as-child>\n                <button\n                    :aria-label=\"tooltipLabel\"\n                    :class=\"\n                        twMerge(\n                            iconColorClasses,\n                            iconSizeWrapperClasses,\n                            'flex-shrink-0 ring-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring transition focus:bg-card-background-separator hover:bg-card-background-separator rounded-full flex items-center justify-center',\n                            faded\n                                ? 'opacity-50 group-hover:opacity-100 focus-visible:opacity-100'\n                                : ''\n                        )\n                    \"\n                    @click=\"toggleBillable\">\n                    <BillableIcon :class=\"iconSizeClasses\"></BillableIcon>\n                </button>\n            </TooltipTrigger>\n            <TooltipContent>\n                {{ tooltipLabel }}\n            </TooltipContent>\n        </Tooltip>\n    </TooltipProvider>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Input/Checkbox.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue';\n\nconst emit = defineEmits(['update:checked']);\n\nconst props = defineProps({\n    checked: {\n        type: [Array, Boolean],\n        default: false,\n    },\n    value: {\n        type: String,\n        default: null,\n    },\n    id: {\n        type: String,\n        default: null,\n    },\n});\n\nconst proxyChecked = computed({\n    get() {\n        return props.checked;\n    },\n\n    set(val) {\n        emit('update:checked', val);\n    },\n});\n</script>\n\n<template>\n    <input\n        :id=\"id\"\n        v-model=\"proxyChecked\"\n        type=\"checkbox\"\n        :value=\"value\"\n        class=\"h-4 w-4 rounded bg-card-background border-input-border text-accent-500/80 focus:outline-none focus:ring-ring/50 focus-visible:outline-none focus-visible:ring-ring/50\" />\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Input/DatePicker.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, inject, ref, type ComputedRef } from 'vue';\nimport {\n    getLocalizedDayJs,\n    formatDate,\n    firstDayIndex,\n    type WeekStartDay,\n} from '@/packages/ui/src/utils/time';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/packages/ui/src/popover';\nimport { Calendar } from '@/Components/ui/calendar';\nimport { Button } from '@/packages/ui/src/Buttons';\nimport { CalendarIcon } from 'lucide-vue-next';\nimport { parseDate, type DateValue } from '@internationalized/date';\nimport type { Organization } from '@/packages/api/src';\n\nconst props = defineProps<{\n    tabindex?: string;\n    class?: string;\n}>();\n\n// This has to be a localized timestamp, not UTC\nconst model = defineModel<string | null>({\n    default: null,\n});\n\nconst emit = defineEmits(['changed']);\n\nconst open = ref(false);\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n\nconst weekStartsOn = computed((): WeekStartDay => firstDayIndex.value as WeekStartDay);\n\nconst dateString = computed(() => {\n    if (!model.value) return null;\n    return getLocalizedDayJs(model.value).format('YYYY-MM-DD');\n});\n\nconst calendarDate = computed(() => {\n    if (!dateString.value) return undefined;\n    return parseDate(dateString.value);\n});\n\nconst displayDate = computed(() => {\n    if (!dateString.value) return '';\n    return formatDate(dateString.value, organization?.value?.date_format);\n});\n\nfunction handleDateSelect(newDate: DateValue | undefined) {\n    if (!newDate) return;\n    // If model.value is null, start from current date/time\n    const baseDate = model.value ? getLocalizedDayJs(model.value) : getLocalizedDayJs();\n    const newValue = baseDate\n        .set('year', newDate.year)\n        .set('month', newDate.month - 1) // CalendarDate months are 1-indexed, dayjs is 0-indexed\n        .set('date', newDate.day)\n        .format();\n    model.value = newValue;\n    emit('changed', newValue);\n    open.value = false;\n}\n</script>\n\n<template>\n    <div class=\"w-full\">\n        <Popover v-model:open=\"open\">\n            <PopoverTrigger as-child>\n                <Button\n                    variant=\"input\"\n                    size=\"sm\"\n                    :tabindex=\"tabindex\"\n                    :class=\"['w-full px-2 gap-1.5', props.class]\">\n                    <CalendarIcon class=\"!size-3 text-muted-foreground\" />\n                    <span>{{ displayDate || 'Pick a date' }}</span>\n                </Button>\n            </PopoverTrigger>\n            <PopoverContent class=\"w-auto p-0\" align=\"center\">\n                <Calendar\n                    mode=\"single\"\n                    :model-value=\"calendarDate\"\n                    :week-starts-on=\"weekStartsOn\"\n                    @update:model-value=\"handleDateSelect\" />\n            </PopoverContent>\n        </Popover>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Input/DateRangePicker.vue",
    "content": "<script setup lang=\"ts\">\nimport { Popover, PopoverContent, PopoverTrigger } from '../popover';\nimport Button from '../Buttons/Button.vue';\nimport { RangeCalendar } from '../range-calendar';\nimport { CalendarDate } from '@internationalized/date';\nimport { CalendarIcon } from 'lucide-vue-next';\nimport { computed, ref, inject, type ComputedRef, watch } from 'vue';\nimport { twMerge } from 'tailwind-merge';\nimport {\n    getDayJsInstance,\n    getLocalizedDayJs,\n    firstDayIndex,\n    type WeekStartDay,\n} from '@/packages/ui/src/utils/time';\nimport { type Organization } from '@/packages/api/src';\nimport { getUserTimezone } from '@/packages/ui/src/utils/settings';\nimport { formatDate } from '@/packages/ui/src/utils/time';\n\nconst weekStartsOn = computed((): WeekStartDay => firstDayIndex.value as WeekStartDay);\n\nconst props = defineProps<{\n    start: string;\n    end: string;\n}>();\n\nconst emit = defineEmits<{\n    (e: 'update:start', value: string): void;\n    (e: 'update:end', value: string): void;\n    (e: 'submit'): void;\n}>();\n\ninterface CalendarDateRange {\n    start: CalendarDate | undefined;\n    end: CalendarDate | undefined;\n}\n\nconst today = computed(() => {\n    const now = getDayJsInstance()();\n    return new CalendarDate(now.year(), now.month() + 1, now.date());\n});\n\nconst modelValue = computed<CalendarDateRange>({\n    get: () => ({\n        start: props.start\n            ? new CalendarDate(\n                  getLocalizedDayJs(props.start).year(),\n                  getLocalizedDayJs(props.start).month() + 1,\n                  getLocalizedDayJs(props.start).date()\n              )\n            : undefined,\n        end: props.end\n            ? new CalendarDate(\n                  getLocalizedDayJs(props.end).year(),\n                  getLocalizedDayJs(props.end).month() + 1,\n                  getLocalizedDayJs(props.end).date()\n              )\n            : undefined,\n    }),\n    set: (newValue) => {\n        if (newValue.start) {\n            console.log(newValue.start);\n            const date = newValue.start.toDate(getUserTimezone());\n            emit('update:start', getLocalizedDayJs(date.toString()).format());\n        }\n        if (newValue.end) {\n            const date = newValue.end.toDate(getUserTimezone());\n            emit('update:end', getLocalizedDayJs(date.toString()).format());\n        }\n    },\n});\n\nconst open = ref(false);\n\nfunction setToday() {\n    emit('update:start', getLocalizedDayJs().startOf('day').format());\n    emit('update:end', getLocalizedDayJs().endOf('day').format());\n    open.value = false;\n}\n\nfunction setThisWeek() {\n    emit('update:start', getLocalizedDayJs().startOf('week').format());\n    emit('update:end', getLocalizedDayJs().endOf('week').format());\n    open.value = false;\n}\n\nfunction setLastWeek() {\n    emit('update:start', getLocalizedDayJs().subtract(1, 'week').startOf('week').format());\n    emit('update:end', getLocalizedDayJs().subtract(1, 'week').endOf('week').format());\n    open.value = false;\n}\n\nfunction setLast14Days() {\n    emit('update:start', getLocalizedDayJs().subtract(14, 'days').format());\n    emit('update:end', getLocalizedDayJs().format());\n    open.value = false;\n}\n\nfunction setThisMonth() {\n    emit('update:start', getLocalizedDayJs().startOf('month').format());\n    emit('update:end', getLocalizedDayJs().endOf('month').format());\n    open.value = false;\n}\n\nfunction setLastMonth() {\n    emit('update:start', getLocalizedDayJs().subtract(1, 'month').startOf('month').format());\n    emit('update:end', getLocalizedDayJs().subtract(1, 'month').endOf('month').format());\n    open.value = false;\n}\n\nfunction setLast30Days() {\n    emit('update:start', getLocalizedDayJs().subtract(30, 'days').format());\n    emit('update:end', getLocalizedDayJs().format());\n    open.value = false;\n}\n\nfunction setLast90Days() {\n    emit('update:start', getDayJsInstance()().subtract(90, 'days').format());\n    emit('update:end', getDayJsInstance()().format());\n    open.value = false;\n}\n\nfunction setLast12Months() {\n    emit('update:start', getLocalizedDayJs().subtract(12, 'months').format());\n    emit('update:end', getLocalizedDayJs().format());\n    open.value = false;\n}\n\nfunction setThisYear() {\n    emit('update:start', getLocalizedDayJs().startOf('year').format());\n    emit('update:end', getLocalizedDayJs().endOf('year').format());\n    open.value = false;\n}\n\nfunction setLastYear() {\n    emit('update:start', getLocalizedDayJs().subtract(1, 'year').startOf('year').format());\n    emit('update:end', getLocalizedDayJs().subtract(1, 'year').endOf('year').format());\n    open.value = false;\n}\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n\nwatch(open, (value) => {\n    if (value === false) {\n        emit('submit');\n    }\n});\n</script>\n\n<template>\n    <Popover v-model:open=\"open\">\n        <PopoverTrigger as-child>\n            <Button\n                variant=\"outline\"\n                :class=\"\n                    twMerge(\n                        'flex w-full items-center justify-between whitespace-nowrap h-[34px] text-start',\n                        !modelValue && 'text-muted-foreground'\n                    )\n                \">\n                <CalendarIcon class=\"-ml-0.5 text-text-quaternary h-4 w-4\" />\n                <template v-if=\"modelValue.start\">\n                    <template v-if=\"modelValue.end\">\n                        {{ formatDate(modelValue.start.toString(), organization?.date_format) }}\n                        -\n                        {{ formatDate(modelValue.end.toString(), organization?.date_format) }}\n                    </template>\n                    <template v-else>\n                        {{ formatDate(modelValue.start.toString(), organization?.date_format) }}\n                    </template>\n                </template>\n                <template v-else> Pick a date </template>\n            </Button>\n        </PopoverTrigger>\n        <PopoverContent class=\"w-auto p-0\">\n            <div class=\"flex divide-x divide-border-secondary\">\n                <div\n                    class=\"text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2\">\n                    <Button variant=\"ghost\" size=\"sm\" class=\"justify-start\" @click=\"setToday\"\n                        >Today</Button\n                    >\n                    <Button variant=\"ghost\" size=\"sm\" class=\"justify-start\" @click=\"setThisWeek\"\n                        >This Week</Button\n                    >\n                    <Button variant=\"ghost\" size=\"sm\" class=\"justify-start\" @click=\"setLastWeek\"\n                        >Last Week</Button\n                    >\n                    <Button variant=\"ghost\" size=\"sm\" class=\"justify-start\" @click=\"setLast14Days\"\n                        >Last 14 days</Button\n                    >\n                    <Button variant=\"ghost\" size=\"sm\" class=\"justify-start\" @click=\"setThisMonth\"\n                        >This Month</Button\n                    >\n                    <Button variant=\"ghost\" size=\"sm\" class=\"justify-start\" @click=\"setLastMonth\"\n                        >Last Month</Button\n                    >\n                    <Button variant=\"ghost\" size=\"sm\" class=\"justify-start\" @click=\"setLast30Days\"\n                        >Last 30 days</Button\n                    >\n                    <Button variant=\"ghost\" size=\"sm\" class=\"justify-start\" @click=\"setLast90Days\"\n                        >Last 90 days</Button\n                    >\n                    <Button variant=\"ghost\" size=\"sm\" class=\"justify-start\" @click=\"setLast12Months\"\n                        >Last 12 months</Button\n                    >\n                    <Button variant=\"ghost\" size=\"sm\" class=\"justify-start\" @click=\"setThisYear\"\n                        >This year</Button\n                    >\n                    <Button variant=\"ghost\" size=\"sm\" class=\"justify-start\" @click=\"setLastYear\"\n                        >Last year</Button\n                    >\n                </div>\n                <div class=\"pl-2\">\n                    <RangeCalendar\n                        v-model=\"modelValue\"\n                        initial-focus\n                        :number-of-months=\"2\"\n                        :max-value=\"today\"\n                        :week-starts-on=\"weekStartsOn\" />\n                </div>\n            </div>\n        </PopoverContent>\n    </Popover>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Input/Dropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport { Popover, PopoverContent, PopoverTrigger } from '../popover';\nimport { watch } from 'vue';\n\nconst props = withDefaults(\n    defineProps<{\n        align?: 'center' | 'end' | 'start';\n        closeOnContentClick?: boolean;\n        autoFocus?: boolean;\n    }>(),\n    {\n        align: 'start',\n        closeOnContentClick: true,\n        autoFocus: true,\n    }\n);\n\nconst emit = defineEmits(['open', 'submit']);\nconst open = defineModel({ default: false });\n\nfunction handleAutofocus(event: Event) {\n    if (props.autoFocus === false) {\n        event.preventDefault();\n    }\n}\n\nfunction onContentClick() {\n    if (props.closeOnContentClick === true) {\n        open.value = false;\n    }\n}\n\nfunction onOpenChange(value: boolean) {\n    open.value = value;\n    if (value === true) {\n        emit('open');\n    }\n}\n\nwatch(open, (value) => {\n    if (value === false) {\n        emit('submit');\n    }\n});\n</script>\n\n<template>\n    <div class=\"min-w-0 isolate\">\n        <Popover v-model:open=\"open\" @update:open=\"onOpenChange\">\n            <PopoverTrigger as-child>\n                <slot class=\"min-w-0 flex items-center\" name=\"trigger\" />\n            </PopoverTrigger>\n            <PopoverContent\n                :align=\"align\"\n                class=\"rounded-lg overflow-hidden relative border border-card-border overflow-none shadow-dropdown bg-secondary\"\n                @open-auto-focus=\"handleAutofocus\"\n                @click=\"onContentClick\">\n                <slot name=\"content\" />\n            </PopoverContent>\n        </Popover>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Input/DurationHumanInput.vue",
    "content": "<script setup lang=\"ts\">\nimport parse from 'parse-duration';\nimport { onMounted, ref, watch, inject } from 'vue';\nimport { formatHumanReadableDuration, getDayJsInstance } from '@/packages/ui/src/utils/time';\nimport dayjs from 'dayjs';\nimport { twMerge } from 'tailwind-merge';\nimport { TextInput } from '@/packages/ui/src';\nimport type { Organization } from '@/packages/api/src';\nimport { type ComputedRef } from 'vue';\n\nconst temporaryCustomTimerEntry = ref<string>('');\n\nconst start = defineModel('start', {\n    default: '',\n});\n\nconst end = defineModel('end', {\n    default: '',\n});\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n\nfunction isHHMM(value: string): boolean {\n    return HHMMtimeRegex.test(value);\n}\n\nfunction parseHHMM(value: string): string[] | null {\n    return value.match(HHMMtimeRegex);\n}\n\nfunction updateDuration() {\n    const time = parse(temporaryCustomTimerEntry.value, 's');\n\n    if (isNumeric(temporaryCustomTimerEntry.value)) {\n        const newStartDate = getDayJsInstance()(end.value).subtract(\n            parseInt(temporaryCustomTimerEntry.value),\n            'm'\n        );\n        start.value = newStartDate.utc().format();\n    } else if (isHHMM(temporaryCustomTimerEntry.value)) {\n        const results = parseHHMM(temporaryCustomTimerEntry.value);\n        if (results) {\n            const newStartDate = getDayJsInstance()(end.value)\n                .subtract(parseInt(results[1]!), 'h')\n                .subtract(parseInt(results[2]!), 'm');\n            start.value = newStartDate.utc().format();\n        }\n    }\n    // try to parse natural language like \"1h 30m\"\n    else if (time && time > 1) {\n        const newStartDate = getDayJsInstance()(end.value).subtract(time, 's');\n        start.value = newStartDate.utc().format();\n    }\n    // fallback to minutes if just a number is given\n    updateTimeEntryInputValue();\n}\n\nfunction isNumeric(value: string) {\n    return /^-?\\d+$/.test(value);\n}\n\nconst props = defineProps<{\n    class?: string;\n}>();\n\nconst HHMMtimeRegex = /^([0-9]{1,2}):([0-5]?[0-9])$/;\n\nwatch([start, end], updateTimeEntryInputValue);\nonMounted(() => updateTimeEntryInputValue());\n\nfunction updateTimeEntryInputValue() {\n    if (start.value && end.value) {\n        const startTime = dayjs(start.value);\n        const diff = getDayJsInstance()(end.value).diff(startTime, 'seconds');\n        temporaryCustomTimerEntry.value = formatHumanReadableDuration(\n            diff,\n            organization?.value?.interval_format,\n            organization?.value?.number_format\n        );\n    }\n}\n</script>\n\n<template>\n    <TextInput\n        ref=\"inputField\"\n        v-model=\"temporaryCustomTimerEntry\"\n        :class=\"twMerge('text-text-secondary', props.class)\"\n        type=\"text\"\n        @blur=\"updateDuration\"\n        @keydown.enter=\"updateDuration\" />\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Input/EstimatedTimeInput.vue",
    "content": "<script setup lang=\"ts\">\nimport { onMounted, ref, watch, inject } from 'vue';\nimport { formatHumanReadableDuration, parseTimeInput } from '@/packages/ui/src/utils/time';\nimport { twMerge } from 'tailwind-merge';\nimport { TextInput } from '@/packages/ui/src';\nimport type { Organization } from '@/packages/api/src';\nimport { type ComputedRef } from 'vue';\n\nconst temporaryInput = ref<string>('');\n\nconst model = defineModel<number | null>({\n    default: null,\n});\n\nconst emit = defineEmits<{\n    submit: [];\n}>();\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n\nfunction updateDuration() {\n    const input = temporaryInput.value.trim();\n\n    if (input === '') {\n        model.value = null;\n        return;\n    }\n\n    // Use parseTimeInput with 'hours' as default unit for estimated time\n    const seconds = parseTimeInput(input, 'hours');\n    if (seconds !== null && seconds > 0) {\n        model.value = seconds;\n    }\n\n    updateInputDisplay();\n}\n\nconst props = defineProps<{\n    class?: string;\n}>();\n\nwatch(model, updateInputDisplay);\nonMounted(() => updateInputDisplay());\n\nfunction updateInputDisplay() {\n    if (model.value !== null && model.value > 0) {\n        temporaryInput.value = formatHumanReadableDuration(\n            model.value,\n            organization?.value?.interval_format,\n            organization?.value?.number_format\n        );\n    } else {\n        temporaryInput.value = '';\n    }\n}\n\nfunction selectInput(event: Event) {\n    const target = event.target as HTMLInputElement;\n    target.select();\n}\n\nfunction updateAndSubmit() {\n    updateDuration();\n    emit('submit');\n}\n</script>\n\n<template>\n    <TextInput\n        ref=\"inputField\"\n        v-model=\"temporaryInput\"\n        :class=\"twMerge('text-text-secondary', props.class)\"\n        type=\"text\"\n        placeholder=\"e.g. 2h 30m or 1.5\"\n        @focus=\"selectInput\"\n        @blur=\"updateDuration\"\n        @keydown.enter=\"updateAndSubmit\" />\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Input/InputError.vue",
    "content": "<script setup lang=\"ts\">\ndefineProps({\n    message: String,\n});\n</script>\n\n<template>\n    <div v-show=\"message\">\n        <p class=\"text-sm text-red-400\">\n            {{ message }}\n        </p>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Input/InputLabel.vue",
    "content": "<script setup lang=\"ts\">\nimport { twMerge } from 'tailwind-merge';\n\nconst props = defineProps<{\n    value?: string;\n    class?: string;\n}>();\n</script>\n\n<template>\n    <label :class=\"twMerge('block font-medium text-sm text-text-primary', props.class)\">\n        <span v-if=\"value\">{{ value }}</span>\n        <span v-else><slot /></span>\n    </label>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Input/MultiselectDropdown.vue",
    "content": "<script setup lang=\"ts\" generic=\"T\">\nimport Dropdown from '@/packages/ui/src/Input/Dropdown.vue';\nimport { computed, type Ref, ref, watch } from 'vue';\nimport Checkbox from '@/packages/ui/src/Input/Checkbox.vue';\nimport {\n    ComboboxAnchor,\n    ComboboxContent,\n    ComboboxInput,\n    ComboboxItem,\n    ComboboxRoot,\n    ComboboxViewport,\n} from 'radix-vue';\n\nconst NONE_ID = 'none';\n\nconst model = defineModel<string[]>({\n    default: [],\n});\n\nconst props = defineProps<{\n    items: T[];\n    searchPlaceholder: string;\n    getKeyFromItem: (item: T) => string;\n    getNameForItem: (item: T) => string;\n    noItemLabel?: string;\n}>();\n\nconst open = ref(false);\nconst searchValue = ref('');\nconst sortedItems = ref<T[]>([]) as Ref<T[]>;\n\nwatch(open, (isOpen) => {\n    if (isOpen) {\n        searchValue.value = '';\n        sortedItems.value = [...props.items].sort((a, b) => {\n            const aSelected = model.value.includes(props.getKeyFromItem(a)) ? 0 : 1;\n            const bSelected = model.value.includes(props.getKeyFromItem(b)) ? 0 : 1;\n            return aSelected - bSelected;\n        });\n    }\n});\n\nconst filteredItems = computed(() => {\n    const search = searchValue.value.toLowerCase().trim();\n    if (!search) return sortedItems.value;\n    return sortedItems.value.filter((item) =>\n        props.getNameForItem(item).toLowerCase().includes(search)\n    );\n});\n\nconst showNoItem = computed(() => {\n    if (!props.noItemLabel) return false;\n    const search = searchValue.value.toLowerCase().trim();\n    if (!search) return true;\n    return props.noItemLabel.toLowerCase().includes(search);\n});\n\nfunction toggleItem(id: string) {\n    if (model.value.includes(id)) {\n        model.value = model.value.filter((itemId) => itemId !== id);\n    } else {\n        model.value = [...model.value, id];\n    }\n    emit('changed');\n}\n\nconst emit = defineEmits(['update:modelValue', 'changed', 'submit']);\n</script>\n\n<template>\n    <Dropdown v-model=\"open\" align=\"start\" :close-on-content-click=\"false\" @submit=\"emit('submit')\">\n        <template #trigger>\n            <slot name=\"trigger\"></slot>\n        </template>\n        <template #content>\n            <ComboboxRoot\n                v-model:search-term=\"searchValue\"\n                v-model:open=\"open\"\n                class=\"p-2\"\n                :filter-function=\"(val: string[]) => val\">\n                <ComboboxAnchor>\n                    <ComboboxInput\n                        class=\"w-full h-8 rounded-md border border-input-border bg-input-background px-3 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none\"\n                        :placeholder=\"searchPlaceholder\" />\n                </ComboboxAnchor>\n                <ComboboxContent\n                    :dismiss-able=\"false\"\n                    position=\"inline\"\n                    class=\"mt-2 min-w-60 max-w-80 max-h-60 overflow-y-auto\">\n                    <ComboboxViewport>\n                        <ComboboxItem\n                            v-if=\"showNoItem\"\n                            :value=\"NONE_ID\"\n                            class=\"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default\"\n                            @select.prevent=\"toggleItem(NONE_ID)\">\n                            <Checkbox\n                                :checked=\"model.includes(NONE_ID)\"\n                                aria-hidden=\"true\"\n                                :tabindex=\"-1\"\n                                class=\"pointer-events-none\" />\n                            <span class=\"truncate\">{{ noItemLabel }}</span>\n                        </ComboboxItem>\n                        <ComboboxItem\n                            v-for=\"item in filteredItems\"\n                            :key=\"getKeyFromItem(item)\"\n                            :value=\"getKeyFromItem(item)\"\n                            class=\"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default\"\n                            @select.prevent=\"toggleItem(getKeyFromItem(item))\">\n                            <Checkbox\n                                :checked=\"model.includes(getKeyFromItem(item))\"\n                                aria-hidden=\"true\"\n                                :tabindex=\"-1\"\n                                class=\"pointer-events-none\" />\n                            <span class=\"truncate\">{{ getNameForItem(item) }}</span>\n                        </ComboboxItem>\n                    </ComboboxViewport>\n                </ComboboxContent>\n            </ComboboxRoot>\n        </template>\n    </Dropdown>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Input/TextInput.vue",
    "content": "<script setup lang=\"ts\">\nimport { onMounted, ref } from 'vue';\nimport { twMerge } from 'tailwind-merge';\n\nconst props = defineProps<{\n    name?: string;\n    class?: string;\n}>();\n\nconst input = ref<HTMLInputElement | null>(null);\n\nonMounted(() => {\n    if (input.value?.hasAttribute('autofocus')) {\n        input.value?.focus();\n    }\n});\n\ndefineExpose({ focus: () => input.value?.focus() });\nconst model = defineModel();\n</script>\n\n<template>\n    <input\n        ref=\"input\"\n        v-model=\"model\"\n        :class=\"\n            twMerge(\n                'h-9 px-3 py-1 text-base sm:text-sm border-input-border border bg-input-background text-text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent rounded-md shadow-sm',\n                props.class\n            )\n        \"\n        :name=\"name\" />\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Input/TextareaInput.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue';\nimport { twMerge } from 'tailwind-merge';\n\nconst model = defineModel<string | number>({\n    default: '',\n});\n\nconst props = withDefaults(\n    defineProps<{\n        id?: string;\n        type?: string;\n        class?: string;\n        rows?: number;\n        placeholder?: string;\n        disabled?: boolean;\n        required?: boolean;\n    }>(),\n    {\n        type: 'text',\n        class: '',\n        rows: 3,\n        disabled: false,\n        required: false,\n    }\n);\n\nconst classes = computed(() => {\n    return twMerge(\n        'block w-full rounded-md bg-input-background  border-input-border shadow-sm focus:border-transparent focus:ring-ring disabled:opacity-50',\n        props.class\n    );\n});\n</script>\n\n<template>\n    <textarea\n        :id=\"id\"\n        v-model=\"model\"\n        :type=\"type\"\n        :class=\"classes\"\n        :rows=\"rows\"\n        :placeholder=\"placeholder\"\n        :disabled=\"disabled\"\n        :required=\"required\" />\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Input/TimePickerSimple.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, watch } from 'vue';\nimport { getLocalizedDayJs } from '@/packages/ui/src/utils/time';\nimport { useFocus } from '@vueuse/core';\nimport { TextInput } from '@/packages/ui/src';\n\n// This has to be a localized timestamp, not UTC\nconst model = defineModel<string | null>({\n    default: null,\n});\n\nconst props = withDefaults(\n    defineProps<{\n        focus?: boolean;\n    }>(),\n    {\n        focus: false,\n    }\n);\n\nfunction updateTime(event: Event) {\n    const target = event.target as HTMLInputElement;\n    const newValue = target.value.trim();\n    if (newValue.split(':').length === 2) {\n        const [hours, minutes] = newValue.split(':') as [string, string];\n        if (!isNaN(parseInt(hours)) && !isNaN(parseInt(minutes))) {\n            const currentTime = getLocalizedDayJs(model.value);\n            const newHours = Math.min(parseInt(hours), 23);\n            const newMinutes = Math.min(parseInt(minutes), 59);\n\n            // Only update if hours or minutes are different\n            if (currentTime.hour() !== newHours || currentTime.minute() !== newMinutes) {\n                model.value = currentTime\n                    .set('hours', newHours)\n                    .set('minutes', newMinutes)\n                    .set('seconds', 0)\n                    .format();\n                emit('changed', model.value);\n            }\n        }\n    }\n    // check if input is only numbers\n    else if (/^\\d+$/.test(newValue)) {\n        if (newValue.length === 4) {\n            // parse 1300 to 13:00\n            const [hours, minutes] = [newValue.slice(0, 2), newValue.slice(2, 4)];\n            model.value = getLocalizedDayJs(model.value)\n                .set('hours', Math.min(parseInt(hours), 23))\n                .set('minutes', Math.min(parseInt(minutes), 59))\n                .set('seconds', 0)\n                .format();\n            emit('changed', model.value);\n        } else if (newValue.length === 3) {\n            // parse 130 to 01:30\n            const [hours, minutes] = [newValue.slice(0, 1), newValue.slice(1, 3)];\n            model.value = getLocalizedDayJs(model.value)\n                .set('hours', Math.min(parseInt(hours), 23))\n                .set('minutes', Math.min(parseInt(minutes), 59))\n                .set('seconds', 0)\n                .format();\n            emit('changed', model.value);\n        } else if (newValue.length === 2) {\n            // parse 13 to 13:00\n            model.value = getLocalizedDayJs(model.value)\n                .set('hours', Math.min(parseInt(newValue), 23))\n                .set('minutes', 0)\n                .set('seconds', 0)\n                .format();\n            emit('changed', model.value);\n        } else if (newValue.length === 1) {\n            // parse 1 to 01:00\n            model.value = getLocalizedDayJs(model.value)\n                .set('hours', Math.min(parseInt(newValue), 23))\n                .set('minutes', 0)\n                .set('seconds', 0)\n                .format();\n            emit('changed', model.value);\n        }\n    }\n\n    inputValue.value = getLocalizedDayJs(model.value).format('HH:mm');\n}\n\nwatch(model, (value) => {\n    inputValue.value = value ? getLocalizedDayJs(value).format('HH:mm') : null;\n});\n\nconst timeInput = ref<HTMLInputElement | null>(null);\nconst emit = defineEmits(['changed']);\n\nuseFocus(timeInput, { initialValue: props.focus });\n\nconst inputValue = ref(model.value ? getLocalizedDayJs(model.value).format('HH:mm') : null);\n</script>\n\n<template>\n    <TextInput\n        v-bind=\"$attrs\"\n        ref=\"timeInput\"\n        v-model=\"inputValue\"\n        class=\"text-center w-full\"\n        data-testid=\"time_picker_input\"\n        type=\"text\"\n        @blur=\"updateTime\"\n        @keydown.enter.prevent=\"updateTime\"\n        @focus=\"($event.target as HTMLInputElement).select()\"\n        @mouseup=\"($event.target as HTMLInputElement).select()\"\n        @click=\"($event.target as HTMLInputElement).select()\"\n        @pointerup=\"($event.target as HTMLInputElement).select()\" />\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Input/TimeRangeSelector.vue",
    "content": "<script setup lang=\"ts\">\nimport { nextTick, ref, watch } from 'vue';\nimport DatePicker from '@/packages/ui/src/Input/DatePicker.vue';\nimport { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';\nimport dayjs from 'dayjs';\nimport TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';\nimport { Button } from '@/packages/ui/src/Buttons';\n\nconst props = defineProps<{\n    start: string;\n    end: string | null;\n    focus?: boolean;\n}>();\n\n// The timestamps for the changed event are UTC\nconst emit = defineEmits(['changed', 'close']);\n\nconst tempStart = ref(props.start ? getLocalizedDayJs(props.start).format() : dayjs().format());\nconst tempEnd = ref(props.end ? getLocalizedDayJs(props.end).format() : null);\nconst showEndTimePicker = ref(false);\n\nwatch(props, () => {\n    tempStart.value = getLocalizedDayJs(props.start).format();\n    tempEnd.value = props.end ? getLocalizedDayJs(props.end).format() : null;\n    showEndTimePicker.value = false;\n});\n\nfunction updateTimeEntry() {\n    const tempStartUtc = getDayJsInstance()(tempStart.value).utc().format();\n    const tempEndUtc = tempEnd.value ? getDayJsInstance()(tempEnd.value).utc().format() : null;\n\n    if (tempStartUtc !== props.start || tempEndUtc !== props.end) {\n        emit(\n            'changed',\n            getDayJsInstance()(tempStart.value).utc().format(),\n            tempEnd.value ? getDayJsInstance()(tempEnd.value).utc().format() : null\n        );\n    }\n}\n\nfunction setEndTime() {\n    showEndTimePicker.value = true;\n    tempEnd.value = getDayJsInstance()().format();\n}\n\nfunction confirmEndTime() {\n    // wait for the v-model for the end time to update\n    nextTick(() => {\n        updateTimeEntry();\n        showEndTimePicker.value = false;\n        emit('close');\n    });\n}\n\nconst dropdownContent = ref();\n</script>\n\n<template>\n    <div\n        ref=\"dropdownContent\"\n        class=\"grid grid-cols-2 divide-x divide-card-background-separator text-center py-2\">\n        <div class=\"px-2\" @keydown.enter.prevent=\"nextTick(() => emit('close'))\">\n            <div class=\"font-semibold text-text-primary text-sm pb-2\">Start</div>\n            <div class=\"flex flex-col items-center space-y-2 w-28 mx-auto\">\n                <TimePickerSimple\n                    v-model=\"tempStart\"\n                    class=\"w-full\"\n                    data-testid=\"time_entry_range_start\"\n                    tabindex=\"0\"\n                    :focus\n                    @keydown.exact.tab.shift.stop.prevent=\"emit('close')\"\n                    @changed=\"updateTimeEntry\"></TimePickerSimple>\n                <DatePicker\n                    v-model=\"tempStart\"\n                    class=\"w-full\"\n                    @changed=\"updateTimeEntry\"></DatePicker>\n            </div>\n        </div>\n        <div class=\"px-2\">\n            <div class=\"font-semibold text-text-primary text-sm pb-2\">End</div>\n            <div\n                v-if=\"end !== null && tempEnd !== null\"\n                class=\"flex flex-col items-center space-y-2 w-28 mx-auto\">\n                <TimePickerSimple\n                    v-model=\"tempEnd\"\n                    class=\"w-full\"\n                    data-testid=\"time_entry_range_end\"\n                    @changed=\"updateTimeEntry\"></TimePickerSimple>\n                <DatePicker\n                    v-model=\"tempEnd\"\n                    class=\"w-full\"\n                    @changed=\"updateTimeEntry\"></DatePicker>\n            </div>\n            <div v-else-if=\"end === null && !showEndTimePicker\">\n                <Button variant=\"outline\" size=\"sm\" @click=\"setEndTime\"> Set End Time </Button>\n            </div>\n            <div\n                v-else-if=\"showEndTimePicker && tempEnd !== null\"\n                class=\"flex flex-col items-center space-y-2 w-28 mx-auto\">\n                <TimePickerSimple\n                    v-model=\"tempEnd\"\n                    class=\"w-full\"\n                    data-testid=\"time_entry_range_end\"\n                    @keydown.enter.prevent.stop=\"confirmEndTime\"></TimePickerSimple>\n                <DatePicker v-model=\"tempEnd\" class=\"w-full\"></DatePicker>\n                <Button variant=\"outline\" size=\"sm\" class=\"w-full\" @click=\"confirmEndTime\">\n                    Confirm\n                </Button>\n            </div>\n            <div v-else class=\"text-text-secondary\">-- : --</div>\n            <div tabindex=\"0\" @focusin=\"emit('close')\"></div>\n        </div>\n    </div>\n</template>\n\n<style></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/LoadingSpinner.vue",
    "content": "<script setup lang=\"ts\">\nimport { twMerge } from 'tailwind-merge';\n\nconst props = defineProps<{\n    class?: string;\n}>();\n</script>\n\n<template>\n    <svg\n        :class=\"twMerge('animate-spin -ml-1 mr-3 h-5 w-5 text-text-primary', props.class)\"\n        xmlns=\"http://www.w3.org/2000/svg\"\n        fill=\"none\"\n        viewBox=\"0 0 24 24\">\n        <circle\n            class=\"opacity-25\"\n            cx=\"12\"\n            cy=\"12\"\n            r=\"10\"\n            stroke=\"currentColor\"\n            stroke-width=\"4\"></circle>\n        <path\n            class=\"opacity-75\"\n            fill=\"currentColor\"\n            d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n    </svg>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/MainContainer.vue",
    "content": "<script setup lang=\"ts\"></script>\n\n<template>\n    <div class=\"px-2 sm:px-4 lg:px-6 mx-auto\">\n        <slot></slot>\n    </div>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Modal.vue",
    "content": "<script setup lang=\"ts\">\nimport { Dialog, DialogContent, DialogFooter } from '@/Components/ui/dialog';\nimport { computed } from 'vue';\n\nconst props = defineProps({\n    show: {\n        type: Boolean,\n        default: false,\n    },\n    maxWidth: {\n        type: String,\n        default: '2xl',\n    },\n    closeable: {\n        type: Boolean,\n        default: true,\n    },\n});\n\nconst emit = defineEmits(['close']);\n\nconst close = () => {\n    if (props.closeable) {\n        emit('close');\n    }\n};\n\nconst maxWidthClass = computed(() => {\n    return {\n        sm: 'sm:max-w-sm',\n        md: 'sm:max-w-md',\n        lg: 'sm:max-w-lg',\n        xl: 'sm:max-w-xl',\n        '2xl': 'sm:max-w-2xl',\n    }[props.maxWidth];\n});\n</script>\n\n<template>\n    <Dialog :open=\"show\" @update:open=\"close\">\n        <DialogContent :class=\"maxWidthClass\">\n            <div class=\"min-w-0\">\n                <slot />\n            </div>\n\n            <DialogFooter>\n                <slot name=\"footer\" />\n            </DialogFooter>\n        </DialogContent>\n    </Dialog>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Project/ProjectBadge.vue",
    "content": "<script setup lang=\"ts\">\nimport { twMerge } from 'tailwind-merge';\nimport Badge from '@/packages/ui/src/Badge.vue';\n\nconst props = withDefaults(\n    defineProps<{\n        name?: string;\n        size?: 'base' | 'large' | 'xlarge';\n        tag?: string;\n        class?: string;\n        color?: string;\n        border?: boolean;\n    }>(),\n    {\n        name: '',\n        size: 'base',\n        tag: 'div',\n        color: 'var(--theme-color-icon-default)',\n        border: true,\n    }\n);\n\nconst indicatorClasses = {\n    base: 'w-2.5 h-2.5',\n    large: 'w-2 sm:w-3 h-2 sm:h-3',\n    xlarge: 'w-3 sm:w-4 h-3 sm:h-4',\n};\n</script>\n\n<template>\n    <Badge :name :size :tag :class=\"props.class\" :color :border>\n        <div\n            :style=\"{ backgroundColor: props.color }\"\n            :class=\"twMerge(indicatorClasses[size], 'inline-block rounded-full shrink-0')\"></div>\n        <div class=\"min-w-0\">\n            <slot>\n                {{ name }}\n            </slot>\n        </div>\n    </Badge>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Project/ProjectBillableRateModal.vue",
    "content": "<script setup lang=\"ts\">\nimport { formatCents } from '@/packages/ui/src/utils/money';\nimport BillableRateModal from '@/packages/ui/src/BillableRateModal.vue';\nimport { inject, type ComputedRef } from 'vue';\nimport type { Organization } from '@/packages/api/src';\n\nconst show = defineModel('show', { default: false });\nconst saving = defineModel('saving', { default: false });\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n\ndefineProps<{\n    newBillableRate?: number | null;\n    projectName: string;\n    currency: string;\n}>();\n\ndefineEmits<{\n    submit: [];\n}>();\n</script>\n\n<template>\n    <BillableRateModal\n        v-model:show=\"show\"\n        v-model:saving=\"saving\"\n        title=\"Update Project Billable Rate\"\n        @submit=\"$emit('submit')\">\n        <p class=\"py-1 text-center\">\n            The billable rate of {{ projectName }} will be updated to\n            <strong>{{\n                newBillableRate\n                    ? formatCents(\n                          newBillableRate,\n                          currency,\n                          organization?.currency_format,\n                          organization?.currency_symbol,\n                          organization?.number_format\n                      )\n                    : ' the default rate of the organization member'\n            }}</strong\n            >.\n        </p>\n        <p class=\"py-1 text-center font-semibold max-w-md mx-auto\">\n            Do you want to update all existing time entries, where the project billable rate applies\n            as well?\n        </p>\n    </BillableRateModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Project/ProjectBillableSelect.vue",
    "content": "<script setup lang=\"ts\">\nimport {\n    Select,\n    SelectContent,\n    SelectItem,\n    SelectTrigger,\n    SelectValue,\n} from '@/Components/ui/select';\nimport type { BillableKey } from '@/types/projects';\n\nconst model = defineModel<BillableKey>({\n    default: 'non-billable',\n});\n\ntype Option = { key: BillableKey; name: string };\n\nconst options: Option[] = [\n    {\n        key: 'non-billable',\n        name: 'Non-billable',\n    },\n    {\n        key: 'default-rate',\n        name: 'Default Rate',\n    },\n    {\n        key: 'custom-rate',\n        name: 'Custom Rate',\n    },\n];\n\nfunction getNameForKey(key: BillableKey | undefined) {\n    const item = options.find((item) => item.key === key);\n    if (item) {\n        return item.name;\n    }\n    return '';\n}\n</script>\n\n<template>\n    <Select v-model=\"model\">\n        <SelectTrigger>\n            <SelectValue>{{ getNameForKey(model) }}</SelectValue>\n        </SelectTrigger>\n        <SelectContent>\n            <SelectItem v-for=\"option in options\" :key=\"option.key\" :value=\"option.key\">\n                {{ option.name }}\n            </SelectItem>\n        </SelectContent>\n    </Select>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Project/ProjectColorSelector.vue",
    "content": "<script setup lang=\"ts\">\nimport Dropdown from '@/packages/ui/src/Input/Dropdown.vue';\nimport { colors } from '@/packages/ui/src/utils/color';\n\nconst model = defineModel<string>({ default: '' });\n</script>\n\n<template>\n    <div>\n        <Dropdown align=\"center\">\n            <template #trigger>\n                <button\n                    class=\"h-9 w-9 flex items-center justify-center bg-input-background hover:bg-tertiary transition rounded-full border border-input-border\">\n                    <div\n                        :style=\"{\n                            backgroundColor: model,\n                        }\"\n                        class=\"w-5 h-5 rounded-full cursor-pointer\"></div>\n                </button>\n            </template>\n            <template #content>\n                <div class=\"text-text-primary grid grid-cols-6 gap-3 px-3 py-3\">\n                    <div\n                        v-for=\"color in colors\"\n                        :key=\"color\"\n                        :style=\"{\n                            backgroundColor: color,\n                            boxShadow: `var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) ${color}30`,\n                        }\"\n                        class=\"w-4 h-4 rounded-full cursor-pointer\"\n                        @click=\"model = color\"></div>\n                </div>\n            </template>\n        </Dropdown>\n    </div>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Project/ProjectCreateModal.vue",
    "content": "<script setup lang=\"ts\">\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport { computed, ref } from 'vue';\nimport type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';\nimport { getRandomColor } from '@/packages/ui/src/utils/color';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport { useFocus } from '@vueuse/core';\nimport ClientDropdown from '@/packages/ui/src/Client/ClientDropdown.vue';\nimport ProjectColorSelector from '@/packages/ui/src/Project/ProjectColorSelector.vue';\nimport { Button } from '@/packages/ui/src/Buttons';\nimport { ChevronDown } from 'lucide-vue-next';\nimport { UserCircleIcon } from '@heroicons/vue/20/solid';\nimport EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue';\nimport { Field, FieldGroup, FieldLabel } from '../field';\nimport ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue';\nimport type { Client } from '@/packages/api/src';\n\nconst show = defineModel('show', { default: false });\nconst saving = ref(false);\n\nconst props = defineProps<{\n    clients: Client[];\n    createProject: (project: CreateProjectBody) => Promise<Project | undefined>;\n    createClient: (client: CreateClientBody) => Promise<Client | undefined>;\n    currency: string;\n    enableEstimatedTime: boolean;\n}>();\n\nconst activeClients = computed(() => {\n    return props.clients.filter((client) => !client.is_archived);\n});\n\nconst project = ref<CreateProjectBody>({\n    name: '',\n    color: getRandomColor(),\n    client_id: null,\n    billable_rate: null,\n    is_billable: false,\n    estimated_time: null,\n});\n\nasync function submit() {\n    await props.createProject(project.value);\n    show.value = false;\n    project.value = {\n        name: '',\n        color: getRandomColor(),\n        client_id: null,\n        billable_rate: null,\n        is_billable: false,\n        estimated_time: null,\n    };\n}\n\nconst projectNameInput = ref<HTMLInputElement | null>(null);\n\nuseFocus(projectNameInput, { initialValue: true });\n\nconst currentClientName = computed(() => {\n    if (project.value.client_id) {\n        return props.clients.find((client) => client.id === project.value.client_id)?.name;\n    }\n    return 'No Client';\n});\n</script>\n\n<template>\n    <DialogModal closeable :show=\"show\" @close=\"show = false\">\n        <template #title>\n            <div class=\"flex space-x-2\">\n                <span> Create Project </span>\n            </div>\n        </template>\n\n        <template #content>\n            <FieldGroup>\n                <FieldGroup class=\"flex-row items-end\">\n                    <Field class=\"w-auto text-center\">\n                        <FieldLabel for=\"color\">Color</FieldLabel>\n                        <ProjectColorSelector v-model=\"project.color\"></ProjectColorSelector>\n                    </Field>\n                    <Field class=\"w-full\">\n                        <FieldLabel for=\"projectName\">Project name</FieldLabel>\n                        <TextInput\n                            id=\"projectName\"\n                            ref=\"projectNameInput\"\n                            v-model=\"project.name\"\n                            name=\"projectName\"\n                            type=\"text\"\n                            placeholder=\"The next big thing\"\n                            class=\"block w-full\"\n                            required\n                            autocomplete=\"projectName\"\n                            @keydown.enter=\"submit()\" />\n                    </Field>\n                </FieldGroup>\n                <Field>\n                    <FieldLabel for=\"client\" :icon=\"UserCircleIcon\">Client</FieldLabel>\n                    <ClientDropdown\n                        v-model=\"project.client_id\"\n                        :create-client=\"createClient\"\n                        :clients=\"activeClients\">\n                        <template #trigger>\n                            <Button variant=\"input\" class=\"w-full justify-between\">\n                                <span class=\"truncate\">{{ currentClientName }}</span>\n                                <ChevronDown class=\"w-4 h-4 text-icon-default\" />\n                            </Button>\n                        </template>\n                    </ClientDropdown>\n                </Field>\n                <ProjectEditBillableSection\n                    v-model:is-billable=\"project.is_billable\"\n                    v-model:billable-rate=\"project.billable_rate\"\n                    :currency=\"currency\"></ProjectEditBillableSection>\n                <EstimatedTimeSection\n                    v-if=\"enableEstimatedTime\"\n                    v-model=\"project.estimated_time\"\n                    @submit=\"submit()\"></EstimatedTimeSection>\n            </FieldGroup>\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"show = false\"> Cancel</SecondaryButton>\n            <PrimaryButton\n                class=\"ms-3\"\n                :class=\"{ 'opacity-25': saving }\"\n                :disabled=\"saving\"\n                @click=\"submit\">\n                Create Project\n            </PrimaryButton>\n        </template>\n    </DialogModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Project/ProjectDropdownItem.vue",
    "content": "<script setup lang=\"ts\">\ndefineProps<{\n    name: string;\n    selected: boolean;\n    color: string;\n}>();\n</script>\n\n<template>\n    <div\n        class=\"flex justify-between items-center w-full text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out\">\n        <div class=\"flex space-x-3 items-center px-3 py-1.5\">\n            <div :style=\"{ backgroundColor: color }\" class=\"w-3 h-3 rounded-full\"></div>\n            <span>{{ name }}</span>\n        </div>\n        <slot name=\"actions\"></slot>\n    </div>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Project/ProjectEditBillableSection.vue",
    "content": "<script setup lang=\"ts\">\nimport { Field, FieldDescription, FieldLabel } from '../field';\nimport BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';\nimport {\n    Select,\n    SelectContent,\n    SelectItem,\n    SelectTrigger,\n    SelectValue,\n} from '@/Components/ui/select';\nimport {\n    Tooltip,\n    TooltipContent,\n    TooltipProvider,\n    TooltipTrigger,\n} from '@/packages/ui/src/tooltip';\nimport { computed, onMounted, ref, watch } from 'vue';\nimport BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';\nimport { useOrganizationQuery } from '@/utils/useOrganizationQuery';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\n\ndefineProps<{\n    currency: string;\n}>();\n\nconst { organization } = useOrganizationQuery(getCurrentOrganizationId()!);\n\ntype RateType = 'default-rate' | 'custom-rate';\n\nconst billableDefault = ref<'billable' | 'non-billable'>('non-billable');\nconst rateType = ref<RateType>('default-rate');\n\nconst billableRate = defineModel<number | null>('billableRate');\nconst isBillable = defineModel<boolean>('isBillable');\n\nonMounted(() => {\n    if (isBillable.value === true) {\n        billableDefault.value = 'billable';\n        rateType.value = billableRate.value ? 'custom-rate' : 'default-rate';\n    }\n});\n\nwatch(billableDefault, () => {\n    if (billableDefault.value === 'non-billable') {\n        isBillable.value = false;\n    } else {\n        isBillable.value = true;\n    }\n});\n\nwatch(rateType, () => {\n    if (rateType.value === 'default-rate') {\n        billableRate.value = null;\n    } else if (rateType.value === 'custom-rate') {\n        billableDefault.value = 'billable';\n        isBillable.value = true;\n        if (!billableRate.value) {\n            billableRate.value = organization.value?.billable_rate ?? null;\n        }\n    }\n});\n\nconst displayedRate = computed({\n    get() {\n        if (rateType.value === 'default-rate') {\n            return organization.value?.billable_rate ?? null;\n        }\n        return billableRate.value;\n    },\n    set(value: number | null) {\n        if (rateType.value === 'custom-rate') {\n            billableRate.value = value;\n        }\n    },\n});\n\nconst billableDescription = computed(() => {\n    if (billableDefault.value === 'non-billable') {\n        return 'New time entries for this project will not be marked billable by default.';\n    }\n    return 'New time entries for this project will be marked billable by default.';\n});\n\nconst emit = defineEmits(['submit']);\n</script>\n\n<template>\n    <Field>\n        <FieldLabel for=\"billable\" :icon=\"BillableIcon\">Billable Default</FieldLabel>\n        <Select v-model=\"billableDefault\">\n            <SelectTrigger id=\"billable\">\n                <SelectValue />\n            </SelectTrigger>\n            <SelectContent>\n                <SelectItem value=\"non-billable\">Non-billable</SelectItem>\n                <SelectItem value=\"billable\">Billable</SelectItem>\n            </SelectContent>\n        </Select>\n        <FieldDescription>{{ billableDescription }}</FieldDescription>\n    </Field>\n    <Field>\n        <FieldLabel :icon=\"BillableIcon\" for=\"billableRateType\">Billable Rate</FieldLabel>\n        <div class=\"grid grid-cols-1 sm:grid-cols-2 gap-2\">\n            <Select v-model=\"rateType\">\n                <SelectTrigger id=\"billableRateType\">\n                    <SelectValue />\n                </SelectTrigger>\n                <SelectContent>\n                    <SelectItem value=\"default-rate\">Default Rate</SelectItem>\n                    <SelectItem value=\"custom-rate\">Custom Rate</SelectItem>\n                </SelectContent>\n            </Select>\n            <TooltipProvider v-if=\"rateType === 'default-rate'\">\n                <Tooltip>\n                    <TooltipTrigger as-child>\n                        <div>\n                            <BillableRateInput\n                                v-model=\"displayedRate\"\n                                :currency=\"currency\"\n                                disabled\n                                name=\"billableRate\" />\n                        </div>\n                    </TooltipTrigger>\n                    <TooltipContent> Uses the default rate of the organization </TooltipContent>\n                </Tooltip>\n            </TooltipProvider>\n            <BillableRateInput\n                v-else\n                v-model=\"displayedRate\"\n                :currency=\"currency\"\n                name=\"billableRate\"\n                @keydown.enter=\"emit('submit')\" />\n        </div>\n    </Field>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Tag/TagBadge.vue",
    "content": "<script setup lang=\"ts\">\nimport { twMerge } from 'tailwind-merge';\nimport Badge from '../Badge.vue';\nimport { TagIcon } from '@heroicons/vue/20/solid';\n\nconst props = withDefaults(\n    defineProps<{\n        name: string;\n        size?: 'base' | 'large';\n        tag?: string;\n        class?: string;\n        color?: string;\n        border?: boolean;\n        showIcon?: boolean;\n    }>(),\n    {\n        size: 'base',\n        tag: 'div',\n        color: 'var(--theme-color-icon-default)',\n        border: true,\n        showIcon: true,\n    }\n);\n\nconst indicatorClasses = {\n    base: 'w-3 h-3',\n    large: 'w-5 h-5',\n};\n</script>\n\n<template>\n    <Badge :name :size :tag :class=\"props.class\" :color :border>\n        <TagIcon v-if=\"showIcon\" :class=\"twMerge(indicatorClasses[size])\"></TagIcon>\n        <span v-if=\"name\">\n            {{ name }}\n        </span>\n    </Badge>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Tag/TagCreateModal.vue",
    "content": "<script setup lang=\"ts\">\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport { ref } from 'vue';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport { useFocus } from '@vueuse/core';\nimport type { CreateTagBody, Tag } from '@/packages/api/src';\nconst show = defineModel('show', { default: false });\nconst saving = ref(false);\n\nconst tag = ref<CreateTagBody>({\n    name: '',\n});\n\nconst props = defineProps<{\n    createTag: (name: string) => Promise<Tag | undefined>;\n}>();\n\nasync function submit() {\n    const newTag = props.createTag(tag.value.name);\n    if (newTag !== undefined) {\n        show.value = false;\n        tag.value.name = '';\n    }\n}\n\nconst tagNameInput = ref<HTMLInputElement | null>(null);\nuseFocus(tagNameInput, { initialValue: true });\n</script>\n\n<template>\n    <DialogModal closeable :show=\"show\" @close=\"show = false\">\n        <template #title>\n            <div class=\"flex space-x-2\">\n                <span> Create Tags </span>\n            </div>\n        </template>\n        <template #content>\n            <div class=\"flex items-center space-x-4\">\n                <div class=\"col-span-6 sm:col-span-4 flex-1\">\n                    <TextInput\n                        id=\"tagName\"\n                        ref=\"tagNameInput\"\n                        v-model=\"tag.name\"\n                        type=\"text\"\n                        placeholder=\"Tag Name\"\n                        class=\"mt-1 block w-full\"\n                        required\n                        autocomplete=\"tagName\"\n                        @keydown.enter=\"submit\" />\n                </div>\n            </div>\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"show = false\"> Cancel </SecondaryButton>\n\n            <PrimaryButton\n                class=\"ms-3\"\n                :class=\"{ 'opacity-25': saving }\"\n                :disabled=\"saving\"\n                @click=\"submit\">\n                Create Tag\n            </PrimaryButton>\n        </template>\n    </DialogModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/Tag/TagDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport { PlusCircleIcon } from '@heroicons/vue/20/solid';\nimport Dropdown from '@/packages/ui/src/Input/Dropdown.vue';\nimport { computed, ref, watch } from 'vue';\nimport TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';\nimport Checkbox from '@/packages/ui/src/Input/Checkbox.vue';\nimport type { Tag } from '@/packages/api/src';\nimport { Button } from '@/packages/ui/src/Buttons';\nimport {\n    ComboboxAnchor,\n    ComboboxContent,\n    ComboboxInput,\n    ComboboxItem,\n    ComboboxRoot,\n    ComboboxViewport,\n} from 'radix-vue';\n\nconst NONE_ID = 'none';\nconst NO_TAG_LABEL = 'No Tag';\n\nconst props = withDefaults(\n    defineProps<{\n        tags: Tag[];\n        createTag: (name: string) => Promise<Tag | undefined>;\n        align?: 'center' | 'end' | 'start';\n        showNoTagOption?: boolean;\n    }>(),\n    {\n        align: 'start',\n        showNoTagOption: true,\n    }\n);\n\nconst model = defineModel<string[]>({\n    default: [],\n});\n\nconst open = ref(false);\nconst searchValue = ref('');\nconst sortedTags = ref<Tag[]>([]);\n\nwatch(open, (isOpen) => {\n    if (isOpen) {\n        searchValue.value = '';\n        sortedTags.value = [...props.tags].sort((a, b) => {\n            const aSelected = model.value.includes(a.id) ? 0 : 1;\n            const bSelected = model.value.includes(b.id) ? 0 : 1;\n            return aSelected - bSelected;\n        });\n    }\n});\n\nconst filteredTags = computed(() => {\n    const search = searchValue.value.toLowerCase().trim();\n    if (!search) return sortedTags.value;\n    return sortedTags.value.filter((tag) => tag.name.toLowerCase().includes(search));\n});\n\nconst showNoTag = computed(() => {\n    if (!props.showNoTagOption) return false;\n    const search = searchValue.value.toLowerCase().trim();\n    if (!search) return true;\n    return NO_TAG_LABEL.toLowerCase().includes(search);\n});\n\nfunction toggleTag(id: string) {\n    if (model.value.includes(id)) {\n        model.value = model.value.filter((tagId) => tagId !== id);\n    } else {\n        model.value = [...model.value, id];\n    }\n    emit('changed');\n}\n\nasync function createAndAddTag(name: string) {\n    const newTag = await props.createTag(name);\n    if (newTag) {\n        toggleTag(newTag.id);\n    }\n    searchValue.value = '';\n    return newTag;\n}\n\nconst emit = defineEmits<{\n    changed: [];\n    submit: [];\n}>();\n\nconst showCreateTagModal = ref(false);\n</script>\n\n<template>\n    <TagCreateModal\n        v-if=\"showCreateTagModal\"\n        v-model:show=\"showCreateTagModal\"\n        :create-tag=\"createAndAddTag\"></TagCreateModal>\n    <Dropdown\n        v-model=\"open\"\n        :align=\"align\"\n        :close-on-content-click=\"false\"\n        @submit=\"emit('submit')\">\n        <template #trigger>\n            <slot name=\"trigger\"></slot>\n        </template>\n        <template #content>\n            <ComboboxRoot\n                v-model:search-term=\"searchValue\"\n                :open=\"true\"\n                class=\"p-2\"\n                :filter-function=\"(val: string[]) => val\">\n                <ComboboxAnchor>\n                    <ComboboxInput\n                        data-testid=\"tag_dropdown_search\"\n                        class=\"w-full rounded-md border border-input-border bg-input-background px-3 py-1.5 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none\"\n                        placeholder=\"Search for a Tag...\" />\n                </ComboboxAnchor>\n                <ComboboxContent\n                    :dismiss-able=\"false\"\n                    position=\"inline\"\n                    class=\"mt-2 w-60 max-h-60 overflow-y-auto\">\n                    <ComboboxViewport>\n                        <ComboboxItem\n                            v-if=\"showNoTag\"\n                            :value=\"NONE_ID\"\n                            data-testid=\"tag_dropdown_entries\"\n                            class=\"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default\"\n                            @select.prevent=\"toggleTag(NONE_ID)\">\n                            <Checkbox\n                                :checked=\"model.includes(NONE_ID)\"\n                                aria-hidden=\"true\"\n                                :tabindex=\"-1\"\n                                class=\"pointer-events-none\" />\n                            <span class=\"truncate\">{{ NO_TAG_LABEL }}</span>\n                        </ComboboxItem>\n                        <ComboboxItem\n                            v-for=\"tag in filteredTags\"\n                            :key=\"tag.id\"\n                            :value=\"tag.id\"\n                            data-testid=\"tag_dropdown_entries\"\n                            class=\"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default\"\n                            @select.prevent=\"toggleTag(tag.id)\">\n                            <Checkbox\n                                :checked=\"model.includes(tag.id)\"\n                                aria-hidden=\"true\"\n                                :tabindex=\"-1\"\n                                class=\"pointer-events-none\" />\n                            <span class=\"truncate\">{{ tag.name }}</span>\n                        </ComboboxItem>\n                    </ComboboxViewport>\n                </ComboboxContent>\n                <div class=\"mt-1 border-t border-card-background-separator pt-1\">\n                    <Button\n                        variant=\"ghost\"\n                        size=\"sm\"\n                        class=\"w-full justify-start gap-2 px-2 py-1.5 text-sm text-text-primary\"\n                        @click=\"\n                            open = false;\n                            showCreateTagModal = true;\n                        \">\n                        <PlusCircleIcon class=\"w-4 h-4 flex-shrink-0 text-icon-default\" />\n                        <span>Create new Tag</span>\n                    </Button>\n                </div>\n            </ComboboxRoot>\n        </template>\n    </Dropdown>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/TimeEntry/TimeEntryAggregateRow.vue",
    "content": "<script setup lang=\"ts\">\nimport MainContainer from '@/packages/ui/src/MainContainer.vue';\nimport TimeTrackerStartStop from '../TimeTrackerStartStop.vue';\nimport type {\n    CreateClientBody,\n    CreateProjectBody,\n    Project,\n    Tag,\n    Task,\n    TimeEntry,\n    Client,\n    Organization,\n} from '@/packages/api/src';\nimport TimeEntryDescriptionInput from '@/packages/ui/src/TimeEntry/TimeEntryDescriptionInput.vue';\nimport TimeEntryRowTagDropdown from '@/packages/ui/src/TimeEntry/TimeEntryRowTagDropdown.vue';\nimport TimeEntryMoreOptionsDropdown from '@/packages/ui/src/TimeEntry/TimeEntryMoreOptionsDropdown.vue';\nimport TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';\nimport BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';\nimport { ref, inject, type ComputedRef } from 'vue';\nimport { formatHumanReadableDuration, formatStartEnd } from '@/packages/ui/src/utils/time';\nimport TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue';\nimport GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';\nimport type { TimeEntriesGroupedByType } from '@/types/time-entries';\nimport { Checkbox } from '@/packages/ui/src';\nimport { twMerge } from 'tailwind-merge';\nconst props = defineProps<{\n    timeEntry: TimeEntriesGroupedByType;\n    projects: Project[];\n    tasks: Task[];\n    tags: Tag[];\n    clients: Client[];\n    createTag: (name: string) => Promise<Tag | undefined>;\n    createProject: (project: CreateProjectBody) => Promise<Project | undefined>;\n    createClient: (client: CreateClientBody) => Promise<Client | undefined>;\n    onStartStopClick: (timeEntry: TimeEntry) => void;\n    duplicateTimeEntry: (timeEntry: TimeEntry) => void;\n    updateTimeEntries: (ids: string[], changes: Partial<TimeEntry>) => void;\n    updateTimeEntry: (timeEntry: TimeEntry) => void;\n    deleteTimeEntries: (timeEntries: TimeEntry[]) => void;\n    currency: string;\n    selectedTimeEntries: TimeEntry[];\n    enableEstimatedTime: boolean;\n    canCreateProject: boolean;\n}>();\nconst emit = defineEmits<{\n    selected: [TimeEntry[]];\n    unselected: [TimeEntry[]];\n}>();\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n\nfunction updateTimeEntryDescription(description: string) {\n    props.updateTimeEntries(\n        props.timeEntry.timeEntries.map((timeEntry: TimeEntry) => timeEntry.id),\n        { description: description }\n    );\n}\n\nfunction updateTimeEntryTags(tags: string[]) {\n    props.updateTimeEntries(\n        props.timeEntry.timeEntries.map((timeEntry: TimeEntry) => timeEntry.id),\n        { tags: tags }\n    );\n}\n\nfunction updateTimeEntryBillable(billable: boolean) {\n    props.updateTimeEntries(\n        props.timeEntry.timeEntries.map((timeEntry: TimeEntry) => timeEntry.id),\n        { billable: billable }\n    );\n}\n\nfunction updateProjectAndTask(projectId: string, taskId: string) {\n    props.updateTimeEntries(\n        props.timeEntry.timeEntries.map((timeEntry: TimeEntry) => timeEntry.id),\n        { project_id: projectId, task_id: taskId }\n    );\n}\n\nconst expanded = ref(false);\n\nfunction onSelectChange(checked: boolean) {\n    if (checked) {\n        emit('selected', [...props.timeEntry.timeEntries]);\n    } else {\n        emit('unselected', [...props.timeEntry.timeEntries]);\n    }\n}\n</script>\n\n<template>\n    <div\n        class=\"border-b border-default-background-separator bg-row-background min-w-0 transition\"\n        data-testid=\"time_entry_row\">\n        <MainContainer class=\"min-w-0\">\n            <div class=\"@xl:flex py-2 items-center min-w-0 justify-between group\">\n                <!-- Desktop layout -->\n                <div class=\"hidden @lg:flex space-x-3 items-center min-w-0\">\n                    <Checkbox\n                        :checked=\"\n                            timeEntry.timeEntries.every((aggregateTimeEntry: TimeEntry) =>\n                                selectedTimeEntries.includes(aggregateTimeEntry)\n                            )\n                        \"\n                        @update:checked=\"onSelectChange\" />\n                    <div class=\"flex items-center min-w-0\">\n                        <GroupedItemsCountButton :expanded=\"expanded\" @click=\"expanded = !expanded\">\n                            {{ timeEntry?.timeEntries?.length }}\n                        </GroupedItemsCountButton>\n                        <TimeEntryDescriptionInput\n                            class=\"min-w-0 mr-4 shrink\"\n                            :model-value=\"timeEntry.description\"\n                            @changed=\"updateTimeEntryDescription\"></TimeEntryDescriptionInput>\n                        <TimeTrackerProjectTaskDropdown\n                            class=\"min-w-0 shrink\"\n                            :clients\n                            :create-project\n                            :create-client\n                            :can-create-project\n                            :projects=\"projects\"\n                            :tasks=\"tasks\"\n                            :project=\"timeEntry.project_id\"\n                            :enable-estimated-time\n                            :currency=\"currency\"\n                            :task=\"timeEntry.task_id\"\n                            @changed=\"updateProjectAndTask\"></TimeTrackerProjectTaskDropdown>\n                    </div>\n                </div>\n                <div\n                    class=\"hidden @lg:flex items-center font-medium space-x-1 @lg:space-x-2 shrink-0\">\n                    <TimeEntryRowTagDropdown\n                        :create-tag\n                        :tags=\"tags\"\n                        :model-value=\"timeEntry.tags\"\n                        @changed=\"updateTimeEntryTags\"></TimeEntryRowTagDropdown>\n                    <BillableToggleButton\n                        :model-value=\"timeEntry.billable\"\n                        size=\"small\"\n                        faded\n                        @changed=\"updateTimeEntryBillable\"></BillableToggleButton>\n                    <div class=\"flex-1\">\n                        <button\n                            :class=\"\n                                twMerge(\n                                    'text-text-secondary px-1 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary',\n                                    organization?.time_format === '12-hours'\n                                        ? 'w-[160px]'\n                                        : 'w-[100px]'\n                                )\n                            \"\n                            @click=\"expanded = !expanded\">\n                            {{\n                                formatStartEnd(\n                                    timeEntry.start,\n                                    timeEntry.end,\n                                    organization?.time_format\n                                )\n                            }}\n                        </button>\n                    </div>\n                    <button\n                        class=\"text-text-primary !mr-2 min-w-[80px] px-1.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary\"\n                        @click=\"expanded = !expanded\">\n                        {{\n                            formatHumanReadableDuration(\n                                timeEntry.duration ?? 0,\n                                organization?.interval_format,\n                                organization?.number_format\n                            )\n                        }}\n                    </button>\n\n                    <TimeTrackerStartStop\n                        :active=\"!!(timeEntry.start && !timeEntry.end)\"\n                        variant=\"secondary\"\n                        class=\"opacity-60 flex group-hover:opacity-100 focus-visible:opacity-100\"\n                        @changed=\"onStartStopClick(timeEntry)\"></TimeTrackerStartStop>\n                    <TimeEntryMoreOptionsDropdown\n                        :show-edit=\"false\"\n                        :show-duplicate=\"false\"\n                        @delete=\"\n                            deleteTimeEntries(timeEntry?.timeEntries ?? [])\n                        \"></TimeEntryMoreOptionsDropdown>\n                </div>\n                <!-- Mobile layout -->\n                <div class=\"@lg:hidden\">\n                    <!-- First row: count + description + duration -->\n                    <div class=\"flex items-center justify-between min-w-0\">\n                        <div class=\"flex items-center min-w-0 flex-1\">\n                            <GroupedItemsCountButton\n                                :expanded=\"expanded\"\n                                @click=\"expanded = !expanded\">\n                                {{ timeEntry?.timeEntries?.length }}\n                            </GroupedItemsCountButton>\n                            <TimeEntryDescriptionInput\n                                class=\"min-w-0 flex-1\"\n                                :model-value=\"timeEntry.description\"\n                                @changed=\"updateTimeEntryDescription\"></TimeEntryDescriptionInput>\n                        </div>\n                        <button\n                            class=\"text-text-primary min-w-[80px] px-1.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary\"\n                            @click=\"expanded = !expanded\">\n                            {{\n                                formatHumanReadableDuration(\n                                    timeEntry.duration ?? 0,\n                                    organization?.interval_format,\n                                    organization?.number_format\n                                )\n                            }}\n                        </button>\n                    </div>\n                    <!-- Second row: project/task - tags - billable - start - more -->\n                    <div class=\"flex items-center justify-between mt-1\">\n                        <TimeTrackerProjectTaskDropdown\n                            class=\"min-w-0\"\n                            :clients\n                            :create-project\n                            :create-client\n                            :can-create-project\n                            :projects=\"projects\"\n                            :tasks=\"tasks\"\n                            :project=\"timeEntry.project_id\"\n                            :enable-estimated-time\n                            :currency=\"currency\"\n                            :task=\"timeEntry.task_id\"\n                            @changed=\"updateProjectAndTask\"></TimeTrackerProjectTaskDropdown>\n                        <div class=\"flex items-center shrink-0\">\n                            <TimeEntryRowTagDropdown\n                                :create-tag\n                                :tags=\"tags\"\n                                :model-value=\"timeEntry.tags\"\n                                compact\n                                @changed=\"updateTimeEntryTags\"></TimeEntryRowTagDropdown>\n                            <BillableToggleButton\n                                :model-value=\"timeEntry.billable\"\n                                size=\"small\"\n                                @changed=\"updateTimeEntryBillable\"></BillableToggleButton>\n                            <TimeTrackerStartStop\n                                :active=\"!!(timeEntry.start && !timeEntry.end)\"\n                                variant=\"secondary\"\n                                class=\"ml-2\"\n                                @changed=\"onStartStopClick(timeEntry)\"></TimeTrackerStartStop>\n                            <TimeEntryMoreOptionsDropdown\n                                :show-edit=\"false\"\n                                :show-duplicate=\"false\"\n                                @delete=\"\n                                    deleteTimeEntries(timeEntry?.timeEntries ?? [])\n                                \"></TimeEntryMoreOptionsDropdown>\n                        </div>\n                    </div>\n                </div>\n            </div>\n        </MainContainer>\n        <div\n            v-if=\"expanded\"\n            class=\"w-full border-t border-default-background-separator bg-black/15\">\n            <TimeEntryRow\n                v-for=\"subEntry in timeEntry.timeEntries\"\n                :key=\"subEntry.id\"\n                :projects=\"projects\"\n                :enable-estimated-time\n                :can-create-project\n                :tasks=\"tasks\"\n                :selected=\"\n                    !!selectedTimeEntries.find(\n                        (filterEntry: TimeEntry) => filterEntry.id === subEntry.id\n                    )\n                \"\n                :create-client\n                :clients\n                :create-project\n                :tags=\"tags\"\n                indent\n                :update-time-entry=\"(timeEntry: TimeEntry) => updateTimeEntry(timeEntry)\"\n                :on-start-stop-click=\"() => onStartStopClick(subEntry)\"\n                :delete-time-entry=\"() => deleteTimeEntries([subEntry])\"\n                :duplicate-time-entry=\"() => duplicateTimeEntry(subEntry)\"\n                :currency=\"currency\"\n                :create-tag\n                :time-entry=\"subEntry\"\n                @selected=\"emit('selected', [subEntry])\"\n                @unselected=\"emit('unselected', [subEntry])\"></TimeEntryRow>\n        </div>\n    </div>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue",
    "content": "<script setup lang=\"ts\">\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport { computed, nextTick, ref, watch } from 'vue';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';\nimport { Field, FieldLabel } from '../field';\nimport { TagIcon } from '@heroicons/vue/20/solid';\nimport { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';\nimport type {\n    CreateClientBody,\n    CreateProjectBody,\n    Project,\n    Client,\n    CreateTimeEntryBody,\n} from '@/packages/api/src';\nimport TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';\nimport BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';\nimport {\n    Select,\n    SelectContent,\n    SelectItem,\n    SelectTrigger,\n    SelectValue,\n} from '@/Components/ui/select';\nimport { Button } from '@/packages/ui/src/Buttons';\nimport DatePicker from '@/packages/ui/src/Input/DatePicker.vue';\nimport DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';\n\nimport { InformationCircleIcon } from '@heroicons/vue/20/solid';\nimport type { Tag, Task } from '@/packages/api/src';\nimport TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';\n\nconst show = defineModel('show', { default: false });\nconst saving = ref(false);\n\nconst props = defineProps<{\n    enableEstimatedTime: boolean;\n    createTimeEntry: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => Promise<void>;\n    createClient: (client: CreateClientBody) => Promise<Client | undefined>;\n    createProject: (project: CreateProjectBody) => Promise<Project | undefined>;\n    createTag: (name: string) => Promise<Tag | undefined>;\n    tags: Tag[];\n    projects: Project[];\n    tasks: Task[];\n    clients: Client[];\n    start?: string;\n    end?: string;\n    currency: string;\n    canCreateProject: boolean;\n}>();\n\nconst description = ref<HTMLInputElement | null>(null);\n\nwatch(show, (value) => {\n    if (value) {\n        nextTick(() => {\n            description.value?.focus();\n        });\n    }\n});\n\nconst timeEntryDefaultValues = {\n    description: '',\n    project_id: null,\n    task_id: null,\n    tags: [],\n    billable: false,\n    start: getDayJsInstance().utc().subtract(1, 'h').second(0).format(),\n    end: getDayJsInstance().utc().second(0).format(),\n};\n\nconst timeEntry = ref({\n    ...timeEntryDefaultValues,\n});\n\n// update the localStart and localEnd when props.start or props.end get updates\nwatch(\n    () => props.start,\n    (value) => {\n        if (value) {\n            localStart.value = getLocalizedDayJs(value).format();\n        }\n    }\n);\nwatch(\n    () => props.end,\n    (value) => {\n        if (value) {\n            localEnd.value = getLocalizedDayJs(value).format();\n        }\n    }\n);\n\nwatch(\n    () => timeEntry.value.project_id,\n    (value) => {\n        if (value) {\n            // check if project is billable by default and set billable accordingly\n            const project = props.projects.find((p) => p.id === value);\n            if (project) {\n                timeEntry.value.billable = project.is_billable;\n            }\n        }\n    }\n);\n\nconst localStart = ref(getLocalizedDayJs(timeEntryDefaultValues.start).format());\n\nconst localEnd = ref(getLocalizedDayJs(timeEntryDefaultValues.end).format());\n\nwatch(localStart, (value) => {\n    timeEntry.value.start = getLocalizedDayJs(value).utc().format();\n    if (getLocalizedDayJs(localEnd.value).isBefore(getLocalizedDayJs(value))) {\n        localEnd.value = value;\n    }\n});\n\nwatch(localEnd, (value) => {\n    timeEntry.value.end = getLocalizedDayJs(value).utc().format();\n});\n\nasync function submit() {\n    await props.createTimeEntry({ ...timeEntry.value });\n    timeEntry.value = { ...timeEntryDefaultValues };\n    localStart.value = getLocalizedDayJs(timeEntryDefaultValues.start).format();\n    localEnd.value = getLocalizedDayJs(timeEntryDefaultValues.end).format();\n    show.value = false;\n}\n\nconst billableProxy = computed({\n    get: () => (timeEntry.value.billable ? 'true' : 'false'),\n    set: (value: string) => {\n        timeEntry.value.billable = value === 'true';\n    },\n});\n</script>\n\n<template>\n    <DialogModal closeable :show=\"show\" @close=\"show = false\">\n        <template #title>\n            <div class=\"flex space-x-2\">\n                <span> Create manual time entry </span>\n            </div>\n        </template>\n\n        <template #content>\n            <div class=\"sm:flex items-end space-y-2 sm:space-y-0 sm:space-x-4\">\n                <div class=\"flex-1\">\n                    <TextInput\n                        id=\"description\"\n                        ref=\"description\"\n                        v-model=\"timeEntry.description\"\n                        aria-label=\"Description\"\n                        placeholder=\"What did you work on?\"\n                        type=\"text\"\n                        class=\"mt-1 block w-full\"\n                        @keydown.enter=\"submit\" />\n                </div>\n            </div>\n            <div class=\"flex flex-col sm:flex-row sm:items-end gap-2 pt-4\">\n                <div class=\"flex-1 min-w-0\">\n                    <TimeTrackerProjectTaskDropdown\n                        v-model:project=\"timeEntry.project_id\"\n                        v-model:task=\"timeEntry.task_id\"\n                        variant=\"input\"\n                        size=\"default\"\n                        :clients\n                        :create-project\n                        :create-client\n                        :can-create-project\n                        :currency\n                        :projects=\"projects\"\n                        :tasks=\"tasks\"\n                        :enable-estimated-time=\"enableEstimatedTime\" />\n                </div>\n                <div class=\"flex items-center gap-2 shrink-0\">\n                    <TagDropdown\n                        v-model=\"timeEntry.tags\"\n                        :create-tag\n                        :tags=\"tags\"\n                        :show-no-tag-option=\"false\">\n                        <template #trigger>\n                            <Button variant=\"input\">\n                                <TagIcon class=\"h-4 text-icon-default\" />\n                                <span>{{\n                                    timeEntry.tags.length === 0\n                                        ? 'Tags'\n                                        : `${timeEntry.tags.length} Tag${timeEntry.tags.length > 1 ? 's' : ''}`\n                                }}</span>\n                            </Button>\n                        </template>\n                    </TagDropdown>\n                    <Select v-model=\"billableProxy\">\n                        <SelectTrigger :show-chevron=\"false\">\n                            <SelectValue class=\"flex items-center gap-2\">\n                                <BillableIcon class=\"h-4 text-icon-default\" />\n                                <span>{{ timeEntry.billable ? 'Billable' : 'Non-Billable' }}</span>\n                            </SelectValue>\n                        </SelectTrigger>\n                        <SelectContent>\n                            <SelectItem value=\"true\">Billable</SelectItem>\n                            <SelectItem value=\"false\">Non Billable</SelectItem>\n                        </SelectContent>\n                    </Select>\n                </div>\n            </div>\n            <div class=\"grid grid-cols-2 sm:grid-cols-5 gap-4 pt-4\">\n                <Field class=\"col-span-2 sm:col-span-3\">\n                    <FieldLabel>Duration</FieldLabel>\n                    <div class=\"space-y-2 flex flex-col\">\n                        <DurationHumanInput\n                            v-model:start=\"localStart\"\n                            v-model:end=\"localEnd\"\n                            name=\"Duration\"></DurationHumanInput>\n                        <div class=\"text-sm flex space-x-1\">\n                            <InformationCircleIcon\n                                class=\"w-4 shrink-0 text-text-quaternary\"></InformationCircleIcon>\n                            <span class=\"text-text-secondary text-xs\">\n                                You can type natural language like\n                                <span class=\"font-semibold\"> 2h 30m</span>\n                            </span>\n                        </div>\n                    </div>\n                </Field>\n                <Field>\n                    <FieldLabel>Start</FieldLabel>\n                    <div class=\"flex flex-col gap-2\">\n                        <TimePickerSimple v-model=\"localStart\" class=\"w-full\"></TimePickerSimple>\n                        <DatePicker v-model=\"localStart\" class=\"w-full\" tabindex=\"1\"></DatePicker>\n                    </div>\n                </Field>\n                <Field>\n                    <FieldLabel>End</FieldLabel>\n                    <div class=\"flex flex-col gap-2\">\n                        <TimePickerSimple v-model=\"localEnd\" class=\"w-full\"></TimePickerSimple>\n                        <DatePicker v-model=\"localEnd\" class=\"w-full\" tabindex=\"1\"></DatePicker>\n                    </div>\n                </Field>\n            </div>\n        </template>\n        <template #footer>\n            <SecondaryButton tabindex=\"2\" @click=\"show = false\"> Cancel</SecondaryButton>\n            <PrimaryButton\n                tabindex=\"2\"\n                class=\"ms-3\"\n                :class=\"{ 'opacity-25': saving }\"\n                :disabled=\"saving\"\n                @click=\"submit\">\n                Create Time Entry\n            </PrimaryButton>\n        </template>\n    </DialogModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/TimeEntry/TimeEntryDescriptionInput.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, ref, watch } from 'vue';\n\nconst value = defineModel();\nconst emit = defineEmits(['changed']);\n\nfunction onChange(event: Event) {\n    const target = event.target as HTMLInputElement;\n    if (target.value !== value.value) {\n        emit('changed', target.value);\n        value.value = target.value;\n    }\n}\n\nwatch(\n    () => value.value,\n    (newValue) => {\n        liveDataValue.value = newValue;\n    }\n);\n\nfunction onInput(event: Event) {\n    liveDataValue.value = (event.target as HTMLInputElement).value;\n}\n\nconst liveDataValue = ref(value.value);\n\nconst displaysPlaceholder = computed(() => {\n    return liveDataValue.value === '' || liveDataValue.value === null;\n});\n</script>\n\n<template>\n    <div class=\"relative min-w-0 text-ellipsis whitespace-nowrap overflow-hidden\">\n        <div class=\"relative text-sm font-medium min-w-0\">\n            <div\n                :class=\"[\n                    'opacity-0 h-4 text-sm whitespace-pre font-medium min-w-0 pl-1.5 @lg:pl-3 pr-1',\n                    { 'min-w-[130px]': displaysPlaceholder },\n                ]\">\n                {{ liveDataValue }}\n            </div>\n            <input\n                data-testid=\"time_entry_description\"\n                :value=\"liveDataValue\"\n                placeholder=\"Add a description\"\n                class=\"absolute px-0 h-full min-w-0 pl-1.5 @lg:pl-3 pr-1 left-0 top-0 w-full text-sm text-text-primary font-medium bg-transparent focus-visible:ring-0 rounded-lg border-0\"\n                @blur=\"onChange\"\n                @input=\"onInput\"\n                @keydown.enter=\"onChange\" />\n        </div>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/TimeEntry/TimeEntryEditModal.vue",
    "content": "<script setup lang=\"ts\">\nimport TextInput from '@/packages/ui/src/Input/TextInput.vue';\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport { computed, nextTick, ref, watch } from 'vue';\nimport PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';\nimport TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';\nimport { Field, FieldLabel } from '../field';\nimport { TagIcon } from '@heroicons/vue/20/solid';\nimport { getLocalizedDayJs } from '@/packages/ui/src/utils/time';\nimport type {\n    CreateClientBody,\n    CreateProjectBody,\n    Project,\n    Client,\n    TimeEntry,\n} from '@/packages/api/src';\nimport TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';\nimport BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';\nimport {\n    Select,\n    SelectContent,\n    SelectItem,\n    SelectTrigger,\n    SelectValue,\n} from '@/Components/ui/select';\nimport { Button } from '@/packages/ui/src/Buttons';\nimport DatePicker from '@/packages/ui/src/Input/DatePicker.vue';\nimport DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';\n\nimport { InformationCircleIcon } from '@heroicons/vue/20/solid';\nimport type { Tag, Task } from '@/packages/api/src';\nimport TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';\n\nconst show = defineModel('show', { default: false });\nconst saving = ref(false);\nconst deleting = ref(false);\n\nconst props = defineProps<{\n    timeEntry: TimeEntry | null;\n    enableEstimatedTime: boolean;\n    updateTimeEntry: (entry: TimeEntry) => Promise<void>;\n    deleteTimeEntry: (timeEntryId: string) => Promise<void>;\n    createClient: (client: CreateClientBody) => Promise<Client | undefined>;\n    createProject: (project: CreateProjectBody) => Promise<Project | undefined>;\n    createTag: (name: string) => Promise<Tag | undefined>;\n    tags: Tag[];\n    projects: Project[];\n    tasks: Task[];\n    clients: Client[];\n    currency: string;\n    canCreateProject: boolean;\n}>();\n\nconst description = ref<HTMLInputElement | null>(null);\n\nwatch(show, (value) => {\n    if (value) {\n        nextTick(() => {\n            description.value?.focus();\n        });\n    }\n});\n\nconst editableTimeEntry = ref<TimeEntry | null>(null);\n\nwatch(\n    () => props.timeEntry,\n    (newTimeEntry) => {\n        if (newTimeEntry) {\n            editableTimeEntry.value = { ...newTimeEntry };\n        }\n    },\n    { immediate: true }\n);\n\nwatch(\n    () => editableTimeEntry.value?.project_id,\n    (value, oldValue) => {\n        if (oldValue !== undefined && value !== oldValue && editableTimeEntry.value) {\n            const project = props.projects.find((p) => p.id === value);\n            if (project) {\n                editableTimeEntry.value.billable = project.is_billable;\n            }\n        }\n    }\n);\n\nconst localStart = computed({\n    get: () =>\n        editableTimeEntry.value ? getLocalizedDayJs(editableTimeEntry.value.start).format() : '',\n    set: (value: string) => {\n        if (editableTimeEntry.value) {\n            editableTimeEntry.value.start = getLocalizedDayJs(value).utc().format();\n            if (getLocalizedDayJs(localEnd.value).isBefore(getLocalizedDayJs(value))) {\n                localEnd.value = value;\n            }\n        }\n    },\n});\n\nconst localEnd = computed({\n    get: () =>\n        editableTimeEntry.value ? getLocalizedDayJs(editableTimeEntry.value.end).format() : '',\n    set: (value: string) => {\n        if (editableTimeEntry.value) {\n            editableTimeEntry.value.end = getLocalizedDayJs(value).utc().format();\n        }\n    },\n});\n\nasync function submit() {\n    if (editableTimeEntry.value) {\n        saving.value = true;\n        try {\n            await props.updateTimeEntry(editableTimeEntry.value);\n            show.value = false;\n        } finally {\n            saving.value = false;\n        }\n    }\n}\n\nasync function deleteEntry() {\n    if (editableTimeEntry.value) {\n        deleting.value = true;\n        try {\n            await props.deleteTimeEntry(editableTimeEntry.value.id);\n            show.value = false;\n        } finally {\n            deleting.value = false;\n        }\n    }\n}\n\nconst billableProxy = computed({\n    get: () =>\n        editableTimeEntry.value ? (editableTimeEntry.value.billable ? 'true' : 'false') : 'false',\n    set: (value: string) => {\n        if (editableTimeEntry.value) {\n            editableTimeEntry.value.billable = value === 'true';\n        }\n    },\n});\n</script>\n\n<template>\n    <DialogModal closeable :show=\"show\" @close=\"show = false\">\n        <template #title>\n            <div class=\"flex space-x-2\">\n                <span> Edit time entry </span>\n            </div>\n        </template>\n\n        <template #content>\n            <div v-if=\"editableTimeEntry\" class=\"space-y-4\">\n                <div class=\"sm:flex items-end space-y-2 sm:space-y-0 sm:space-x-4\">\n                    <div class=\"flex-1\">\n                        <TextInput\n                            id=\"description\"\n                            ref=\"description\"\n                            v-model=\"editableTimeEntry.description\"\n                            placeholder=\"What did you work on?\"\n                            type=\"text\"\n                            class=\"mt-1 block w-full\"\n                            @keydown.enter=\"submit\" />\n                    </div>\n                </div>\n                <div class=\"flex flex-col sm:flex-row sm:items-end gap-2 sm:gap-4\">\n                    <div class=\"flex-1 min-w-0\">\n                        <TimeTrackerProjectTaskDropdown\n                            v-model:project=\"editableTimeEntry.project_id\"\n                            v-model:task=\"editableTimeEntry.task_id\"\n                            variant=\"input\"\n                            :clients\n                            :create-project\n                            :create-client\n                            :can-create-project=\"canCreateProject\"\n                            :currency=\"currency\"\n                            :projects=\"projects\"\n                            :tasks=\"tasks\"\n                            :enable-estimated-time=\"enableEstimatedTime\" />\n                    </div>\n                    <div class=\"flex items-center gap-2 shrink-0\">\n                        <TagDropdown\n                            v-model=\"editableTimeEntry.tags\"\n                            :create-tag\n                            :tags=\"tags\"\n                            :show-no-tag-option=\"false\">\n                            <template #trigger>\n                                <Button variant=\"input\" size=\"sm\">\n                                    <TagIcon class=\"h-4 text-icon-default\" />\n                                    <span>{{\n                                        editableTimeEntry.tags.length === 0\n                                            ? 'Tags'\n                                            : `${editableTimeEntry.tags.length} Tag${editableTimeEntry.tags.length > 1 ? 's' : ''}`\n                                    }}</span>\n                                </Button>\n                            </template>\n                        </TagDropdown>\n                        <Select v-model=\"billableProxy\">\n                            <SelectTrigger size=\"sm\" :show-chevron=\"false\">\n                                <SelectValue class=\"flex items-center gap-2\">\n                                    <BillableIcon class=\"h-4 text-icon-default\" />\n                                    <span>{{\n                                        editableTimeEntry.billable ? 'Billable' : 'Non-Billable'\n                                    }}</span>\n                                </SelectValue>\n                            </SelectTrigger>\n                            <SelectContent>\n                                <SelectItem value=\"true\">Billable</SelectItem>\n                                <SelectItem value=\"false\">Non Billable</SelectItem>\n                            </SelectContent>\n                        </Select>\n                    </div>\n                </div>\n                <div class=\"grid grid-cols-2 sm:grid-cols-5 gap-4 pt-4\">\n                    <Field class=\"col-span-2 sm:col-span-3\">\n                        <FieldLabel>Duration</FieldLabel>\n                        <div class=\"space-y-2 flex flex-col\">\n                            <DurationHumanInput\n                                v-model:start=\"localStart\"\n                                v-model:end=\"localEnd\"\n                                name=\"Duration\"></DurationHumanInput>\n                            <div class=\"text-sm flex space-x-1\">\n                                <InformationCircleIcon\n                                    class=\"w-4 shrink-0 text-text-quaternary\"></InformationCircleIcon>\n                                <span class=\"text-text-secondary text-xs\">\n                                    You can type natural language like\n                                    <span class=\"font-semibold\"> 2h 30m</span>\n                                </span>\n                            </div>\n                        </div>\n                    </Field>\n                    <Field>\n                        <FieldLabel>Start</FieldLabel>\n                        <div class=\"flex flex-col gap-2\">\n                            <TimePickerSimple\n                                v-model=\"localStart\"\n                                class=\"w-full\"></TimePickerSimple>\n                            <DatePicker\n                                v-model=\"localStart\"\n                                class=\"w-full\"\n                                tabindex=\"1\"></DatePicker>\n                        </div>\n                    </Field>\n                    <Field>\n                        <FieldLabel>End</FieldLabel>\n                        <div class=\"flex flex-col gap-2\">\n                            <TimePickerSimple v-model=\"localEnd\" class=\"w-full\"></TimePickerSimple>\n                            <DatePicker v-model=\"localEnd\" class=\"w-full\" tabindex=\"1\"></DatePicker>\n                        </div>\n                    </Field>\n                </div>\n            </div>\n        </template>\n        <template #footer>\n            <div class=\"flex justify-between w-full\">\n                <SecondaryButton\n                    tabindex=\"2\"\n                    class=\"bg-red-600 hover:bg-red-700 text-white border-red-600 hover:border-red-700\"\n                    :disabled=\"deleting || saving\"\n                    @click=\"deleteEntry\">\n                    {{ deleting ? 'Deleting...' : 'Delete' }}\n                </SecondaryButton>\n                <div class=\"flex space-x-3\">\n                    <SecondaryButton tabindex=\"2\" @click=\"show = false\"> Cancel</SecondaryButton>\n                    <PrimaryButton\n                        tabindex=\"2\"\n                        :class=\"{ 'opacity-25': saving }\"\n                        :disabled=\"saving || deleting\"\n                        @click=\"submit\">\n                        {{ saving ? 'Updating...' : 'Update Time Entry' }}\n                    </PrimaryButton>\n                </div>\n            </div>\n        </template>\n    </DialogModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue';\nimport type {\n    CreateClientBody,\n    CreateProjectBody,\n    CreateTimeEntryBody,\n    Project,\n    Tag,\n    Task,\n    TimeEntry,\n    Client,\n} from '@/packages/api/src';\nimport { getDayJsInstance, getLocalizedDateFromTimestamp } from '@/packages/ui/src/utils/time';\nimport TimeEntryAggregateRow from '@/packages/ui/src/TimeEntry/TimeEntryAggregateRow.vue';\nimport TimeEntryRowHeading from '@/packages/ui/src/TimeEntry/TimeEntryRowHeading.vue';\nimport TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue';\nimport type { TimeEntriesGroupedByType } from '@/types/time-entries';\n\nconst selectedTimeEntries = defineModel<TimeEntry[]>('selected', {\n    default: [],\n});\n\nconst props = defineProps<{\n    timeEntries: TimeEntry[];\n    projects: Project[];\n    tasks: Task[];\n    tags: Tag[];\n    clients: Client[];\n    createTag: (name: string) => Promise<Tag | undefined>;\n    updateTimeEntry: (entry: TimeEntry) => void;\n    updateTimeEntries: (ids: string[], changes: Partial<TimeEntry>) => void;\n    deleteTimeEntries: (entries: TimeEntry[]) => void;\n    createTimeEntry: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => void;\n    createProject: (project: CreateProjectBody) => Promise<Project | undefined>;\n    createClient: (client: CreateClientBody) => Promise<Client | undefined>;\n    currency: string;\n    enableEstimatedTime: boolean;\n    canCreateProject: boolean;\n}>();\n\nconst groupedTimeEntries = computed(() => {\n    const groupedEntriesByDay: Record<string, TimeEntry[]> = {};\n    for (const entry of props.timeEntries) {\n        // skip current time entry\n        if (entry.end === null) {\n            continue;\n        }\n        const oldEntries = groupedEntriesByDay[getLocalizedDateFromTimestamp(entry.start)];\n        groupedEntriesByDay[getLocalizedDateFromTimestamp(entry.start)] = [\n            ...(oldEntries ?? []),\n            entry,\n        ];\n    }\n    const groupedEntriesByDayAndType: Record<string, TimeEntriesGroupedByType[]> = {};\n    for (const dailyEntriesKey in groupedEntriesByDay) {\n        const dailyEntries = groupedEntriesByDay[dailyEntriesKey]!;\n        const newDailyEntries: TimeEntriesGroupedByType[] = [];\n\n        for (const entry of dailyEntries) {\n            // check if same entry already exists\n            const oldEntriesIndex = newDailyEntries.findIndex(\n                (e) =>\n                    e.project_id === entry.project_id &&\n                    e.task_id === entry.task_id &&\n                    e.billable === entry.billable &&\n                    e.description === entry.description\n            );\n            if (oldEntriesIndex !== -1 && newDailyEntries[oldEntriesIndex]) {\n                const existingEntry = newDailyEntries[oldEntriesIndex]!;\n                existingEntry.timeEntries.push(entry);\n\n                // Add up durations for time entries of the same type\n                existingEntry.duration = (existingEntry.duration ?? 0) + (entry?.duration ?? 0);\n\n                // adapt start end times so they show the earliest start and latest end time\n                if (\n                    getDayJsInstance()(entry.start).isBefore(\n                        getDayJsInstance()(existingEntry.start)\n                    )\n                ) {\n                    existingEntry.start = entry.start;\n                }\n                if (getDayJsInstance()(entry.end).isAfter(getDayJsInstance()(existingEntry.end))) {\n                    existingEntry.end = entry.end;\n                }\n            } else {\n                newDailyEntries.push({ ...entry, timeEntries: [entry] });\n            }\n        }\n\n        groupedEntriesByDayAndType[dailyEntriesKey] = newDailyEntries;\n    }\n\n    return groupedEntriesByDayAndType;\n});\n\nfunction startTimeEntryFromExisting(entry: TimeEntry) {\n    props.createTimeEntry({\n        project_id: entry.project_id,\n        task_id: entry.task_id,\n        start: getDayJsInstance().utc().format(),\n        end: null,\n        billable: entry.billable,\n        description: entry.description,\n        tags: [...entry.tags],\n    });\n}\n\nfunction sumDuration(timeEntries: TimeEntry[]) {\n    return timeEntries.reduce((acc, entry) => acc + (entry?.duration ?? 0), 0);\n}\nfunction selectAllTimeEntries(value: TimeEntriesGroupedByType[]) {\n    for (const timeEntry of value) {\n        if ('timeEntries' in timeEntry) {\n            for (const subTimeEntry of timeEntry.timeEntries) {\n                selectedTimeEntries.value.push(subTimeEntry);\n            }\n        } else {\n            selectedTimeEntries.value.push(timeEntry);\n        }\n    }\n}\nfunction unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {\n    selectedTimeEntries.value = selectedTimeEntries.value.filter((timeEntry) => {\n        return !value.find(\n            (filterTimeEntry) =>\n                filterTimeEntry.id === timeEntry.id ||\n                filterTimeEntry.timeEntries?.find(\n                    (subTimeEntry) => subTimeEntry.id === timeEntry.id\n                )\n        );\n    });\n}\n</script>\n\n<template>\n    <div class=\"@container\">\n        <div v-for=\"(value, key) in groupedTimeEntries\" :key=\"key\">\n            <TimeEntryRowHeading\n                :date=\"String(key)\"\n                :duration=\"sumDuration(value)\"\n                :checked=\"\n                    value.every((timeEntry: TimeEntry) => selectedTimeEntries.includes(timeEntry))\n                \"\n                @select-all=\"selectAllTimeEntries(value)\"\n                @unselect-all=\"unselectAllTimeEntries(value)\"></TimeEntryRowHeading>\n            <template v-for=\"entry in value\" :key=\"entry.id\">\n                <TimeEntryAggregateRow\n                    v-if=\"'timeEntries' in entry && entry.timeEntries.length > 1\"\n                    :create-project\n                    :can-create-project\n                    :enable-estimated-time\n                    :selected-time-entries=\"selectedTimeEntries\"\n                    :create-client\n                    :projects=\"projects\"\n                    :tasks=\"tasks\"\n                    :tags=\"tags\"\n                    :clients\n                    :on-start-stop-click=\"startTimeEntryFromExisting\"\n                    :duplicate-time-entry=\"createTimeEntry\"\n                    :update-time-entries\n                    :update-time-entry\n                    :delete-time-entries\n                    :create-tag\n                    :currency=\"currency\"\n                    :time-entry=\"entry\"\n                    @selected=\"\n                        (timeEntries: TimeEntry[]) => {\n                            selectedTimeEntries = [...selectedTimeEntries, ...timeEntries];\n                        }\n                    \"\n                    @unselected=\"\n                        (timeEntriesToUnselect: TimeEntry[]) => {\n                            selectedTimeEntries = selectedTimeEntries.filter(\n                                (item: TimeEntry) =>\n                                    !timeEntriesToUnselect.find(\n                                        (filterEntry: TimeEntry) => filterEntry.id === item.id\n                                    )\n                            );\n                        }\n                    \"></TimeEntryAggregateRow>\n                <TimeEntryRow\n                    v-else\n                    :create-client\n                    :enable-estimated-time\n                    :can-create-project\n                    :create-project\n                    :projects=\"projects\"\n                    :selected=\"\n                        !!selectedTimeEntries.find(\n                            (filterEntry: TimeEntry) => filterEntry.id === entry.id\n                        )\n                    \"\n                    :tasks=\"tasks\"\n                    :tags=\"tags\"\n                    :clients\n                    :create-tag\n                    :update-time-entry\n                    :on-start-stop-click=\"() => startTimeEntryFromExisting(entry)\"\n                    :delete-time-entry=\"() => deleteTimeEntries([entry])\"\n                    :duplicate-time-entry=\"() => createTimeEntry(entry)\"\n                    :currency=\"currency\"\n                    :time-entry=\"entry.timeEntries[0]!\"\n                    @selected=\"selectedTimeEntries.push(entry)\"\n                    @unselected=\"\n                        selectedTimeEntries = selectedTimeEntries.filter(\n                            (item: TimeEntry) => item.id !== entry.id\n                        )\n                    \"></TimeEntryRow>\n            </template>\n        </div>\n    </div>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue",
    "content": "<script setup lang=\"ts\">\nimport MainContainer from '@/packages/ui/src/MainContainer.vue';\nimport { PencilSquareIcon, TrashIcon } from '@heroicons/vue/20/solid';\nimport TimeEntryMassUpdateModal from '@/packages/ui/src/TimeEntry/TimeEntryMassUpdateModal.vue';\nimport type {\n    Client,\n    CreateClientBody,\n    CreateProjectBody,\n    Project,\n    Tag,\n    Task,\n    TimeEntry,\n    UpdateMultipleTimeEntriesChangeset,\n} from '@/packages/api/src';\nimport { ref } from 'vue';\nimport { twMerge } from 'tailwind-merge';\nimport { Checkbox } from '@/packages/ui/src';\nimport { FieldLabel } from '../field';\n\nconst props = defineProps<{\n    selectedTimeEntries: TimeEntry[];\n    deleteSelected: () => void;\n    class?: string;\n    allSelected: boolean;\n    projects: Project[];\n    tasks: Task[];\n    tags: Tag[];\n    clients: Client[];\n    createTag: (name: string) => Promise<Tag | undefined>;\n    createProject: (project: CreateProjectBody) => Promise<Project | undefined>;\n    createClient: (client: CreateClientBody) => Promise<Client | undefined>;\n    updateTimeEntries: (changeset: UpdateMultipleTimeEntriesChangeset) => Promise<void>;\n    currency: string;\n    enableEstimatedTime: boolean;\n    canCreateProject: boolean;\n}>();\n\nconst emit = defineEmits<{\n    submit: [];\n    selectAll: [];\n    unselectAll: [];\n}>();\n\nconst showMassUpdateModal = ref(false);\n</script>\n\n<template>\n    <TimeEntryMassUpdateModal\n        v-model:show=\"showMassUpdateModal\"\n        :projects\n        :tasks\n        :tags\n        :clients\n        :create-tag\n        :create-project\n        :create-client\n        :update-time-entries\n        :enable-estimated-time\n        :can-create-project\n        :currency\n        :time-entries=\"selectedTimeEntries\"\n        @submit=\"emit('submit')\"></TimeEntryMassUpdateModal>\n    <MainContainer\n        :class=\"\n            twMerge(\n                props.class,\n                'text-sm py-1.5 font-medium hidden sm:flex border-b border-border-primary items-center space-x-3'\n            )\n        \">\n        <Checkbox\n            id=\"selectAll\"\n            :checked=\"allSelected && selectedTimeEntries.length > 0\"\n            @update:checked=\"allSelected ? emit('unselectAll') : emit('selectAll')\">\n        </Checkbox>\n        <FieldLabel\n            v-if=\"selectedTimeEntries.length > 0\"\n            for=\"selectAll\"\n            class=\"select-none text-text-secondary\">\n            {{ selectedTimeEntries.length }} selected\n        </FieldLabel>\n        <FieldLabel v-else for=\"selectAll\" class=\"text-text-secondary select-none\"\n            >Select All</FieldLabel\n        >\n        <button\n            v-if=\"selectedTimeEntries.length\"\n            class=\"text-text-tertiary flex space-x-1 items-center hover:text-text-secondary transition focus-visible:ring-2 outline-0 focus-visible:text-text-primary focus-visible:ring-ring rounded h-full px-2\"\n            @click=\"showMassUpdateModal = true\">\n            <PencilSquareIcon class=\"w-4\"></PencilSquareIcon>\n            <span> Edit </span>\n        </button>\n        <button\n            v-if=\"selectedTimeEntries.length\"\n            class=\"text-red-400 h-full px-2 space-x-1 items-center flex hover:text-red-500 transition focus-visible:ring-2 outline-0 focus-visible:text-red-500 focus-visible:ring-ring rounded\"\n            @click=\"deleteSelected\">\n            <TrashIcon class=\"w-3.5\"></TrashIcon>\n            <span> Delete </span>\n        </button>\n    </MainContainer>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/TimeEntry/TimeEntryMassUpdateModal.vue",
    "content": "<script setup lang=\"ts\">\nimport TextInput from '../Input/TextInput.vue';\nimport SecondaryButton from '../Buttons/SecondaryButton.vue';\nimport DialogModal from '@/packages/ui/src/DialogModal.vue';\nimport { computed, nextTick, ref, watch } from 'vue';\nimport PrimaryButton from '../Buttons/PrimaryButton.vue';\nimport TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';\nimport { Field, FieldLabel } from '../field';\nimport {\n    type CreateClientBody,\n    type CreateProjectBody,\n    type Project,\n    type Client,\n    type TimeEntry,\n    type UpdateMultipleTimeEntriesChangeset,\n} from '@/packages/api/src';\nimport { Checkbox } from '@/packages/ui/src';\nimport { TagIcon } from '@heroicons/vue/20/solid';\nimport {\n    Select,\n    SelectContent,\n    SelectItem,\n    SelectTrigger,\n    SelectValue,\n} from '@/Components/ui/select';\nimport { Button } from '@/packages/ui/src/Buttons';\nimport TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';\nimport type { Tag, Task } from '@/packages/api/src';\n\nconst show = defineModel('show', { default: false });\nconst saving = ref(false);\n\nconst props = defineProps<{\n    timeEntries: TimeEntry[];\n    projects: Project[];\n    tasks: Task[];\n    clients: Client[];\n    tags: Tag[];\n    createProject: (project: CreateProjectBody) => Promise<Project | undefined>;\n    createClient: (client: CreateClientBody) => Promise<Client | undefined>;\n    createTag: (name: string) => Promise<Tag | undefined>;\n    updateTimeEntries: (changeset: UpdateMultipleTimeEntriesChangeset) => Promise<void>;\n    currency: string;\n    enableEstimatedTime: boolean;\n    canCreateProject: boolean;\n}>();\n\nconst emit = defineEmits<{\n    submit: [];\n}>();\n\nconst descriptionInput = ref<HTMLInputElement | null>(null);\n\nwatch(show, (value) => {\n    if (value) {\n        nextTick(() => {\n            descriptionInput.value?.focus();\n        });\n    }\n});\n\nconst description = ref<string>('');\nconst taskId = ref<string | null | undefined>(undefined);\nconst projectId = ref<string | null>(null);\nconst billable = ref<boolean | undefined>(undefined);\nconst selectedTags = ref<string[]>([]);\n\nconst timeEntryBillable = computed({\n    get: () => {\n        if (billable.value === undefined) {\n            return 'do-not-update';\n        }\n        return billable.value ? 'billable' : 'non-billable';\n    },\n    set: (value) => {\n        if (value === 'do-not-update') {\n            billable.value = undefined;\n        } else if (value === 'billable') {\n            billable.value = true;\n        } else {\n            billable.value = false;\n        }\n    },\n});\n\nasync function submit() {\n    saving.value = true;\n    const timeEntryUpdatesBody = {} as UpdateMultipleTimeEntriesChangeset;\n    if (description.value && description.value !== '') {\n        timeEntryUpdatesBody.description = description.value;\n    }\n    if (projectId.value !== null) {\n        if (projectId.value === '') {\n            // \"No Project\" is selected\n            timeEntryUpdatesBody.project_id = null;\n        } else {\n            timeEntryUpdatesBody.project_id = projectId.value;\n        }\n        timeEntryUpdatesBody.task_id = null;\n        if (taskId.value !== undefined) {\n            timeEntryUpdatesBody.task_id = taskId.value;\n        }\n    }\n\n    if (billable.value !== undefined) {\n        timeEntryUpdatesBody.billable = billable.value;\n    }\n    if (selectedTags.value.length > 0) {\n        timeEntryUpdatesBody.tags = selectedTags.value;\n    }\n    if (removeAllTags.value) {\n        timeEntryUpdatesBody.tags = [];\n    }\n\n    try {\n        await props.updateTimeEntries({ ...timeEntryUpdatesBody });\n\n        show.value = false;\n        emit('submit');\n        description.value = '';\n        projectId.value = null;\n        taskId.value = undefined;\n        selectedTags.value = [];\n        billable.value = undefined;\n        saving.value = false;\n        removeAllTags.value = false;\n    } catch {\n        saving.value = false;\n    }\n}\nconst removeAllTags = ref(false);\nwatch(removeAllTags, () => {\n    if (removeAllTags.value) {\n        selectedTags.value = [];\n    }\n});\n</script>\n\n<template>\n    <DialogModal closeable :show=\"show\" @close=\"show = false\">\n        <template #title>\n            <div class=\"flex space-x-2\">\n                <span> Update {{ timeEntries.length }} time entries </span>\n            </div>\n        </template>\n\n        <template #content>\n            <div class=\"space-y-4\">\n                <Field>\n                    <FieldLabel for=\"description\">Description</FieldLabel>\n                    <TextInput\n                        id=\"description\"\n                        ref=\"descriptionInput\"\n                        v-model=\"description\"\n                        type=\"text\"\n                        class=\"block w-full\"\n                        @keydown.enter=\"submit\" />\n                </Field>\n                <Field>\n                    <FieldLabel for=\"project\">Project</FieldLabel>\n                    <TimeTrackerProjectTaskDropdown\n                        v-model:project=\"projectId\"\n                        v-model:task=\"taskId\"\n                        variant=\"input\"\n                        align=\"start\"\n                        size=\"default\"\n                        :clients\n                        :create-project\n                        :create-client\n                        :currency=\"currency\"\n                        :can-create-project\n                        empty-placeholder=\"Select project...\"\n                        allow-reset\n                        :enable-estimated-time\n                        :projects=\"projects\"\n                        :tasks=\"tasks\"></TimeTrackerProjectTaskDropdown>\n                </Field>\n                <Field>\n                    <FieldLabel>Tag</FieldLabel>\n                    <div class=\"flex space-x-5\">\n                        <TagDropdown\n                            v-model=\"selectedTags\"\n                            :create-tag\n                            :tags=\"tags\"\n                            :show-no-tag-option=\"false\">\n                            <template #trigger>\n                                <Button variant=\"input\" :disabled=\"removeAllTags\">\n                                    <TagIcon class=\"h-4 text-icon-default\" />\n                                    <span v-if=\"selectedTags.length > 0\">\n                                        Set {{ selectedTags.length }} tags\n                                    </span>\n                                    <span v-else>Select Tags...</span>\n                                </Button>\n                            </template>\n                        </TagDropdown>\n                        <Field orientation=\"horizontal\">\n                            <Checkbox id=\"no_tags\" v-model:checked=\"removeAllTags\"></Checkbox>\n                            <FieldLabel for=\"no_tags\">Remove all tags</FieldLabel>\n                        </Field>\n                    </div>\n                </Field>\n                <Field>\n                    <FieldLabel>Billable</FieldLabel>\n                    <Select v-model=\"timeEntryBillable\">\n                        <SelectTrigger>\n                            <SelectValue>\n                                <span v-if=\"billable === undefined\">Set billable status</span>\n                                <span v-else-if=\"billable === true\">Billable</span>\n                                <span v-else>Non Billable</span>\n                            </SelectValue>\n                        </SelectTrigger>\n                        <SelectContent>\n                            <SelectItem value=\"do-not-update\">\n                                Keep current billable status\n                            </SelectItem>\n                            <SelectItem value=\"billable\">Billable</SelectItem>\n                            <SelectItem value=\"non-billable\">Non Billable</SelectItem>\n                        </SelectContent>\n                    </Select>\n                </Field>\n            </div>\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"show = false\"> Cancel</SecondaryButton>\n            <PrimaryButton\n                class=\"ms-3\"\n                :class=\"{ 'opacity-25': saving }\"\n                :disabled=\"saving\"\n                @click=\"submit\">\n                Update Time Entries\n            </PrimaryButton>\n        </template>\n    </DialogModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/TimeEntry/TimeEntryMoreOptionsDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport { TrashIcon, PencilIcon, DocumentDuplicateIcon } from '@heroicons/vue/20/solid';\nimport {\n    DropdownMenu,\n    DropdownMenuContent,\n    DropdownMenuItem,\n    DropdownMenuTrigger,\n} from '@/Components/ui/dropdown-menu';\n\nconst props = withDefaults(\n    defineProps<{\n        showEdit?: boolean;\n        showDuplicate?: boolean;\n    }>(),\n    {\n        showDuplicate: true,\n        showEdit: true,\n    }\n);\n\nconst emit = defineEmits<{\n    edit: [];\n    delete: [];\n    duplicate: [];\n}>();\n</script>\n\n<template>\n    <DropdownMenu>\n        <DropdownMenuTrigger as-child>\n            <button\n                class=\"focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-2 focus-visible:ring-ring focus-visible:opacity-100 hover:bg-card-background group-hover:opacity-100 opacity-20 transition-opacity text-text-secondary\"\n                aria-label=\"Actions for the time entry\">\n                <svg\n                    class=\"h-8 w-8 p-1 rounded-full\"\n                    viewBox=\"0 0 24 24\"\n                    xmlns=\"http://www.w3.org/2000/svg\">\n                    <path\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        stroke-linecap=\"round\"\n                        stroke-linejoin=\"round\"\n                        stroke-width=\"1.5\"\n                        d=\"M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92\" />\n                </svg>\n            </button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent class=\"min-w-[150px]\" align=\"end\">\n            <DropdownMenuItem\n                v-if=\"props.showEdit\"\n                data-testid=\"time_entry_edit\"\n                class=\"flex items-center space-x-3 cursor-pointer\"\n                @click=\"emit('edit')\">\n                <PencilIcon class=\"w-5\" />\n                <span>Edit</span>\n            </DropdownMenuItem>\n            <DropdownMenuItem\n                v-if=\"props.showDuplicate\"\n                data-testid=\"time_entry_duplicate\"\n                class=\"flex items-center space-x-3 cursor-pointer\"\n                @click=\"emit('duplicate')\">\n                <DocumentDuplicateIcon class=\"w-5\" />\n                <span>Duplicate</span>\n            </DropdownMenuItem>\n            <DropdownMenuItem\n                data-testid=\"time_entry_delete\"\n                class=\"flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive\"\n                @click=\"emit('delete')\">\n                <TrashIcon class=\"w-5\" />\n                <span>Delete</span>\n            </DropdownMenuItem>\n        </DropdownMenuContent>\n    </DropdownMenu>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/TimeEntry/TimeEntryRangeSelector.vue",
    "content": "<script setup lang=\"ts\">\nimport Dropdown from '@/packages/ui/src/Input/Dropdown.vue';\nimport { ref, inject, type ComputedRef } from 'vue';\nimport { formatDateLocalized, formatStartEnd } from '@/packages/ui/src/utils/time';\nimport TimeRangeSelector from '@/packages/ui/src/Input/TimeRangeSelector.vue';\nimport { twMerge } from 'tailwind-merge';\nimport type { Organization } from '@/packages/api/src';\n\ndefineProps<{\n    start: string;\n    end: string | null;\n    showDate?: boolean;\n}>();\n\nconst emit = defineEmits<{\n    changed: [start: string, end: string | null];\n}>();\n\nconst open = ref(false);\nconst triggerElement = ref<HTMLButtonElement | null>(null);\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n</script>\n\n<template>\n    <div class=\"relative\">\n        <Dropdown\n            v-model=\"open\"\n            align=\"center\"\n            :close-on-content-click=\"false\"\n            @submit=\"open = false\">\n            <template #trigger>\n                <button\n                    ref=\"triggerElement\"\n                    data-testid=\"time_entry_range_selector\"\n                    :class=\"\n                        twMerge(\n                            'text-text-secondary px-1 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:text-text-primary focus-visible:ring-ring focus-visible:bg-tertiary',\n                            showDate\n                                ? 'text-xs py-1.5 font-semibold'\n                                : 'text-sm py-1.5 font-medium',\n                            organization?.time_format === '12-hours' ? 'w-[160px]' : 'w-[100px]',\n                            open && 'border-card-border bg-card-background'\n                        )\n                    \">\n                    {{ formatStartEnd(start, end, organization?.time_format) }}\n                    <span v-if=\"showDate\" class=\"text-text-tertiary font-medium\"\n                        >{{ formatDateLocalized(start, organization?.date_format) }}\n                    </span>\n                </button>\n            </template>\n            <template #content>\n                <TimeRangeSelector\n                    focus\n                    :start=\"start\"\n                    :end=\"end\"\n                    @changed=\"\n                        (newStart: string, newEnd: string) => emit('changed', newStart, newEnd)\n                    \"\n                    @close=\"open = false\">\n                </TimeRangeSelector>\n            </template>\n        </Dropdown>\n    </div>\n</template>\n\n<style></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/TimeEntry/TimeEntryRow.vue",
    "content": "<script setup lang=\"ts\">\nimport MainContainer from '@/packages/ui/src/MainContainer.vue';\nimport TimeTrackerStartStop from '@/packages/ui/src/TimeTrackerStartStop.vue';\nimport TimeEntryRangeSelector from '@/packages/ui/src/TimeEntry/TimeEntryRangeSelector.vue';\nimport type {\n    Client,\n    CreateClientBody,\n    CreateProjectBody,\n    Member,\n    Project,\n    Tag,\n    Task,\n    TimeEntry,\n} from '@/packages/api/src';\nimport TimeEntryDescriptionInput from '@/packages/ui/src/TimeEntry/TimeEntryDescriptionInput.vue';\nimport TimeEntryRowTagDropdown from '@/packages/ui/src/TimeEntry/TimeEntryRowTagDropdown.vue';\nimport TimeEntryRowDurationInput from '@/packages/ui/src/TimeEntry/TimeEntryRowDurationInput.vue';\nimport TimeEntryMoreOptionsDropdown from '@/packages/ui/src/TimeEntry/TimeEntryMoreOptionsDropdown.vue';\nimport { TimeEntryEditModal } from '@/packages/ui/src';\nimport BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';\nimport { computed, ref } from 'vue';\nimport TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';\nimport { Checkbox } from '@/packages/ui/src';\n\nconst props = defineProps<{\n    timeEntry: TimeEntry;\n    indent?: boolean;\n    projects: Project[];\n    tasks: Task[];\n    tags: Tag[];\n    clients: Client[];\n    members?: Member[];\n    createTag: (name: string) => Promise<Tag | undefined>;\n    createProject: (project: CreateProjectBody) => Promise<Project | undefined>;\n    createClient: (client: CreateClientBody) => Promise<Client | undefined>;\n    onStartStopClick: () => void;\n    deleteTimeEntry: () => void;\n    duplicateTimeEntry?: () => void;\n    updateTimeEntry: (timeEntry: TimeEntry) => void;\n    currency: string;\n    showMember?: boolean;\n    showDate?: boolean;\n    selected?: boolean;\n    canCreateProject: boolean;\n    enableEstimatedTime: boolean;\n}>();\n\nconst emit = defineEmits<{ selected: []; unselected: [] }>();\n\nconst showEditModal = ref(false);\n\nfunction updateTimeEntryDescription(description: string) {\n    props.updateTimeEntry({ ...props.timeEntry, description });\n}\n\nfunction updateTimeEntryTags(tags: string[]) {\n    props.updateTimeEntry({ ...props.timeEntry, tags });\n}\n\nfunction updateTimeEntryBillable(billable: boolean) {\n    props.updateTimeEntry({ ...props.timeEntry, billable });\n}\n\nfunction updateStartEndTime(start: string, end: string | null) {\n    props.updateTimeEntry({ ...props.timeEntry, start, end });\n}\n\nfunction updateProjectAndTask(projectId: string, taskId: string) {\n    const project = props.projects.find((p) => p.id === projectId);\n    props.updateTimeEntry({\n        ...props.timeEntry,\n        project_id: projectId,\n        task_id: taskId,\n        billable: project ? project.is_billable : props.timeEntry.billable,\n    });\n}\n\nconst memberName = computed(() => {\n    if (props.members) {\n        const member = props.members.find((member) => member.user_id === props.timeEntry.user_id);\n        if (member) {\n            return member.name;\n        }\n    }\n    return '';\n});\n\nfunction onSelectChange(checked: boolean) {\n    if (checked) {\n        emit('selected');\n    } else {\n        emit('unselected');\n    }\n}\n\nfunction handleEdit() {\n    showEditModal.value = true;\n}\n\nasync function handleUpdateTimeEntry(updatedEntry: TimeEntry) {\n    props.updateTimeEntry(updatedEntry);\n    showEditModal.value = false;\n}\n\nasync function handleDeleteTimeEntry() {\n    props.deleteTimeEntry();\n    showEditModal.value = false;\n}\n</script>\n\n<template>\n    <div\n        class=\"border-b border-default-background-separator transition min-w-0 bg-row-background\"\n        data-testid=\"time_entry_row\">\n        <MainContainer class=\"min-w-0\">\n            <div class=\"@xl:flex py-2 min-w-0 items-center justify-between group\">\n                <!-- Desktop layout -->\n                <div class=\"hidden @lg:flex items-center min-w-0\">\n                    <Checkbox :checked=\"selected\" @update:checked=\"onSelectChange\" />\n                    <div v-if=\"indent === true\" class=\"w-10 h-7\"></div>\n                    <TimeEntryDescriptionInput\n                        class=\"min-w-0 mr-4 shrink\"\n                        :model-value=\"timeEntry.description\"\n                        @changed=\"updateTimeEntryDescription\"></TimeEntryDescriptionInput>\n                    <TimeTrackerProjectTaskDropdown\n                        class=\"min-w-0 shrink\"\n                        :create-project\n                        :create-client\n                        :can-create-project\n                        :clients\n                        :projects=\"projects\"\n                        :tasks=\"tasks\"\n                        :project=\"timeEntry.project_id\"\n                        :currency=\"currency\"\n                        :enable-estimated-time\n                        :task=\"timeEntry.task_id\"\n                        @changed=\"updateProjectAndTask\"></TimeTrackerProjectTaskDropdown>\n                </div>\n                <div\n                    class=\"hidden @lg:flex items-center font-medium space-x-1 @lg:space-x-2 shrink-0\">\n                    <div v-if=\"showMember && members\" class=\"text-sm px-2\">\n                        {{ memberName }}\n                    </div>\n                    <TimeEntryRowTagDropdown\n                        :create-tag\n                        :tags=\"tags\"\n                        :model-value=\"timeEntry.tags\"\n                        @changed=\"updateTimeEntryTags\"></TimeEntryRowTagDropdown>\n                    <BillableToggleButton\n                        :model-value=\"timeEntry.billable\"\n                        size=\"small\"\n                        faded\n                        @changed=\"updateTimeEntryBillable\"></BillableToggleButton>\n                    <div class=\"flex-1\">\n                        <TimeEntryRangeSelector\n                            :start=\"timeEntry.start\"\n                            :end=\"timeEntry.end\"\n                            :show-date\n                            @changed=\"updateStartEndTime\"></TimeEntryRangeSelector>\n                    </div>\n                    <TimeEntryRowDurationInput\n                        :start=\"timeEntry.start\"\n                        :end=\"timeEntry.end\"\n                        @changed=\"updateStartEndTime\"></TimeEntryRowDurationInput>\n                    <TimeTrackerStartStop\n                        :active=\"!!(timeEntry.start && !timeEntry.end)\"\n                        variant=\"secondary\"\n                        class=\"opacity-60 flex focus-visible:opacity-100 group-hover:opacity-100\"\n                        @changed=\"onStartStopClick\"></TimeTrackerStartStop>\n                    <TimeEntryMoreOptionsDropdown\n                        @edit=\"handleEdit\"\n                        @duplicate=\"duplicateTimeEntry\"\n                        @delete=\"deleteTimeEntry\"></TimeEntryMoreOptionsDropdown>\n                </div>\n                <!-- Mobile layout -->\n                <div class=\"@lg:hidden\">\n                    <!-- First row: description + duration -->\n                    <div class=\"flex items-center justify-between min-w-0\">\n                        <TimeEntryDescriptionInput\n                            class=\"min-w-0 flex-1\"\n                            :model-value=\"timeEntry.description\"\n                            @changed=\"updateTimeEntryDescription\"></TimeEntryDescriptionInput>\n                        <TimeEntryRowDurationInput\n                            :start=\"timeEntry.start\"\n                            :end=\"timeEntry.end\"\n                            @changed=\"updateStartEndTime\"></TimeEntryRowDurationInput>\n                    </div>\n                    <!-- Second row: project/task - tags - billable - start - more -->\n                    <div class=\"flex items-center justify-between mt-1\">\n                        <TimeTrackerProjectTaskDropdown\n                            class=\"min-w-0\"\n                            :create-project\n                            :create-client\n                            :can-create-project\n                            :clients\n                            :projects=\"projects\"\n                            :tasks=\"tasks\"\n                            :project=\"timeEntry.project_id\"\n                            :currency=\"currency\"\n                            :enable-estimated-time\n                            :task=\"timeEntry.task_id\"\n                            @changed=\"updateProjectAndTask\"></TimeTrackerProjectTaskDropdown>\n                        <div class=\"flex items-center shrink-0\">\n                            <TimeEntryRowTagDropdown\n                                :create-tag\n                                :tags=\"tags\"\n                                :model-value=\"timeEntry.tags\"\n                                compact\n                                @changed=\"updateTimeEntryTags\"></TimeEntryRowTagDropdown>\n                            <BillableToggleButton\n                                :model-value=\"timeEntry.billable\"\n                                size=\"small\"\n                                @changed=\"updateTimeEntryBillable\"></BillableToggleButton>\n                            <TimeTrackerStartStop\n                                :active=\"!!(timeEntry.start && !timeEntry.end)\"\n                                variant=\"secondary\"\n                                class=\"ml-2\"\n                                @changed=\"onStartStopClick\"></TimeTrackerStartStop>\n                            <TimeEntryMoreOptionsDropdown\n                                @edit=\"handleEdit\"\n                                @duplicate=\"duplicateTimeEntry\"\n                                @delete=\"deleteTimeEntry\"></TimeEntryMoreOptionsDropdown>\n                        </div>\n                    </div>\n                </div>\n            </div>\n        </MainContainer>\n    </div>\n\n    <TimeEntryEditModal\n        v-if=\"showEditModal\"\n        v-model:show=\"showEditModal\"\n        :time-entry=\"timeEntry\"\n        :enable-estimated-time=\"enableEstimatedTime\"\n        :update-time-entry=\"handleUpdateTimeEntry\"\n        :delete-time-entry=\"handleDeleteTimeEntry\"\n        :create-client=\"createClient\"\n        :create-project=\"createProject\"\n        :create-tag=\"createTag\"\n        :tags=\"tags\"\n        :projects=\"projects\"\n        :tasks=\"tasks\"\n        :clients=\"clients\"\n        :currency=\"currency\"\n        :can-create-project=\"canCreateProject\" />\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/TimeEntry/TimeEntryRowDurationInput.vue",
    "content": "<script setup lang=\"ts\">\nimport {\n    calculateDifference,\n    formatHumanReadableDuration,\n    parseTimeInput,\n} from '@/packages/ui/src/utils/time';\nimport { computed, ref, inject, type ComputedRef } from 'vue';\nimport dayjs from 'dayjs';\nimport type { Organization } from '@/packages/api/src';\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n\nconst organizationSettings = computed(() => ({\n    intervalFormat: organization?.value?.interval_format ?? 'hours-minutes',\n    numberFormat: organization?.value?.number_format ?? 'point',\n}));\n\nconst props = defineProps<{\n    start: string;\n    end: string | null;\n}>();\nconst emit = defineEmits<{\n    changed: [start: string, end: string | null];\n}>();\n\nconst temporaryCustomTimerEntry = ref<string>('');\nconst open = ref(false);\n\nfunction updateTimerAndStartLiveTimerUpdate() {\n    const defaultUnit =\n        organizationSettings?.value?.intervalFormat === 'decimal' ? 'hours' : 'minutes';\n    const seconds = parseTimeInput(temporaryCustomTimerEntry.value, defaultUnit);\n    if (seconds && seconds > 0) {\n        let newEndDate = props.end;\n        let newStartDate = props.start;\n        if (props.end) {\n            // only update end for time entries that are already finished\n            newEndDate = dayjs(props.start).utc().add(seconds, 's').format();\n        } else {\n            newStartDate = dayjs().utc().subtract(seconds, 's').format();\n        }\n        emit('changed', newStartDate, newEndDate);\n    }\n    temporaryCustomTimerEntry.value = '';\n}\n\nconst currentTime = computed({\n    get() {\n        if (temporaryCustomTimerEntry.value !== '') {\n            return temporaryCustomTimerEntry.value;\n        }\n        return formatHumanReadableDuration(\n            calculateDifference(props.start, props.end),\n            organizationSettings.value.intervalFormat,\n            organizationSettings.value.numberFormat\n        );\n    },\n    // setter\n    set(newValue) {\n        if (newValue) {\n            temporaryCustomTimerEntry.value = newValue;\n        } else {\n            temporaryCustomTimerEntry.value = '';\n        }\n    },\n});\n\nfunction selectInput(event: Event) {\n    open.value = true;\n    const target = event.target as HTMLInputElement;\n    target.select();\n}\n</script>\n\n<template>\n    <input\n        v-model=\"currentTime\"\n        data-testid=\"time_entry_duration_input\"\n        name=\"Duration\"\n        class=\"text-text-primary w-[80px] !mr-2 px-1.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:bg-tertiary focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring\"\n        @focus=\"selectInput\"\n        @keydown.tab=\"open = false\"\n        @blur=\"updateTimerAndStartLiveTimerUpdate\"\n        @keydown.enter=\"updateTimerAndStartLiveTimerUpdate\" />\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/TimeEntry/TimeEntryRowHeading.vue",
    "content": "<script setup lang=\"ts\">\nimport MainContainer from '@/packages/ui/src/MainContainer.vue';\nimport {\n    formatDate,\n    formatHumanReadableDuration,\n    formatWeekday,\n} from '@/packages/ui/src/utils/time';\nimport Checkbox from '../Input/Checkbox.vue';\nimport { inject, type ComputedRef } from 'vue';\nimport type { Organization } from '@/packages/api/src';\nimport { CalendarIcon } from '@heroicons/vue/20/solid';\n\nconst organization = inject<ComputedRef<Organization>>('organization');\n\ndefineProps<{\n    date: string;\n    duration: number;\n    checked: boolean;\n}>();\nconst emit = defineEmits<{\n    selectAll: [];\n    unselectAll: [];\n}>();\n\nfunction selectUnselectAll(value: boolean) {\n    if (value) {\n        emit('selectAll');\n    } else {\n        emit('unselectAll');\n    }\n}\n</script>\n\n<template>\n    <div\n        class=\"bg-background dark:bg-secondary border-b border-border-primary py-1 text-xs @sm:text-sm\">\n        <MainContainer>\n            <div class=\"flex group justify-between items-center\">\n                <div class=\"flex items-center @lg:space-x-2 pl-1.5 @lg:pl-0\">\n                    <div class=\"w-5 hidden @lg:block\">\n                        <CalendarIcon\n                            class=\"w-3 @sm:w-4 text-icon-default group-hover:hidden block\">\n                        </CalendarIcon>\n\n                        <Checkbox\n                            :checked=\"checked\"\n                            class=\"group-hover:block hidden\"\n                            @update:checked=\"selectUnselectAll\"></Checkbox>\n                    </div>\n                    <span class=\"font-medium text-text-secondary\">\n                        {{ formatWeekday(date) }}\n                    </span>\n                    <span class=\"text-text-tertiary ml-2\">\n                        {{ formatDate(date, organization?.date_format) }}\n                    </span>\n                </div>\n                <div class=\"text-text-secondary pr-2 @lg:pr-[92px]\">\n                    <span class=\"font-medium\">\n                        {{\n                            formatHumanReadableDuration(\n                                duration,\n                                organization?.interval_format,\n                                organization?.number_format\n                            )\n                        }}\n                    </span>\n                </div>\n            </div>\n        </MainContainer>\n    </div>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/TimeEntry/TimeEntryRowTagDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';\nimport { computed } from 'vue';\nimport TagBadge from '@/packages/ui/src/Tag/TagBadge.vue';\nimport type { Tag } from '@/packages/api/src';\n\nconst props = withDefaults(\n    defineProps<{\n        tags: Tag[];\n        createTag: (name: string) => Promise<Tag | undefined>;\n        compact?: boolean;\n    }>(),\n    {\n        compact: false,\n    }\n);\n\nconst emit = defineEmits<{\n    changed: [model: string[]];\n}>();\n\nconst model = defineModel<string[]>({\n    default: [],\n});\n\nconst timeEntryTags = computed<Tag[]>(() => {\n    return props.tags.filter((tag) => model.value.includes(tag.id));\n});\n\nconst displayName = computed(() => {\n    if (props.compact && timeEntryTags.value.length > 0) {\n        const count = timeEntryTags.value.length;\n        return count === 1 ? '1 tag' : `${count} tags`;\n    }\n    if (timeEntryTags.value.length >= 3) {\n        const firstTag = timeEntryTags.value[0]?.name || '';\n        const remaining = timeEntryTags.value.length - 1;\n        return `${firstTag} + ${remaining} more`;\n    }\n    return timeEntryTags.value.map((tag: Tag) => tag.name).join(', ');\n});\n</script>\n<template>\n    <TagDropdown\n        v-model=\"model\"\n        :tags=\"tags\"\n        align=\"end\"\n        :show-no-tag-option=\"false\"\n        :create-tag\n        @changed=\"emit('changed', model)\">\n        <template #trigger>\n            <button\n                data-testid=\"time_entry_tag_dropdown\"\n                :class=\"[\n                    'group/dropdown focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:opacity-100 transition focus:bg-card-background-separator hover:bg-card-background-separator rounded-full flex items-center justify-center',\n                    compact ? '' : 'opacity-50 group-hover:opacity-100',\n                ]\">\n                <TagBadge\n                    :border=\"false\"\n                    size=\"large\"\n                    :show-icon=\"!(compact && timeEntryTags.length > 0)\"\n                    class=\"border-0 sm:px-1.5 text-icon-default group-focus-within/dropdown:text-text-primary whitespace-nowrap\"\n                    :name=\"displayName\"></TagBadge>\n            </button>\n        </template>\n    </TagDropdown>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/TimeTracker/TimeTrackerControls.vue",
    "content": "<script setup lang=\"ts\">\nimport TimeTrackerTagDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerTagDropdown.vue';\nimport TimeTrackerStartStop from '@/packages/ui/src/TimeTrackerStartStop.vue';\nimport TimeTrackerRangeSelector from '@/packages/ui/src/TimeTracker/TimeTrackerRangeSelector.vue';\nimport BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';\nimport TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';\nimport type {\n    CreateClientBody,\n    CreateProjectBody,\n    Project,\n    Tag,\n    Task,\n    TimeEntry,\n    Client,\n} from '@/packages/api/src';\nimport { computed, nextTick, ref, watch } from 'vue';\nimport type { Dayjs } from 'dayjs';\nimport { useFocus } from '@vueuse/core';\nimport { autoUpdate, flip, limitShift, offset, shift, useFloating } from '@floating-ui/vue';\nimport TimeTrackerRecentlyTrackedEntry from '@/packages/ui/src/TimeTracker/TimeTrackerRecentlyTrackedEntry.vue';\nimport { useSelectEvents } from '@/packages/ui/src/utils/select';\n\nconst currentTimeEntry = defineModel<TimeEntry>('currentTimeEntry', {\n    required: true,\n});\nconst liveTimer = defineModel<Dayjs | null>('liveTimer', { required: true });\n\nconst currentTimeEntryDescriptionInput = ref<HTMLInputElement | null>(null);\n\nconst props = defineProps<{\n    projects: Project[];\n    tasks: Task[];\n    tags: Tag[];\n    clients: Client[];\n    timeEntries: TimeEntry[];\n    createTag: (name: string) => Promise<Tag | undefined>;\n    createProject: (project: CreateProjectBody) => Promise<Project | undefined>;\n    createClient: (client: CreateClientBody) => Promise<Client | undefined>;\n    isActive: boolean;\n    currency: string;\n    enableEstimatedTime: boolean;\n    canCreateProject: boolean;\n}>();\n\nconst emit = defineEmits<{\n    startTimer: [];\n    stopTimer: [];\n    updateTimeEntry: [];\n    startLiveTimer: [];\n    stopLiveTimer: [];\n    createTimeEntry: [];\n}>();\n\nfunction updateProject() {\n    setBillableDefaultForProject();\n    emit('updateTimeEntry');\n}\n\nfunction setAndStartTimer(timeEntry: TimeEntry) {\n    setCurrentTimeEntry(timeEntry);\n    if (!props.isActive) {\n        emit('startTimer');\n    } else {\n        emit('updateTimeEntry');\n    }\n}\n\nfunction setCurrentTimeEntry(timeEntry: TimeEntry) {\n    currentTimeEntry.value.description = timeEntry.description;\n    currentTimeEntry.value.project_id = timeEntry.project_id;\n    currentTimeEntry.value.task_id = timeEntry.task_id;\n    currentTimeEntry.value.tags = timeEntry.tags;\n    currentTimeEntry.value.billable = timeEntry.billable;\n}\n\nfunction startTimerIfNotActive() {\n    if (highlightedDropdownEntryId.value) {\n        const timeEntry = filteredRecentlyTrackedTimeEntries.value.find(\n            (item) => item.id === highlightedDropdownEntryId.value\n        );\n        if (timeEntry) {\n            setCurrentTimeEntry(timeEntry);\n            showDropdown.value = false;\n        }\n    } else {\n        currentTimeEntry.value.description = tempDescription.value;\n    }\n\n    if (!props.isActive) {\n        emit('startTimer');\n    } else {\n        emit('updateTimeEntry');\n    }\n}\n\nfunction setBillableDefaultForProject() {\n    const project = props.projects.find(\n        (project) => project.id === currentTimeEntry.value.project_id\n    );\n    if (project) {\n        currentTimeEntry.value.billable = project.is_billable;\n    }\n}\n\nconst blockRefocus = ref(false);\n\nfunction onToggleButtonPress(newState: boolean) {\n    if (newState) {\n        emit('startTimer');\n        if (!blockRefocus.value) {\n            currentTimeEntryDescriptionInput.value?.focus();\n        }\n    } else {\n        emit('stopTimer');\n    }\n}\n\nconst tempDescription = ref(currentTimeEntry.value.description);\nwatch(\n    () => currentTimeEntry.value.description,\n    () => {\n        tempDescription.value = currentTimeEntry.value.description;\n    }\n);\n\nfunction updateTimeEntryDescription() {\n    if (currentTimeEntry.value.description !== tempDescription.value) {\n        currentTimeEntry.value.description = tempDescription.value;\n        emit('updateTimeEntry');\n    }\n}\n\nconst filteredRecentlyTrackedTimeEntries = computed(() => {\n    // do not include running time entries\n    const finishedTimeEntries = props.timeEntries.filter((item) => item.end !== null);\n\n    // filter out duplicates based on description, task, project, tags and billable\n    const nonDuplicateTimeEntries = finishedTimeEntries.filter((item, index, self) => {\n        return (\n            index ===\n            self.findIndex(\n                (t) =>\n                    t.description === item.description &&\n                    t.task_id === item.task_id &&\n                    t.project_id === item.project_id &&\n                    t.tags.length === item.tags.length &&\n                    t.tags.every((tag) => item.tags.includes(tag)) &&\n                    t.billable === item.billable\n            )\n        );\n    });\n\n    // filter time entries based on current description\n    return nonDuplicateTimeEntries\n        .filter((item) => {\n            return item.description\n                ?.toLowerCase()\n                ?.includes(tempDescription.value?.toLowerCase()?.trim() || '');\n        })\n        .slice(0, 5);\n});\n\nconst showDropdown = ref(false);\nconst { focused } = useFocus(currentTimeEntryDescriptionInput);\n\nwatch(focused, (focused) => {\n    nextTick(() => {\n        // make sure the click event on the dropdown does not get interrupted\n        showDropdown.value = focused;\n\n        // make sure that the input does not get refocused after the dropdown is closed\n        if (!focused) {\n            blockRefocus.value = true;\n            setTimeout(() => {\n                blockRefocus.value = false;\n            }, 100);\n        }\n    });\n});\n\nconst floating = ref(null);\nconst { floatingStyles } = useFloating(currentTimeEntryDescriptionInput, floating, {\n    placement: 'bottom-start',\n    whileElementsMounted: autoUpdate,\n    middleware: [\n        offset(10),\n        shift({\n            limiter: limitShift({\n                offset: 5,\n            }),\n        }),\n        flip({\n            fallbackAxisSideDirection: 'start',\n        }),\n    ],\n});\nconst highlightedDropdownEntryId = ref<string | null>(null);\n\nuseSelectEvents(\n    filteredRecentlyTrackedTimeEntries,\n    highlightedDropdownEntryId,\n    (item) => item.id,\n    showDropdown\n);\n</script>\n\n<template>\n    <div class=\"flex items-center relative @container\" data-testid=\"dashboard_timer\">\n        <div\n            class=\"flex flex-col @2xl:flex-row w-full justify-between rounded-lg bg-card-background border-card-border border transition shadow-card\">\n            <div class=\"flex flex-1 items-center relative\">\n                <input\n                    ref=\"currentTimeEntryDescriptionInput\"\n                    v-model=\"tempDescription\"\n                    placeholder=\"What are you working on?\"\n                    data-testid=\"time_entry_description\"\n                    class=\"w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-lg text-text-primary bg-transparent border-none placeholder-text-secondary font-medium focus:ring-0 transition\"\n                    type=\"text\"\n                    @keydown.enter=\"startTimerIfNotActive\"\n                    @keydown.esc=\"showDropdown = false\"\n                    @blur=\"updateTimeEntryDescription\" />\n                <div class=\"@2xl:hidden pr-3 shrink-0\">\n                    <TimeTrackerStartStop\n                        :active=\"isActive\"\n                        @changed=\"onToggleButtonPress\"></TimeTrackerStartStop>\n                </div>\n                <div\n                    v-if=\"showDropdown && filteredRecentlyTrackedTimeEntries.length > 0\"\n                    ref=\"floating\"\n                    class=\"z-50 w-[min(640px,100vw-2rem)]\"\n                    :style=\"floatingStyles\">\n                    <div\n                        class=\"rounded-lg w-full border border-card-border overflow-hidden shadow-dropdown bg-card-background\">\n                        <div\n                            class=\"text-text-tertiary text-xs font-semibold border-b border-border-tertiary px-2 py-1.5\">\n                            Recently Tracked Time Entries\n                        </div>\n                        <div class=\"text-text-secondary py-1 px-1.5\">\n                            <TimeTrackerRecentlyTrackedEntry\n                                v-for=\"timeEntry in filteredRecentlyTrackedTimeEntries\"\n                                :key=\"timeEntry.id\"\n                                :time-entry=\"timeEntry\"\n                                :highlighted=\"highlightedDropdownEntryId === timeEntry.id\"\n                                :projects=\"projects\"\n                                :tasks=\"tasks\"\n                                @mousedown=\"setAndStartTimer(timeEntry)\"\n                                @mouseenter=\"\n                                    highlightedDropdownEntryId = timeEntry.id\n                                \"></TimeTrackerRecentlyTrackedEntry>\n                        </div>\n                    </div>\n                </div>\n            </div>\n            <div class=\"flex items-center justify-between pl-2 shrink min-w-0\">\n                <div class=\"flex items-center w-[130px] @2xl:w-auto shrink min-w-0\">\n                    <TimeTrackerProjectTaskDropdown\n                        v-model:project=\"currentTimeEntry.project_id\"\n                        v-model:task=\"currentTimeEntry.task_id\"\n                        variant=\"outline\"\n                        :create-client\n                        :can-create-project\n                        :clients\n                        :create-project\n                        :currency=\"currency\"\n                        :projects=\"projects\"\n                        :tasks=\"tasks\"\n                        :enable-estimated-time=\"enableEstimatedTime\"\n                        @changed=\"updateProject\"></TimeTrackerProjectTaskDropdown>\n                </div>\n                <div class=\"flex items-center space-x-1 @2xl:space-x-2 px-2 @2xl:px-4 shrink-0\">\n                    <TimeTrackerTagDropdown\n                        v-model=\"currentTimeEntry.tags\"\n                        :create-tag\n                        :tags=\"tags\"\n                        @changed=\"$emit('updateTimeEntry')\"></TimeTrackerTagDropdown>\n                    <BillableToggleButton\n                        v-model=\"currentTimeEntry.billable\"\n                        @changed=\"$emit('updateTimeEntry')\"></BillableToggleButton>\n                </div>\n                <div class=\"border-l border-card-border\">\n                    <TimeTrackerRangeSelector\n                        v-model:current-time-entry=\"currentTimeEntry\"\n                        v-model:live-timer=\"liveTimer\"\n                        @start-live-timer=\"emit('startLiveTimer')\"\n                        @stop-live-timer=\"emit('stopLiveTimer')\"\n                        @update-timer=\"emit('updateTimeEntry')\"\n                        @start-timer=\"emit('startTimer')\"\n                        @create-time-entry=\"emit('createTimeEntry')\"\n                        @keydown.enter=\"startTimerIfNotActive\"></TimeTrackerRangeSelector>\n                </div>\n            </div>\n        </div>\n        <div class=\"pl-4 @2xl:pl-6 pr-3 hidden @2xl:block\">\n            <TimeTrackerStartStop\n                :active=\"isActive\"\n                size=\"large\"\n                @changed=\"onToggleButtonPress\"></TimeTrackerStartStop>\n        </div>\n    </div>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/TimeTracker/TimeTrackerMoreOptionsDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport { PlusIcon, XMarkIcon } from '@heroicons/vue/20/solid';\nimport {\n    DropdownMenu,\n    DropdownMenuContent,\n    DropdownMenuItem,\n    DropdownMenuTrigger,\n} from '@/Components/ui/dropdown-menu';\n\nconst props = defineProps<{\n    hasActiveTimer: boolean;\n}>();\n\nconst emit = defineEmits<{\n    manualEntry: [];\n    discard: [];\n}>();\n</script>\n\n<template>\n    <DropdownMenu>\n        <DropdownMenuTrigger as-child>\n            <button\n                class=\"focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-2 focus-visible:ring-ring hover:bg-card-background hover:opacity-100 opacity-20 transition-opacity text-text-secondary\"\n                aria-label=\"Time entry actions\">\n                <svg\n                    class=\"h-8 w-8 p-1 rounded-full\"\n                    viewBox=\"0 0 24 24\"\n                    xmlns=\"http://www.w3.org/2000/svg\">\n                    <path\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        stroke-linecap=\"round\"\n                        stroke-linejoin=\"round\"\n                        stroke-width=\"1.5\"\n                        d=\"M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92\" />\n                </svg>\n            </button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent class=\"min-w-[150px]\" align=\"end\">\n            <DropdownMenuItem\n                class=\"flex items-center space-x-3 cursor-pointer\"\n                @click=\"emit('manualEntry')\">\n                <PlusIcon class=\"w-5\" />\n                <span>Manual time entry</span>\n            </DropdownMenuItem>\n            <DropdownMenuItem\n                v-if=\"props.hasActiveTimer\"\n                class=\"flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive\"\n                @click=\"emit('discard')\">\n                <XMarkIcon class=\"w-5\" />\n                <span>Discard</span>\n            </DropdownMenuItem>\n        </DropdownMenuContent>\n    </DropdownMenu>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport { ChevronRightIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';\nimport Dropdown from '@/packages/ui/src/Input/Dropdown.vue';\nimport { computed, nextTick, ref, watch } from 'vue';\nimport ProjectDropdownItem from '@/packages/ui/src/Project/ProjectDropdownItem.vue';\nimport type {\n    CreateClientBody,\n    CreateProjectBody,\n    Project,\n    Task,\n    Client,\n} from '@/packages/api/src';\n\nimport { PlusIcon, PlusCircleIcon, MinusIcon, XMarkIcon } from '@heroicons/vue/16/solid';\nimport ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';\nimport { twMerge } from 'tailwind-merge';\nimport { Button } from '@/packages/ui/src/Buttons';\n\nconst task = defineModel<string | null>('task', {\n    default: null,\n});\n\nconst project = defineModel<string | null>('project', {\n    default: null,\n});\n\nconst searchInput = ref<HTMLInputElement | null>(null);\nconst open = ref(false);\nconst dropdownViewport = ref<HTMLElement | null>(null);\nimport { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';\n\nconst searchValue = ref('');\n\nwatch(open, (isOpen) => {\n    if (isOpen) {\n        updateFilteredResults();\n        nextTick(() => {\n            initializeHighlightedItem();\n            searchInput.value?.focus({ preventScroll: true });\n        });\n    }\n});\n\ntype ProjectWithTasks = Project & { expanded: boolean; tasks: Task[] };\n\ntype ClientWithProjectsWithTasks = Client & { projects: ProjectWithTasks[] };\n\ntype ClientsWithProjectsWithTasks = ClientWithProjectsWithTasks[];\n\nconst props = withDefaults(\n    defineProps<{\n        projects: Project[];\n        tasks: Task[];\n        clients: Client[];\n        createProject: (project: CreateProjectBody) => Promise<Project | undefined>;\n        createClient: (client: CreateClientBody) => Promise<Client | undefined>;\n        currency: string;\n        emptyPlaceholder?: string;\n        allowReset?: boolean;\n        enableEstimatedTime: boolean;\n        canCreateProject: boolean;\n        class?: string;\n        variant?: 'input' | 'ghost' | 'outline';\n        align?: 'center' | 'end' | 'start';\n        size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon';\n    }>(),\n    {\n        emptyPlaceholder: 'No Project',\n        allowReset: false,\n        variant: 'ghost',\n        align: 'center',\n        size: 'sm',\n    }\n);\n\nconst filteredResults = ref([] as ClientsWithProjectsWithTasks);\n\n// computed filterProjects that flattens the first layer of filteredResults and combines all the projects\nconst filteredProjects = computed(() => {\n    return filteredResults.value.map((client) => client.projects).flat();\n});\n\nfunction addProjectToFilterObject(\n    tempFilteredClients: ClientsWithProjectsWithTasks,\n    project: Project,\n    filteredTasks: Task[],\n    expanded = false\n) {\n    // check if client already exists in filter array\n    const projectClientIndex = tempFilteredClients.findIndex(\n        (client) => client.id === project.client_id\n    );\n\n    const client = props.clients.find((client) => client.id === project.client_id);\n\n    if (projectClientIndex !== -1) {\n        // client already exists in filter array\n        tempFilteredClients[projectClientIndex]!.projects.push({\n            ...project,\n            expanded: expanded,\n            tasks: filteredTasks,\n        });\n    } else if (client) {\n        // project has client but is not already in filter array\n        // client is not yet in filter array\n        tempFilteredClients.push({\n            ...client,\n            projects: [\n                {\n                    ...project,\n                    expanded: expanded,\n                    tasks: filteredTasks,\n                },\n            ],\n        });\n    } else {\n        // project has no client\n        const customNoClientId = 'no_client';\n        const noClientIndex = tempFilteredClients.findIndex(\n            (client) => client.id === customNoClientId\n        );\n\n        if (noClientIndex !== -1) {\n            // no client group already exists in filter array\n            tempFilteredClients[noClientIndex]!.projects.push({\n                ...project,\n                expanded: expanded,\n                tasks: filteredTasks,\n            });\n        } else {\n            // no client group is not yet in filter array\n            tempFilteredClients.push({\n                id: customNoClientId,\n                name: 'No Client',\n                color: 'var(--theme-color-icon-default)',\n                created_at: '',\n                updated_at: '',\n                value: '',\n                is_archived: false,\n                projects: [\n                    {\n                        ...project,\n                        expanded: expanded,\n                        tasks: filteredTasks,\n                    },\n                ],\n            });\n        }\n    }\n}\n\nfunction updateFilteredResults() {\n    const tempFilteredClients: ClientsWithProjectsWithTasks = [];\n\n    if (searchValue.value.length === 0) {\n        tempFilteredClients.push({\n            id: 'no_project_no_client',\n            name: 'No Client',\n            color: 'var(--theme-color-icon-default)',\n            created_at: '',\n            updated_at: '',\n            value: '',\n            is_archived: false,\n            projects: [\n                {\n                    id: '',\n                    name: 'No Project',\n                    color: 'var(--theme-color-icon-default)',\n                    value: '',\n                    client_id: null,\n                    billable_rate: null,\n                    is_archived: false,\n                    is_billable: false,\n                    expanded: false,\n                    tasks: [],\n                    estimated_time: null,\n                    spent_time: 0,\n                    is_public: false,\n                },\n            ],\n        });\n    }\n\n    for (const filterProject of props.projects) {\n        const projectNameIncludesSearchTerm = filterProject.name\n            .toLowerCase()\n            .includes(searchValue.value?.toLowerCase()?.trim() || '');\n\n        const clientNameIncludesSearchTerm = props.clients\n            .find((client) => client.id === filterProject.client_id)\n            ?.name.toLowerCase()\n            .includes(searchValue.value?.toLowerCase()?.trim() || '');\n\n        // check if one of the project tasks\n        const projectTasks = props.tasks.filter((task) => {\n            return task.project_id === filterProject.id;\n        });\n\n        const filteredTasks = projectTasks.filter((filterTask) => {\n            return (\n                filterTask.name\n                    .toLowerCase()\n                    .includes(searchValue.value?.toLowerCase()?.trim() || '') &&\n                (!filterTask.is_done || filterTask.id === task.value)\n            );\n        });\n\n        if (\n            (projectNameIncludesSearchTerm || clientNameIncludesSearchTerm) &&\n            (!filterProject.is_archived || project.value === filterProject.id)\n        ) {\n            // search term matches project name\n            addProjectToFilterObject(tempFilteredClients, filterProject, filteredTasks, false);\n        } else if (filteredTasks.length > 0 && !filterProject.is_archived) {\n            // search term matches task name\n            addProjectToFilterObject(tempFilteredClients, filterProject, filteredTasks, true);\n        }\n    }\n\n    // sort tempFilteredClients by client name\n    tempFilteredClients.sort((a, b) => {\n        // Make sure No Project entry is always on top\n        if (a.id === 'no_project_no_client') {\n            return -1;\n        }\n        if (b.id === 'no_project_no_client') {\n            return 1;\n        }\n        // Make sure that No client group is above all regular clients\n        if (a.id === 'no_client') {\n            return -1;\n        }\n        if (b.id === 'no_client') {\n            return 1;\n        }\n\n        if (a.name < b.name) {\n            return -1;\n        }\n        if (a.name > b.name) {\n            return 1;\n        }\n        return 0;\n    });\n\n    filteredResults.value = tempFilteredClients;\n}\n\n// Recompute filtered results when search value changes while open\nwatch(searchValue, () => {\n    if (open.value) {\n        updateFilteredResults();\n    }\n});\n\nasync function addClientIfNoneExists() {\n    setProjectAndClientBasedOnHighlightedItem();\n}\n\nfunction isProjectSelected(project: Project) {\n    return project.value === project.id;\n}\n\nfunction initializeHighlightedItem() {\n    if (filteredProjects.value.length > 0) {\n        highlightedItemId.value = filteredProjects.value[0]!.id;\n    }\n}\n\nwatch(filteredProjects, () => {\n    initializeHighlightedItem();\n});\n\nfunction setProjectAndClientBasedOnHighlightedItem() {\n    const highlightedProject = filteredProjects.value.find(\n        (project) => project.id === highlightedItemId.value\n    );\n    const highlightedTask = filteredProjects.value\n        .map((project) => project.tasks)\n        .flat()\n        .find((task) => task.id === highlightedItemId.value);\n    if (highlightedProject) {\n        selectProject(highlightedProject.id);\n    }\n    if (highlightedTask) {\n        selectTask(highlightedTask.id);\n    }\n}\n\nfunction updateSearchValue(event: Event) {\n    const newInput = (event.target as HTMLInputElement).value;\n    if (newInput === ' ') {\n        searchValue.value = '';\n        setProjectAndClientBasedOnHighlightedItem();\n    } else {\n        searchValue.value = newInput;\n    }\n}\n\nconst emit = defineEmits(['update:modelValue', 'changed']);\n\nfunction moveHighlightUp() {\n    mouseEnterHighlightActivated.value = false;\n    const currentHighlightedIndex = filteredProjects.value.findIndex(\n        (projectWithTasks) => projectWithTasks.id === highlightedItemId.value\n    );\n    // check if it is a project id\n    if (currentHighlightedIndex === -1) {\n        // the ID is a task ID\n        const currentProjectWithTasks = filteredProjects.value.find((projectWithTasks) =>\n            projectWithTasks.tasks.some((task) => task.id === highlightedItemId.value)\n        );\n        if (currentProjectWithTasks) {\n            const taskIndex = currentProjectWithTasks.tasks.findIndex(\n                (task) => task.id === highlightedItemId.value\n            );\n            if (taskIndex === -1) {\n                return;\n            }\n            if (taskIndex === 0) {\n                // highlight the project if it was the first task before\n                highlightedItemId.value = currentProjectWithTasks.id;\n                return;\n            }\n            highlightedItemId.value = currentProjectWithTasks.tasks[taskIndex - 1]!.id;\n        }\n    }\n    if (currentHighlightedIndex === 0) {\n        // selected project is the first project in the list\n        // highlight the last project or the last task of the last project\n        const lastProject = filteredProjects.value[filteredProjects.value.length - 1]!;\n        if (lastProject.tasks.length > 0 && lastProject.expanded) {\n            // highlight last task of last project\n            highlightedItemId.value = lastProject.tasks[lastProject.tasks.length - 1]!.id;\n        } else {\n            highlightedItemId.value = filteredProjects.value[filteredProjects.value.length - 1]!.id;\n        }\n    } else {\n        // selected item is a project that is not the first project in the list\n        const previousProject = filteredProjects.value[currentHighlightedIndex - 1]!;\n        if (previousProject.tasks.length > 0 && previousProject.expanded) {\n            // highlight last task of previous project\n            highlightedItemId.value = previousProject.tasks[previousProject.tasks.length - 1]!.id;\n        } else {\n            highlightedItemId.value = filteredProjects.value[currentHighlightedIndex - 1]!.id;\n        }\n    }\n}\n\nfunction moveHighlightDown() {\n    mouseEnterHighlightActivated.value = false;\n\n    const currentHighlightedIndex = filteredProjects.value.findIndex(\n        (projectWithTasks) => projectWithTasks.id === highlightedItemId.value\n    );\n    // check if it is a project id\n    if (currentHighlightedIndex === -1) {\n        // the ID is a task ID\n        const currentProjectWithTasks = filteredProjects.value.find((projectWithTasks) =>\n            projectWithTasks.tasks.some((task) => task.id === highlightedItemId.value)\n        );\n        if (currentProjectWithTasks) {\n            const taskIndex = currentProjectWithTasks.tasks.findIndex(\n                (task) => task.id === highlightedItemId.value\n            );\n            if (taskIndex === -1) {\n                return;\n            }\n            if (taskIndex === currentProjectWithTasks.tasks.length - 1) {\n                // highlight the next project if it was the last task in current project\n                const projectIndex = filteredProjects.value.indexOf(currentProjectWithTasks);\n                if (projectIndex === filteredProjects.value.length - 1) {\n                    // highlight the first project if it was the last project\n                    highlightedItemId.value = filteredProjects.value[0]!.id;\n                } else {\n                    highlightedItemId.value = filteredProjects.value[projectIndex + 1]!.id;\n                }\n                return;\n            }\n            highlightedItemId.value = currentProjectWithTasks.tasks[taskIndex + 1]!.id;\n        }\n    }\n    if (currentHighlightedIndex === filteredProjects.value.length - 1) {\n        // selected project is the last project in the list\n        // highlight the first project or the last project of the last project\n        const lastProject = filteredProjects.value[filteredProjects.value.length - 1]!;\n        if (lastProject.tasks.length > 0 && lastProject.expanded) {\n            // highlight last task of last project\n            highlightedItemId.value = lastProject.tasks[0]!.id;\n        } else {\n            highlightedItemId.value = filteredProjects.value[0]!.id;\n        }\n    } else {\n        // selected item is a project that is not the last project in the list\n        const currentProjectWithTasks = filteredProjects.value[currentHighlightedIndex]!;\n        if (currentProjectWithTasks.tasks.length > 0 && currentProjectWithTasks.expanded) {\n            // highlight last task of previous project\n            highlightedItemId.value = currentProjectWithTasks.tasks[0]!.id;\n        } else {\n            highlightedItemId.value = filteredProjects.value[currentHighlightedIndex + 1]!.id;\n        }\n    }\n}\n\nconst highlightedItemId = ref<string | null>(null);\n\nwatch(highlightedItemId, () => {\n    const highlightedItem = dropdownViewport.value?.querySelector(\n        `[data-project-id=\"${highlightedItemId.value}\"]`\n    );\n    if (highlightedItem) {\n        highlightedItem.scrollIntoView({\n            block: 'nearest',\n            inline: 'nearest',\n        });\n    } else {\n        const highlightedTask = dropdownViewport.value?.querySelector(\n            `[data-task-id=\"${highlightedItemId.value}\"]`\n        );\n        if (highlightedTask) {\n            highlightedTask.scrollIntoView({\n                block: 'nearest',\n                inline: 'nearest',\n            });\n        }\n    }\n});\n\nfunction expandProject() {\n    const currentHighlightedIndex = filteredProjects.value.findIndex(\n        (projectWithTasks) => projectWithTasks.id === highlightedItemId.value\n    );\n    if (currentHighlightedIndex === -1) {\n        return;\n    }\n    const currentProject = filteredProjects.value[currentHighlightedIndex]!;\n    currentProject.expanded = true;\n}\n\nfunction collapseProject() {\n    const currentHighlightedIndex = filteredProjects.value.findIndex(\n        (projectWithTasks) => projectWithTasks.id === highlightedItemId.value\n    );\n    if (currentHighlightedIndex === -1) {\n        return;\n    }\n    const currentProject = filteredProjects.value[currentHighlightedIndex]!;\n    currentProject.expanded = false;\n}\n\nconst currentProject = computed(() => {\n    return props.projects.find((iteratingProject) => iteratingProject.id === project.value);\n});\n\nconst currentTask = computed(() => {\n    return props.tasks.find((iteratingTasks) => iteratingTasks.id === task.value);\n});\n\nconst selectedProjectName = computed(() => {\n    if (project.value === null) {\n        return props.emptyPlaceholder;\n    }\n    if (project.value === '') {\n        return 'No Project';\n    }\n    return currentProject.value?.name;\n});\n\nconst selectedProjectColor = computed(() => {\n    return currentProject.value?.color || 'var(--theme-color-icon-default)';\n});\n\n// This state prevents the selection to jump to random items when the mouse cursor is\n// over an item and some Item in the Dropdown is selected by keyboard navigation to scroll into view\nconst mouseEnterHighlightActivated = ref(true);\n\nfunction setHighlightItemId(newId: string) {\n    if (mouseEnterHighlightActivated.value) {\n        highlightedItemId.value = newId;\n    }\n}\n\nfunction selectTask(taskId: string) {\n    task.value = taskId;\n    project.value = props.tasks.find((task) => task.id === taskId)?.project_id || null;\n    open.value = false;\n    searchValue.value = '';\n    emit('changed', project.value, task.value);\n}\n\nfunction selectProject(projectId: string) {\n    project.value = projectId;\n    task.value = null;\n    open.value = false;\n    searchValue.value = '';\n    emit('changed', project.value, task.value);\n}\n\nfunction resetProject() {\n    project.value = null;\n    task.value = null;\n    emit('changed', project.value, task.value);\n}\n\nconst showCreateProject = ref(false);\n</script>\n\n<template>\n    <template v-if=\"projects.length === 0 && canCreateProject\">\n        <Button\n            :variant=\"props.variant\"\n            :size=\"props.size\"\n            :class=\"twMerge('w-full justify-start', props.class)\"\n            @click=\"showCreateProject = true\">\n            <PlusIcon class=\"w-4\" />\n            <span class=\"truncate\">Add new project</span>\n        </Button>\n    </template>\n    <Dropdown v-else v-model=\"open\" :close-on-content-click=\"false\" :align=\"props.align\">\n        <template #trigger>\n            <div class=\"flex items-center gap-1\">\n                <Button\n                    :variant=\"props.variant\"\n                    :size=\"props.size\"\n                    :class=\"twMerge('w-full justify-start overflow-hidden', props.class)\">\n                    <div\n                        class=\"w-3 h-3 rounded-full shrink-0\"\n                        :style=\"{ backgroundColor: selectedProjectColor }\"></div>\n                    <span class=\"truncate shrink-[1] pr-1\">{{ selectedProjectName }}</span>\n                    <template v-if=\"currentTask\">\n                        <ChevronRightIcon class=\"w-4 h-4 text-text-tertiary shrink-0\" />\n                        <span class=\"truncate shrink-[100]\">{{ currentTask.name }}</span>\n                    </template>\n                </Button>\n                <button\n                    v-if=\"allowReset && project !== null\"\n                    type=\"button\"\n                    data-testid=\"project_reset_button\"\n                    class=\"p-1 rounded hover:bg-quaternary text-text-tertiary hover:text-text-primary\"\n                    @click.stop=\"resetProject\">\n                    <XMarkIcon class=\"w-4 h-4\" />\n                </button>\n            </div>\n        </template>\n        <template #content>\n            <UseFocusTrap v-if=\"open\" :options=\"{ immediate: true, allowOutsideClick: true }\">\n                <input\n                    ref=\"searchInput\"\n                    :value=\"searchValue\"\n                    data-testid=\"client_dropdown_search\"\n                    class=\"bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full\"\n                    placeholder=\"Search for a project or task...\"\n                    @input=\"updateSearchValue\"\n                    @keydown.enter.prevent=\"addClientIfNoneExists\"\n                    @keydown.esc.prevent=\"open = false\"\n                    @keydown.up.prevent=\"moveHighlightUp\"\n                    @keydown.down.prevent=\"moveHighlightDown\"\n                    @keydown.right.prevent=\"expandProject\"\n                    @keydown.left.prevent=\"collapseProject\" />\n                <div\n                    ref=\"dropdownViewport\"\n                    class=\"min-w-[350px] max-h-[350px] overflow-y-scroll relative\"\n                    @mousemove=\"mouseEnterHighlightActivated = true\">\n                    <template v-for=\"client in filteredResults\" :key=\"client.id\">\n                        <div\n                            v-if=\"client.id !== 'no_project_no_client'\"\n                            class=\"w-full pb-1 pt-2 px-2 text-text-tertiary text-xs font-semibold flex space-x-1 items-center\">\n                            <span>\n                                {{ client.name }}\n                            </span>\n                        </div>\n                        <template\n                            v-for=\"projectWithTasks in client.projects\"\n                            :key=\"projectWithTasks.id\">\n                            <div\n                                role=\"option\"\n                                class=\"px-1 py-0.5 cursor-default\"\n                                :value=\"projectWithTasks.id\"\n                                :data-project-id=\"projectWithTasks.id\"\n                                @click=\"selectProject(projectWithTasks.id)\">\n                                <div\n                                    class=\"rounded-lg\"\n                                    :class=\"{\n                                        'bg-card-background-active':\n                                            projectWithTasks.id === highlightedItemId,\n                                    }\">\n                                    <ProjectDropdownItem\n                                        class=\"hover:!bg-transparent\"\n                                        :selected=\"isProjectSelected(projectWithTasks)\"\n                                        :name=\"projectWithTasks.name\"\n                                        :color=\"projectWithTasks.color\"\n                                        @mouseenter=\"setHighlightItemId(projectWithTasks.id)\">\n                                        <template #actions>\n                                            <button\n                                                v-if=\"projectWithTasks.tasks.length > 0\"\n                                                tabindex=\"-1\"\n                                                class=\"px-2 py-0.5 mr-2 relative transition items-center rounded flex space-x-0.5 text-xs\"\n                                                :class=\"{\n                                                    'bg-white/5 text-text-secondary':\n                                                        projectWithTasks.expanded,\n                                                    'hover:bg-white/5 hover:text-text-secondary text-text-tertiary':\n                                                        !projectWithTasks.expanded,\n                                                }\"\n                                                @click.prevent.stop=\"\n                                                    () => {\n                                                        projectWithTasks.expanded =\n                                                            !projectWithTasks.expanded;\n                                                        searchInput?.focus();\n                                                    }\n                                                \">\n                                                <span\n                                                    >{{ projectWithTasks.tasks.length }} Tasks</span\n                                                >\n                                                <ChevronDownIcon\n                                                    :class=\"{\n                                                        'transform rotate-180':\n                                                            projectWithTasks.expanded,\n                                                    }\"\n                                                    class=\"w-4\"></ChevronDownIcon>\n                                            </button>\n                                        </template>\n                                    </ProjectDropdownItem>\n                                </div>\n                            </div>\n                            <div v-if=\"projectWithTasks.expanded\" class=\"bg-quaternary\">\n                                <div\n                                    v-for=\"task in projectWithTasks.tasks\"\n                                    :key=\"task.id\"\n                                    :data-task-id=\"task.id\"\n                                    :class=\"{\n                                        'bg-card-background-active': task.id === highlightedItemId,\n                                    }\"\n                                    class=\"flex items-center space-x-2 w-full px-5 py-1.5 text-start text-xs font-semibold leading-5 text-text-primary focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out\"\n                                    @click=\"selectTask(task.id)\"\n                                    @mouseenter=\"setHighlightItemId(task.id)\">\n                                    <MinusIcon class=\"w-3 h-3 text-text-quaternary\"></MinusIcon>\n                                    <span>{{ task.name }}</span>\n                                </div>\n                            </div>\n                        </template>\n                    </template>\n                </div>\n                <div v-if=\"canCreateProject\" class=\"hover:bg-card-background-active rounded-b-lg\">\n                    <button\n                        class=\"text-text-primary flex space-x-3 items-center px-4 py-3 text-xs font-semibold border-t border-card-background-separator\"\n                        @click=\"\n                            open = false;\n                            showCreateProject = true;\n                        \">\n                        <PlusCircleIcon\n                            class=\"w-5 flex-shrink-0 text-icon-default\"></PlusCircleIcon>\n                        <span>Create new Project</span>\n                    </button>\n                </div>\n            </UseFocusTrap>\n        </template>\n    </Dropdown>\n    <ProjectCreateModal\n        v-if=\"showCreateProject\"\n        v-model:show=\"showCreateProject\"\n        :create-client\n        :enable-estimated-time=\"enableEstimatedTime\"\n        :currency=\"currency\"\n        :clients=\"clients\"\n        :create-project></ProjectCreateModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/TimeTracker/TimeTrackerRangeSelector.vue",
    "content": "<script setup lang=\"ts\">\nimport Dropdown from '@/packages/ui/src/Input/Dropdown.vue';\nimport { computed, ref } from 'vue';\nimport TimeRangeSelector from '@/packages/ui/src/Input/TimeRangeSelector.vue';\nimport dayjs, { Dayjs } from 'dayjs';\nimport { formatDuration, getDayJsInstance, parseTimeInput } from '@/packages/ui/src/utils/time';\nimport type { TimeEntry } from '@/packages/api/src';\n\nconst currentTimeEntry = defineModel<TimeEntry>('currentTimeEntry', {\n    required: true,\n});\nconst now = defineModel<null | Dayjs>('liveTimer');\n\nconst emit = defineEmits<{\n    startLiveTimer: [];\n    stopLiveTimer: [];\n    updateTimer: [];\n    startTimer: [];\n    createTimeEntry: [];\n}>();\n\nconst open = ref(false);\n\nfunction pauseLiveTimerUpdate(event: FocusEvent) {\n    (event.target as HTMLInputElement).select();\n    emit('stopLiveTimer');\n}\n\nfunction onTimeEntryEnterPress() {\n    updateTimerAndStartLiveTimerUpdate();\n    open.value = false;\n    const activeElement = document.activeElement as HTMLElement;\n    activeElement?.blur();\n}\n\nconst currentTime = computed({\n    get() {\n        if (temporaryCustomTimerEntry.value !== '') {\n            return temporaryCustomTimerEntry.value;\n        }\n        if (now.value && currentTimeEntry.value.start) {\n            const startTime = dayjs(currentTimeEntry.value.start);\n            const diff = now.value.diff(startTime, 'seconds');\n            return formatDuration(diff);\n        }\n        return null;\n    },\n    // setter\n    set(newValue) {\n        if (newValue) {\n            temporaryCustomTimerEntry.value = newValue;\n        } else {\n            temporaryCustomTimerEntry.value = '';\n        }\n    },\n});\n\nfunction updateTimerAndStartLiveTimerUpdate() {\n    const seconds = parseTimeInput(temporaryCustomTimerEntry.value, 'minutes');\n\n    if (seconds && seconds > 0) {\n        const newStartDate = dayjs().subtract(seconds, 's');\n        currentTimeEntry.value.start = newStartDate.utc().format();\n        if (currentTimeEntry.value.id !== '') {\n            emit('updateTimer');\n        } else {\n            emit('startTimer');\n        }\n    }\n    now.value = dayjs().utc();\n    temporaryCustomTimerEntry.value = '';\n    emit('startLiveTimer');\n}\n\nconst temporaryCustomTimerEntry = ref<string>('');\n\nasync function updateTimeRange(newStart: string, newEnd: string | null) {\n    // prohibit updates in the future\n    if (getDayJsInstance()(newStart).isBefore(getDayJsInstance()())) {\n        currentTimeEntry.value.start = newStart;\n        currentTimeEntry.value.end = newEnd;\n        if (currentTimeEntry.value.id) {\n            emit('updateTimer');\n        } else if (newEnd !== null) {\n            // If there's no ID but we have both start and end, create a new time entry\n            emit('createTimeEntry');\n        } else {\n            emit('startTimer');\n        }\n    }\n}\n\nconst startTime = computed(() => {\n    if (currentTimeEntry.value.start && currentTimeEntry.value.start !== '') {\n        return currentTimeEntry.value.start;\n    }\n    return dayjs().utc().format();\n});\n\nconst endTime = computed(() => {\n    if (currentTimeEntry.value.end && currentTimeEntry.value.end !== '') {\n        return currentTimeEntry.value.end;\n    }\n    return null;\n});\n\nconst inputField = ref<HTMLInputElement | null>(null);\n\nconst timeRangeSelector = ref<HTMLElement | null>(null);\n\nfunction openModalOnTab(e: FocusEvent) {\n    pauseLiveTimerUpdate(e);\n\n    // check if the source is inside the dropdown\n    const source = e.relatedTarget as HTMLElement;\n    if (source && window.document.body.querySelector<HTMLElement>('#app')?.contains(source)) {\n        open.value = true;\n    }\n}\n\nfunction openModalOnClick(e: MouseEvent) {\n    pauseLiveTimerUpdate(e);\n\n    open.value = true;\n}\n\nfunction focusNextElement(e: KeyboardEvent) {\n    if (open.value) {\n        e.preventDefault();\n        const focusableElement = timeRangeSelector.value?.querySelector<HTMLElement>(\n            'button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])'\n        );\n        focusableElement?.focus();\n    }\n}\n\nfunction closeAndFocusInput() {\n    open.value = false;\n    inputField.value?.focus();\n}\n</script>\n\n<template>\n    <div class=\"relative\">\n        <Dropdown\n            v-model=\"open\"\n            align=\"center\"\n            :auto-focus=\"false\"\n            :close-on-content-click=\"false\"\n            @submit=\"closeAndFocusInput\">\n            <template #trigger>\n                <input\n                    ref=\"inputField\"\n                    v-model=\"currentTime\"\n                    placeholder=\"00:00:00\"\n                    data-testid=\"time_entry_time\"\n                    class=\"w-[110px] lg:w-[130px] h-full text-text-primary py-2.5 rounded-lg border-border-secondary border text-center px-4 text-base lg:text-lg font-semibold bg-card-background border-none placeholder-text-tertiary focus:ring-0 transition\"\n                    type=\"text\"\n                    @focusin=\"openModalOnTab\"\n                    @click=\"openModalOnClick\"\n                    @keydown.exact.tab=\"focusNextElement\"\n                    @keydown.exact.shift.tab=\"open = false\"\n                    @blur=\"updateTimerAndStartLiveTimerUpdate\"\n                    @keydown.enter=\"onTimeEntryEnterPress\" />\n            </template>\n            <template #content>\n                <div ref=\"timeRangeSelector\">\n                    <TimeRangeSelector\n                        :start=\"startTime\"\n                        :end=\"endTime\"\n                        @changed=\"updateTimeRange\"\n                        @close=\"closeAndFocusInput\">\n                    </TimeRangeSelector>\n                </div>\n            </template>\n        </Dropdown>\n    </div>\n</template>\n\n<style></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/TimeTracker/TimeTrackerRecentlyTrackedEntry.vue",
    "content": "<script setup lang=\"ts\">\nimport { ProjectBadge } from '@/packages/ui/src';\nimport type { TimeEntry } from '@/packages/api/src';\nimport { twMerge } from 'tailwind-merge';\nimport { ChevronRightIcon } from '@heroicons/vue/16/solid';\nimport { computed } from 'vue';\nimport type { Project, Task } from '@/packages/api/src';\n\nconst props = defineProps<{\n    timeEntry: TimeEntry;\n    highlighted: boolean;\n    projects?: Project[];\n    tasks?: Task[];\n}>();\nconst project = computed(() => {\n    return props.projects?.find(\n        (iteratingProject) => iteratingProject.id === props.timeEntry.project_id\n    );\n});\nconst task = computed(() => {\n    return props.tasks?.find((iteratingTask) => iteratingTask.id === props.timeEntry.task_id);\n});\n</script>\n\n<template>\n    <button\n        tabindex=\"-1\"\n        :data-select-id=\"timeEntry.id\"\n        :class=\"\n            twMerge(\n                'px-2 py-1.5 flex items-center space-x-2 w-full rounded text-left',\n                props.highlighted && 'bg-card-background-active'\n            )\n        \">\n        <span\n            v-if=\"timeEntry.description !== ''\"\n            class=\"text-sm font-medium truncate min-w-0 flex-1\">\n            {{ timeEntry.description }}\n        </span>\n        <span v-else class=\"text-sm text-text-tertiary font-medium flex-1\"> No Description </span>\n        <ProjectBadge\n            ref=\"projectDropdownTrigger\"\n            :color=\"project?.color\"\n            :name=\"project?.name\"\n            class=\"shrink min-w-0 max-w-[50%]\">\n            <div v-if=\"project\" class=\"flex items-center lg:space-x-1 min-w-0\">\n                <span class=\"text-xs whitespace-nowrap shrink-0\">\n                    {{ project?.name }}\n                </span>\n                <ChevronRightIcon\n                    v-if=\"task\"\n                    class=\"w-4 lg:w-5 text-text-secondary shrink-0\"></ChevronRightIcon>\n                <div v-if=\"task\" class=\"min-w-0 text-xs truncate\">\n                    {{ task.name }}\n                </div>\n            </div>\n            <div v-else>No Project</div>\n        </ProjectBadge>\n    </button>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue",
    "content": "<script setup lang=\"ts\">\nimport SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';\n\ndefineEmits<{\n    switchOrganization: [];\n}>();\n</script>\n\n<template>\n    <div class=\"absolute w-full h-full backdrop-blur-sm z-10 flex items-center justify-center\">\n        <div\n            class=\"w-full h-[calc(100%+10px)] absolute bg-default-background opacity-75 backdrop-blur-sm\"></div>\n        <div class=\"flex space-x-3 items-center w-full z-20 justify-center\">\n            <span class=\"text-sm text-text-primary\">\n                The Timer is running in a different organization.\n            </span>\n            <SecondaryButton @click=\"$emit('switchOrganization')\"\n                >Switch to organization</SecondaryButton\n            >\n        </div>\n    </div>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/TimeTracker/TimeTrackerTagDropdown.vue",
    "content": "<script setup lang=\"ts\">\nimport TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';\nimport { twMerge } from 'tailwind-merge';\nimport { TagIcon } from '@heroicons/vue/20/solid';\nimport { computed } from 'vue';\nimport type { Tag } from '@/packages/api/src';\n\nconst emit = defineEmits<{\n    changed: [];\n}>();\n\nconst model = defineModel<string[]>({\n    default: [],\n});\nconst iconColorClasses = computed(() => {\n    if (model.value.length > 0) {\n        return 'text-input-select-active focus:text-input-select-active-hover hover:text-input-select-active-hover';\n    } else {\n        return 'text-icon-default hover:text-icon-active focus:text-icon-active';\n    }\n});\ndefineProps<{\n    tags: Tag[];\n    createTag: (name: string) => Promise<Tag | undefined>;\n}>();\n</script>\n\n<template>\n    <TagDropdown\n        v-model=\"model\"\n        :create-tag\n        :tags=\"tags\"\n        :show-no-tag-option=\"false\"\n        @changed=\"emit('changed')\">\n        <template #trigger>\n            <button\n                data-testid=\"tag_dropdown\"\n                :class=\"\n                    twMerge(\n                        iconColorClasses,\n                        'relative flex-shrink-0 ring-0 focus:outline-none focus:ring-2 focus:ring-ring transition focus-visible:bg-card-background-separator hover:bg-card-background-separator rounded-full w-10 h-10 flex items-center justify-center'\n                    )\n                \">\n                <TagIcon class=\"w-5 h-5 lg:h-6 lg:w-6\"></TagIcon>\n            </button>\n        </template>\n    </TagDropdown>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/TimeTrackerStartStop.vue",
    "content": "<script setup lang=\"ts\">\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst timeTrackerVariants = cva(\n    'flex items-center justify-center transition focus:outline-0 rounded-full',\n    {\n        variants: {\n            variant: {\n                primary:\n                    'text-white ring-accent-200/10 focus-visible:ring-ring focus-visible:ring-2 ring-4 sm:ring-[6px]',\n                secondary:\n                    'bg-quaternary text-text-tertiary hover:text-text-primary focus:ring-2 focus:ring-border-tertiary',\n            },\n            size: {\n                small: 'w-6 h-6',\n                base: 'w-8 h-8',\n                large: 'w-11 h-11 hover:scale-110',\n            },\n            active: {\n                true: '',\n                false: '',\n            },\n        },\n        compoundVariants: [\n            {\n                variant: 'primary',\n                active: true,\n                class: 'bg-red-400/80 hover:bg-red-500/80 focus:bg-red-500/80',\n            },\n            {\n                variant: 'primary',\n                active: false,\n                class: 'bg-accent-300/70 hover:bg-accent-400/70 focus:bg-accent-700',\n            },\n        ],\n        defaultVariants: {\n            variant: 'primary',\n            size: 'base',\n            active: false,\n        },\n    }\n);\n\ntype TimeTrackerVariants = VariantProps<typeof timeTrackerVariants>;\n\nconst emit = defineEmits(['changed']);\n\nconst props = withDefaults(\n    defineProps<{\n        variant?: TimeTrackerVariants['variant'];\n        size?: TimeTrackerVariants['size'];\n        active?: boolean;\n    }>(),\n    {\n        variant: 'primary',\n        size: 'base',\n        active: false,\n    }\n);\n\nconst iconClass = {\n    small: 'w-2.5 h-2.5',\n    base: 'w-3 h-3',\n    large: 'w-4 h-4',\n};\n\nfunction toggleState() {\n    emit('changed', !props.active);\n}\n</script>\n\n<template>\n    <button\n        data-testid=\"timer_button\"\n        :class=\"cn(timeTrackerVariants({ variant, size, active }))\"\n        @click=\"toggleState\">\n        <Transition name=\"fade\" mode=\"out-in\">\n            <svg\n                v-if=\"props.active\"\n                :class=\"iconClass[size ?? 'base']\"\n                viewBox=\"0 0 14 14\"\n                fill=\"none\"\n                xmlns=\"http://www.w3.org/2000/svg\">\n                <path\n                    fill-rule=\"evenodd\"\n                    clip-rule=\"evenodd\"\n                    d=\"M0.461426 2.74913C0.461426 1.48677 1.48666 0.461538 2.75076 0.461538H11.249C12.5131 0.461538 13.5383 1.48677 13.5383 2.75087V11.2491C13.5383 12.5132 12.5131 13.5385 11.249 13.5385H2.7525C2.4518 13.5387 2.154 13.4796 1.87614 13.3647C1.59828 13.2497 1.34582 13.0811 1.13319 12.8684C0.920559 12.6558 0.751936 12.4033 0.636968 12.1255C0.521999 11.8476 0.462941 11.5498 0.46317 11.2491V2.75262L0.461426 2.74913Z\"\n                    fill=\"currentColor\" />\n            </svg>\n            <svg\n                v-else\n                :class=\"iconClass[size ?? 'base']\"\n                viewBox=\"0 0 7 8\"\n                fill=\"none\"\n                xmlns=\"http://www.w3.org/2000/svg\">\n                <path\n                    fill-rule=\"evenodd\"\n                    clip-rule=\"evenodd\"\n                    d=\"M6.56167 3.18089C6.70764 3.26214 6.82926 3.38092 6.91393 3.52494C6.99859 3.66896 7.04324 3.83299 7.04324 4.00005C7.04324 4.16712 6.99859 4.33115 6.91393 4.47517C6.82926 4.61919 6.70764 4.73797 6.56167 4.81922L1.8925 7.41339C1.74982 7.49259 1.58895 7.53317 1.42578 7.53113C1.26261 7.52909 1.1028 7.48449 0.962147 7.40175C0.821497 7.31901 0.704879 7.20099 0.623826 7.05937C0.542772 6.91774 0.50009 6.7574 0.5 6.59422V1.40589C0.5 0.691721 1.2675 0.239221 1.8925 0.586721L6.56167 3.18089Z\"\n                    fill=\"currentColor\" />\n            </svg>\n        </Transition>\n    </button>\n</template>\n\n<style scoped>\n.fade-enter-active,\n.fade-leave-active {\n    transition: opacity 0.2s ease;\n}\n\n.fade-enter-from,\n.fade-leave-to {\n    opacity: 0;\n}\n</style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/TimezoneMismatchModal.vue",
    "content": "<script setup lang=\"ts\">\nimport SecondaryButton from './Buttons/SecondaryButton.vue';\nimport DialogModal from './DialogModal.vue';\nimport PrimaryButton from './Buttons/PrimaryButton.vue';\nimport { onMounted, ref } from 'vue';\nimport { getUserTimezone } from './utils/settings';\nimport { getDayJsInstance } from './utils/time';\nimport { useSessionStorage } from '@vueuse/core';\n\nconst show = defineModel('show', { default: false });\n\nconst emit = defineEmits<{\n    update: [timezone: string];\n    cancel: [];\n}>();\n\ndefineProps<{\n    saving?: boolean;\n}>();\n\nconst timezone = ref('');\nconst userTimezone = ref('');\nconst shouldShow = ref(false);\n\nconst hideTimezoneMismatchModal = useSessionStorage<boolean>('hide-timezone-mismatch-modal', false);\n\n/**\n * Check if timezone mismatch exists and should be shown\n */\nfunction checkTimezoneMismatch(): boolean {\n    timezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;\n    userTimezone.value = getUserTimezone();\n\n    const now = getDayJsInstance()();\n\n    const hasMismatch =\n        now.tz(timezone.value).format() !== now.tz(userTimezone.value).format() &&\n        !hideTimezoneMismatchModal.value;\n\n    shouldShow.value = hasMismatch;\n    return hasMismatch;\n}\n\nonMounted(() => {\n    checkTimezoneMismatch();\n    if (shouldShow.value) {\n        show.value = true;\n    }\n});\n\nfunction submit() {\n    emit('update', timezone.value);\n}\n\nfunction cancel() {\n    show.value = false;\n    hideTimezoneMismatchModal.value = true;\n    emit('cancel');\n}\n\n// Expose methods for parent component\ndefineExpose({\n    checkTimezoneMismatch,\n    currentTimezone: timezone,\n    userTimezone,\n});\n</script>\n\n<template>\n    <DialogModal closeable :show=\"show && shouldShow\" @close=\"cancel\">\n        <template #title>\n            <div class=\"flex justify-center\">\n                <span> Timezone mismatch detected </span>\n            </div>\n        </template>\n        <template #content>\n            <div class=\"flex items-center space-x-4\">\n                <div class=\"col-span-6 sm:col-span-4 flex-1 space-y-2\">\n                    <p>\n                        The timezone of your device does not match the timezone in your user\n                        settings. <br />\n                        <strong\n                            >We highly recommend that you update your timezone settings to your\n                            current timezone.</strong\n                        >\n                    </p>\n\n                    <p>\n                        Want to change your timezone setting from\n                        <strong>{{ userTimezone }}</strong> to <strong>{{ timezone }}</strong\n                        >.\n                    </p>\n                </div>\n            </div>\n        </template>\n        <template #footer>\n            <SecondaryButton @click=\"cancel\"> Cancel</SecondaryButton>\n            <PrimaryButton\n                class=\"ms-3\"\n                :class=\"{ 'opacity-25': saving }\"\n                :disabled=\"saving\"\n                @click=\"submit()\">\n                Update timezone\n            </PrimaryButton>\n        </template>\n    </DialogModal>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "resources/js/packages/ui/src/accordion/Accordion.vue",
    "content": "<script setup lang=\"ts\">\nimport {\n    AccordionRoot,\n    type AccordionRootEmits,\n    type AccordionRootProps,\n    useForwardPropsEmits,\n} from 'reka-ui';\n\nconst props = defineProps<AccordionRootProps>();\nconst emits = defineEmits<AccordionRootEmits>();\n\nconst forwarded = useForwardPropsEmits(props, emits);\n</script>\n\n<template>\n    <AccordionRoot v-bind=\"forwarded\">\n        <slot />\n    </AccordionRoot>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/accordion/AccordionContent.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '../utils/cn';\nimport { AccordionContent, type AccordionContentProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<AccordionContentProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n</script>\n\n<template>\n    <AccordionContent\n        v-bind=\"delegatedProps\"\n        class=\"overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down px-0.5\">\n        <div :class=\"cn('pb-4 pt-0', props.class)\">\n            <slot />\n        </div>\n    </AccordionContent>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/accordion/AccordionItem.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '../utils/cn';\nimport { AccordionItem, type AccordionItemProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<AccordionItemProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <AccordionItem v-bind=\"forwardedProps\" :class=\"cn('border-b', props.class)\">\n        <slot />\n    </AccordionItem>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/accordion/AccordionTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '../utils/cn';\nimport { ChevronDown } from 'lucide-vue-next';\nimport { AccordionHeader, AccordionTrigger, type AccordionTriggerProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n</script>\n\n<template>\n    <AccordionHeader class=\"flex\">\n        <AccordionTrigger\n            v-bind=\"delegatedProps\"\n            :class=\"\n                cn(\n                    'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',\n                    props.class\n                )\n            \">\n            <slot />\n            <slot name=\"icon\">\n                <ChevronDown\n                    class=\"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200\" />\n            </slot>\n        </AccordionTrigger>\n    </AccordionHeader>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/accordion/index.ts",
    "content": "export { default as Accordion } from './Accordion.vue';\nexport { default as AccordionContent } from './AccordionContent.vue';\nexport { default as AccordionItem } from './AccordionItem.vue';\nexport { default as AccordionTrigger } from './AccordionTrigger.vue';\n"
  },
  {
    "path": "resources/js/packages/ui/src/command/Command.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ListboxRootEmits, ListboxRootProps } from 'reka-ui';\nimport type { HTMLAttributes } from 'vue';\nimport { reactiveOmit } from '@vueuse/core';\nimport { ListboxRoot, useFilter, useForwardPropsEmits } from 'reka-ui';\nimport { reactive, ref, watch } from 'vue';\nimport { cn } from '../utils/cn';\nimport { provideCommandContext } from '.';\n\nconst props = withDefaults(\n    defineProps<ListboxRootProps & { class?: HTMLAttributes['class']; searchTerm?: string }>(),\n    {\n        modelValue: '',\n        searchTerm: '',\n    }\n);\n\nconst emits = defineEmits<ListboxRootEmits & { 'update:searchTerm': [value: string] }>();\n\nconst delegatedProps = reactiveOmit(props, 'class', 'searchTerm');\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits);\n\nconst allItems = ref<Map<string, string>>(new Map());\nconst allGroups = ref<Map<string, Set<string>>>(new Map());\n\nconst { contains } = useFilter({ sensitivity: 'base' });\nconst filterState = reactive({\n    search: props.searchTerm || '',\n    filtered: {\n        /** The count of all visible items. */\n        count: 0,\n        /** Map from visible item id to its search score. */\n        items: new Map() as Map<string, number>,\n        /** Set of groups with at least one visible item. */\n        groups: new Set() as Set<string>,\n    },\n});\n\nfunction filterItems() {\n    if (!filterState.search) {\n        filterState.filtered.count = allItems.value.size;\n        // Do nothing, each item will know to show itself because search is empty\n        return;\n    }\n\n    // Reset the groups\n    filterState.filtered.groups = new Set();\n    let itemCount = 0;\n\n    // Check which items should be included\n    for (const [id, value] of allItems.value) {\n        const score = contains(value, filterState.search);\n        filterState.filtered.items.set(id, score ? 1 : 0);\n        if (score) itemCount++;\n    }\n\n    // Check which groups have at least 1 item shown\n    for (const [groupId, group] of allGroups.value) {\n        for (const itemId of group) {\n            if (filterState.filtered.items.get(itemId)! > 0) {\n                filterState.filtered.groups.add(groupId);\n                break;\n            }\n        }\n    }\n\n    filterState.filtered.count = itemCount;\n}\n\nwatch(\n    () => filterState.search,\n    (newSearch) => {\n        filterItems();\n        emits('update:searchTerm', newSearch);\n    }\n);\n\n// Re-run filter when items are dynamically added/removed (e.g. entity search results)\nwatch(\n    allItems,\n    () => {\n        if (filterState.search) {\n            filterItems();\n        }\n    },\n    { deep: true }\n);\n\n// Sync external searchTerm prop changes to internal state\nwatch(\n    () => props.searchTerm,\n    (newTerm) => {\n        if (newTerm !== filterState.search) {\n            filterState.search = newTerm || '';\n        }\n    }\n);\n\nprovideCommandContext({\n    allItems,\n    allGroups,\n    filterState,\n});\n</script>\n\n<template>\n    <ListboxRoot\n        v-bind=\"forwarded\"\n        :class=\"\n            cn(\n                'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',\n                props.class\n            )\n        \">\n        <slot />\n    </ListboxRoot>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/command/CommandGroup.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ListboxGroupProps } from 'reka-ui';\nimport type { HTMLAttributes } from 'vue';\nimport { reactiveOmit } from '@vueuse/core';\nimport { ListboxGroup, ListboxGroupLabel, useId } from 'reka-ui';\nimport { computed, onMounted, onUnmounted } from 'vue';\nimport { cn } from '../utils/cn';\nimport { provideCommandGroupContext, useCommand } from '.';\n\nconst props = defineProps<\n    ListboxGroupProps & {\n        class?: HTMLAttributes['class'];\n        heading?: string;\n    }\n>();\n\nconst delegatedProps = reactiveOmit(props, 'class');\n\nconst { allGroups, filterState } = useCommand();\nconst id = useId();\n\nconst isRender = computed(() => (!filterState.search ? true : filterState.filtered.groups.has(id)));\n\nprovideCommandGroupContext({ id });\nonMounted(() => {\n    if (!allGroups.value.has(id)) allGroups.value.set(id, new Set());\n});\nonUnmounted(() => {\n    allGroups.value.delete(id);\n});\n</script>\n\n<template>\n    <ListboxGroup\n        v-bind=\"delegatedProps\"\n        :id=\"id\"\n        :class=\"\n            cn(\n                'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',\n                props.class\n            )\n        \"\n        :hidden=\"isRender ? undefined : true\">\n        <ListboxGroupLabel\n            v-if=\"heading\"\n            class=\"px-2 py-1.5 text-xs font-medium text-muted-foreground\">\n            {{ heading }}\n        </ListboxGroupLabel>\n        <slot />\n    </ListboxGroup>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/command/CommandInput.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ListboxFilterProps } from 'reka-ui';\nimport type { HTMLAttributes } from 'vue';\nimport { reactiveOmit } from '@vueuse/core';\nimport { Search } from 'lucide-vue-next';\nimport { ListboxFilter, useForwardProps } from 'reka-ui';\nimport { cn } from '../utils/cn';\nimport { useCommand } from '.';\n\ndefineOptions({\n    inheritAttrs: false,\n});\n\nconst props = defineProps<\n    ListboxFilterProps & {\n        class?: HTMLAttributes['class'];\n    }\n>();\n\nconst delegatedProps = reactiveOmit(props, 'class');\n\nconst forwardedProps = useForwardProps(delegatedProps);\n\nconst { filterState } = useCommand();\n</script>\n\n<template>\n    <div class=\"flex items-center border-b border-border-tertiary px-3\" cmdk-input-wrapper>\n        <Search class=\"mr-1.5 h-4 w-4 shrink-0 opacity-50\" />\n        <ListboxFilter\n            v-bind=\"{ ...forwardedProps, ...$attrs }\"\n            v-model=\"filterState.search\"\n            auto-focus\n            :class=\"\n                cn(\n                    'flex h-10 w-full rounded-md bg-transparent py-3 text-sm border-none outline-none ring-0 focus:border-none focus:outline-none focus:ring-0 placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',\n                    props.class\n                )\n            \" />\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/command/CommandItem.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ListboxItemEmits, ListboxItemProps } from 'reka-ui';\nimport type { HTMLAttributes } from 'vue';\nimport { reactiveOmit, useCurrentElement } from '@vueuse/core';\nimport { ListboxItem, useForwardPropsEmits, useId } from 'reka-ui';\nimport { computed, onMounted, onUnmounted, ref } from 'vue';\nimport { cn } from '../utils/cn';\nimport { useCommand, useCommandGroup } from '.';\n\nconst props = defineProps<ListboxItemProps & { class?: HTMLAttributes['class'] }>();\nconst emits = defineEmits<ListboxItemEmits>();\n\nconst delegatedProps = reactiveOmit(props, 'class');\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits);\n\nconst id = useId();\nconst { filterState, allItems, allGroups } = useCommand();\nconst groupContext = useCommandGroup();\n\nconst isRender = computed(() => {\n    if (!filterState.search) {\n        return true;\n    } else {\n        const filteredCurrentItem = filterState.filtered.items.get(id);\n        // If the filtered items is undefined means not in the all times map yet\n        // Do the first render to add into the map\n        if (filteredCurrentItem === undefined) {\n            return true;\n        }\n\n        // Check with filter\n        return filteredCurrentItem > 0;\n    }\n});\n\nconst itemRef = ref();\nconst currentElement = useCurrentElement(itemRef);\nonMounted(() => {\n    if (!(currentElement.value instanceof HTMLElement)) return;\n\n    // textValue to perform filter\n    allItems.value.set(id, currentElement.value.textContent ?? props?.value!.toString());\n\n    const groupId = groupContext?.id;\n    if (groupId) {\n        if (!allGroups.value.has(groupId)) {\n            allGroups.value.set(groupId, new Set([id]));\n        } else {\n            allGroups.value.get(groupId)?.add(id);\n        }\n    }\n});\nonUnmounted(() => {\n    allItems.value.delete(id);\n});\n</script>\n\n<template>\n    <ListboxItem\n        v-if=\"isRender\"\n        v-bind=\"forwarded\"\n        :id=\"id\"\n        ref=\"itemRef\"\n        :class=\"\n            cn(\n                'relative flex cursor-default gap-1.5 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg]:text-icon-default data-[highlighted]:[&_svg]:text-icon-active data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0',\n                props.class\n            )\n        \"\n        @select=\"\n            () => {\n                filterState.search = '';\n            }\n        \">\n        <slot />\n    </ListboxItem>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/command/CommandList.vue",
    "content": "<script setup lang=\"ts\">\nimport type { ListboxContentProps } from 'reka-ui';\nimport type { HTMLAttributes } from 'vue';\nimport { reactiveOmit } from '@vueuse/core';\nimport { ListboxContent, useForwardProps } from 'reka-ui';\nimport { cn } from '../utils/cn';\n\nconst props = defineProps<ListboxContentProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = reactiveOmit(props, 'class');\n\nconst forwarded = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <ListboxContent\n        v-bind=\"forwarded\"\n        :class=\"cn('max-h-[300px] overflow-y-auto overflow-x-hidden', props.class)\">\n        <div role=\"presentation\">\n            <slot />\n        </div>\n    </ListboxContent>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/command/CommandSeparator.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SeparatorProps } from 'reka-ui';\nimport type { HTMLAttributes } from 'vue';\nimport { reactiveOmit } from '@vueuse/core';\nimport { Separator } from 'reka-ui';\nimport { cn } from '../utils/cn';\n\nconst props = defineProps<SeparatorProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = reactiveOmit(props, 'class');\n</script>\n\n<template>\n    <Separator v-bind=\"delegatedProps\" :class=\"cn('-mx-1 h-px bg-border', props.class)\">\n        <slot />\n    </Separator>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/command/CommandShortcut.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport { cn } from '../utils/cn';\n\nconst props = defineProps<{\n    class?: HTMLAttributes['class'];\n}>();\n</script>\n\n<template>\n    <span :class=\"cn('ml-auto text-xs tracking-widest text-muted-foreground', props.class)\">\n        <slot />\n    </span>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/command/index.ts",
    "content": "import type { Ref } from 'vue';\nimport { createContext } from 'reka-ui';\n\nexport { default as Command } from './Command.vue';\nexport { default as CommandGroup } from './CommandGroup.vue';\nexport { default as CommandInput } from './CommandInput.vue';\nexport { default as CommandItem } from './CommandItem.vue';\nexport { default as CommandList } from './CommandList.vue';\nexport { default as CommandSeparator } from './CommandSeparator.vue';\nexport { default as CommandShortcut } from './CommandShortcut.vue';\n\nexport const [useCommand, provideCommandContext] = createContext<{\n    allItems: Ref<Map<string, string>>;\n    allGroups: Ref<Map<string, Set<string>>>;\n    filterState: {\n        search: string;\n        filtered: {\n            count: number;\n            items: Map<string, number>;\n            groups: Set<string>;\n        };\n    };\n}>('Command');\n\nexport const [useCommandGroup, provideCommandGroupContext] = createContext<{\n    id?: string;\n}>('CommandGroup');\n"
  },
  {
    "path": "resources/js/packages/ui/src/field/Field.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport type { FieldVariants } from '.';\nimport { cn } from '@/lib/utils';\nimport { fieldVariants } from '.';\n\nconst props = defineProps<{\n    class?: HTMLAttributes['class'];\n    orientation?: FieldVariants['orientation'];\n}>();\n</script>\n\n<template>\n    <div\n        role=\"group\"\n        data-slot=\"field\"\n        :data-orientation=\"orientation\"\n        :class=\"cn(fieldVariants({ orientation }), props.class)\">\n        <slot />\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/field/FieldContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport { cn } from '@/lib/utils';\n\nconst props = defineProps<{\n    class?: HTMLAttributes['class'];\n}>();\n</script>\n\n<template>\n    <div\n        data-slot=\"field-content\"\n        :class=\"cn('group/field-content flex flex-1 flex-col gap-1.5 leading-snug', props.class)\">\n        <slot />\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/field/FieldDescription.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport { cn } from '@/lib/utils';\n\nconst props = defineProps<{\n    class?: HTMLAttributes['class'];\n}>();\n</script>\n\n<template>\n    <p\n        data-slot=\"field-description\"\n        :class=\"\n            cn(\n                'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',\n                'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',\n                '[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',\n                props.class\n            )\n        \">\n        <slot />\n    </p>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/field/FieldError.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport { computed } from 'vue';\nimport { cn } from '@/lib/utils';\n\nconst props = defineProps<{\n    class?: HTMLAttributes['class'];\n    errors?: Array<{ message?: string } | undefined>;\n}>();\n\nconst content = computed(() => {\n    if (!props.errors || props.errors.length === 0) return null;\n\n    if (props.errors.length === 1 && props.errors[0]?.message) {\n        return props.errors[0].message;\n    }\n\n    return props.errors.some((e) => e?.message) ? props.errors : null;\n});\n</script>\n\n<template>\n    <div\n        v-if=\"$slots.default || content\"\n        role=\"alert\"\n        data-slot=\"field-error\"\n        :class=\"cn('text-destructive text-sm font-normal', props.class)\">\n        <slot v-if=\"$slots.default\" />\n\n        <template v-else-if=\"typeof content === 'string'\">\n            {{ content }}\n        </template>\n\n        <ul v-else-if=\"Array.isArray(content)\" class=\"ml-4 flex list-disc flex-col gap-1\">\n            <li v-for=\"(error, index) in content\" :key=\"index\">\n                {{ error?.message }}\n            </li>\n        </ul>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/field/FieldGroup.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport { cn } from '@/lib/utils';\n\nconst props = defineProps<{\n    class?: HTMLAttributes['class'];\n}>();\n</script>\n\n<template>\n    <div\n        data-slot=\"field-group\"\n        :class=\"\n            cn(\n                'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',\n                props.class\n            )\n        \">\n        <slot />\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/field/FieldLabel.vue",
    "content": "<script setup lang=\"ts\">\nimport type { Component, HTMLAttributes } from 'vue';\nimport { cn } from '@/lib/utils';\nimport { Label } from '@/Components/ui/label';\n\nconst props = defineProps<{\n    class?: HTMLAttributes['class'];\n    icon?: Component;\n}>();\n</script>\n\n<template>\n    <Label\n        data-slot=\"field-label\"\n        :class=\"\n            cn(\n                'group/field-label peer/field-label flex w-fit gap-1.5 leading-snug group-data-[disabled=true]/field:opacity-50',\n                'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&_>[data-slot=field]]:p-3',\n                'has-[[data-state=checked]]:bg-primary/5 has-[[data-state=checked]]:border-primary dark:has-[[data-state=checked]]:bg-primary/10',\n                icon ? 'items-center' : '',\n                props.class\n            )\n        \">\n        <component :is=\"icon\" v-if=\"icon\" class=\"h-4 w-4 text-text-quaternary shrink-0\" />\n        <slot />\n    </Label>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/field/FieldLegend.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport { cn } from '@/lib/utils';\n\nconst props = defineProps<{\n    class?: HTMLAttributes['class'];\n    variant?: 'legend' | 'label';\n}>();\n</script>\n\n<template>\n    <legend\n        data-slot=\"field-legend\"\n        :data-variant=\"variant\"\n        :class=\"\n            cn(\n                'mb-3 font-medium',\n                'data-[variant=legend]:text-base',\n                'data-[variant=label]:text-sm',\n                props.class\n            )\n        \">\n        <slot />\n    </legend>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/field/FieldSeparator.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport { cn } from '@/lib/utils';\nimport { Separator } from '../separator';\n\nconst props = defineProps<{\n    class?: HTMLAttributes['class'];\n}>();\n</script>\n\n<template>\n    <div\n        data-slot=\"field-separator\"\n        :data-content=\"!!$slots.default\"\n        :class=\"\n            cn(\n                'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',\n                props.class\n            )\n        \">\n        <Separator class=\"absolute inset-0 top-1/2\" />\n        <span\n            v-if=\"$slots.default\"\n            class=\"bg-background text-muted-foreground relative mx-auto block w-fit px-2\"\n            data-slot=\"field-separator-content\">\n            <slot />\n        </span>\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/field/FieldSet.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport { cn } from '@/lib/utils';\n\nconst props = defineProps<{\n    class?: HTMLAttributes['class'];\n}>();\n</script>\n\n<template>\n    <fieldset\n        data-slot=\"field-set\"\n        :class=\"\n            cn(\n                'flex flex-col gap-6',\n                'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',\n                props.class\n            )\n        \">\n        <slot />\n    </fieldset>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/field/FieldTitle.vue",
    "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue';\nimport { cn } from '@/lib/utils';\n\nconst props = defineProps<{\n    class?: HTMLAttributes['class'];\n}>();\n</script>\n\n<template>\n    <div\n        data-slot=\"field-label\"\n        :class=\"\n            cn(\n                'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',\n                props.class\n            )\n        \">\n        <slot />\n    </div>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/field/index.ts",
    "content": "import type { VariantProps } from 'class-variance-authority';\nimport { cva } from 'class-variance-authority';\n\nexport const fieldVariants = cva(\n    'group/field flex w-full gap-3 data-[invalid=true]:text-destructive',\n    {\n        variants: {\n            orientation: {\n                vertical: ['flex-col [&>*]:w-full [&>.sr-only]:w-auto'],\n                horizontal: [\n                    'flex-row items-center',\n                    '[&>[data-slot=field-label]]:flex-auto',\n                    'has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',\n                ],\n                responsive: [\n                    'flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto',\n                    '@md/field-group:[&>[data-slot=field-label]]:flex-auto',\n                    '@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',\n                ],\n            },\n        },\n        defaultVariants: {\n            orientation: 'vertical',\n        },\n    }\n);\n\nexport type FieldVariants = VariantProps<typeof fieldVariants>;\n\nexport { default as Field } from './Field.vue';\nexport { default as FieldContent } from './FieldContent.vue';\nexport { default as FieldDescription } from './FieldDescription.vue';\nexport { default as FieldError } from './FieldError.vue';\nexport { default as FieldGroup } from './FieldGroup.vue';\nexport { default as FieldLabel } from './FieldLabel.vue';\nexport { default as FieldLegend } from './FieldLegend.vue';\nexport { default as FieldSeparator } from './FieldSeparator.vue';\nexport { default as FieldSet } from './FieldSet.vue';\nexport { default as FieldTitle } from './FieldTitle.vue';\n"
  },
  {
    "path": "resources/js/packages/ui/src/index.ts",
    "content": "declare global {\n    interface Window {\n        getWeekStartSetting: () => string;\n        getTimezoneSetting: () => string;\n    }\n}\n\nimport * as money from './utils/money';\nimport * as color from './utils/color';\nimport * as random from './utils/random';\nimport * as time from './utils/time';\n\nexport { cn } from './utils/cn';\nexport { buttonVariants, type ButtonVariants } from './Buttons/index';\n\nimport PrimaryButton from './Buttons/PrimaryButton.vue';\nimport SecondaryButton from './Buttons/SecondaryButton.vue';\nimport Button from './Buttons/Button.vue';\nimport TimeTrackerStartStop from './TimeTrackerStartStop.vue';\nimport ProjectBadge from './Project/ProjectBadge.vue';\nimport LoadingSpinner from './LoadingSpinner.vue';\nimport Modal from './Modal.vue';\nimport TextInput from './Input/TextInput.vue';\nimport InputLabel from './Input/InputLabel.vue';\nimport TimeTrackerRunningInDifferentOrganizationOverlay from './TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue';\nimport TimeTrackerControls from './TimeTracker/TimeTrackerControls.vue';\nimport TimeTrackerMoreOptionsDropdown from './TimeTracker/TimeTrackerMoreOptionsDropdown.vue';\nimport CardTitle from './CardTitle.vue';\nimport Badge from './Badge.vue';\nimport Checkbox from './Input/Checkbox.vue';\nimport TimeEntryGroupedTable from './TimeEntry/TimeEntryGroupedTable.vue';\nimport TimeEntryMassActionRow from './TimeEntry/TimeEntryMassActionRow.vue';\nimport TimeEntryCreateModal from './TimeEntry/TimeEntryCreateModal.vue';\nimport TimeEntryEditModal from './TimeEntry/TimeEntryEditModal.vue';\n\nimport FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';\nimport FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';\nimport TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';\nimport CalendarSettingsPopover from './FullCalendar/CalendarSettingsPopover.vue';\nimport DateRangePicker from './Input/DateRangePicker.vue';\nimport TimezoneMismatchModal from './TimezoneMismatchModal.vue';\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index';\nimport { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './accordion/index';\nimport { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from './popover/index';\nimport { RangeCalendar } from './range-calendar/index';\nimport { CommandPalette } from './CommandPalette/index';\nimport { Separator } from './separator/index';\nimport {\n    Field,\n    FieldContent,\n    FieldDescription,\n    FieldError,\n    FieldGroup,\n    FieldLabel,\n    FieldLegend,\n    FieldSeparator,\n    FieldSet,\n    FieldTitle,\n    fieldVariants,\n} from './field/index';\nexport type { FieldVariants } from './field/index';\nexport type { ActivityPeriod } from './FullCalendar/idleStatusPlugin';\nexport type { CalendarSettings } from './FullCalendar/calendarSettings';\nexport type {\n    CommandPaletteCommand,\n    CommandPaletteGroup,\n    EntitySearchResult,\n} from './CommandPalette/index';\n\nexport {\n    money,\n    color,\n    random,\n    time,\n    Button,\n    PrimaryButton,\n    SecondaryButton,\n    TimeTrackerStartStop,\n    ProjectBadge,\n    LoadingSpinner,\n    Modal,\n    TextInput,\n    InputLabel,\n    TimeTrackerRunningInDifferentOrganizationOverlay,\n    TimeTrackerControls,\n    TimeTrackerMoreOptionsDropdown,\n    CardTitle,\n    Badge,\n    Checkbox,\n    TimeEntryGroupedTable,\n    TimeEntryMassActionRow,\n    TimeEntryCreateModal,\n    TimeEntryEditModal,\n    FullCalendarEventContent,\n    FullCalendarDayHeader,\n    TimeEntryCalendar,\n    CalendarSettingsPopover,\n    DateRangePicker,\n    TimezoneMismatchModal,\n    Tooltip,\n    TooltipContent,\n    TooltipProvider,\n    TooltipTrigger,\n    Accordion,\n    AccordionContent,\n    AccordionItem,\n    AccordionTrigger,\n    Popover,\n    PopoverContent,\n    PopoverTrigger,\n    PopoverAnchor,\n    RangeCalendar,\n    CommandPalette,\n    Separator,\n    Field,\n    FieldContent,\n    FieldDescription,\n    FieldError,\n    FieldGroup,\n    FieldLabel,\n    FieldLegend,\n    FieldSeparator,\n    FieldSet,\n    FieldTitle,\n    fieldVariants,\n};\n"
  },
  {
    "path": "resources/js/packages/ui/src/popover/Popover.vue",
    "content": "<script setup lang=\"ts\">\nimport type { PopoverRootEmits, PopoverRootProps } from 'reka-ui';\nimport { PopoverRoot, useForwardPropsEmits } from 'reka-ui';\n\nconst props = defineProps<PopoverRootProps>();\nconst emits = defineEmits<PopoverRootEmits>();\n\nconst forwarded = useForwardPropsEmits(props, emits);\n</script>\n\n<template>\n    <PopoverRoot v-bind=\"forwarded\">\n        <slot />\n    </PopoverRoot>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/popover/PopoverContent.vue",
    "content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils';\nimport {\n    PopoverContent,\n    type PopoverContentEmits,\n    type PopoverContentProps,\n    PopoverPortal,\n    useForwardPropsEmits,\n} from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\ndefineOptions({\n    inheritAttrs: false,\n});\n\nconst props = withDefaults(\n    defineProps<PopoverContentProps & { class?: HTMLAttributes['class'] }>(),\n    {\n        align: 'center',\n        sideOffset: 4,\n    }\n);\nconst emits = defineEmits<PopoverContentEmits>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits);\n</script>\n\n<template>\n    <PopoverPortal>\n        <PopoverContent\n            v-bind=\"{ ...forwarded, ...$attrs }\"\n            :class=\"\n                cn(\n                    'z-50 rounded-md border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n                    props.class\n                )\n            \">\n            <slot />\n        </PopoverContent>\n    </PopoverPortal>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/popover/PopoverTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport { PopoverTrigger, type PopoverTriggerProps } from 'reka-ui';\n\nconst props = defineProps<PopoverTriggerProps>();\n</script>\n\n<template>\n    <PopoverTrigger v-bind=\"props\">\n        <slot />\n    </PopoverTrigger>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/popover/index.ts",
    "content": "export { default as Popover } from './Popover.vue';\nexport { default as PopoverContent } from './PopoverContent.vue';\nexport { default as PopoverTrigger } from './PopoverTrigger.vue';\nexport { PopoverAnchor } from 'reka-ui';\n"
  },
  {
    "path": "resources/js/packages/ui/src/range-calendar/RangeCalendar.vue",
    "content": "<script lang=\"ts\" setup>\nimport { cn } from '@/lib/utils';\nimport {\n    RangeCalendarRoot,\n    type RangeCalendarRootEmits,\n    type RangeCalendarRootProps,\n    useForwardPropsEmits,\n} from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\nimport {\n    RangeCalendarCell,\n    RangeCalendarCellTrigger,\n    RangeCalendarGrid,\n    RangeCalendarGridBody,\n    RangeCalendarGridHead,\n    RangeCalendarGridRow,\n    RangeCalendarHeadCell,\n    RangeCalendarHeader,\n    RangeCalendarHeading,\n    RangeCalendarNextButton,\n    RangeCalendarPrevButton,\n} from '.';\n\nconst props = defineProps<RangeCalendarRootProps & { class?: HTMLAttributes['class'] }>();\n\nconst emits = defineEmits<RangeCalendarRootEmits>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits);\n</script>\n\n<template>\n    <RangeCalendarRoot\n        v-slot=\"{ grid, weekDays }\"\n        :class=\"cn('p-3', props.class)\"\n        v-bind=\"forwarded\">\n        <RangeCalendarHeader>\n            <RangeCalendarPrevButton />\n            <RangeCalendarHeading />\n            <RangeCalendarNextButton />\n        </RangeCalendarHeader>\n\n        <div class=\"flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0\">\n            <RangeCalendarGrid v-for=\"month in grid\" :key=\"month.value.toString()\">\n                <RangeCalendarGridHead>\n                    <RangeCalendarGridRow>\n                        <RangeCalendarHeadCell v-for=\"day in weekDays\" :key=\"day\">\n                            {{ day }}\n                        </RangeCalendarHeadCell>\n                    </RangeCalendarGridRow>\n                </RangeCalendarGridHead>\n                <RangeCalendarGridBody>\n                    <RangeCalendarGridRow\n                        v-for=\"(weekDates, index) in month.rows\"\n                        :key=\"`weekDate-${index}`\"\n                        class=\"mt-2 w-full\">\n                        <RangeCalendarCell\n                            v-for=\"weekDate in weekDates\"\n                            :key=\"weekDate.toString()\"\n                            :date=\"weekDate\">\n                            <RangeCalendarCellTrigger :day=\"weekDate\" :month=\"month.value\" />\n                        </RangeCalendarCell>\n                    </RangeCalendarGridRow>\n                </RangeCalendarGridBody>\n            </RangeCalendarGrid>\n        </div>\n    </RangeCalendarRoot>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/range-calendar/RangeCalendarCell.vue",
    "content": "<script lang=\"ts\" setup>\nimport { cn } from '@/lib/utils';\nimport { RangeCalendarCell, type RangeCalendarCellProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<RangeCalendarCellProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <RangeCalendarCell\n        :class=\"\n            cn(\n                'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:bg-accent first:[&:has([data-selected])]:rounded-l-md last:[&:has([data-selected])]:rounded-r-md [&:has([data-selected][data-outside-view])]:bg-accent/50 [&:has([data-selected][data-selection-end])]:rounded-r-md [&:has([data-selected][data-selection-start])]:rounded-l-md',\n                props.class\n            )\n        \"\n        v-bind=\"forwardedProps\">\n        <slot />\n    </RangeCalendarCell>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/range-calendar/RangeCalendarCellTrigger.vue",
    "content": "<script lang=\"ts\" setup>\nimport { cn, buttonVariants } from '@/packages/ui/src';\nimport {\n    RangeCalendarCellTrigger,\n    type RangeCalendarCellTriggerProps,\n    useForwardProps,\n} from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<RangeCalendarCellTriggerProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <RangeCalendarCellTrigger\n        :class=\"\n            cn(\n                buttonVariants({ variant: 'ghost' }),\n                'h-8 w-8 p-0 font-normal data-[selected]:opacity-100',\n                '[&[data-today]:not([data-selected])]:border-accent [&[data-today]:not([data-selected])]:border [&[data-today]:not([data-selected])]:text-accent-foreground',\n                // Selection Start\n                'data-[selection-start]:bg-quaternary data-[selection-start]:text-primary-foreground data-[selection-start]:hover:bg-quaternary data-[selection-start]:hover:text-primary-foreground data-[selection-start]:focus:bg-primary data-[selection-start]:focus:text-primary-foreground',\n                // Selection End\n                'data-[selection-end]:bg-quaternary data-[selection-end]:text-primary-foreground data-[selection-end]:hover:bg-quaternary data-[selection-end]:hover:text-primary-foreground data-[selection-end]:focus:bg-primary data-[selection-end]:focus:text-primary-foreground',\n                // Outside months\n                'data-[outside-view]:text-muted-foreground data-[outside-view]:opacity-50 [&[data-outside-view][data-selected]]:text-muted-foreground [&[data-outside-view][data-selected]]:opacity-30',\n                // Disabled\n                'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',\n                // Unavailable\n                'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',\n                props.class\n            )\n        \"\n        v-bind=\"forwardedProps\">\n        <slot />\n    </RangeCalendarCellTrigger>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/range-calendar/RangeCalendarGrid.vue",
    "content": "<script lang=\"ts\" setup>\nimport { cn } from '@/lib/utils';\nimport { RangeCalendarGrid, type RangeCalendarGridProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<RangeCalendarGridProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <RangeCalendarGrid\n        :class=\"cn('w-full border-collapse space-y-1', props.class)\"\n        v-bind=\"forwardedProps\">\n        <slot />\n    </RangeCalendarGrid>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/range-calendar/RangeCalendarGridBody.vue",
    "content": "<script lang=\"ts\" setup>\nimport { RangeCalendarGridBody, type RangeCalendarGridBodyProps } from 'reka-ui';\n\nconst props = defineProps<RangeCalendarGridBodyProps>();\n</script>\n\n<template>\n    <RangeCalendarGridBody v-bind=\"props\">\n        <slot />\n    </RangeCalendarGridBody>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/range-calendar/RangeCalendarGridHead.vue",
    "content": "<script lang=\"ts\" setup>\nimport { RangeCalendarGridHead, type RangeCalendarGridHeadProps } from 'reka-ui';\n\nconst props = defineProps<RangeCalendarGridHeadProps>();\n</script>\n\n<template>\n    <RangeCalendarGridHead v-bind=\"props\">\n        <slot />\n    </RangeCalendarGridHead>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/range-calendar/RangeCalendarGridRow.vue",
    "content": "<script lang=\"ts\" setup>\nimport { cn } from '@/lib/utils';\nimport { RangeCalendarGridRow, type RangeCalendarGridRowProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<RangeCalendarGridRowProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <RangeCalendarGridRow :class=\"cn('flex', props.class)\" v-bind=\"forwardedProps\">\n        <slot />\n    </RangeCalendarGridRow>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/range-calendar/RangeCalendarHeadCell.vue",
    "content": "<script lang=\"ts\" setup>\nimport { cn } from '@/lib/utils';\nimport { RangeCalendarHeadCell, type RangeCalendarHeadCellProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<RangeCalendarHeadCellProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <RangeCalendarHeadCell\n        :class=\"cn('w-8 rounded-md text-[0.8rem] font-normal text-muted-foreground', props.class)\"\n        v-bind=\"forwardedProps\">\n        <slot />\n    </RangeCalendarHeadCell>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/range-calendar/RangeCalendarHeader.vue",
    "content": "<script lang=\"ts\" setup>\nimport { cn } from '@/lib/utils';\nimport { RangeCalendarHeader, type RangeCalendarHeaderProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<RangeCalendarHeaderProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <RangeCalendarHeader\n        :class=\"cn('relative flex w-full items-center justify-between pt-1', props.class)\"\n        v-bind=\"forwardedProps\">\n        <slot />\n    </RangeCalendarHeader>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/range-calendar/RangeCalendarHeading.vue",
    "content": "<script lang=\"ts\" setup>\nimport { cn } from '@/lib/utils';\nimport { RangeCalendarHeading, type RangeCalendarHeadingProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<RangeCalendarHeadingProps & { class?: HTMLAttributes['class'] }>();\n\ndefineSlots<{\n    default: (props: { headingValue: string }) => unknown;\n}>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <RangeCalendarHeading\n        v-slot=\"{ headingValue }\"\n        :class=\"cn('text-sm font-medium', props.class)\"\n        v-bind=\"forwardedProps\">\n        <slot :heading-value>\n            {{ headingValue }}\n        </slot>\n    </RangeCalendarHeading>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/range-calendar/RangeCalendarNextButton.vue",
    "content": "<script lang=\"ts\" setup>\nimport { cn, buttonVariants } from '@/packages/ui/src';\nimport { ChevronRight } from 'lucide-vue-next';\nimport { RangeCalendarNext, type RangeCalendarNextProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<RangeCalendarNextProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <RangeCalendarNext\n        :class=\"\n            cn(\n                buttonVariants({ variant: 'outline' }),\n                'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',\n                props.class\n            )\n        \"\n        v-bind=\"forwardedProps\">\n        <slot>\n            <ChevronRight class=\"h-4 w-4\" />\n        </slot>\n    </RangeCalendarNext>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/range-calendar/RangeCalendarPrevButton.vue",
    "content": "<script lang=\"ts\" setup>\nimport { cn, buttonVariants } from '@/packages/ui/src';\nimport { ChevronLeft } from 'lucide-vue-next';\nimport { RangeCalendarPrev, type RangeCalendarPrevProps, useForwardProps } from 'reka-ui';\nimport { computed, type HTMLAttributes } from 'vue';\n\nconst props = defineProps<RangeCalendarPrevProps & { class?: HTMLAttributes['class'] }>();\n\nconst delegatedProps = computed(() => {\n    const { class: _, ...delegated } = props;\n\n    return delegated;\n});\n\nconst forwardedProps = useForwardProps(delegatedProps);\n</script>\n\n<template>\n    <RangeCalendarPrev\n        :class=\"\n            cn(\n                buttonVariants({ variant: 'outline' }),\n                'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',\n                props.class\n            )\n        \"\n        v-bind=\"forwardedProps\">\n        <slot>\n            <ChevronLeft class=\"h-4 w-4\" />\n        </slot>\n    </RangeCalendarPrev>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/range-calendar/index.ts",
    "content": "export { default as RangeCalendar } from './RangeCalendar.vue';\nexport { default as RangeCalendarCell } from './RangeCalendarCell.vue';\nexport { default as RangeCalendarCellTrigger } from './RangeCalendarCellTrigger.vue';\nexport { default as RangeCalendarGrid } from './RangeCalendarGrid.vue';\nexport { default as RangeCalendarGridBody } from './RangeCalendarGridBody.vue';\nexport { default as RangeCalendarGridHead } from './RangeCalendarGridHead.vue';\nexport { default as RangeCalendarGridRow } from './RangeCalendarGridRow.vue';\nexport { default as RangeCalendarHeadCell } from './RangeCalendarHeadCell.vue';\nexport { default as RangeCalendarHeader } from './RangeCalendarHeader.vue';\nexport { default as RangeCalendarHeading } from './RangeCalendarHeading.vue';\nexport { default as RangeCalendarNextButton } from './RangeCalendarNextButton.vue';\nexport { default as RangeCalendarPrevButton } from './RangeCalendarPrevButton.vue';\n"
  },
  {
    "path": "resources/js/packages/ui/src/separator/Separator.vue",
    "content": "<script setup lang=\"ts\">\nimport type { SeparatorProps } from 'reka-ui';\nimport type { HTMLAttributes } from 'vue';\nimport { reactiveOmit } from '@vueuse/core';\nimport { Separator } from 'reka-ui';\nimport { cn } from '@/lib/utils';\n\nconst props = withDefaults(defineProps<SeparatorProps & { class?: HTMLAttributes['class'] }>(), {\n    orientation: 'horizontal',\n    decorative: true,\n});\n\nconst delegatedProps = reactiveOmit(props, 'class');\n</script>\n\n<template>\n    <Separator\n        v-bind=\"delegatedProps\"\n        :class=\"\n            cn(\n                'shrink-0 bg-border',\n                props.orientation === 'horizontal' ? 'h-px w-full' : 'w-px h-full',\n                props.class\n            )\n        \" />\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/separator/index.ts",
    "content": "export { default as Separator } from './Separator.vue';\n"
  },
  {
    "path": "resources/js/packages/ui/src/tooltip/Tooltip.vue",
    "content": "<script setup lang=\"ts\">\nimport type { TooltipRootEmits, TooltipRootProps } from 'reka-ui';\nimport { TooltipRoot, useForwardPropsEmits } from 'reka-ui';\n\nconst props = defineProps<TooltipRootProps>();\nconst emits = defineEmits<TooltipRootEmits>();\n\nconst forwarded = useForwardPropsEmits(props, emits);\n</script>\n\n<template>\n    <TooltipRoot v-bind=\"forwarded\">\n        <slot />\n    </TooltipRoot>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/tooltip/TooltipContent.vue",
    "content": "<script setup lang=\"ts\">\nimport type { TooltipContentEmits, TooltipContentProps } from 'reka-ui';\nimport type { HTMLAttributes } from 'vue';\nimport { reactiveOmit } from '@vueuse/core';\nimport { TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui';\nimport { cn } from '@/lib/utils';\n\ndefineOptions({\n    inheritAttrs: false,\n});\n\nconst props = withDefaults(\n    defineProps<TooltipContentProps & { class?: HTMLAttributes['class'] }>(),\n    {\n        sideOffset: 4,\n    }\n);\n\nconst emits = defineEmits<TooltipContentEmits>();\n\nconst delegatedProps = reactiveOmit(props, 'class');\n\nconst forwarded = useForwardPropsEmits(delegatedProps, emits);\n</script>\n\n<template>\n    <TooltipPortal>\n        <TooltipContent\n            v-bind=\"{ ...forwarded, ...$attrs }\"\n            :class=\"\n                cn(\n                    'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n                    props.class\n                )\n            \">\n            <slot />\n        </TooltipContent>\n    </TooltipPortal>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/tooltip/TooltipProvider.vue",
    "content": "<script setup lang=\"ts\">\nimport type { TooltipProviderProps } from 'reka-ui';\nimport { TooltipProvider } from 'reka-ui';\n\nconst props = defineProps<TooltipProviderProps>();\n</script>\n\n<template>\n    <TooltipProvider v-bind=\"props\">\n        <slot />\n    </TooltipProvider>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/tooltip/TooltipTrigger.vue",
    "content": "<script setup lang=\"ts\">\nimport type { TooltipTriggerProps } from 'reka-ui';\nimport { TooltipTrigger } from 'reka-ui';\n\nconst props = defineProps<TooltipTriggerProps>();\n</script>\n\n<template>\n    <TooltipTrigger v-bind=\"props\">\n        <slot />\n    </TooltipTrigger>\n</template>\n"
  },
  {
    "path": "resources/js/packages/ui/src/tooltip/index.ts",
    "content": "export { default as Tooltip } from './Tooltip.vue';\nexport { default as TooltipContent } from './TooltipContent.vue';\nexport { default as TooltipProvider } from './TooltipProvider.vue';\nexport { default as TooltipTrigger } from './TooltipTrigger.vue';\n"
  },
  {
    "path": "resources/js/packages/ui/src/utils/cn.ts",
    "content": "import { type ClassValue, clsx } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n    return twMerge(clsx(inputs.filter(Boolean)));\n}\n"
  },
  {
    "path": "resources/js/packages/ui/src/utils/color.ts",
    "content": "import Prando from '@/packages/ui/src/utils/random';\n\nexport const colors = [\n    '#ef5350',\n    '#ec407a',\n    '#ab47bc',\n    '#7e57c2',\n    '#5c6bc0',\n    '#42a5f5',\n    '#29b6f6',\n    '#26c6da',\n    '#26a69a',\n    '#66bb6a',\n    '#9ccc65',\n    '#d4e157',\n    '#ffee58',\n    '#ffca28',\n    '#ffa726',\n    '#ff7043',\n    '#8d6e63',\n    '#bdbdbd',\n    '#78909c',\n];\n\nexport function getRandomColor() {\n    return colors[Math.floor(Math.random() * colors.length)]!;\n}\n\nexport function getRandomColorWithSeed(seed: string) {\n    const pseudoRandom = new Prando(seed);\n    const index = pseudoRandom.nextInt(0, colors.length - 1);\n    return colors[index]!;\n}\n"
  },
  {
    "path": "resources/js/packages/ui/src/utils/money.ts",
    "content": "import { formatNumber, type NumberFormat } from './number';\n\nexport type CurrencyFormat =\n    | 'iso-code-before-with-space'\n    | 'iso-code-after-with-space'\n    | 'symbol-before'\n    | 'symbol-after'\n    | 'symbol-before-with-space'\n    | 'symbol-after-with-space';\n\nfunction formatMoney(\n    amount: number,\n    currency?: string,\n    format?: CurrencyFormat,\n    currencySymbol?: string,\n    numberFormat?: NumberFormat\n) {\n    const formattedAmount = formatNumber(amount, numberFormat);\n\n    switch (format) {\n        case 'iso-code-before-with-space':\n            return `${currency} ${formattedAmount}`;\n        case 'iso-code-after-with-space':\n            return `${formattedAmount} ${currency}`;\n        case 'symbol-before':\n            return `${currencySymbol}${formattedAmount}`;\n        case 'symbol-after':\n            return `${formattedAmount}${currencySymbol}`;\n        case 'symbol-before-with-space':\n            return `${currencySymbol} ${formattedAmount}`;\n        case 'symbol-after-with-space':\n            return `${formattedAmount} ${currencySymbol}`;\n    }\n}\n\nexport function formatCents(\n    amount: number,\n    currency?: string,\n    format?: CurrencyFormat,\n    currencySymbol?: string,\n    numberFormat?: NumberFormat\n) {\n    return formatMoney(amount / 100, currency, format, currencySymbol, numberFormat);\n}\n\nexport function getOrganizationCurrencySymbol(currency: string) {\n    return (0)\n        .toLocaleString('de-DE', {\n            style: 'currency',\n            currency: currency,\n            minimumFractionDigits: 0,\n            maximumFractionDigits: 0,\n        })\n        .replace(/\\d/g, '')\n        .trim();\n}\n"
  },
  {
    "path": "resources/js/packages/ui/src/utils/number.ts",
    "content": "export type NumberFormat =\n    | 'point-comma'\n    | 'comma-point'\n    | 'space-comma'\n    | 'space-point'\n    | 'apostrophe-point';\n\n/**\n * Formats a number according to the specified format\n * @param value - The number to format\n * @param format - The format to use\n * @returns The formatted number as a string\n */\nexport function formatNumber(value: number, format?: string): string {\n    // Convert to fixed 2 decimal places first\n    const parts = value.toFixed(2).split('.');\n    const wholePart = parts[0] ?? '0';\n    const decimalPart = parts[1] ?? '00';\n\n    // Format the whole number part based on the format\n    let formattedWhole: string;\n    switch (format) {\n        case 'point-comma':\n            formattedWhole = wholePart.replace(/\\B(?=(\\d{3})+(?!\\d))/g, '.');\n            return `${formattedWhole},${decimalPart}`;\n        case 'comma-point':\n            formattedWhole = wholePart.replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',');\n            return `${formattedWhole}.${decimalPart}`;\n        case 'space-comma':\n            formattedWhole = wholePart.replace(/\\B(?=(\\d{3})+(?!\\d))/g, ' ');\n            return `${formattedWhole},${decimalPart}`;\n        case 'space-point':\n            formattedWhole = wholePart.replace(/\\B(?=(\\d{3})+(?!\\d))/g, ' ');\n            return `${formattedWhole}.${decimalPart}`;\n        case 'apostrophe-point':\n            formattedWhole = wholePart.replace(/\\B(?=(\\d{3})+(?!\\d))/g, \"'\");\n            return `${formattedWhole}.${decimalPart}`;\n        default:\n            return value.toString();\n    }\n}\n"
  },
  {
    "path": "resources/js/packages/ui/src/utils/random.ts",
    "content": "/**\n * This is a hardfork of Prando, a pseudo-random number generator.\n * @source https://github.com/zeh/prando\n */\n\nexport default class Prando {\n    private static readonly MIN: number = -2147483648; // Int32 min\n    private static readonly MAX: number = 2147483647; // Int32 max\n\n    private _seed: number;\n    private _value = NaN;\n\n    // ================================================================================================================\n    // CONSTRUCTOR ----------------------------------------------------------------------------------------------------\n\n    /**\n     * Generate a new Prando pseudo-random number generator.\n     *\n     * @param seed - A number or string seed that determines which pseudo-random number sequence will be created. Defaults to a random seed based on `Math.random()`.\n     */\n    constructor(seed?: number | string) {\n        if (typeof seed === 'string') {\n            // String seed\n            this._seed = this.hashCode(seed);\n        } else if (typeof seed === 'number') {\n            // Numeric seed\n            this._seed = this.getSafeSeed(seed);\n        } else {\n            // Pseudo-random seed\n            this._seed = this.getSafeSeed(\n                Prando.MIN + Math.floor((Prando.MAX - Prando.MIN) * Math.random())\n            );\n        }\n        this.reset();\n    }\n\n    // ================================================================================================================\n    // PUBLIC INTERFACE -----------------------------------------------------------------------------------------------\n\n    /**\n     * Generates a pseudo-random number between a lower (inclusive) and a higher (exclusive) bounds.\n     *\n     * @param min - The minimum number that can be randomly generated.\n     * @param pseudoMax - The maximum number that can be randomly generated (exclusive).\n     * @return The generated pseudo-random number.\n     */\n    public next(min = 0, pseudoMax = 1): number {\n        this.recalculate();\n        return this.map(this._value, Prando.MIN, Prando.MAX, min, pseudoMax);\n    }\n\n    /**\n     * Generates a pseudo-random integer number in a range (inclusive).\n     *\n     * @param min - The minimum number that can be randomly generated.\n     * @param max - The maximum number that can be randomly generated.\n     * @return The generated pseudo-random number.\n     */\n    public nextInt(min = 10, max = 100): number {\n        this.recalculate();\n        return Math.floor(this.map(this._value, Prando.MIN, Prando.MAX, min, max + 1));\n    }\n\n    /**\n     * Generates a pseudo-random string sequence of a particular length from a specific character range.\n     *\n     * Note: keep in mind that creating a random string sequence does not guarantee uniqueness; there is always a\n     * 1 in (char_length^string_length) chance of collision. For real unique string ids, always check for\n     * pre-existing ids, or employ a robust GUID/UUID generator.\n     *\n     * @param length - Length of the string to be generated.\n     * @param chars - Characters that are used when creating the random string. Defaults to all alphanumeric chars (A-Z, a-z, 0-9).\n     * @return The generated string sequence.\n     */\n    public nextString(\n        length = 16,\n        chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'\n    ): string {\n        let str = '';\n        while (str.length < length) {\n            str += this.nextChar(chars);\n        }\n        return str;\n    }\n\n    /**\n     * Generates a pseudo-random string of 1 character specific character range.\n     *\n     * @param chars - Characters that are used when creating the random string. Defaults to all alphanumeric chars (A-Z, a-z, 0-9).\n     * @return The generated character.\n     */\n    public nextChar(\n        chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'\n    ): string {\n        return chars.substr(this.nextInt(0, chars.length - 1), 1);\n    }\n\n    /**\n     * Picks a pseudo-random item from an array. The array is left unmodified.\n     *\n     * Note: keep in mind that while the returned item will be random enough, picking one item from the array at a time\n     * does not guarantee nor imply that a sequence of random non-repeating items will be picked. If you want to\n     * *pick items in a random order* from an array, instead of *pick one random item from an array*, it's best to\n     * apply a *shuffle* transformation to the array instead, then read it linearly.\n     *\n     * @param array - Array of any type containing one or more candidates for random picking.\n     * @return An item from the array.\n     */\n    public nextArrayItem<T>(array: T[]): T {\n        return array[this.nextInt(0, array.length - 1)] as T;\n    }\n\n    /**\n     * Generates a pseudo-random boolean.\n     *\n     * @return A value of true or false.\n     */\n    public nextBoolean(): boolean {\n        this.recalculate();\n        return this._value > 0.5;\n    }\n\n    /**\n     * Skips ahead in the sequence of numbers that are being generated. This is equivalent to\n     * calling next() a specified number of times, but faster since it doesn't need to map the\n     * new random numbers to a range and return it.\n     *\n     * @param iterations - The number of items to skip ahead.\n     */\n    public skip(iterations = 1): void {\n        while (iterations-- > 0) {\n            this.recalculate();\n        }\n    }\n\n    /**\n     * Reset the pseudo-random number sequence back to its starting seed. Further calls to next()\n     * will then produce the same sequence of numbers it had produced before. This is equivalent to\n     * creating a new Prando instance with the same seed as another Prando instance.\n     *\n     * Example:\n     * let rng = new Prando(12345678);\n     * console.log(rng.next()); // 0.6177754114889017\n     * console.log(rng.next()); // 0.5784605181725837\n     * rng.reset();\n     * console.log(rng.next()); // 0.6177754114889017 again\n     * console.log(rng.next()); // 0.5784605181725837 again\n     */\n    public reset(): void {\n        this._value = this._seed;\n    }\n\n    // ================================================================================================================\n    // PRIVATE INTERFACE ----------------------------------------------------------------------------------------------\n\n    private recalculate(): void {\n        this._value = this.xorshift(this._value);\n    }\n\n    private xorshift(value: number): number {\n        // Xorshift*32\n        // Based on George Marsaglia's work: http://www.jstatsoft.org/v08/i14/paper\n        value ^= value << 13;\n        value ^= value >> 17;\n        value ^= value << 5;\n        return value;\n    }\n\n    private map(\n        val: number,\n        minFrom: number,\n        maxFrom: number,\n        minTo: number,\n        maxTo: number\n    ): number {\n        return ((val - minFrom) / (maxFrom - minFrom)) * (maxTo - minTo) + minTo;\n    }\n\n    private hashCode(str: string): number {\n        let hash = 0;\n        if (str) {\n            const l = str.length;\n            for (let i = 0; i < l; i++) {\n                hash = (hash << 5) - hash + str.charCodeAt(i);\n                hash |= 0;\n                hash = this.xorshift(hash);\n            }\n        }\n        return this.getSafeSeed(hash);\n    }\n\n    private getSafeSeed(seed: number): number {\n        if (seed === 0) return 1;\n        return seed;\n    }\n}\n"
  },
  {
    "path": "resources/js/packages/ui/src/utils/select.ts",
    "content": "import { computed, type Ref, watch } from 'vue';\nimport { onKeyStroke } from '@vueuse/core';\n\nexport function useSelectEvents<Type>(\n    filteredItems: Ref<Array<Type>>,\n    highlightedItemId: Ref<string | null>,\n    getKeyFromItem: (item: Type) => string,\n    open: Ref<boolean>\n) {\n    function moveHighlightUp() {\n        if (highlightedItem.value) {\n            const currentHightlightedIndex = filteredItems.value.indexOf(highlightedItem.value);\n            if (currentHightlightedIndex === 0) {\n                highlightedItemId.value = getKeyFromItem(\n                    filteredItems.value[filteredItems.value.length - 1]!\n                );\n            } else {\n                highlightedItemId.value = getKeyFromItem(\n                    filteredItems.value[currentHightlightedIndex - 1]!\n                );\n            }\n        } else {\n            highlightedItemId.value = getKeyFromItem(\n                filteredItems.value[filteredItems.value.length - 1]!\n            );\n        }\n    }\n\n    function moveHighlightDown() {\n        if (highlightedItem.value) {\n            const currentHightlightedIndex = filteredItems.value.indexOf(highlightedItem.value);\n            if (currentHightlightedIndex === filteredItems.value.length - 1) {\n                highlightedItemId.value = getKeyFromItem(filteredItems.value[0]!);\n            } else {\n                highlightedItemId.value = getKeyFromItem(\n                    filteredItems.value[currentHightlightedIndex + 1]!\n                );\n            }\n        } else {\n            highlightedItemId.value = getKeyFromItem(filteredItems.value[0]!);\n        }\n    }\n\n    const highlightedItem = computed(() => {\n        return filteredItems.value.find((item) => getKeyFromItem(item) === highlightedItemId.value);\n    });\n\n    onKeyStroke('ArrowDown', (e) => {\n        if (open.value === true) {\n            moveHighlightDown();\n            e.preventDefault();\n        }\n    });\n\n    onKeyStroke('ArrowUp', (e) => {\n        if (open.value === true) {\n            moveHighlightUp();\n            e.preventDefault();\n        }\n    });\n\n    watch(open, (newOpen) => {\n        if (newOpen === false) {\n            highlightedItemId.value = null;\n        }\n    });\n}\n"
  },
  {
    "path": "resources/js/packages/ui/src/utils/settings.ts",
    "content": "export function getWeekStart() {\n    const weekStart = window?.getWeekStartSetting() as string;\n\n    if (!weekStart) {\n        throw new Error(\n            'Please make sure to provide the current user week start setting as a vue inject (week_start)'\n        );\n    }\n    return weekStart;\n}\nexport function getUserTimezone() {\n    const timezone = window?.getTimezoneSetting() as string;\n    if (!timezone) {\n        throw new Error(\n            'Please make sure to provide the current user timezone as a vue inject (timezone)'\n        );\n    }\n    return timezone;\n}\n"
  },
  {
    "path": "resources/js/packages/ui/src/utils/time.ts",
    "content": "import dayjs from 'dayjs';\nimport duration from 'dayjs/plugin/duration';\nimport relativeTime from 'dayjs/plugin/relativeTime';\nimport isToday from 'dayjs/plugin/isToday';\nimport isYesterday from 'dayjs/plugin/isYesterday';\nimport utc from 'dayjs/plugin/utc';\nimport timezone from 'dayjs/plugin/timezone';\nimport weekOfYear from 'dayjs/plugin/weekOfYear';\nimport parse from 'parse-duration';\n\nimport { getUserTimezone, getWeekStart } from './settings';\nimport updateLocale from 'dayjs/plugin/updateLocale';\nimport { computed } from 'vue';\nimport { formatNumber } from './number';\n\nexport type DateFormat =\n    | 'point-separated-d-m-yyyy'\n    | 'slash-separated-mm-dd-yyyy'\n    | 'slash-separated-dd-mm-yyyy'\n    | 'hyphen-separated-dd-mm-yyyy'\n    | 'hyphen-separated-mm-dd-yyyy'\n    | 'hyphen-separated-yyyy-mm-dd';\n\n// Day of week index type for calendar components (0 = Sunday, 6 = Saturday)\nexport type WeekStartDay = 0 | 1 | 2 | 3 | 4 | 5 | 6;\n\nconst dateFormatMap: Record<DateFormat, string> = {\n    'point-separated-d-m-yyyy': 'D.M.YYYY',\n    'slash-separated-mm-dd-yyyy': 'MM/DD/YYYY',\n    'slash-separated-dd-mm-yyyy': 'DD/MM/YYYY',\n    'hyphen-separated-dd-mm-yyyy': 'DD-MM-YYYY',\n    'hyphen-separated-mm-dd-yyyy': 'MM-DD-YYYY',\n    'hyphen-separated-yyyy-mm-dd': 'YYYY-MM-DD',\n};\n\nexport type TimeFormat = '12-hours' | '24-hours';\nexport type IntervalFormat =\n    | 'decimal'\n    | 'hours-minutes'\n    | 'hours-minutes-colon-separated'\n    | 'hours-minutes-seconds-colon-separated';\nexport type TimeInputUnit = 'minutes' | 'hours';\n\ndayjs.extend(relativeTime);\ndayjs.extend(isToday);\ndayjs.extend(isYesterday);\ndayjs.extend(duration);\ndayjs.extend(utc);\ndayjs.extend(timezone);\ndayjs.extend(updateLocale);\ndayjs.extend(weekOfYear);\n\nexport function getDayJsInstance() {\n    dayjs.updateLocale('en', {\n        weekStart: firstDayIndex.value,\n    });\n    return dayjs;\n}\n\nexport const firstDayIndex = computed(() => {\n    const apiDayOrder = [\n        'sunday',\n        'monday',\n        'tuesday',\n        'wednesday',\n        'thursday',\n        'friday',\n        'saturday',\n    ];\n    return apiDayOrder.indexOf(getWeekStart());\n});\n\nexport function formatHumanReadableDuration(\n    duration: number,\n    intervalFormat?: string,\n    numberFormat?: string\n): string {\n    const dayJsDuration = dayjs.duration(duration, 's');\n    const hours = Math.floor(dayJsDuration.asHours());\n    const minutes = dayJsDuration.minutes();\n    const seconds = dayJsDuration.seconds();\n\n    switch (intervalFormat) {\n        case 'decimal':\n            return formatNumber(dayJsDuration.asHours(), numberFormat) + ' h';\n        case 'hours-minutes':\n            return `${hours}h ${minutes.toString().padStart(2, '0')}min`;\n        case 'hours-minutes-colon-separated':\n            return `${hours}:${minutes.toString().padStart(2, '0')}`;\n        case 'hours-minutes-seconds-colon-separated':\n            return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;\n        default:\n            return `${hours}h ${minutes.toString().padStart(2, '0')}min`;\n    }\n}\n\nexport function formatDuration(duration: number): string {\n    const dayJsDuration = dayjs.duration(duration, 's');\n    const hours = Math.floor(dayJsDuration.asHours());\n    const minutes = dayJsDuration.minutes();\n    const seconds = dayJsDuration.seconds();\n    return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;\n}\n\nexport function calculateDifference(start: string, end: string | null) {\n    if (end === null) {\n        end = dayjs().utc().format();\n    }\n    return dayjs(end).diff(dayjs(start), 'second');\n}\n\n/**\n * Returns a formatted time.\n * @param date - A UTC date time string.\n * @param timeFormat - The time format to use ('12-hours' or '24-hours')\n */\nexport function formatTime(date: string, timeFormat: TimeFormat = '24-hours') {\n    const format = timeFormat === '12-hours' ? 'hh:mm A' : 'HH:mm';\n    return dayjs.utc(date).tz(getUserTimezone()).format(format);\n}\n\nexport function getLocalizedDayJs(timestamp?: string | null) {\n    return dayjs.utc(timestamp).tz(getUserTimezone());\n}\n\nexport function getLocalizedDateFromTimestamp(timestamp: string) {\n    return getLocalizedDayJs(timestamp).format('YYYY-MM-DD');\n}\n\n/*\n * Returns a formatted date.\n * @param date - date in the format of 'YYYY-MM-DD'\n */\nexport function formatDate(date: string, format: DateFormat = 'point-separated-d-m-yyyy'): string {\n    if (date?.includes('+')) {\n        console.warn('Date contains timezone information, use formatDateLocalized instead');\n    }\n    return getDayJsInstance()(date).format(dateFormatMap[format]);\n}\n\n/*\n * Returns a formatted date.\n * @param date - date in the format of 'YYYY-MM-DD'\n */\nexport function formatDateLocalized(\n    date: string,\n    format: DateFormat = 'point-separated-d-m-yyyy'\n): string {\n    return getLocalizedDayJs(date).format(dateFormatMap[format]);\n}\n\nexport function formatDateTimeLocalized(\n    date: string,\n    dateFormat?: DateFormat,\n    timeFormat?: TimeFormat\n): string {\n    const format = `${dateFormatMap[dateFormat ?? 'point-separated-d-m-yyyy']} ${timeFormat === '12-hours' ? 'hh:mm A' : 'HH:mm'}`;\n    return getLocalizedDayJs(date).format(format);\n}\n\nexport function formatWeek(date: string | null): string {\n    return 'Week ' + getDayJsInstance()(date).week();\n}\n\n/*\n * Returns a human readable date format.\n * @param date - date in the format of 'YYYY-MM-DD'\n */\nexport function formatHumanReadableDate(date: string) {\n    const dateObj = dayjs(date);\n    const today = dayjs();\n\n    if (dateObj.isToday()) {\n        return 'Today';\n    } else if (dateObj.isYesterday()) {\n        return 'Yesterday';\n    }\n\n    // Calculate difference in days\n    const diffInDays = today.diff(dateObj, 'day');\n\n    if (diffInDays > 0 && diffInDays <= 30) {\n        // For dates in the past (2-30 days ago)\n        return `${diffInDays} ${diffInDays === 1 ? 'day' : 'days'} ago`;\n    } else if (diffInDays < 0 && diffInDays >= -30) {\n        // For dates in the future (within 30 days)\n        const futureDays = Math.abs(diffInDays);\n        return `In ${futureDays} ${futureDays === 1 ? 'day' : 'days'}`;\n    }\n\n    // For dates older than 30 days, show the actual date\n    return dateObj.format('MMM D, YYYY');\n}\n\nexport function formatWeekday(date: string) {\n    return dayjs(date).format('dddd');\n}\n\nexport function formatStartEnd(\n    start: string,\n    end: string | null,\n    timeFormat: TimeFormat = '24-hours'\n) {\n    if (end) {\n        return `${formatTime(start, timeFormat)} - ${formatTime(end, timeFormat)}`;\n    } else {\n        return `${formatTime(start, timeFormat)} - ...`;\n    }\n}\n\nexport function parseTimeInput(\n    input: string,\n    defaultUnit: TimeInputUnit = 'minutes'\n): number | null {\n    // Check if input is a decimal number (hours)\n    const decimalRegex = /^-?\\d+[.,]\\d+$/;\n    if (decimalRegex.test(input)) {\n        const hours = parseFloat(input.replace(',', '.'));\n        return Math.round(hours * 3600);\n    }\n\n    // Check if input is just a number (minutes or hours based on defaultUnit)\n    if (/^-?\\d+$/.test(input)) {\n        const value = parseInt(input);\n        return defaultUnit === 'minutes' ? value * 60 : value * 3600;\n    }\n\n    // Check if input is in HH:MM:SS format\n    const HHMMSStimeRegex = /^([0-9]{1,2}):([0-5]?[0-9]):([0-5]?[0-9])$/;\n    if (HHMMSStimeRegex.test(input)) {\n        const match = input.match(HHMMSStimeRegex);\n        if (match) {\n            const hours = parseInt(match[1]!);\n            const minutes = parseInt(match[2]!);\n            const seconds = parseInt(match[3]!);\n            return hours * 3600 + minutes * 60 + seconds;\n        }\n    }\n\n    // Check if input is in HH:MM format\n    const HHMMtimeRegex = /^([0-9]{1,2}):([0-5]?[0-9])$/;\n    if (HHMMtimeRegex.test(input)) {\n        const match = input.match(HHMMtimeRegex);\n        if (match) {\n            const hours = parseInt(match[1]!);\n            const minutes = parseInt(match[2]!);\n            return (hours * 60 + minutes) * 60;\n        }\n    }\n\n    // Try to parse natural language like \"1h 30m\"\n    const parsedDuration = parse(input, 's');\n    if (parsedDuration && parsedDuration > 0) {\n        return parsedDuration;\n    }\n\n    return null;\n}\n"
  },
  {
    "path": "resources/js/packages/ui/styles.css",
    "content": "/**\n * Shared styles for solidtime\n * This CSS file contains all the shared theme variables and base styles\n * used by both the main solidtime app and the desktop app.\n *\n * Font-face declarations are intentionally omitted here as they differ between apps:\n * - Main app uses 'Inter'\n * - Desktop app uses 'Outfit'\n * Each app should include their own font-face declarations.\n */\n\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n:root.dark {\n    --color-bg-primary: oklch(0.14 0.0041 285.97);\n    --color-bg-secondary: oklch(0.18 0.005 285.97);\n    --color-bg-tertiary: oklch(0.22 0.0112 285.97);\n    --color-bg-quaternary: oklch(0.26 0.015 285.97);\n    --color-bg-background: oklch(0.1 0 0);\n    --color-text-primary: #ffffff;\n    --color-text-secondary: #e3e4e6;\n    --color-text-tertiary: #969799;\n    --color-text-quaternary: #595a5c;\n\n    --color-border-primary: #191b1f;\n    --color-border-secondary: oklch(0.25 0.0098 268.31);\n    --color-border-tertiary: #2c2e33;\n    --color-border-quaternary: #393b42;\n    --color-input-border-active: rgba(255, 255, 255, 0.15);\n\n    --theme-color-chart: var(--color-accent-200);\n\n    --theme-color-menu-active: var(--color-bg-secondary);\n    --theme-color-card-background: var(--color-bg-secondary);\n    --theme-shadow-card: 0 4px 7px 0px rgb(0 0 0 / 15%);\n    --theme-shadow-dropdown: 0 4px 7px 0px rgb(0 0 0 / 40%);\n\n    --theme-color-card-background-active: var(--color-bg-tertiary);\n\n    --theme-color-row-background: var(--color-bg-primary);\n    --theme-color-row-heading-background: var(--color-bg-primary);\n    --theme-color-row-heading-border: var(--theme-color-card-border);\n    --theme-color-icon-default: var(--color-text-tertiary);\n\n    --theme-color-ring: rgba(255, 255, 255, 0.5);\n\n    --theme-color-button-primary-background: rgba(var(--color-accent-300), 0.1);\n    --theme-color-button-primary-background-hover: rgba(var(--color-accent-300), 0.2);\n    --theme-color-button-primary-border: rgba(var(--color-accent-300), 0.2);\n    --theme-color-button-primary-text: var(--color-text-primary);\n\n    --theme-color-input-background: var(--color-bg-secondary);\n\n    --theme-color-input-select-active: rgb(var(--color-accent-300));\n    --theme-color-input-select-active-hover: rgb(var(--color-accent-400));\n\n    --color-accent-default: rgba(var(--color-accent-300), 0.2);\n    --color-accent-foreground: rgb(var(--color-accent-100));\n    --theme-color-default-background: var(--color-bg-primary);\n}\n\n:root.light {\n    --color-bg-primary: #ffffff;\n    --color-bg-secondary: #fcfcfc;\n    --color-bg-tertiary: #eeeeef;\n    --color-bg-quaternary: #e1e1e3;\n    --color-bg-background: #f5f5f5;\n    --color-text-primary: #18181b;\n    --color-text-secondary: #3f3f46;\n    --color-text-tertiary: #57575c;\n    --color-text-quaternary: #a1a1aa;\n    --color-border-primary: #e7e7e7;\n    --color-border-secondary: #e5e5e5;\n    --color-border-tertiary: #dfdfdf;\n    --color-border-quaternary: #d1d1d1;\n    --color-input-border-active: rgba(0, 0, 0, 0.3);\n    --theme-color-menu-active: var(--color-bg-quaternary);\n\n    --theme-color-card-background: var(--color-bg-primary);\n    --theme-color-card-background-active: var(--color-bg-tertiary);\n\n    --theme-color-chart: var(--color-accent-400);\n\n    --theme-shadow-card: lch(0 0 0 / 0.022) 0px 3px 6px -2px, lch(0 0 0 / 0.044) 0px 1px 1px;\n    --theme-shadow-dropdown: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);\n\n    --theme-color-row-background: var(--theme-color-primary);\n    --theme-color-row-heading-background: var(--theme-color-primary);\n    --theme-color-row-heading-border: var(--color-border-tertiary);\n    --theme-color-icon-default: var(--color-text-quaternary);\n\n    --theme-color-ring: rgba(0, 0, 0, 0.7);\n\n    --theme-color-button-primary-background: rgba(var(--color-accent-600), 0.9);\n    --theme-color-button-primary-background-hover: rgba(var(--color-accent-600), 1);\n    --theme-color-button-primary-border: rgba(var(--color-accent-600), 1);\n    --theme-color-button-primary-text: #ffffff;\n\n    --theme-color-input-background: transparent;\n\n    --theme-color-input-select-active: rgb(var(--color-accent-400));\n    --theme-color-input-select-active-hover: rgb(var(--color-accent-500));\n\n    --color-accent-default: rgb(var(--color-accent-100));\n    --color-accent-foreground: rgb(var(--color-accent-800));\n    --theme-color-default-background: #fcfcfc;\n}\n\n:root {\n    --theme-color-icon-active: rgb(var(--color-text-tertiary));\n    --theme-color-card-background-separator: var(--color-border-tertiary);\n    --theme-color-card-border: var(--color-border-secondary);\n    --theme-color-card-border-active: var(--color-border-tertiary);\n    --theme-color-default-background-separator: var(--color-border-primary);\n    --theme-color-primary-text: var(--color-text-primary);\n    --theme-color-input-border: var(--color-border-quaternary);\n    --theme-color-tab-background: var(--theme-color-card-background);\n    --theme-color-tab-background-active: var(--theme-color-card-background-active);\n    --theme-color-tab-border: var(--theme-color-card-border);\n    --theme-color-row-separator-background: var(--theme-color-default-background-separator);\n    --theme-color-row-border: var(--theme-color-card-border);\n\n    --color-accent-50: 240, 249, 255; /* sky-50 */\n    --color-accent-100: 224, 242, 254; /* sky-100 */\n    --color-accent-200: 186, 230, 253; /* sky-200 */\n    --color-accent-300: 125, 211, 252; /* sky-300 */\n    --color-accent-400: 56, 189, 248; /* sky-400 */\n    --color-accent-500: 14, 165, 233; /* sky-500 */\n    --color-accent-600: 2, 132, 199; /* sky-600 */\n    --color-accent-700: 3, 105, 161; /* sky-700 */\n    --color-accent-800: 7, 89, 133; /* sky-800 */\n    --color-accent-900: 12, 74, 110; /* sky-900 */\n    --color-accent-950: 8, 47, 73; /* sky-950 */\n\n    --theme-button-secondary-background: var(--theme-color-card-background);\n    --theme-button-secondary-background-active: var(--theme-color-card-background-active);\n    --popover-border: var(--color-border-secondary);\n}\n\n* {\n    -webkit-font-smoothing: antialiased;\n    -moz-osx-font-smoothing: grayscale;\n    scrollbar-width: thin;\n    scrollbar-color: var(--color-bg-tertiary) transparent;\n}\n\n[x-cloak] {\n    display: none;\n}\n\nbody {\n    background-color: var(--theme-color-default-background);\n}\n\n@layer base {\n    :root {\n        --background: var(--color-bg-background);\n        --foreground: var(--color-text-primary);\n        --card: var(--theme-color-card-background);\n        --card-foreground: var(--color-text-primary);\n        --popover: var(--theme-color-card-background);\n        --popover-foreground: var(--color-text-primary);\n        --primary: var(--color-bg-primary);\n        --primary-foreground: var(--color-text-primary);\n        --secondary: var(--color-bg-secondary);\n        --secondary-foreground: var(--color-text-primary);\n        --muted: var(--color-bg-tertiary);\n        --muted-foreground: var(--color-text-tertiary);\n        --accent: var(--color-bg-tertiary);\n        --accent-foreground: var(--color-text-primary);\n        --destructive: 0 84.2% 60.2%;\n        --destructive-foreground: var(--color-text-primary);\n        --border: var(--color-border-primary);\n        --input: var(--color-border-tertiary);\n        --ring: var(--theme-color-ring);\n        --chart-1: var(--color-accent-400);\n        --chart-2: var(--color-accent-500);\n        --chart-3: var(--color-accent-600);\n        --chart-4: var(--color-accent-700);\n        --chart-5: var(--color-accent-800);\n        --radius: 0.5rem;\n    }\n    .dark {\n        --background: var(--color-bg-background);\n        --foreground: var(--color-text-primary);\n        --card: var(--theme-color-card-background);\n        --card-foreground: var(--color-text-primary);\n        --popover: var(--color-bg-secondary);\n        --popover-foreground: var(--color-text-primary);\n        --primary: var(--color-bg-primary);\n        --primary-foreground: var(--color-text-primary);\n        --secondary: var(--color-bg-secondary);\n        --secondary-foreground: var(--color-text-primary);\n        --muted: var(--color-bg-tertiary);\n        --muted-foreground: var(--color-text-tertiary);\n        --accent: var(--color-bg-tertiary);\n        --accent-foreground: var(--color-text-primary);\n        --destructive: 0 62.8% 30.6%;\n        --destructive-foreground: var(--color-text-primary);\n        --border: var(--color-border-primary);\n        --input: var(--color-border-tertiary);\n        --ring: var(--theme-color-ring);\n        --chart-1: var(--color-accent-200);\n        --chart-2: var(--color-accent-300);\n        --chart-3: var(--color-accent-400);\n        --chart-4: var(--color-accent-500);\n        --chart-5: var(--color-accent-600);\n    }\n}\n\n@layer base {\n    * {\n        @apply border-border;\n    }\n    body {\n        @apply bg-background text-foreground;\n    }\n}\n"
  },
  {
    "path": "resources/js/packages/ui/tailwind.theme.js",
    "content": "/**\n * Shared Tailwind theme configuration for solidtime\n * This configuration is used by both the main solidtime app and the desktop app\n *\n * Note: fontFamily is intentionally omitted here as it differs between apps:\n * - Main app uses 'Inter'\n * - Desktop app uses 'Outfit'\n * Each app should override the fontFamily in their own config.\n */\nexport const solidtimeTheme = {\n    boxShadow: {\n        card: 'var(--theme-shadow-card)',\n        dropdown: 'var(--theme-shadow-dropdown)',\n    },\n    containers: {\n        '2xs': '16rem',\n    },\n    fontSize: {\n        '2xs': ['0.625rem', { lineHeight: '0.75rem' }],\n        xs: ['0.75rem', { lineHeight: '1rem' }],\n        sm: ['0.8125rem', { lineHeight: '1.125rem' }],\n        base: ['0.875rem', { lineHeight: '1.25rem' }],\n        lg: ['1rem', { lineHeight: '1.5rem' }],\n        xl: ['1.125rem', { lineHeight: '1.75rem' }],\n        '2xl': ['1.25rem', { lineHeight: '1.75rem' }],\n        '3xl': ['1.5rem', { lineHeight: '2rem' }],\n        '4xl': ['1.75rem', { lineHeight: '2.25rem' }],\n        '5xl': ['2rem', { lineHeight: '1' }],\n        '6xl': ['2.25rem', { lineHeight: '1' }],\n        '7xl': ['2.5rem', { lineHeight: '1' }],\n        '8xl': ['3rem', { lineHeight: '1' }],\n        '9xl': ['3.5rem', { lineHeight: '1' }],\n    },\n    colors: {\n        ring: 'var(--ring)',\n        primary: {\n            DEFAULT: 'var(--primary)',\n            foreground: 'var(--primary-foreground)',\n        },\n        secondary: {\n            DEFAULT: 'var(--secondary)',\n            foreground: 'hsl(var(--secondary-foreground))',\n        },\n        tertiary: 'var(--color-bg-tertiary)',\n        quaternary: 'var(--color-bg-quaternary)',\n        background: 'var(--background)',\n        'text-primary': 'var(--color-text-primary)',\n        'text-secondary': 'var(--color-text-secondary)',\n        'text-tertiary': 'var(--color-text-tertiary)',\n        'text-quaternary': 'var(--color-text-quaternary)',\n        'border-primary': 'var(--color-border-primary)',\n        'border-secondary': 'var(--color-border-secondary)',\n        'border-tertiary': 'var(--color-border-tertiary)',\n        'default-background': 'var(--theme-color-default-background)',\n        'default-background-separator': 'var(--theme-color-default-background-separator)',\n        'row-background': 'var(--theme-color-row-background)',\n        'card-background': 'var(--theme-color-card-background)',\n        'card-background-active': 'var(--theme-color-card-background-active)',\n        'card-background-separator': 'var(--theme-color-card-background-separator)',\n        'card-border': 'var(--theme-color-card-border)',\n        'card-border-active': 'var(--theme-color-card-border-active)',\n        muted: {\n            DEFAULT: 'var(--muted)',\n            foreground: 'var(--muted-foreground)',\n        },\n        'tab-background': 'var(--theme-color-tab-background)',\n        'tab-background-active': 'var(--theme-color-tab-background-active)',\n        'tab-border': 'var(--theme-color-tab-border)',\n        'icon-default': 'var(--theme-color-icon-default)',\n        'icon-active': 'var(--theme-color-icon-active)',\n        'menu-active': 'var(--theme-color-menu-active)',\n        'input-border': 'var(--theme-color-input-border)',\n        'input-border-active': 'var(--color-input-border-active)',\n        'input-background': 'var(--theme-color-input-background)',\n        'button-secondary-background': 'var(--theme-button-secondary-background)',\n        'button-secondary-background-hover': 'var(--theme-button-secondary-background-active)',\n        'button-secondary-border': 'var(--theme-color-card-border)',\n        'row-separator': 'var(--theme-color-row-separator-background)',\n        'row-heading-background': 'var(--theme-color-row-heading-background)',\n        'row-heading-border': 'var(--theme-color-row-heading-border)',\n        accent: {\n            '50': 'rgba(var(--color-accent-50), <alpha-value>)',\n            '100': 'rgba(var(--color-accent-100), <alpha-value>)',\n            '200': 'rgba(var(--color-accent-200), <alpha-value>)',\n            '300': 'rgba(var(--color-accent-300), <alpha-value>)',\n            '400': 'rgba(var(--color-accent-400), <alpha-value>)',\n            '500': 'rgba(var(--color-accent-500), <alpha-value>)',\n            '600': 'rgba(var(--color-accent-600), <alpha-value>)',\n            '700': 'rgba(var(--color-accent-700), <alpha-value>)',\n            '800': 'rgba(var(--color-accent-800), <alpha-value>)',\n            '900': 'rgba(var(--color-accent-900), <alpha-value>)',\n            '950': 'rgba(var(--color-accent-950), <alpha-value>)',\n            DEFAULT: 'var(--accent)',\n            foreground: 'var(--accent-foreground)',\n        },\n        'button-primary-background': 'var(--theme-color-button-primary-background)',\n        'button-primary-background-hover': 'var(--theme-color-button-primary-background-hover)',\n        'button-primary-border': 'var(--theme-color-button-primary-border)',\n        'button-primary-text': 'var(--theme-color-button-primary-text)',\n        'input-select-active': 'var(--theme-color-input-select-active)',\n        'input-select-active-hover': 'var(--theme-color-input-select-active-hover)',\n        foreground: 'var(--foreground)',\n        card: {\n            DEFAULT: 'var(--card))',\n            foreground: 'var(--card-foreground))',\n        },\n        popover: {\n            DEFAULT: 'var(--popover)',\n            foreground: 'var(--popover-foreground)',\n            border: 'var(--popover-border)',\n        },\n        destructive: {\n            DEFAULT: 'var(--destructive)',\n            foreground: 'var(--destructive-foreground)',\n        },\n        border: 'var(--border)',\n        input: 'var(--input)',\n        chart: {\n            '1': 'hsl(var(--chart-1))',\n            '2': 'hsl(var(--chart-2))',\n            '3': 'hsl(var(--chart-3))',\n            '4': 'hsl(var(--chart-4))',\n            '5': 'hsl(var(--chart-5))',\n        },\n    },\n    borderRadius: {\n        lg: 'var(--radius)',\n        md: 'calc(var(--radius) - 2px)',\n        sm: 'calc(var(--radius) - 4px)',\n    },\n};\n"
  },
  {
    "path": "resources/js/packages/ui/tsconfig.json",
    "content": "{\n    \"compilerOptions\": {\n        \"module\": \"ESNext\",\n        \"moduleResolution\": \"Node\",\n        \"resolveJsonModule\": true,\n        \"noImplicitThis\": true,\n        \"strict\": true,\n        \"verbatimModuleSyntax\": true,\n        \"target\": \"ESNext\",\n        \"useDefineForClassFields\": true,\n        \"esModuleInterop\": true,\n        \"forceConsistentCasingInFileNames\": true,\n        \"skipLibCheck\": true,\n        \"outDir\": \"./dist\",\n        \"rootDir\": \"../../\",\n        \"declaration\": true,\n        \"paths\": {\n            \"@/*\": [\"../../*\"],\n        }\n    },\n    \"include\": [\n        \"src/**/*.ts\"\n    ],\n\n    \"exclude\": [\n        \"./dist\",\n        \"./node_modules\",\n        \"./__tests__\",\n        \"./coverage\"\n    ],\n}\n"
  },
  {
    "path": "resources/js/packages/ui/vite.config.js",
    "content": "import { defineConfig } from 'vite';\nimport { resolve } from 'path';\nimport vue from '@vitejs/plugin-vue';\nimport dts from 'vite-plugin-dts';\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n    plugins: [vue(), dts()],\n    build: {\n        lib: {\n            // src/indext.ts is where we have exported the component(s)\n            entry: resolve(__dirname, 'src/index.ts'),\n            name: 'SolidTimeUiLib',\n            // the name of the output files when the build is run\n            fileName: 'solidtime-ui-lib',\n        },\n        rollupOptions: {\n            // make sure to externalize deps that shouldn't be bundled\n            // into your library\n            external: ['vue'],\n            output: {\n                // Provide global variables to use in the UMD build\n                // for externalized deps\n                globals: {\n                    vue: 'Vue',\n                },\n            },\n        },\n    },\n    resolve: {\n        alias: {\n            '@': resolve('../../'),\n        },\n    },\n});\n"
  },
  {
    "path": "resources/js/types/dom.d.ts",
    "content": "export type HtmlButtonType = 'button' | 'submit' | 'reset';\n"
  },
  {
    "path": "resources/js/types/dom.ts",
    "content": "export type HtmlButtonType = 'button' | 'submit' | 'reset';\n"
  },
  {
    "path": "resources/js/types/global.d.ts",
    "content": "import { PageProps as InertiaPageProps } from '@inertiajs/core';\nimport { AxiosInstance } from 'axios';\nimport ziggyRoute from 'ziggy-js';\nimport { PageProps as AppPageProps } from './';\nimport type { App } from 'vue';\n\ndeclare global {\n    interface Window {\n        axios: AxiosInstance;\n        initialDataLoaded: boolean;\n        vueAppSetupHook?: (app: App) => void;\n        getWeekStartSetting: () => string;\n        getTimezoneSetting: () => string;\n    }\n\n    let route: typeof ziggyRoute;\n}\n\ndeclare module 'vue' {\n    interface ComponentCustomProperties {\n        route: typeof ziggyRoute;\n    }\n}\n\ndeclare module '@inertiajs/core' {\n    interface PageProps extends InertiaPageProps, AppPageProps {}\n}\n"
  },
  {
    "path": "resources/js/types/inertia.d.ts",
    "content": "export {};\ndeclare global {\n    export namespace inertia {\n        export interface Props {\n            user: {\n                id: number;\n                name: string;\n                email: string;\n                created_at: Date;\n                updated_at: Date;\n            };\n            jetstream: {\n                [key: string]: boolean;\n            };\n            errorBags: unknown;\n            errors: unknown;\n        }\n    }\n}\n"
  },
  {
    "path": "resources/js/types/jetstream.ts",
    "content": "import type { User } from '@/types/models';\n\nexport interface Permissions {\n    canAddTeamMembers: boolean;\n    canDeleteTeam: boolean;\n    canRemoveTeamMembers: boolean;\n    canUpdateTeam: boolean;\n    canUpdateTeamMembers: boolean;\n}\n\nexport interface Session {\n    agent: {\n        platform: string;\n        browser: string;\n\n        is_desktop: boolean;\n    };\n    ip_address: string;\n    is_current_device: string;\n    last_active: boolean;\n}\n\nexport interface Membership {\n    role: string;\n}\n\nexport interface Role {\n    key: string;\n    name: string;\n    description: string;\n}\n\nexport type JetstreamUser = User & {\n    two_factor_enabled: boolean;\n};\nexport interface Token {\n    name: string;\n    token: string;\n    abilities: string[];\n    id: string;\n    last_used_ago: string;\n}\n"
  },
  {
    "path": "resources/js/types/models.d.ts",
    "content": "export interface Client {\n    id: string;\n    name: string;\n    organization_id: string;\n    created_at: string | null;\n    updated_at: string | null;\n    organization: Organization;\n}\nexport interface Membership {\n    id: string;\n    organization_id: string;\n    user_id: string;\n    role: string | null;\n    created_at: string | null;\n    updated_at: string | null;\n}\nexport interface Organization {\n    id: string;\n    user_id: string;\n    name: string;\n    personal_team: boolean;\n    currency: string;\n    created_at: string | null;\n    updated_at: string | null;\n    owner: User;\n    users: User[];\n    team_invitations: OrganizationInvitation[];\n}\nexport interface OrganizationInvitation {\n    id: string;\n    organization_id: string;\n    email: string;\n    role: string | null;\n    created_at: string | null;\n    updated_at: string | null;\n    organization: Organization;\n    team: Organization;\n}\nexport interface Project {\n    id: string;\n    name: string;\n    color: string;\n    client_id: string | null;\n    organization_id: string;\n    created_at: string | null;\n    updated_at: string | null;\n    organization: Organization;\n    client: Client;\n    tasks: Task[];\n}\nexport interface Task {\n    id: string;\n    name: string;\n    project_id: string;\n    organization_id: string;\n    created_at: string | null;\n    updated_at: string | null;\n    project: Project;\n    organization: Organization;\n}\ntype OrganizationWithMembership = Organization & {\n    membership: Membership;\n};\nexport interface User {\n    id: string;\n    name: string;\n    email: string;\n    email_verified_at: string | null;\n    password?: string;\n    remember_token?: string | null;\n    current_team_id: string | null;\n    profile_photo_path: string | null;\n    created_at: string | null;\n    updated_at: string | null;\n    two_factor_secret?: string | null;\n    two_factor_recovery_codes?: string | null;\n    two_factor_confirmed_at: string | null;\n    timezone: string;\n    week_start: string;\n    profile_photo_url: string;\n    organizations: Organization[];\n    clients: Client[];\n    current_team: Organization;\n    all_teams: OrganizationWithMembership[];\n    owned_teams: Organization[];\n    teams: Organization[];\n}\nexport {};\n"
  },
  {
    "path": "resources/js/types/models.ts",
    "content": "export interface Client {\n    // columns\n    id: string;\n    name: string;\n    organization_id: string;\n    created_at: string | null;\n    updated_at: string | null;\n    // relations\n    organization: Organization;\n}\n\nexport interface Membership {\n    // columns\n    id: string;\n    organization_id: string;\n    user_id: string;\n    role: string | null;\n    created_at: string | null;\n    updated_at: string | null;\n}\n\nexport interface Organization {\n    // columns\n    id: string;\n    user_id: string;\n    name: string;\n    personal_team: boolean;\n    currency: string;\n    created_at: string | null;\n    updated_at: string | null;\n    // relations\n    owner: User;\n    users: User[];\n    team_invitations: OrganizationInvitation[];\n}\n\nexport interface OrganizationInvitation {\n    // columns\n    id: string;\n    organization_id: string;\n    email: string;\n    role: string | null;\n    created_at: string | null;\n    updated_at: string | null;\n    // relations\n    organization: Organization;\n    team: Organization;\n}\n\nexport interface Project {\n    // columns\n    id: string;\n    name: string;\n    color: string;\n    client_id: string | null;\n    organization_id: string;\n    created_at: string | null;\n    updated_at: string | null;\n    // relations\n    organization: Organization;\n    client: Client;\n    tasks: Task[];\n}\n\nexport interface Task {\n    // columns\n    id: string;\n    name: string;\n    project_id: string;\n    organization_id: string;\n    created_at: string | null;\n    updated_at: string | null;\n    // relations\n    project: Project;\n    organization: Organization;\n}\n\ntype OrganizationWithMembership = Organization & { membership: Membership };\n\nexport interface User {\n    // columns\n    id: string;\n    name: string;\n    email: string;\n    email_verified_at: string | null;\n    password?: string;\n    remember_token?: string | null;\n    current_team_id: string | null;\n    profile_photo_path: string | null;\n    created_at: string | null;\n    updated_at: string | null;\n    two_factor_secret?: string | null;\n    two_factor_recovery_codes?: string | null;\n    two_factor_confirmed_at: string | null;\n    timezone: string;\n    week_start: string;\n    // mutators\n    profile_photo_url: string;\n    // relations\n    organizations: Organization[];\n    clients: Client[];\n    current_team: Organization;\n    all_teams: OrganizationWithMembership[];\n    owned_teams: Organization[];\n    teams: Organization[];\n}\n"
  },
  {
    "path": "resources/js/types/projects.d.ts",
    "content": "export type BillableKey = 'non-billable' | 'default-rate' | 'custom-rate';\n"
  },
  {
    "path": "resources/js/types/reporting.ts",
    "content": "export type ExportFormat = 'xlsx' | 'csv' | 'ods' | 'pdf';\n"
  },
  {
    "path": "resources/js/types/time-entries.d.ts",
    "content": "import type { TimeEntry } from '@/packages/api/src';\n\nexport type TimeEntriesGroupedByType = TimeEntry & { timeEntries: TimeEntry[] };\n"
  },
  {
    "path": "resources/js/types/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "resources/js/types/vue-shim.d.ts",
    "content": "declare module '*.vue' {\n    import { defineComponent } from 'vue';\n    const component: ReturnType<typeof defineComponent>;\n    export default component;\n}\n"
  },
  {
    "path": "resources/js/utils/billing.ts",
    "content": "import { usePage } from '@inertiajs/vue3';\nimport { getDayJsInstance } from '@/packages/ui/src/utils/time';\n\nexport function isBillingActivated() {\n    const page = usePage<{\n        has_billing_extension: boolean;\n    }>();\n\n    return page.props.has_billing_extension;\n}\n\nexport function isInvoicingActivated() {\n    const page = usePage<{\n        has_invoicing_extension: boolean;\n    }>();\n\n    return page.props.has_invoicing_extension;\n}\n\nexport function isInTrial() {\n    const page = usePage<{\n        billing: {\n            has_trial: boolean;\n        };\n    }>();\n\n    return page.props.billing.has_trial;\n}\n\nexport function daysLeftInTrial() {\n    const page = usePage<{\n        billing: {\n            trial_until: string;\n        };\n    }>();\n\n    return (\n        getDayJsInstance()(page.props.billing.trial_until).diff(getDayJsInstance()(), 'days') + 1\n    );\n}\n\nexport function isBlocked() {\n    const page = usePage<{\n        billing: {\n            is_blocked: boolean;\n        };\n    }>();\n\n    return page.props.billing.is_blocked;\n}\n\nexport function isFreePlan() {\n    return !hasActiveSubscription() && !isInTrial();\n}\n\nexport function hasActiveSubscription() {\n    const page = usePage<{\n        billing: {\n            has_subscription: boolean;\n        };\n    }>();\n\n    return page.props.billing.has_subscription;\n}\n\nexport function isAllowedToPerformPremiumAction() {\n    return (\n        !isBillingActivated() ||\n        (isBillingActivated() && hasActiveSubscription()) ||\n        (isBillingActivated() && isInTrial())\n    );\n}\n"
  },
  {
    "path": "resources/js/utils/commandPaletteCommands.ts",
    "content": "import type { Component } from 'vue';\nimport {\n    HomeIcon,\n    ClockIcon,\n    CalendarIcon,\n    ChartBarIcon,\n    FolderIcon,\n    UserCircleIcon,\n    UserGroupIcon,\n    TagIcon,\n    DocumentTextIcon,\n    CreditCardIcon,\n    ArrowsRightLeftIcon,\n    Cog6ToothIcon,\n    UserIcon,\n    PlayIcon,\n    StopIcon,\n    PlusIcon,\n    ArrowPathIcon,\n    SunIcon,\n    MoonIcon,\n    ComputerDesktopIcon,\n    ClipboardDocumentListIcon,\n    BuildingOfficeIcon,\n} from '@heroicons/vue/20/solid';\nimport BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';\nimport type { Organization } from '@/types/models';\n\nexport type CommandGroup =\n    | 'timer'\n    | 'active-timer'\n    | 'navigation'\n    | 'create'\n    | 'theme'\n    | 'organization'\n    | 'entity';\n\nexport interface Command {\n    id: string;\n    label: string;\n    icon?: Component;\n    keywords: string[];\n    group: CommandGroup;\n    action: () => void | Promise<void>;\n    shortcut?: string;\n    permission?: () => boolean;\n    condition?: () => boolean;\n    priority: number;\n}\n\nexport const GROUP_PRIORITIES: Record<CommandGroup, number> = {\n    timer: 1000,\n    'active-timer': 900,\n    navigation: 500,\n    create: 400,\n    organization: 300,\n    theme: 200,\n    entity: 100,\n};\n\nexport function createNavigationCommands(\n    navigate: (route: string, params?: Record<string, string>) => void,\n    permissions: {\n        canViewProjects: () => boolean;\n        canViewClients: () => boolean;\n        canViewMembers: () => boolean;\n        canViewTags: () => boolean;\n        canViewReport: () => boolean;\n        canViewInvoices: () => boolean;\n        canManageBilling: () => boolean;\n        canUpdateOrganization: () => boolean;\n    },\n    features: {\n        isInvoicingActivated: () => boolean;\n        isBillingActivated: () => boolean;\n    },\n    currentTeamId: () => string\n): Command[] {\n    return [\n        {\n            id: 'nav-dashboard',\n            label: 'Go to Dashboard',\n            icon: HomeIcon,\n            keywords: ['home', 'overview', 'dashboard'],\n            group: 'navigation',\n            action: () => navigate('dashboard'),\n            priority: GROUP_PRIORITIES.navigation + 10,\n        },\n        {\n            id: 'nav-time',\n            label: 'Go to Time',\n            icon: ClockIcon,\n            keywords: ['time', 'tracking', 'entries', 'timesheet'],\n            group: 'navigation',\n            action: () => navigate('time'),\n            priority: GROUP_PRIORITIES.navigation + 9,\n        },\n        {\n            id: 'nav-calendar',\n            label: 'Go to Calendar',\n            icon: CalendarIcon,\n            keywords: ['calendar', 'week', 'schedule'],\n            group: 'navigation',\n            action: () => navigate('calendar'),\n            priority: GROUP_PRIORITIES.navigation + 8,\n        },\n        {\n            id: 'nav-reporting',\n            label: 'Go to Reporting Overview',\n            icon: ChartBarIcon,\n            keywords: ['reports', 'analytics', 'overview', 'statistics'],\n            group: 'navigation',\n            action: () => navigate('reporting'),\n            priority: GROUP_PRIORITIES.navigation + 7,\n        },\n        {\n            id: 'nav-reporting-detailed',\n            label: 'Go to Reporting Detailed',\n            icon: ChartBarIcon,\n            keywords: ['detailed', 'reports', 'breakdown'],\n            group: 'navigation',\n            action: () => navigate('reporting.detailed'),\n            priority: GROUP_PRIORITIES.navigation + 6,\n        },\n        {\n            id: 'nav-reporting-shared',\n            label: 'Go to Shared Reports',\n            icon: ChartBarIcon,\n            keywords: ['shared', 'public', 'reports'],\n            group: 'navigation',\n            action: () => navigate('reporting.shared'),\n            permission: permissions.canViewReport,\n            priority: GROUP_PRIORITIES.navigation + 5,\n        },\n        {\n            id: 'nav-projects',\n            label: 'Go to Projects',\n            icon: FolderIcon,\n            keywords: ['projects', 'work'],\n            group: 'navigation',\n            action: () => navigate('projects'),\n            permission: permissions.canViewProjects,\n            priority: GROUP_PRIORITIES.navigation + 4,\n        },\n        {\n            id: 'nav-clients',\n            label: 'Go to Clients',\n            icon: UserCircleIcon,\n            keywords: ['clients', 'customers'],\n            group: 'navigation',\n            action: () => navigate('clients'),\n            permission: permissions.canViewClients,\n            priority: GROUP_PRIORITIES.navigation + 3,\n        },\n        {\n            id: 'nav-members',\n            label: 'Go to Members',\n            icon: UserGroupIcon,\n            keywords: ['members', 'team', 'users', 'employees'],\n            group: 'navigation',\n            action: () => navigate('members'),\n            permission: permissions.canViewMembers,\n            priority: GROUP_PRIORITIES.navigation + 2,\n        },\n        {\n            id: 'nav-tags',\n            label: 'Go to Tags',\n            icon: TagIcon,\n            keywords: ['tags', 'labels', 'categories'],\n            group: 'navigation',\n            action: () => navigate('tags'),\n            permission: permissions.canViewTags,\n            priority: GROUP_PRIORITIES.navigation + 1,\n        },\n        {\n            id: 'nav-invoices',\n            label: 'Go to Invoices',\n            icon: DocumentTextIcon,\n            keywords: ['invoices', 'billing', 'payments'],\n            group: 'navigation',\n            action: () => navigate('/invoices', {}),\n            permission: permissions.canViewInvoices,\n            condition: features.isInvoicingActivated,\n            priority: GROUP_PRIORITIES.navigation,\n        },\n        {\n            id: 'nav-billing',\n            label: 'Go to Billing',\n            icon: CreditCardIcon,\n            keywords: ['billing', 'subscription', 'plan'],\n            group: 'navigation',\n            action: () => navigate('/billing', {}),\n            permission: permissions.canManageBilling,\n            condition: features.isBillingActivated,\n            priority: GROUP_PRIORITIES.navigation - 1,\n        },\n        {\n            id: 'nav-import',\n            label: 'Go to Import / Export',\n            icon: ArrowsRightLeftIcon,\n            keywords: ['import', 'export', 'data', 'backup'],\n            group: 'navigation',\n            action: () => navigate('import'),\n            permission: permissions.canUpdateOrganization,\n            priority: GROUP_PRIORITIES.navigation - 2,\n        },\n        {\n            id: 'nav-settings',\n            label: 'Go to Settings',\n            icon: Cog6ToothIcon,\n            keywords: ['settings', 'organization', 'configuration'],\n            group: 'navigation',\n            action: () => navigate('teams.show', { team: currentTeamId() }),\n            permission: permissions.canUpdateOrganization,\n            priority: GROUP_PRIORITIES.navigation - 3,\n        },\n        {\n            id: 'nav-profile',\n            label: 'Go to Profile',\n            icon: UserIcon,\n            keywords: ['profile', 'account', 'user', 'personal'],\n            group: 'navigation',\n            action: () => navigate('profile.show'),\n            priority: GROUP_PRIORITIES.navigation - 4,\n        },\n    ];\n}\n\nexport function createTimerCommands(\n    timerActions: {\n        startTimer: () => Promise<void>;\n        stopTimer: () => Promise<void>;\n        openCreateTimeEntryModal: () => void;\n        continueLastEntry: () => Promise<void>;\n    },\n    conditions: {\n        isActive: () => boolean;\n        hasTimeEntries: () => boolean;\n    }\n): Command[] {\n    return [\n        {\n            id: 'timer-start',\n            label: 'Start Timer',\n            icon: PlayIcon,\n            keywords: ['start', 'begin', 'track', 'timer'],\n            group: 'timer',\n            action: timerActions.startTimer,\n            condition: () => !conditions.isActive(),\n            priority: GROUP_PRIORITIES.timer + 10,\n        },\n        {\n            id: 'timer-stop',\n            label: 'Stop Timer',\n            icon: StopIcon,\n            keywords: ['stop', 'end', 'finish', 'timer'],\n            group: 'timer',\n            action: timerActions.stopTimer,\n            condition: conditions.isActive,\n            priority: GROUP_PRIORITIES.timer + 10,\n        },\n        {\n            id: 'timer-create',\n            label: 'Create Time Entry',\n            icon: PlusIcon,\n            keywords: ['create', 'manual', 'log', 'time', 'entry', 'new'],\n            group: 'timer',\n            action: timerActions.openCreateTimeEntryModal,\n            priority: GROUP_PRIORITIES.timer + 5,\n        },\n        {\n            id: 'timer-continue',\n            label: 'Continue Last Time Entry',\n            icon: ArrowPathIcon,\n            keywords: ['continue', 'repeat', 'restart', 'last', 'previous'],\n            group: 'timer',\n            action: timerActions.continueLastEntry,\n            condition: () => !conditions.isActive() && conditions.hasTimeEntries(),\n            priority: GROUP_PRIORITIES.timer + 4,\n        },\n    ];\n}\n\nexport function createActiveTimerCommands(\n    activeTimerActions: {\n        openProjectSelector: () => void;\n        openTaskSelector: () => void;\n        openTagsSelector: () => void;\n        toggleBillable: () => void;\n        addMinutes: (minutes: number) => void;\n    },\n    conditions: {\n        isActive: () => boolean;\n    }\n): Command[] {\n    const minuteOptions = [5, 10, 15, 20, 25, 30, 45, 60];\n\n    const addMinutesCommands: Command[] = minuteOptions.map((minutes) => ({\n        id: `timer-add-${minutes}`,\n        label: `Add ${minutes} minutes to timer`,\n        icon: ClockIcon,\n        keywords: [`+${minutes}`, `add ${minutes}`, minutes === 60 ? 'add hour' : ''],\n        group: 'active-timer' as CommandGroup,\n        action: () => activeTimerActions.addMinutes(minutes),\n        condition: conditions.isActive,\n        priority: GROUP_PRIORITIES['active-timer'] - minutes,\n    }));\n\n    return [\n        {\n            id: 'timer-set-project',\n            label: 'Set Project',\n            icon: FolderIcon,\n            keywords: ['project', 'change project', 'select project'],\n            group: 'active-timer',\n            action: activeTimerActions.openProjectSelector,\n            condition: conditions.isActive,\n            priority: GROUP_PRIORITIES['active-timer'] + 10,\n        },\n        {\n            id: 'timer-set-task',\n            label: 'Set Task',\n            icon: ClipboardDocumentListIcon,\n            keywords: ['task', 'change task', 'select task'],\n            group: 'active-timer',\n            action: activeTimerActions.openTaskSelector,\n            condition: conditions.isActive,\n            priority: GROUP_PRIORITIES['active-timer'] + 9,\n        },\n        {\n            id: 'timer-set-tags',\n            label: 'Set Tags',\n            icon: TagIcon,\n            keywords: ['tags', 'add tags', 'labels'],\n            group: 'active-timer',\n            action: activeTimerActions.openTagsSelector,\n            condition: conditions.isActive,\n            priority: GROUP_PRIORITIES['active-timer'] + 8,\n        },\n        {\n            id: 'timer-toggle-billable',\n            label: 'Toggle Billable',\n            icon: BillableIcon,\n            keywords: ['billable', 'non-billable', 'money'],\n            group: 'active-timer',\n            action: activeTimerActions.toggleBillable,\n            condition: conditions.isActive,\n            priority: GROUP_PRIORITIES['active-timer'] + 7,\n        },\n        ...addMinutesCommands,\n    ];\n}\n\nexport function createThemeCommands(\n    setTheme: (theme: 'light' | 'dark' | 'system') => void\n): Command[] {\n    return [\n        {\n            id: 'theme-light',\n            label: 'Switch to Light Theme',\n            icon: SunIcon,\n            keywords: ['light', 'bright', 'day', 'theme'],\n            group: 'theme',\n            action: () => setTheme('light'),\n            priority: GROUP_PRIORITIES.theme + 3,\n        },\n        {\n            id: 'theme-dark',\n            label: 'Switch to Dark Theme',\n            icon: MoonIcon,\n            keywords: ['dark', 'night', 'theme'],\n            group: 'theme',\n            action: () => setTheme('dark'),\n            priority: GROUP_PRIORITIES.theme + 2,\n        },\n        {\n            id: 'theme-system',\n            label: 'Switch to System Theme',\n            icon: ComputerDesktopIcon,\n            keywords: ['system', 'auto', 'default', 'theme'],\n            group: 'theme',\n            action: () => setTheme('system'),\n            priority: GROUP_PRIORITIES.theme + 1,\n        },\n    ];\n}\n\nexport function createCreateCommands(\n    createActions: {\n        openProjectModal: () => void;\n        openClientModal: () => void;\n        openTaskModal: () => void;\n        openTagModal: () => void;\n        openInviteModal: () => void;\n    },\n    permissions: {\n        canCreateProjects: () => boolean;\n        canCreateClients: () => boolean;\n        canCreateTasks: () => boolean;\n        canCreateTags: () => boolean;\n        canCreateInvitations: () => boolean;\n    }\n): Command[] {\n    return [\n        {\n            id: 'create-project',\n            label: 'Create Project',\n            icon: FolderIcon,\n            keywords: ['new project', 'add project', 'create'],\n            group: 'create',\n            action: createActions.openProjectModal,\n            permission: permissions.canCreateProjects,\n            priority: GROUP_PRIORITIES.create + 5,\n        },\n        {\n            id: 'create-client',\n            label: 'Create Client',\n            icon: UserCircleIcon,\n            keywords: ['new client', 'add client', 'create'],\n            group: 'create',\n            action: createActions.openClientModal,\n            permission: permissions.canCreateClients,\n            priority: GROUP_PRIORITIES.create + 4,\n        },\n        {\n            id: 'create-task',\n            label: 'Create Task',\n            icon: ClipboardDocumentListIcon,\n            keywords: ['new task', 'add task', 'create'],\n            group: 'create',\n            action: createActions.openTaskModal,\n            permission: permissions.canCreateTasks,\n            priority: GROUP_PRIORITIES.create + 3,\n        },\n        {\n            id: 'create-tag',\n            label: 'Create Tag',\n            icon: TagIcon,\n            keywords: ['new tag', 'add tag', 'create'],\n            group: 'create',\n            action: createActions.openTagModal,\n            permission: permissions.canCreateTags,\n            priority: GROUP_PRIORITIES.create + 2,\n        },\n        {\n            id: 'create-invite',\n            label: 'Invite Member',\n            icon: UserGroupIcon,\n            keywords: ['invite', 'add member', 'team'],\n            group: 'create',\n            action: createActions.openInviteModal,\n            permission: permissions.canCreateInvitations,\n            priority: GROUP_PRIORITIES.create + 1,\n        },\n    ];\n}\n\nexport function createOrganizationCommands(\n    organizations: Organization[],\n    currentOrgId: string,\n    switchOrganization: (orgId: string) => void\n): Command[] {\n    if (organizations.length <= 1) return [];\n\n    return organizations\n        .filter((org) => org.id !== currentOrgId)\n        .map((org) => ({\n            id: `org-switch-${org.id}`,\n            label: `Switch to ${org.name}`,\n            icon: BuildingOfficeIcon,\n            keywords: ['switch', 'organization', 'workspace', org.name.toLowerCase()],\n            group: 'organization' as CommandGroup,\n            action: () => switchOrganization(org.id),\n            priority: GROUP_PRIORITIES.organization + 1,\n        }));\n}\n\nexport function scoreEntity(name: string, query: string, baseScore: number): number {\n    const normalizedName = name.toLowerCase();\n    const normalizedQuery = query.toLowerCase().trim();\n\n    if (normalizedName === normalizedQuery) return baseScore + 50;\n    if (normalizedName.startsWith(normalizedQuery)) return baseScore + 30;\n    if (normalizedName.includes(normalizedQuery)) return baseScore + 10;\n    return baseScore;\n}\n"
  },
  {
    "path": "resources/js/utils/feedback.ts",
    "content": "export function openFeedback(): void {\n    if (\n        typeof window !== 'undefined' &&\n        'showChatWindow' in window &&\n        typeof window.showChatWindow === 'function'\n    ) {\n        window.showChatWindow();\n    }\n}\n"
  },
  {
    "path": "resources/js/utils/fetchAllPages.ts",
    "content": "/**\n * Fetches all pages from a paginated Laravel API endpoint.\n * Uses `meta.last_page` to determine the total number of pages,\n * so only a single request is made when all data fits on one page.\n */\nexport async function fetchAllPages<T>(\n    fetchPage: (page: number) => Promise<{\n        data: T[];\n        meta: { per_page: number; last_page: number };\n    }>\n): Promise<T[]> {\n    const firstResponse = await fetchPage(1);\n    const allItems: T[] = [...firstResponse.data];\n    const { last_page } = firstResponse.meta;\n\n    for (let page = 2; page <= last_page; page++) {\n        const response = await fetchPage(page);\n        allItems.push(...response.data);\n    }\n\n    return allItems;\n}\n"
  },
  {
    "path": "resources/js/utils/format.ts",
    "content": "export function capitalizeFirstLetter(string: string) {\n    return string?.charAt(0)?.toUpperCase() + string?.slice(1);\n}\n"
  },
  {
    "path": "resources/js/utils/init.ts",
    "content": "import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';\n\nexport function initializeStores() {\n    // TanStack Query now handles projects, tasks, tags, clients, and members fetching automatically\n    // Only initialize stores that aren't migrated to TanStack Query yet\n    useCurrentTimeEntryStore().fetchCurrentTimeEntry();\n}\n"
  },
  {
    "path": "resources/js/utils/money.ts",
    "content": "import { usePage } from '@inertiajs/vue3';\n\nconst page = usePage<{\n    auth: {\n        user: {\n            current_team: {\n                currency: string;\n            };\n        };\n    };\n}>();\n\nexport function getOrganizationCurrencyString() {\n    return page.props?.auth?.user?.current_team?.currency ?? 'EUR';\n}\n"
  },
  {
    "path": "resources/js/utils/notification.ts",
    "content": "import { defineStore } from 'pinia';\nimport { ref } from 'vue';\nimport axios from 'axios';\nimport { router } from '@inertiajs/vue3';\nimport { fetchToken } from '@/utils/session';\n\nexport type NotificationType = 'success' | 'error';\n\nexport const useNotificationsStore = defineStore('notifications', () => {\n    const notifications = ref<\n        {\n            title: string;\n            message?: string;\n            uuid: string;\n            type: NotificationType;\n        }[]\n    >([]);\n\n    const showActionBlockedModal = ref(false);\n\n    function addNotification(type: NotificationType, title: string, message?: string) {\n        const uuid = Math.random().toString(36).substring(7);\n        notifications.value.push({ title, message, type, uuid });\n\n        setTimeout(() => {\n            removeNotification(uuid);\n        }, 5000);\n    }\n\n    function removeNotification(uuid: string) {\n        const index = notifications.value.findIndex((notification) => notification.uuid === uuid);\n        if (index !== -1) {\n            notifications.value.splice(index, 1);\n        }\n    }\n\n    async function handleApiRequestNotifications<T>(\n        apiRequest: () => Promise<T>,\n        successMessage?: string,\n        errorMessage?: string,\n        onSuccess?: (response: T) => void\n    ) {\n        try {\n            const response = await apiRequest();\n            if (successMessage) {\n                addNotification('success', successMessage);\n            }\n            if (onSuccess) {\n                onSuccess(response);\n            }\n            return response;\n        } catch (error) {\n            if (axios.isAxiosError(error)) {\n                if (error?.response?.status === 403 || error?.response?.status === 400) {\n                    if (\n                        error?.response?.data?.key ===\n                        'organization_has_no_subscription_but_multiple_members'\n                    ) {\n                        showActionBlockedModal.value = true;\n                    } else {\n                        addNotification(\n                            'error',\n                            errorMessage ?? 'Request Error',\n                            error.response?.data?.errorMessage ??\n                                error?.response?.data?.message ??\n                                'An request error occurred. Please try again later.'\n                        );\n                    }\n                } else if (error?.response?.status === 422) {\n                    const message = error.response.data.message;\n                    addNotification('error', message);\n                } else if (error?.response?.status === 401) {\n                    await fetchToken();\n                    try {\n                        const response = await apiRequest();\n                        if (successMessage) {\n                            addNotification('success', successMessage);\n                        }\n                        if (onSuccess) {\n                            onSuccess(response);\n                        }\n                        return response;\n                    } catch {\n                        router.get('/login');\n                    }\n                } else {\n                    addNotification('error', 'The action failed. Please try again later.');\n                }\n            }\n            throw new Error('Failed to handle API request');\n        }\n    }\n\n    return {\n        addNotification,\n        notifications,\n        handleApiRequestNotifications,\n        showActionBlockedModal,\n    };\n});\n"
  },
  {
    "path": "resources/js/utils/permissions.ts",
    "content": "import { usePage } from '@inertiajs/vue3';\n\nconst page = usePage<{\n    auth: {\n        permissions: string[];\n    };\n}>();\n\nfunction currentUserHasPermission(permission: string) {\n    if (Array.isArray(page.props.auth.permissions)) {\n        return page.props.auth.permissions.includes(permission);\n    }\n    return false;\n}\n\nexport function canUpdateOrganization() {\n    return currentUserHasPermission('organizations:update');\n}\n\nexport function canViewProjects() {\n    return currentUserHasPermission('projects:view');\n}\n\nexport function canCreateProjects() {\n    return currentUserHasPermission('projects:create');\n}\n\nexport function canUpdateProjects() {\n    return currentUserHasPermission('projects:update');\n}\n\nexport function canDeleteProjects() {\n    return currentUserHasPermission('projects:delete');\n}\n\nexport function canViewProjectMembers() {\n    return currentUserHasPermission('project-members:view');\n}\n\nexport function canCreateTasks() {\n    return currentUserHasPermission('tasks:create');\n}\n\nexport function canUpdateTasks() {\n    return currentUserHasPermission('tasks:update');\n}\n\nexport function canDeleteTasks() {\n    return currentUserHasPermission('tasks:delete');\n}\n\nexport function canCreateClients() {\n    return currentUserHasPermission('clients:create');\n}\n\nexport function canUpdateClients() {\n    return currentUserHasPermission('clients:update');\n}\n\nexport function canDeleteClients() {\n    return currentUserHasPermission('clients:delete');\n}\n\nexport function canViewClients() {\n    return currentUserHasPermission('clients:view');\n}\n\nexport function canViewMembers() {\n    return currentUserHasPermission('members:view');\n}\n\nexport function canUpdateMembers() {\n    return currentUserHasPermission('members:update');\n}\n\nexport function canDeleteMembers() {\n    return currentUserHasPermission('members:delete');\n}\n\nexport function canMergeMembers() {\n    return currentUserHasPermission('members:merge-into');\n}\n\nexport function canMakeMembersPlaceholders() {\n    return currentUserHasPermission('members:make-placeholder');\n}\n\nexport function canInvitePlaceholderMembers() {\n    return currentUserHasPermission('members:invite-placeholder');\n}\n\nexport function canCreateInvitations() {\n    return currentUserHasPermission('invitations:create');\n}\n\nexport function canViewTags() {\n    return currentUserHasPermission('tags:view');\n}\n\nexport function canCreateTags() {\n    return currentUserHasPermission('tags:create');\n}\n\nexport function canUpdateTags() {\n    return currentUserHasPermission('tags:update');\n}\n\nexport function canDeleteTags() {\n    return currentUserHasPermission('tags:delete');\n}\n\nexport function canManageBilling() {\n    return currentUserHasPermission('billing');\n}\n\nexport function canViewReport() {\n    return currentUserHasPermission('reports:view');\n}\nexport function canUpdateReport() {\n    return currentUserHasPermission('reports:update');\n}\nexport function canDeleteReport() {\n    return currentUserHasPermission('reports:delete');\n}\n\nexport function canViewAllTimeEntries() {\n    return currentUserHasPermission('time-entries:view:all');\n}\nexport function canViewInvoices() {\n    return currentUserHasPermission('invoices:view');\n}\nexport function canCreateReports() {\n    return currentUserHasPermission('reports:create');\n}\n"
  },
  {
    "path": "resources/js/utils/prefetch.ts",
    "content": "import type { QueryClient } from '@tanstack/vue-query';\nimport { api } from '@/packages/api/src';\nimport { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';\nimport { canViewClients, canViewMembers } from '@/utils/permissions';\nimport {\n    getInitialWeekRange,\n    getExpandedCalendarDateRange,\n    createCalendarQueryKey,\n    fetchAllCalendarEntries,\n} from '@/utils/useTimeEntriesCalendarQuery';\nimport { fetchAllProjects } from '@/utils/useProjectsQuery';\nimport { fetchAllTasks } from '@/utils/useTasksQuery';\nimport { fetchAllTags } from '@/utils/useTagsQuery';\nimport { fetchAllClients } from '@/utils/useClientsQuery';\nimport { fetchAllMembers } from '@/utils/useMembersQuery';\nimport { fetchAllReports } from '@/utils/useReportsQuery';\nimport { fetchAllProjectMembers } from '@/utils/useProjectMembersQuery';\n\n/**\n * Route patterns mapped to their prefetch functions.\n * Each function receives the QueryClient and prefetches relevant data.\n */\nconst routePrefetchers: Record<string, (queryClient: QueryClient) => void> = {\n    '/': (queryClient) => {\n        prefetchDashboard(queryClient);\n    },\n\n    '/dashboard': (queryClient) => {\n        prefetchDashboard(queryClient);\n    },\n\n    '/time': (queryClient) => {\n        prefetchProjects(queryClient);\n        prefetchTasks(queryClient);\n        prefetchTags(queryClient);\n        prefetchClients(queryClient);\n        prefetchTimeEntries(queryClient);\n    },\n\n    '/calendar': (queryClient) => {\n        prefetchProjects(queryClient);\n        prefetchTasks(queryClient);\n        prefetchTags(queryClient);\n        prefetchClients(queryClient);\n        prefetchCalendarTimeEntries(queryClient);\n    },\n\n    '/projects': (queryClient) => {\n        prefetchProjects(queryClient);\n        prefetchClients(queryClient);\n    },\n\n    '/clients': (queryClient) => {\n        prefetchClients(queryClient);\n    },\n\n    '/tags': (queryClient) => {\n        prefetchTags(queryClient);\n    },\n\n    '/members': (queryClient) => {\n        prefetchMembers(queryClient);\n    },\n\n    '/reporting': (queryClient) => {\n        prefetchProjects(queryClient);\n        prefetchTags(queryClient);\n        prefetchClients(queryClient);\n        prefetchMembers(queryClient);\n    },\n\n    '/reporting/detailed': (queryClient) => {\n        prefetchProjects(queryClient);\n        prefetchTasks(queryClient);\n        prefetchTags(queryClient);\n        prefetchClients(queryClient);\n        prefetchMembers(queryClient);\n    },\n\n    '/reporting/shared': (queryClient) => {\n        prefetchReports(queryClient);\n    },\n};\n\nfunction prefetchDashboard(queryClient: QueryClient) {\n    const organizationId = getCurrentOrganizationId();\n    if (!organizationId) return;\n\n    // Prefetch projects and tasks for RecentlyTrackedTasksCard\n    prefetchProjects(queryClient);\n    prefetchTasks(queryClient);\n\n    // Prefetch all dashboard card data\n    queryClient.prefetchQuery({\n        queryKey: ['timeEntries', organizationId],\n        queryFn: () =>\n            api.getTimeEntries({\n                params: { organization: organizationId },\n                queries: { limit: 10, offset: 0, only_full_dates: 'true' },\n            }),\n        staleTime: 30000,\n    });\n\n    queryClient.prefetchQuery({\n        queryKey: ['lastSevenDays', organizationId],\n        queryFn: () => api.lastSevenDays({ params: { organization: organizationId } }),\n        staleTime: 30000,\n    });\n\n    queryClient.prefetchQuery({\n        queryKey: ['dailyTrackedHours', organizationId],\n        queryFn: () => api.dailyTrackedHours({ params: { organization: organizationId } }),\n        staleTime: 30000,\n    });\n\n    queryClient.prefetchQuery({\n        queryKey: ['weeklyProjectOverview', organizationId],\n        queryFn: () => api.weeklyProjectOverview({ params: { organization: organizationId } }),\n        staleTime: 30000,\n    });\n\n    queryClient.prefetchQuery({\n        queryKey: ['totalWeeklyTime', organizationId],\n        queryFn: () => api.totalWeeklyTime({ params: { organization: organizationId } }),\n        staleTime: 30000,\n    });\n\n    queryClient.prefetchQuery({\n        queryKey: ['totalWeeklyBillableTime', organizationId],\n        queryFn: () => api.totalWeeklyBillableTime({ params: { organization: organizationId } }),\n        staleTime: 30000,\n    });\n\n    queryClient.prefetchQuery({\n        queryKey: ['totalWeeklyBillableAmount', organizationId],\n        queryFn: () => api.totalWeeklyBillableAmount({ params: { organization: organizationId } }),\n        staleTime: 30000,\n    });\n\n    queryClient.prefetchQuery({\n        queryKey: ['weeklyHistory', organizationId],\n        queryFn: () => api.weeklyHistory({ params: { organization: organizationId } }),\n        staleTime: 30000,\n    });\n\n    // Prefetch team activity only if user has permission\n    if (canViewMembers()) {\n        queryClient.prefetchQuery({\n            queryKey: ['latestTeamActivity', organizationId],\n            queryFn: () => api.latestTeamActivity({ params: { organization: organizationId } }),\n            staleTime: 30000,\n        });\n    }\n}\n\nfunction prefetchProjects(queryClient: QueryClient) {\n    const organizationId = getCurrentOrganizationId();\n    if (!organizationId) return;\n\n    queryClient.prefetchQuery({\n        queryKey: ['projects', organizationId],\n        queryFn: async () => ({ data: await fetchAllProjects(organizationId) }),\n        staleTime: 30000,\n    });\n}\n\nfunction prefetchTasks(queryClient: QueryClient) {\n    const organizationId = getCurrentOrganizationId();\n    if (!organizationId) return;\n\n    queryClient.prefetchQuery({\n        queryKey: ['tasks', organizationId],\n        queryFn: async () => ({ data: await fetchAllTasks(organizationId) }),\n        staleTime: 30000,\n    });\n}\n\nfunction prefetchTags(queryClient: QueryClient) {\n    const organizationId = getCurrentOrganizationId();\n    if (!organizationId) return;\n\n    queryClient.prefetchQuery({\n        queryKey: ['tags', organizationId],\n        queryFn: async () => ({ data: await fetchAllTags(organizationId) }),\n        staleTime: 30000,\n    });\n}\n\nfunction prefetchClients(queryClient: QueryClient) {\n    const organizationId = getCurrentOrganizationId();\n    if (!organizationId || !canViewClients()) return;\n\n    queryClient.prefetchQuery({\n        queryKey: ['clients', organizationId],\n        queryFn: async () => ({ data: await fetchAllClients(organizationId) }),\n        staleTime: 30000,\n    });\n}\n\nfunction prefetchMembers(queryClient: QueryClient) {\n    const organizationId = getCurrentOrganizationId();\n    if (!organizationId || !canViewMembers()) return;\n\n    queryClient.prefetchQuery({\n        queryKey: ['members', organizationId],\n        queryFn: async () => ({ data: await fetchAllMembers(organizationId) }),\n        staleTime: 30000,\n    });\n}\n\nfunction prefetchReports(queryClient: QueryClient) {\n    const organizationId = getCurrentOrganizationId();\n    if (!organizationId) return;\n\n    queryClient.prefetchQuery({\n        queryKey: ['reports', organizationId],\n        queryFn: async () => ({ data: await fetchAllReports(organizationId) }),\n        staleTime: 30000,\n    });\n}\n\nfunction prefetchTimeEntries(queryClient: QueryClient) {\n    const organizationId = getCurrentOrganizationId();\n    const memberId = getCurrentMembershipId();\n    if (!organizationId) return;\n\n    queryClient.prefetchInfiniteQuery({\n        queryKey: ['timeEntries', 'infinite', { organizationId, memberId }],\n        queryFn: async () => {\n            const response = await api.getTimeEntries({\n                params: { organization: organizationId },\n                queries: {\n                    only_full_dates: 'true',\n                    member_id: memberId,\n                },\n            });\n            return response;\n        },\n        initialPageParam: undefined,\n        staleTime: 30000,\n    });\n}\n\nfunction prefetchCalendarTimeEntries(queryClient: QueryClient) {\n    const organizationId = getCurrentOrganizationId();\n    const memberId = getCurrentMembershipId();\n    if (!organizationId) return;\n\n    const { start, end } = getInitialWeekRange();\n    const { start: formattedStart, end: formattedEnd } = getExpandedCalendarDateRange(start, end);\n\n    queryClient.prefetchQuery({\n        queryKey: createCalendarQueryKey(formattedStart, formattedEnd, organizationId),\n        queryFn: () =>\n            fetchAllCalendarEntries(organizationId, memberId, formattedStart, formattedEnd),\n        staleTime: 30000,\n    });\n}\n\nfunction prefetchProjectMembers(queryClient: QueryClient, projectId: string) {\n    const organizationId = getCurrentOrganizationId();\n    if (!organizationId || !canViewMembers()) return;\n\n    queryClient.prefetchQuery({\n        queryKey: ['projectMembers', organizationId, projectId],\n        queryFn: async () => ({\n            data: await fetchAllProjectMembers(organizationId, projectId),\n        }),\n        staleTime: 30000,\n    });\n}\n\n/**\n * Matches a URL to find the appropriate prefetcher.\n * Handles both exact matches and pattern matching for dynamic routes.\n */\nfunction findPrefetcher(url: string): ((queryClient: QueryClient) => void) | undefined {\n    // Extract pathname from URL\n    const pathname = url.startsWith('http') ? new URL(url).pathname : url.split('?')[0]!;\n\n    // Try exact match first\n    if (pathname && routePrefetchers[pathname]) {\n        return routePrefetchers[pathname];\n    }\n\n    // Try pattern matching for dynamic routes like /projects/{id}\n    const projectMatch = pathname?.match(/^\\/projects\\/([^/]+)$/);\n    if (projectMatch) {\n        const projectId = projectMatch[1]!;\n        return (queryClient) => {\n            prefetchProjects(queryClient);\n            prefetchTasks(queryClient);\n            prefetchProjectMembers(queryClient, projectId);\n        };\n    }\n\n    return undefined;\n}\n\n/**\n * Sets up Inertia prefetch event listener to warm TanStack Query cache.\n * Call this once during app initialization.\n */\nexport function setupPrefetching(queryClient: QueryClient) {\n    // Listen for the 'prefetching' event which fires when Inertia starts prefetching a page\n    // The event detail contains the visit object with the URL being prefetched\n    document.addEventListener('inertia:prefetching', ((event: CustomEvent) => {\n        const visit = event.detail?.visit;\n        if (!visit?.url) return;\n\n        const url = visit.url.href || visit.url.toString();\n        const prefetcher = findPrefetcher(url);\n\n        if (prefetcher) {\n            prefetcher(queryClient);\n        }\n    }) as EventListener);\n}\n"
  },
  {
    "path": "resources/js/utils/roles.ts",
    "content": "import type { Role } from '@/types/jetstream';\n\nexport function filterRoles(roles: Role[]) {\n    return roles.filter(function (role) {\n        return role.key !== 'placeholder' && role.key !== 'owner';\n    });\n}\n"
  },
  {
    "path": "resources/js/utils/session.ts",
    "content": "import { router } from '@inertiajs/vue3';\n\nexport async function fetchToken() {\n    return new Promise((resolve) => {\n        router.reload({\n            only: [],\n            onFinish: () => {\n                resolve(null);\n            },\n        });\n    });\n}\nexport function isTokenValid() {\n    return window.document.cookie.includes('XSRF-TOKEN');\n}\n"
  },
  {
    "path": "resources/js/utils/theme.ts",
    "content": "import { usePreferredColorScheme, useStorage } from '@vueuse/core';\nimport { computed, watch } from 'vue';\n\ntype themeOption = 'system' | 'light' | 'dark';\nconst themeSetting = useStorage<themeOption>('theme', 'system');\nconst preferredColor = usePreferredColorScheme();\nconst theme = computed(() => {\n    if (themeSetting.value === 'system') {\n        console.log(preferredColor.value);\n        if (preferredColor.value === 'no-preference') {\n            return 'dark';\n        }\n        return preferredColor.value;\n    }\n    return themeSetting.value;\n});\n\nfunction useTheme() {\n    document.documentElement.classList.add(theme.value);\n    watch(theme, (newTheme, oldTheme) => {\n        document.documentElement.classList.remove(oldTheme);\n        document.documentElement.classList.add(newTheme);\n    });\n}\n\nexport { type themeOption, themeSetting, theme, useTheme };\n"
  },
  {
    "path": "resources/js/utils/useAggregatedTimeEntriesQuery.ts",
    "content": "import { useQuery } from '@tanstack/vue-query';\nimport {\n    api,\n    type AggregatedTimeEntriesQueryParams,\n    type ReportingResponse,\n} from '@/packages/api/src';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport { computed, type ComputedRef, unref } from 'vue';\n\nexport function useAggregatedTimeEntriesQuery(\n    queryKeyPrefix: string,\n    filterParams: ComputedRef<AggregatedTimeEntriesQueryParams>\n) {\n    const query = useQuery<ReportingResponse>({\n        queryKey: computed(() => [\n            'aggregatedTimeEntries',\n            queryKeyPrefix,\n            getCurrentOrganizationId(),\n            unref(filterParams),\n        ]),\n        queryFn: () =>\n            api.getAggregatedTimeEntries({\n                params: {\n                    organization: getCurrentOrganizationId() || '',\n                },\n                queries: unref(filterParams),\n            }),\n        enabled: computed(() => !!getCurrentOrganizationId()),\n        placeholderData: (previousData) => previousData,\n        staleTime: 1000 * 30,\n    });\n\n    return query;\n}\n"
  },
  {
    "path": "resources/js/utils/useClients.ts",
    "content": "import { defineStore } from 'pinia';\nimport { api } from '@/packages/api/src';\nimport type { CreateClientBody, Client, UpdateClientBody } from '@/packages/api/src';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport { useNotificationsStore } from '@/utils/notification';\nimport { useQueryClient } from '@tanstack/vue-query';\n\nexport const useClientsStore = defineStore('clients', () => {\n    const { handleApiRequestNotifications } = useNotificationsStore();\n    const queryClient = useQueryClient();\n\n    async function createClient(clientBody: CreateClientBody): Promise<Client | undefined> {\n        const organization = getCurrentOrganizationId();\n        if (organization) {\n            const response = await handleApiRequestNotifications(\n                () =>\n                    api.createClient(clientBody, {\n                        params: {\n                            organization: organization,\n                        },\n                    }),\n                'Client created successfully',\n                'Failed to create client'\n            );\n            queryClient.invalidateQueries({ queryKey: ['clients'] });\n            return response?.data;\n        }\n    }\n\n    async function updateClient(clientId: string, clientBody: UpdateClientBody) {\n        const organization = getCurrentOrganizationId();\n        if (organization) {\n            await handleApiRequestNotifications(\n                () =>\n                    api.updateClient(clientBody, {\n                        params: {\n                            organization: organization,\n                            client: clientId,\n                        },\n                    }),\n                'Client updated successfully',\n                'Failed to update client'\n            );\n            queryClient.invalidateQueries({ queryKey: ['clients'] });\n        }\n    }\n\n    async function deleteClient(clientId: string) {\n        const organization = getCurrentOrganizationId();\n        if (organization) {\n            await handleApiRequestNotifications(\n                () =>\n                    api.deleteClient(undefined, {\n                        params: {\n                            organization: organization,\n                            client: clientId,\n                        },\n                    }),\n                'Client deleted successfully',\n                'Failed to delete client'\n            );\n            queryClient.invalidateQueries({ queryKey: ['clients'] });\n        }\n    }\n\n    return { createClient, deleteClient, updateClient };\n});\n"
  },
  {
    "path": "resources/js/utils/useClientsQuery.ts",
    "content": "import { useQuery, useQueryClient } from '@tanstack/vue-query';\nimport { api } from '@/packages/api/src';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport type { Client } from '@/packages/api/src';\nimport { computed } from 'vue';\nimport { fetchAllPages } from '@/utils/fetchAllPages';\n\nexport async function fetchAllClients(organizationId: string): Promise<Client[]> {\n    return fetchAllPages((page) =>\n        api.getClients({\n            params: { organization: organizationId },\n            queries: { archived: 'all', page },\n        })\n    );\n}\n\nexport function useClientsQuery() {\n    const queryClient = useQueryClient();\n\n    const query = useQuery({\n        queryKey: computed(() => ['clients', getCurrentOrganizationId()]),\n        queryFn: async () => {\n            const organizationId = getCurrentOrganizationId();\n            if (!organizationId) throw new Error('No organization');\n            const data = await fetchAllClients(organizationId);\n            return { data };\n        },\n        enabled: () => !!getCurrentOrganizationId(),\n        staleTime: 1000 * 30, // 30 seconds\n    });\n\n    const clients = computed<Client[]>(() => query.data.value?.data ?? []);\n\n    const invalidateClients = () => {\n        queryClient.invalidateQueries({ queryKey: ['clients'] });\n    };\n\n    return {\n        ...query,\n        clients,\n        invalidateClients,\n    };\n}\n"
  },
  {
    "path": "resources/js/utils/useCommandPalette.ts",
    "content": "import { ref, computed } from 'vue';\nimport { router } from '@inertiajs/vue3';\nimport { storeToRefs } from 'pinia';\nimport { getDayJsInstance } from '@/packages/ui/src/utils/time';\nimport { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';\nimport { themeSetting, type themeOption } from '@/utils/theme';\nimport {\n    canViewProjects,\n    canViewClients,\n    canViewMembers,\n    canViewTags,\n    canViewReport,\n    canViewInvoices,\n    canManageBilling,\n    canUpdateOrganization,\n    canCreateProjects,\n    canCreateClients,\n    canCreateTasks,\n    canCreateTags,\n    canCreateInvitations,\n} from '@/utils/permissions';\nimport { isBillingActivated, isInvoicingActivated } from '@/utils/billing';\nimport { useTimeEntriesInfiniteQuery } from '@/utils/useTimeEntriesInfiniteQuery';\nimport { useProjectsQuery } from '@/utils/useProjectsQuery';\nimport { useClientsQuery } from '@/utils/useClientsQuery';\nimport { useTasksQuery } from '@/utils/useTasksQuery';\nimport { useTagsQuery } from '@/utils/useTagsQuery';\nimport { useMembersQuery } from '@/utils/useMembersQuery';\nimport {\n    createNavigationCommands,\n    createTimerCommands,\n    createActiveTimerCommands,\n    createThemeCommands,\n    createCreateCommands,\n    createOrganizationCommands,\n    scoreEntity,\n    GROUP_PRIORITIES,\n    type Command,\n    type CommandGroup as CommandGroupType,\n} from '@/utils/commandPaletteCommands';\nimport { usePage } from '@inertiajs/vue3';\nimport type { Organization, User } from '@/types/models';\nimport { switchOrganization } from '@/utils/useOrganization';\nimport type {\n    CommandPaletteGroup,\n    EntitySearchResult,\n} from '@/packages/ui/src/CommandPalette/CommandPaletteTypes';\nimport type { Project, Client, Task, Tag, Member } from '@/packages/api/src';\nimport {\n    FolderIcon,\n    UserCircleIcon,\n    TagIcon,\n    UserGroupIcon,\n    ClipboardDocumentListIcon,\n} from '@heroicons/vue/20/solid';\n\n// Global state (singleton pattern - shared across all useCommandPalette() calls)\nconst isOpen = ref(false);\nconst searchTerm = ref('');\n\n// Modal states for create actions\nconst showCreateProjectModal = ref(false);\nconst showCreateClientModal = ref(false);\nconst showCreateTaskModal = ref(false);\nconst showCreateTagModal = ref(false);\nconst showInviteMemberModal = ref(false);\nconst showCreateTimeEntryModal = ref(false);\n\n// Active timer selector states\nconst showProjectSelector = ref(false);\nconst showTaskSelector = ref(false);\nconst showTagsSelector = ref(false);\n\n// Group display order and headings\nconst GROUP_CONFIG: { id: CommandGroupType; heading: string }[] = [\n    { id: 'timer', heading: 'Timer' },\n    { id: 'active-timer', heading: 'Active Timer' },\n    { id: 'navigation', heading: 'Navigation' },\n    { id: 'create', heading: 'Create' },\n    { id: 'organization', heading: 'Organization' },\n    { id: 'theme', heading: 'Theme' },\n];\n\n// Entity badge classes\nconst ENTITY_BADGE_CLASSES: Record<string, string> = {\n    project: 'bg-violet-500/20 text-violet-500',\n    client: 'bg-blue-500/20 text-blue-500',\n    task: 'bg-gray-500/20 text-gray-400',\n    tag: 'bg-amber-500/20 text-amber-500',\n    member: 'bg-green-500/20 text-green-500',\n};\n\n// Entity icons\nconst ENTITY_ICONS: Record<string, typeof FolderIcon> = {\n    project: FolderIcon,\n    client: UserCircleIcon,\n    task: ClipboardDocumentListIcon,\n    tag: TagIcon,\n    member: UserGroupIcon,\n};\n\nexport function useCommandPalette() {\n    const currentTimeEntryStore = useCurrentTimeEntryStore();\n    const { currentTimeEntry, isActive } = storeToRefs(currentTimeEntryStore);\n    const { setActiveState, updateTimer } = currentTimeEntryStore;\n\n    // Data queries (consolidated here - single source of truth)\n    const timeEntriesQuery = useTimeEntriesInfiniteQuery();\n    const { projects } = useProjectsQuery();\n    const { clients } = useClientsQuery();\n    const { tasks } = useTasksQuery();\n    const { tags } = useTagsQuery();\n    const { members } = useMembersQuery();\n\n    const page = usePage<{\n        auth: {\n            user: User & {\n                all_teams: Organization[];\n                current_team_id: string;\n            };\n        };\n    }>();\n\n    const getCurrentTeamId = () => page.props.auth.user.current_team?.id ?? '';\n    const allOrganizations = computed(() => page.props.auth.user.all_teams || []);\n    const currentOrgId = computed(() => page.props.auth.user.current_team_id || '');\n\n    const lastTimeEntry = computed(() => {\n        const pages = timeEntriesQuery.data.value?.pages;\n        if (!pages || pages.length === 0) return null;\n        const firstPage = pages[0];\n        if (!firstPage?.data || firstPage.data.length === 0) return null;\n        return firstPage.data[0];\n    });\n\n    const hasTimeEntries = computed(() => lastTimeEntry.value !== null);\n\n    // Helper to close palette\n    function closePaletteAfterAction() {\n        isOpen.value = false;\n    }\n\n    // Navigation helper\n    function navigate(routeName: string, params?: Record<string, string>) {\n        closePaletteAfterAction();\n        if (routeName.startsWith('/')) {\n            router.visit(routeName);\n        } else {\n            router.visit(route(routeName, params));\n        }\n    }\n\n    // Theme helper\n    function setTheme(theme: themeOption) {\n        themeSetting.value = theme;\n        closePaletteAfterAction();\n    }\n\n    // Timer actions\n    async function startTimer() {\n        closePaletteAfterAction();\n        await setActiveState(true);\n    }\n\n    async function stopTimer() {\n        closePaletteAfterAction();\n        await setActiveState(false);\n    }\n\n    function openCreateTimeEntryModal() {\n        closePaletteAfterAction();\n        showCreateTimeEntryModal.value = true;\n    }\n\n    async function continueLastEntry() {\n        if (!lastTimeEntry.value) return;\n        closePaletteAfterAction();\n\n        currentTimeEntry.value.description = lastTimeEntry.value.description;\n        currentTimeEntry.value.project_id = lastTimeEntry.value.project_id;\n        currentTimeEntry.value.task_id = lastTimeEntry.value.task_id;\n        currentTimeEntry.value.tags = lastTimeEntry.value.tags;\n        currentTimeEntry.value.billable = lastTimeEntry.value.billable;\n        currentTimeEntry.value.start = getDayJsInstance()().utc().format();\n        await setActiveState(true);\n    }\n\n    // Active timer actions\n    function openProjectSelector() {\n        closePaletteAfterAction();\n        showProjectSelector.value = true;\n    }\n\n    function openTaskSelector() {\n        closePaletteAfterAction();\n        showTaskSelector.value = true;\n    }\n\n    function openTagsSelector() {\n        closePaletteAfterAction();\n        showTagsSelector.value = true;\n    }\n\n    async function toggleBillable() {\n        closePaletteAfterAction();\n        currentTimeEntry.value.billable = !currentTimeEntry.value.billable;\n        await updateTimer();\n    }\n\n    async function addMinutes(minutes: number) {\n        closePaletteAfterAction();\n        currentTimeEntry.value.start = getDayJsInstance()(currentTimeEntry.value.start)\n            .subtract(minutes, 'minutes')\n            .utc()\n            .format();\n        await updateTimer();\n    }\n\n    // Create actions\n    function openCreateProjectModal() {\n        closePaletteAfterAction();\n        showCreateProjectModal.value = true;\n    }\n\n    function openCreateClientModal() {\n        closePaletteAfterAction();\n        showCreateClientModal.value = true;\n    }\n\n    function openCreateTaskModal() {\n        closePaletteAfterAction();\n        showCreateTaskModal.value = true;\n    }\n\n    function openCreateTagModal() {\n        closePaletteAfterAction();\n        showCreateTagModal.value = true;\n    }\n\n    function openInviteMemberModal() {\n        closePaletteAfterAction();\n        showInviteMemberModal.value = true;\n    }\n\n    // Organization switch action\n    function handleSwitchOrganization(orgId: string) {\n        closePaletteAfterAction();\n        switchOrganization(orgId);\n    }\n\n    // Build all internal commands\n    const navigationCommands = computed(() =>\n        createNavigationCommands(\n            navigate,\n            {\n                canViewProjects,\n                canViewClients,\n                canViewMembers,\n                canViewTags,\n                canViewReport,\n                canViewInvoices,\n                canManageBilling,\n                canUpdateOrganization,\n            },\n            {\n                isInvoicingActivated,\n                isBillingActivated,\n            },\n            getCurrentTeamId\n        )\n    );\n\n    const timerCommands = computed(() =>\n        createTimerCommands(\n            {\n                startTimer,\n                stopTimer,\n                openCreateTimeEntryModal,\n                continueLastEntry,\n            },\n            {\n                isActive: () => isActive.value,\n                hasTimeEntries: () => hasTimeEntries.value,\n            }\n        )\n    );\n\n    const activeTimerCommands = computed(() =>\n        createActiveTimerCommands(\n            {\n                openProjectSelector,\n                openTaskSelector,\n                openTagsSelector,\n                toggleBillable,\n                addMinutes,\n            },\n            {\n                isActive: () => isActive.value,\n            }\n        )\n    );\n\n    const themeCommands = computed(() => createThemeCommands(setTheme));\n\n    const createCommands = computed(() =>\n        createCreateCommands(\n            {\n                openProjectModal: openCreateProjectModal,\n                openClientModal: openCreateClientModal,\n                openTaskModal: openCreateTaskModal,\n                openTagModal: openCreateTagModal,\n                openInviteModal: openInviteMemberModal,\n            },\n            {\n                canCreateProjects,\n                canCreateClients,\n                canCreateTasks,\n                canCreateTags,\n                canCreateInvitations,\n            }\n        )\n    );\n\n    const organizationCommands = computed(() =>\n        createOrganizationCommands(\n            allOrganizations.value,\n            currentOrgId.value,\n            handleSwitchOrganization\n        )\n    );\n\n    // Internal commands grouped by type\n    const commandsByGroup = computed<Record<string, Command[]>>(() => {\n        const allCommands: Command[] = [\n            ...timerCommands.value,\n            ...activeTimerCommands.value,\n            ...navigationCommands.value,\n            ...createCommands.value,\n            ...organizationCommands.value,\n            ...themeCommands.value,\n        ];\n\n        const grouped: Record<string, Command[]> = {};\n        for (const config of GROUP_CONFIG) {\n            grouped[config.id] = [];\n        }\n\n        for (const cmd of allCommands) {\n            if (cmd.permission && !cmd.permission()) continue;\n            if (cmd.condition && !cmd.condition()) continue;\n\n            if (grouped[cmd.group] !== undefined) {\n                grouped[cmd.group]!.push(cmd);\n            }\n        }\n\n        return grouped;\n    });\n\n    // Map internal commands to UI-friendly CommandPaletteGroup[]\n    const groups = computed<CommandPaletteGroup[]>(() =>\n        GROUP_CONFIG.map((config) => ({\n            id: config.id,\n            heading: config.heading,\n            commands: (commandsByGroup.value[config.id] ?? []).map((cmd) => ({\n                id: cmd.id,\n                label: cmd.label,\n                icon: cmd.icon,\n                keywords: cmd.keywords,\n                action: cmd.action,\n                shortcut: cmd.shortcut,\n            })),\n        }))\n    );\n\n    // Entity search results (moved from old CommandPalette.vue)\n    const entityResults = computed<EntitySearchResult[]>(() => {\n        const query = searchTerm.value.toLowerCase().trim();\n        if (!query || query.length < 2) return [];\n\n        const results: EntitySearchResult[] = [];\n        const maxPerType = 5;\n\n        if (canViewProjects()) {\n            const matching = projects.value\n                .filter((p: Project) => p.name.toLowerCase().includes(query))\n                .slice(0, maxPerType)\n                .map(\n                    (p: Project): EntitySearchResult => ({\n                        id: `entity-project-${p.id}`,\n                        label: p.name,\n                        icon: ENTITY_ICONS.project,\n                        keywords: ['project'],\n                        action: () => {\n                            closePaletteAfterAction();\n                            router.visit(route('projects.show', { project: p.id }));\n                        },\n                        entityType: 'project',\n                        color: p.color,\n                        badgeClass: ENTITY_BADGE_CLASSES.project,\n                    })\n                );\n            results.push(...matching);\n        }\n\n        if (canViewClients()) {\n            const matching = clients.value\n                .filter((c: Client) => c.name.toLowerCase().includes(query))\n                .slice(0, maxPerType)\n                .map(\n                    (c: Client): EntitySearchResult => ({\n                        id: `entity-client-${c.id}`,\n                        label: c.name,\n                        icon: ENTITY_ICONS.client,\n                        keywords: ['client'],\n                        action: () => {\n                            closePaletteAfterAction();\n                            router.visit(route('clients'));\n                        },\n                        entityType: 'client',\n                        badgeClass: ENTITY_BADGE_CLASSES.client,\n                    })\n                );\n            results.push(...matching);\n        }\n\n        if (canViewProjects()) {\n            const matching = tasks.value\n                .filter((t: Task) => t.name.toLowerCase().includes(query))\n                .slice(0, maxPerType)\n                .map(\n                    (t: Task): EntitySearchResult => ({\n                        id: `entity-task-${t.id}`,\n                        label: t.name,\n                        icon: ENTITY_ICONS.task,\n                        keywords: ['task'],\n                        action: () => {\n                            closePaletteAfterAction();\n                            if (t.project_id) {\n                                router.visit(route('projects.show', { project: t.project_id }));\n                            }\n                        },\n                        entityType: 'task',\n                        badgeClass: ENTITY_BADGE_CLASSES.task,\n                    })\n                );\n            results.push(...matching);\n        }\n\n        if (canViewTags()) {\n            const matching = tags.value\n                .filter((t: Tag) => t.name.toLowerCase().includes(query))\n                .slice(0, maxPerType)\n                .map(\n                    (t: Tag): EntitySearchResult => ({\n                        id: `entity-tag-${t.id}`,\n                        label: t.name,\n                        icon: ENTITY_ICONS.tag,\n                        keywords: ['tag'],\n                        action: () => {\n                            closePaletteAfterAction();\n                            router.visit(route('tags'));\n                        },\n                        entityType: 'tag',\n                        badgeClass: ENTITY_BADGE_CLASSES.tag,\n                    })\n                );\n            results.push(...matching);\n        }\n\n        if (canViewMembers()) {\n            const matching = members.value\n                .filter((m: Member) => m.name.toLowerCase().includes(query))\n                .slice(0, maxPerType)\n                .map(\n                    (m: Member): EntitySearchResult => ({\n                        id: `entity-member-${m.id}`,\n                        label: m.name,\n                        icon: ENTITY_ICONS.member,\n                        keywords: ['member'],\n                        action: () => {\n                            closePaletteAfterAction();\n                            router.visit(route('members'));\n                        },\n                        entityType: 'member',\n                        badgeClass: ENTITY_BADGE_CLASSES.member,\n                    })\n                );\n            results.push(...matching);\n        }\n\n        return results.sort(\n            (a, b) =>\n                scoreEntity(b.label, query, GROUP_PRIORITIES.entity) -\n                scoreEntity(a.label, query, GROUP_PRIORITIES.entity)\n        );\n    });\n\n    // Open/close\n    function openPalette() {\n        isOpen.value = true;\n    }\n\n    function closePalette() {\n        isOpen.value = false;\n    }\n\n    function togglePalette() {\n        if (isOpen.value) {\n            closePalette();\n        } else {\n            openPalette();\n        }\n    }\n\n    return {\n        // State\n        isOpen,\n        searchTerm,\n\n        // Modal states\n        showCreateProjectModal,\n        showCreateClientModal,\n        showCreateTaskModal,\n        showCreateTagModal,\n        showInviteMemberModal,\n        showCreateTimeEntryModal,\n        showProjectSelector,\n        showTaskSelector,\n        showTagsSelector,\n\n        // UI data (for CommandPalette component props)\n        groups,\n        entityResults,\n\n        // Query data (for Provider modals)\n        projects,\n        clients,\n        tasks,\n        tags,\n\n        // Computed\n        isActive,\n        currentTimeEntry,\n\n        // Actions\n        openPalette,\n        closePalette,\n        togglePalette,\n        updateTimer,\n    };\n}\n"
  },
  {
    "path": "resources/js/utils/useCssVariable.ts",
    "content": "import { ref, onMounted, onUnmounted } from 'vue';\n\nexport function useCssVariable(variableName: string) {\n    const value = ref('');\n    let observer: MutationObserver | null = null;\n    let mediaQuery: MediaQueryList | null = null;\n\n    const updateValue = () => {\n        const computedStyle = getComputedStyle(document.documentElement);\n        const cssValue = computedStyle.getPropertyValue(variableName).trim();\n        value.value = cssValue;\n    };\n\n    onMounted(() => {\n        // Initialize with current value\n        updateValue();\n\n        // Watch for class changes on document.documentElement (where theme classes are applied)\n        observer = new MutationObserver((mutations) => {\n            mutations.forEach((mutation) => {\n                if (mutation.type === 'attributes' && mutation.attributeName === 'class') {\n                    updateValue();\n                }\n            });\n        });\n\n        observer.observe(document.documentElement, {\n            attributes: true,\n            attributeFilter: ['class'],\n        });\n\n        // Also watch for system color scheme changes\n        if (window.matchMedia) {\n            mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n            mediaQuery.addEventListener('change', updateValue);\n        }\n    });\n\n    onUnmounted(() => {\n        if (observer) {\n            observer.disconnect();\n        }\n        if (mediaQuery) {\n            mediaQuery.removeEventListener('change', updateValue);\n        }\n    });\n\n    return value;\n}\n"
  },
  {
    "path": "resources/js/utils/useCurrentTimeEntry.ts",
    "content": "import { defineStore } from 'pinia';\nimport { computed, ref } from 'vue';\nimport { api } from '@/packages/api/src';\nimport type { TimeEntry } from '@/packages/api/src';\nimport dayjs, { Dayjs } from 'dayjs';\nimport utc from 'dayjs/plugin/utc';\nimport {\n    getCurrentMembershipId,\n    getCurrentOrganizationId,\n    getCurrentUserId,\n} from '@/utils/useUser';\nimport { useLocalStorage } from '@vueuse/core';\nimport { useNotificationsStore } from '@/utils/notification';\nimport { useQueryClient } from '@tanstack/vue-query';\n\ndayjs.extend(utc);\n\nconst emptyTimeEntry = {\n    id: '',\n    description: '',\n    user_id: '',\n    start: '',\n    end: null,\n    duration: null,\n    task_id: null,\n    project_id: null,\n    tags: [],\n    billable: false,\n    organization_id: '',\n} as TimeEntry;\n\nexport const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {\n    const currentTimeEntry = ref<TimeEntry>({ ...emptyTimeEntry });\n    const { handleApiRequestNotifications } = useNotificationsStore();\n    const queryClient = useQueryClient();\n\n    useLocalStorage('solidtime/current-time-entry', currentTimeEntry, {\n        deep: true,\n    });\n\n    function $reset() {\n        currentTimeEntry.value = { ...emptyTimeEntry };\n    }\n\n    const now = ref<null | Dayjs>(null);\n    const interval = ref<ReturnType<typeof setInterval> | null>(null);\n\n    function startLiveTimer() {\n        stopLiveTimer();\n        now.value = dayjs().utc();\n        interval.value = setInterval(() => {\n            now.value = dayjs().utc();\n        }, 1000);\n    }\n\n    function stopLiveTimer() {\n        if (interval.value !== null) {\n            clearInterval(interval.value);\n        }\n    }\n\n    async function fetchCurrentTimeEntry() {\n        const organizationId = getCurrentOrganizationId();\n        if (organizationId) {\n            try {\n                const timeEntriesResponse = await api.getMyActiveTimeEntry({});\n                if (timeEntriesResponse?.data) {\n                    if (timeEntriesResponse.data) {\n                        currentTimeEntry.value = timeEntriesResponse.data;\n                        if (\n                            currentTimeEntry.value.start !== '' &&\n                            currentTimeEntry.value.end === null\n                        ) {\n                            startLiveTimer();\n                        }\n                    } else {\n                        // No active time entry on server\n                        // Only reset if we had a previously started timer (has an ID)\n                        // Don't reset if user is preparing a new time entry (no ID yet)\n                        if (currentTimeEntry.value.id !== '') {\n                            currentTimeEntry.value = { ...emptyTimeEntry };\n                            stopLiveTimer();\n                        }\n                    }\n                }\n            } catch {\n                // API error (e.g., 404 when no active time entry)\n                // Only reset if we had a previously started timer (has an ID)\n                // Don't reset if user is preparing a new time entry (no ID yet)\n                if (currentTimeEntry.value.id !== '') {\n                    currentTimeEntry.value = { ...emptyTimeEntry };\n                    stopLiveTimer();\n                }\n            }\n        } else {\n            throw new Error(\n                'Failed to fetch current time entry because organization ID is missing.'\n            );\n        }\n    }\n\n    async function startTimer() {\n        const organization = getCurrentOrganizationId();\n        const membership = getCurrentMembershipId();\n        if (organization && membership) {\n            const startTime =\n                currentTimeEntry.value.start !== ''\n                    ? currentTimeEntry.value.start\n                    : dayjs().utc().format();\n            const response = await handleApiRequestNotifications(\n                () =>\n                    api.createTimeEntry(\n                        {\n                            member_id: membership,\n                            start: startTime,\n                            description: currentTimeEntry.value?.description,\n                            project_id: currentTimeEntry.value?.project_id,\n                            task_id: currentTimeEntry.value?.task_id,\n                            billable: currentTimeEntry.value.billable,\n                            tags: currentTimeEntry.value?.tags,\n                        },\n                        { params: { organization: organization } }\n                    ),\n                'Timer started!'\n            );\n            if (response?.data) {\n                currentTimeEntry.value = response.data;\n            }\n        } else {\n            throw new Error(\n                'Failed to fetch current time entry because organization ID is missing.'\n            );\n        }\n    }\n\n    async function stopTimer() {\n        const user = getCurrentUserId();\n        const organization = getCurrentOrganizationId();\n        if (organization) {\n            const currentDateTime = dayjs().utc().format();\n            await handleApiRequestNotifications(\n                () =>\n                    api.updateTimeEntry(\n                        {\n                            user_id: user,\n                            start: currentTimeEntry.value.start,\n                            end: currentDateTime,\n                        },\n                        {\n                            params: {\n                                organization: organization,\n                                timeEntry: currentTimeEntry.value.id,\n                            },\n                        }\n                    ),\n                'Timer stopped!'\n            );\n            $reset();\n        } else {\n            throw new Error('Failed to stop current timer because organization ID is missing.');\n        }\n    }\n\n    async function updateTimer() {\n        const user = getCurrentUserId();\n        const organization = getCurrentOrganizationId();\n        if (organization) {\n            const response = await handleApiRequestNotifications(\n                () =>\n                    api.updateTimeEntry(\n                        {\n                            description: currentTimeEntry.value.description,\n                            user_id: user,\n                            project_id: currentTimeEntry.value.project_id,\n                            task_id: currentTimeEntry.value.task_id,\n                            start: currentTimeEntry.value.start,\n                            billable: currentTimeEntry.value.billable,\n                            end: currentTimeEntry.value.end,\n                            tags: currentTimeEntry.value.tags,\n                        },\n                        {\n                            params: {\n                                organization: organization,\n                                timeEntry: currentTimeEntry.value.id,\n                            },\n                        }\n                    ),\n                'Time entry updated!'\n            );\n            if (response?.data) {\n                if (response.data.end === null) {\n                    currentTimeEntry.value = response.data;\n                } else {\n                    $reset();\n                    stopLiveTimer();\n                }\n            }\n        } else {\n            throw new Error(\n                'Failed to fetch current time entry because organization ID is missing.'\n            );\n        }\n    }\n\n    const isActive = computed(() => {\n        if (currentTimeEntry.value) {\n            return (\n                currentTimeEntry.value.start !== '' &&\n                currentTimeEntry.value.start !== null &&\n                currentTimeEntry.value.end === null\n            );\n        }\n        return false;\n    });\n\n    async function setActiveState(newState: boolean) {\n        if (newState) {\n            startLiveTimer();\n            await startTimer();\n        } else {\n            stopLiveTimer();\n            await stopTimer();\n        }\n        queryClient.invalidateQueries({ queryKey: ['timeEntries'] });\n    }\n\n    return {\n        currentTimeEntry,\n        fetchCurrentTimeEntry,\n        updateTimer,\n        isActive,\n        startLiveTimer,\n        stopLiveTimer,\n        now,\n        setActiveState,\n        $reset,\n    };\n});\n"
  },
  {
    "path": "resources/js/utils/useInvitations.ts",
    "content": "import { defineStore } from 'pinia';\nimport { api } from '@/packages/api/src';\nimport { computed, ref } from 'vue';\nimport type { CreateInvitationBody, Invitation } from '@/packages/api/src';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport { useNotificationsStore } from '@/utils/notification';\nimport { fetchAllPages } from '@/utils/fetchAllPages';\n\nexport async function fetchAllInvitations(organizationId: string): Promise<Invitation[]> {\n    return fetchAllPages((page) =>\n        api.getInvitations({\n            params: { organization: organizationId },\n            queries: { page },\n        })\n    );\n}\n\nexport const useInvitationsStore = defineStore('invitations', () => {\n    const invitationsData = ref<Invitation[]>([]);\n    const { handleApiRequestNotifications } = useNotificationsStore();\n\n    async function fetchInvitations() {\n        const organization = getCurrentOrganizationId();\n        if (organization) {\n            const data = await handleApiRequestNotifications(\n                () => fetchAllInvitations(organization),\n                undefined,\n                'Failed to fetch invitations'\n            );\n            if (data) {\n                invitationsData.value = data;\n            }\n        }\n    }\n\n    async function createInvitation(inviteBody: CreateInvitationBody): Promise<undefined> {\n        const organization = getCurrentOrganizationId();\n        if (organization) {\n            await handleApiRequestNotifications(\n                () =>\n                    api.invite(inviteBody, {\n                        params: {\n                            organization: organization,\n                        },\n                    }),\n                'User successfully invited',\n                'Failed to invite user'\n            );\n            await fetchInvitations();\n        }\n    }\n\n    const invitations = computed<Invitation[]>(() => {\n        return invitationsData.value;\n    });\n\n    return { invitations, fetchInvitations, createInvitation };\n});\n"
  },
  {
    "path": "resources/js/utils/useMembers.ts",
    "content": "import { defineStore } from 'pinia';\nimport { api } from '@/packages/api/src';\nimport type { UpdateMemberBody } from '@/packages/api/src';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport { useNotificationsStore } from '@/utils/notification';\nimport { useQueryClient } from '@tanstack/vue-query';\n\nexport type MemberBillableKey = 'default-rate' | 'custom-rate';\n\nexport const useMembersStore = defineStore('members', () => {\n    const { handleApiRequestNotifications } = useNotificationsStore();\n    const queryClient = useQueryClient();\n\n    async function removeMember(membershipId: string) {\n        const organization = getCurrentOrganizationId();\n        if (organization) {\n            await handleApiRequestNotifications(\n                () =>\n                    api.removeMember(undefined, {\n                        params: {\n                            organization: organization,\n                            member: membershipId,\n                        },\n                    }),\n                'Member deleted successfully',\n                'Failed to delete member'\n            );\n            queryClient.invalidateQueries({ queryKey: ['members'] });\n        }\n    }\n\n    async function updateMember(memberId: string, memberBody: UpdateMemberBody) {\n        const organization = getCurrentOrganizationId();\n        if (organization) {\n            await handleApiRequestNotifications(\n                () =>\n                    api.updateMember(memberBody, {\n                        params: {\n                            organization: organization,\n                            member: memberId,\n                        },\n                    }),\n                'Member updated successfully',\n                'Failed to update member'\n            );\n            queryClient.invalidateQueries({ queryKey: ['members'] });\n        }\n    }\n\n    return { removeMember, updateMember };\n});\n"
  },
  {
    "path": "resources/js/utils/useMembersQuery.ts",
    "content": "import { useQuery, useQueryClient } from '@tanstack/vue-query';\nimport { api } from '@/packages/api/src';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport type { Member } from '@/packages/api/src';\nimport { computed } from 'vue';\nimport { fetchAllPages } from '@/utils/fetchAllPages';\n\nexport async function fetchAllMembers(organizationId: string): Promise<Member[]> {\n    return fetchAllPages((page) =>\n        api.getMembers({\n            params: { organization: organizationId },\n            queries: { page },\n        })\n    );\n}\n\nexport function useMembersQuery() {\n    const queryClient = useQueryClient();\n\n    const query = useQuery({\n        queryKey: computed(() => ['members', getCurrentOrganizationId()]),\n        queryFn: async () => {\n            const organizationId = getCurrentOrganizationId();\n            if (!organizationId) throw new Error('No organization');\n            const data = await fetchAllMembers(organizationId);\n            return { data };\n        },\n        enabled: () => !!getCurrentOrganizationId(),\n        staleTime: 1000 * 30, // 30 seconds\n    });\n\n    const members = computed<Member[]>(() => query.data.value?.data ?? []);\n\n    const invalidateMembers = () => {\n        queryClient.invalidateQueries({ queryKey: ['members'] });\n    };\n\n    return {\n        ...query,\n        members,\n        invalidateMembers,\n    };\n}\n"
  },
  {
    "path": "resources/js/utils/useOrganization.ts",
    "content": "import { router } from '@inertiajs/vue3';\nimport { initializeStores } from '@/utils/init';\nimport { defineStore } from 'pinia';\nimport { computed, ref } from 'vue';\nimport type {\n    Organization,\n    OrganizationResponse,\n    UpdateOrganizationBody,\n} from '@/packages/api/src';\nimport { useNotificationsStore } from '@/utils/notification';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport { api } from '@/packages/api/src';\n\nexport function switchOrganization(organizationId: string) {\n    // Clear Inertia's prefetch cache to prevent stale pages from the old\n    // organization being served when navigating after the switch.\n    router.flushAll();\n\n    router.put(\n        route('current-team.update'),\n        {\n            team_id: organizationId,\n        },\n        {\n            preserveState: false,\n            onSuccess: () => {\n                initializeStores();\n            },\n        }\n    );\n}\n\nexport const useOrganizationStore = defineStore('organization', () => {\n    const organizationResponse = ref<OrganizationResponse | null>(null);\n    const { handleApiRequestNotifications } = useNotificationsStore();\n\n    async function fetchOrganization() {\n        const organization = getCurrentOrganizationId();\n        if (organization) {\n            organizationResponse.value = await handleApiRequestNotifications(\n                () =>\n                    api.getOrganization({\n                        params: {\n                            organization: organization,\n                        },\n                    }),\n                undefined,\n                'Failed to fetch organization'\n            );\n        }\n    }\n\n    async function updateOrganization(organizationBody: UpdateOrganizationBody) {\n        const organization = getCurrentOrganizationId();\n        if (organization) {\n            await handleApiRequestNotifications(\n                () =>\n                    api.updateOrganization(organizationBody, {\n                        params: {\n                            organization: organization,\n                        },\n                    }),\n                'Organization updated successfully',\n                'Failed to update organization'\n            );\n            await fetchOrganization();\n        }\n    }\n\n    const organization = computed<Organization | null>(() => {\n        return organizationResponse.value?.data || null;\n    });\n\n    return { organization, fetchOrganization, updateOrganization };\n});\n"
  },
  {
    "path": "resources/js/utils/useOrganizationQuery.ts",
    "content": "import { useQuery } from '@tanstack/vue-query';\nimport { api } from '@/packages/api/src';\nimport { computed } from 'vue';\n\nexport function useOrganizationQuery(organizationId: string) {\n    const query = useQuery({\n        queryKey: ['organization', organizationId],\n        queryFn: () =>\n            api.getOrganization({\n                params: {\n                    organization: organizationId,\n                },\n            }),\n        staleTime: 1000 * 30,\n    });\n\n    const organization = computed(() => query.data.value?.data);\n\n    return {\n        ...query,\n        organization,\n    };\n}\n"
  },
  {
    "path": "resources/js/utils/useProjectMembers.ts",
    "content": "import { defineStore } from 'pinia';\nimport { api } from '@/packages/api/src';\nimport type { CreateProjectMemberBody, UpdateProjectMemberBody } from '@/packages/api/src';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport { useNotificationsStore } from '@/utils/notification';\nimport { useQueryClient } from '@tanstack/vue-query';\n\nexport const useProjectMembersStore = defineStore('project-members', () => {\n    const { handleApiRequestNotifications } = useNotificationsStore();\n    const queryClient = useQueryClient();\n\n    async function createProjectMember(\n        projectId: string,\n        projectMemberBody: CreateProjectMemberBody\n    ) {\n        const organization = getCurrentOrganizationId();\n        if (organization) {\n            await handleApiRequestNotifications(\n                () =>\n                    api.createProjectMember(projectMemberBody, {\n                        params: {\n                            organization: organization,\n                            project: projectId,\n                        },\n                    }),\n                'Project member added successfully',\n                'Failed to add project member'\n            );\n            queryClient.invalidateQueries({\n                queryKey: ['projectMembers', organization, projectId],\n            });\n        }\n    }\n\n    async function updateProjectMember(\n        projectMemberId: string,\n        projectMemberBody: UpdateProjectMemberBody\n    ) {\n        const organization = getCurrentOrganizationId();\n        if (organization) {\n            const response = await handleApiRequestNotifications(\n                () =>\n                    api.updateProjectMember(projectMemberBody, {\n                        params: {\n                            organization: organization,\n                            projectMember: projectMemberId,\n                        },\n                    }),\n                'Project member updated successfully',\n                'Failed to update project member'\n            );\n            if (response?.data?.project_id) {\n                queryClient.invalidateQueries({\n                    queryKey: ['projectMembers', organization, response.data.project_id],\n                });\n            }\n        }\n    }\n\n    async function deleteProjectMember(projectId: string, projectMemberId: string) {\n        const organizationId = getCurrentOrganizationId();\n        if (organizationId) {\n            await handleApiRequestNotifications(\n                () =>\n                    api.deleteProjectMember(undefined, {\n                        params: {\n                            organization: organizationId,\n                            projectMember: projectMemberId,\n                        },\n                    }),\n                'Project member removed successfully',\n                'Failed to remove project member'\n            );\n            queryClient.invalidateQueries({\n                queryKey: ['projectMembers', organizationId, projectId],\n            });\n        }\n    }\n\n    return {\n        createProjectMember,\n        deleteProjectMember,\n        updateProjectMember,\n    };\n});\n"
  },
  {
    "path": "resources/js/utils/useProjectMembersQuery.ts",
    "content": "import { useQuery, useQueryClient } from '@tanstack/vue-query';\nimport { api } from '@/packages/api/src';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport type { ProjectMember } from '@/packages/api/src';\nimport { computed, type Ref } from 'vue';\nimport { fetchAllPages } from '@/utils/fetchAllPages';\n\nexport async function fetchAllProjectMembers(\n    organizationId: string,\n    projectId: string\n): Promise<ProjectMember[]> {\n    return fetchAllPages((page) =>\n        api.getProjectMembers({\n            params: { organization: organizationId, project: projectId },\n            queries: { page },\n        })\n    );\n}\n\nexport function useProjectMembersQuery(projectId: Ref<string | null> | string) {\n    const queryClient = useQueryClient();\n\n    const projectIdValue = computed(() => {\n        return typeof projectId === 'string' ? projectId : projectId.value;\n    });\n\n    const query = useQuery({\n        queryKey: computed(() => [\n            'projectMembers',\n            getCurrentOrganizationId(),\n            projectIdValue.value,\n        ]),\n        queryFn: async () => {\n            const organizationId = getCurrentOrganizationId();\n            const pid = projectIdValue.value;\n            if (!organizationId || !pid) throw new Error('No organization or project');\n            const data = await fetchAllProjectMembers(organizationId, pid);\n            return { data };\n        },\n        enabled: () => !!getCurrentOrganizationId() && !!projectIdValue.value,\n        staleTime: 1000 * 30, // 30 seconds\n    });\n\n    const projectMembers = computed<ProjectMember[]>(() => query.data.value?.data ?? []);\n\n    const invalidateProjectMembers = () => {\n        queryClient.invalidateQueries({\n            queryKey: ['projectMembers', getCurrentOrganizationId(), projectIdValue.value],\n        });\n    };\n\n    return {\n        ...query,\n        projectMembers,\n        invalidateProjectMembers,\n    };\n}\n"
  },
  {
    "path": "resources/js/utils/useProjects.ts",
    "content": "import { defineStore } from 'pinia';\nimport { api } from '@/packages/api/src';\nimport type { CreateProjectBody, UpdateProjectBody } from '@/packages/api/src';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport { useNotificationsStore } from '@/utils/notification';\nimport { useQueryClient } from '@tanstack/vue-query';\n\nexport const useProjectsStore = defineStore('projects', () => {\n    const { handleApiRequestNotifications } = useNotificationsStore();\n    const queryClient = useQueryClient();\n\n    async function createProject(projectBody: CreateProjectBody) {\n        const organization = getCurrentOrganizationId();\n\n        if (organization) {\n            const response = await handleApiRequestNotifications(\n                () =>\n                    api.createProject(projectBody, {\n                        params: {\n                            organization: organization,\n                        },\n                    }),\n                'Project created successfully',\n                'Failed to create project'\n            );\n\n            queryClient.invalidateQueries({ queryKey: ['projects'] });\n            return response['data'];\n        }\n    }\n\n    async function deleteProject(projectId: string) {\n        const organizationId = getCurrentOrganizationId();\n        if (organizationId) {\n            await handleApiRequestNotifications(\n                () =>\n                    api.deleteProject(undefined, {\n                        params: {\n                            organization: organizationId,\n                            project: projectId,\n                        },\n                    }),\n                'Project deleted successfully',\n                'Failed to delete project'\n            );\n            queryClient.invalidateQueries({ queryKey: ['projects'] });\n        }\n    }\n\n    async function updateProject(projectId: string, updateProjectBody: UpdateProjectBody) {\n        const organizationId = getCurrentOrganizationId();\n        if (organizationId) {\n            await handleApiRequestNotifications(\n                () =>\n                    api.updateProject(updateProjectBody, {\n                        params: {\n                            organization: organizationId,\n                            project: projectId,\n                        },\n                    }),\n                'Project updated successfully',\n                'Failed to update project'\n            );\n            queryClient.invalidateQueries({ queryKey: ['projects'] });\n        }\n    }\n\n    return {\n        createProject,\n        deleteProject,\n        updateProject,\n    };\n});\n"
  },
  {
    "path": "resources/js/utils/useProjectsQuery.ts",
    "content": "import { useQuery, useQueryClient } from '@tanstack/vue-query';\nimport { api } from '@/packages/api/src';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport type { Project } from '@/packages/api/src';\nimport { computed } from 'vue';\nimport { fetchAllPages } from '@/utils/fetchAllPages';\n\nexport async function fetchAllProjects(organizationId: string): Promise<Project[]> {\n    return fetchAllPages((page) =>\n        api.getProjects({\n            params: { organization: organizationId },\n            queries: { archived: 'all', page },\n        })\n    );\n}\n\nexport function useProjectsQuery() {\n    const queryClient = useQueryClient();\n\n    const query = useQuery({\n        queryKey: computed(() => ['projects', getCurrentOrganizationId()]),\n        queryFn: async () => {\n            const organizationId = getCurrentOrganizationId();\n            if (!organizationId) throw new Error('No organization');\n            const data = await fetchAllProjects(organizationId);\n            return { data };\n        },\n        enabled: () => !!getCurrentOrganizationId(),\n        staleTime: 1000 * 30, // 30 seconds\n    });\n\n    const projects = computed<Project[]>(() => query.data.value?.data ?? []);\n\n    const invalidateProjects = () => {\n        queryClient.invalidateQueries({ queryKey: ['projects'] });\n    };\n\n    return {\n        ...query,\n        projects,\n        invalidateProjects,\n    };\n}\n"
  },
  {
    "path": "resources/js/utils/useReporting.ts",
    "content": "import { defineStore } from 'pinia';\nimport { type Component } from 'vue';\nimport { getCurrentRole, getCurrentUser } from '@/utils/useUser';\nimport { useProjectsQuery } from '@/utils/useProjectsQuery';\nimport { useMembersQuery } from '@/utils/useMembersQuery';\nimport { useTasksQuery } from '@/utils/useTasksQuery';\nimport { useClientsQuery } from '@/utils/useClientsQuery';\nimport { useTagsQuery } from '@/utils/useTagsQuery';\nimport { CheckCircleIcon, UserCircleIcon, UserGroupIcon } from '@heroicons/vue/20/solid';\nimport { DocumentTextIcon, FolderIcon } from '@heroicons/vue/16/solid';\nimport BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';\n\nexport type GroupingOption =\n    | 'project'\n    | 'task'\n    | 'user'\n    | 'billable'\n    | 'client'\n    | 'description'\n    | 'tag';\n\nexport const useReportingStore = defineStore('reporting', () => {\n    // Cache query composables to avoid creating new subscriptions on every call\n    const { projects } = useProjectsQuery();\n    const { members } = useMembersQuery();\n    const { tasks } = useTasksQuery();\n    const { clients } = useClientsQuery();\n    const { tags } = useTagsQuery();\n\n    const emptyPlaceholder = {\n        user: 'No User',\n        project: 'No Project',\n        task: 'No Task',\n        billable: 'Non-Billable',\n        client: 'No Client',\n        description: 'No Description',\n        tag: 'No Tag',\n    } as Record<string, string>;\n\n    function getNameForReportingRowEntry(key: string | null, type: string | null) {\n        if (type === null) {\n            return null;\n        }\n        if (key === null) {\n            return emptyPlaceholder[type as keyof typeof emptyPlaceholder];\n        }\n\n        if (type === 'project') {\n            return projects.value.find((project) => project.id === key)?.name;\n        }\n        if (type === 'user') {\n            if (getCurrentRole() === 'employee') {\n                return getCurrentUser().name;\n            }\n            return members.value.find((member) => member.user_id === key)?.name;\n        }\n        if (type === 'task') {\n            return tasks.value.find((task) => task.id === key)?.name;\n        }\n        if (type === 'client') {\n            return clients.value.find((client) => client.id === key)?.name;\n        }\n        if (type === 'tag') {\n            return tags.value.find((tag) => tag.id === key)?.name;\n        }\n        if (type === 'billable') {\n            if (key === '0') {\n                return 'Non-Billable';\n            } else {\n                return 'Billable';\n            }\n        }\n        return key;\n    }\n\n    const groupByOptions: {\n        label: string;\n        value: GroupingOption;\n        icon: Component;\n    }[] = [\n        {\n            label: 'Members',\n            value: 'user',\n            icon: UserGroupIcon,\n        },\n        {\n            label: 'Projects',\n            value: 'project',\n            icon: FolderIcon,\n        },\n        {\n            label: 'Tasks',\n            value: 'task',\n            icon: CheckCircleIcon,\n        },\n        {\n            label: 'Clients',\n            value: 'client',\n            icon: UserCircleIcon,\n        },\n        {\n            label: 'Billable',\n            value: 'billable',\n            icon: BillableIcon,\n        },\n        {\n            label: 'Description',\n            value: 'description',\n            icon: DocumentTextIcon,\n        },\n        {\n            label: 'Tags',\n            value: 'tag',\n            icon: DocumentTextIcon,\n        },\n    ];\n\n    return {\n        getNameForReportingRowEntry,\n        groupByOptions,\n        emptyPlaceholder,\n    };\n});\n"
  },
  {
    "path": "resources/js/utils/useReportsQuery.ts",
    "content": "import { api } from '@/packages/api/src';\nimport type { Report } from '@/packages/api/src';\nimport { fetchAllPages } from '@/utils/fetchAllPages';\n\nexport async function fetchAllReports(organizationId: string): Promise<Report[]> {\n    return fetchAllPages((page) =>\n        api.getReports({\n            params: { organization: organizationId },\n            queries: { page },\n        })\n    );\n}\n"
  },
  {
    "path": "resources/js/utils/useTags.ts",
    "content": "import { defineStore } from 'pinia';\nimport type { Tag, UpdateTagBody } from '@/packages/api/src';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport { api } from '@/packages/api/src';\nimport { useNotificationsStore } from '@/utils/notification';\nimport { useMutation, useQueryClient } from '@tanstack/vue-query';\n\nexport const useTagsStore = defineStore('tags', () => {\n    const { handleApiRequestNotifications } = useNotificationsStore();\n    const queryClient = useQueryClient();\n\n    async function deleteTag(tagId: string) {\n        const organizationId = getCurrentOrganizationId();\n        if (organizationId) {\n            await handleApiRequestNotifications(\n                () =>\n                    api.deleteTag(undefined, {\n                        params: {\n                            organization: organizationId,\n                            tag: tagId,\n                        },\n                    }),\n                'Tag deleted successfully',\n                'Failed to delete tag'\n            );\n            queryClient.invalidateQueries({ queryKey: ['tags'] });\n        }\n    }\n\n    async function createTag(name: string): Promise<Tag | undefined> {\n        const organizationId = getCurrentOrganizationId();\n        if (organizationId) {\n            const response = await handleApiRequestNotifications(\n                () =>\n                    api.createTag(\n                        {\n                            name: name,\n                        },\n                        {\n                            params: {\n                                organization: organizationId,\n                            },\n                        }\n                    ),\n                'Tag created successfully',\n                'Failed to create tag'\n            );\n            if (response?.data) {\n                queryClient.invalidateQueries({ queryKey: ['tags'] });\n                return response.data;\n            }\n        } else {\n            throw new Error('Failed to create tag because organization ID is missing.');\n        }\n    }\n\n    const { mutateAsync: updateTag } = useMutation({\n        mutationFn: async ({ tagId, tagBody }: { tagId: string; tagBody: UpdateTagBody }) => {\n            const organizationId = getCurrentOrganizationId();\n            if (organizationId) {\n                return await handleApiRequestNotifications(\n                    () =>\n                        api.updateTag(tagBody, {\n                            params: {\n                                organization: organizationId,\n                                tag: tagId,\n                            },\n                        }),\n                    'Tag updated successfully',\n                    'Failed to update tag'\n                );\n            }\n        },\n        onSuccess: () => {\n            queryClient.invalidateQueries({ queryKey: ['tags'] });\n        },\n    });\n\n    return { createTag, updateTag, deleteTag };\n});\n"
  },
  {
    "path": "resources/js/utils/useTagsQuery.ts",
    "content": "import { useQuery, useQueryClient } from '@tanstack/vue-query';\nimport { api } from '@/packages/api/src';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport type { Tag } from '@/packages/api/src';\nimport { computed } from 'vue';\nimport { fetchAllPages } from '@/utils/fetchAllPages';\n\nexport async function fetchAllTags(organizationId: string): Promise<Tag[]> {\n    return fetchAllPages((page) =>\n        api.getTags({\n            params: { organization: organizationId },\n            queries: { page },\n        })\n    );\n}\n\nexport function useTagsQuery() {\n    const queryClient = useQueryClient();\n\n    const query = useQuery({\n        queryKey: computed(() => ['tags', getCurrentOrganizationId()]),\n        queryFn: async () => {\n            const organizationId = getCurrentOrganizationId();\n            if (!organizationId) throw new Error('No organization');\n            const data = await fetchAllTags(organizationId);\n            return { data };\n        },\n        enabled: () => !!getCurrentOrganizationId(),\n        staleTime: 1000 * 30, // 30 seconds\n    });\n\n    const tags = computed<Tag[]>(() => query.data.value?.data ?? []);\n\n    const invalidateTags = () => {\n        queryClient.invalidateQueries({ queryKey: ['tags'] });\n    };\n\n    return {\n        ...query,\n        tags,\n        invalidateTags,\n    };\n}\n"
  },
  {
    "path": "resources/js/utils/useTasks.ts",
    "content": "import { defineStore } from 'pinia';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport { api } from '@/packages/api/src';\nimport type { CreateTaskBody, UpdateTaskBody } from '@/packages/api/src';\nimport { useNotificationsStore } from '@/utils/notification';\nimport { useQueryClient } from '@tanstack/vue-query';\n\nexport const useTasksStore = defineStore('tasks', () => {\n    const { handleApiRequestNotifications } = useNotificationsStore();\n    const queryClient = useQueryClient();\n\n    async function updateTask(taskId: string, taskBody: UpdateTaskBody) {\n        const organizationId = getCurrentOrganizationId();\n        if (organizationId) {\n            await handleApiRequestNotifications(\n                () =>\n                    api.updateTask(taskBody, {\n                        params: {\n                            task: taskId,\n                            organization: organizationId,\n                        },\n                    }),\n                'Task updated successfully',\n                'Failed to update task'\n            );\n            queryClient.invalidateQueries({ queryKey: ['tasks'] });\n        }\n    }\n\n    async function createTask(task: CreateTaskBody) {\n        const organizationId = getCurrentOrganizationId();\n        if (organizationId) {\n            await handleApiRequestNotifications(\n                () =>\n                    api.createTask(task, {\n                        params: {\n                            organization: organizationId,\n                        },\n                    }),\n                'Task created successfully',\n                'Failed to create task'\n            );\n            queryClient.invalidateQueries({ queryKey: ['tasks'] });\n        }\n    }\n\n    async function deleteTask(taskId: string) {\n        const organizationId = getCurrentOrganizationId();\n        if (organizationId) {\n            await handleApiRequestNotifications(\n                () =>\n                    api.deleteTask(undefined, {\n                        params: {\n                            organization: organizationId,\n                            task: taskId,\n                        },\n                    }),\n                'Task deleted successfully',\n                'Failed to delete task'\n            );\n            queryClient.invalidateQueries({ queryKey: ['tasks'] });\n        }\n    }\n\n    return {\n        updateTask,\n        createTask,\n        deleteTask,\n    };\n});\n"
  },
  {
    "path": "resources/js/utils/useTasksQuery.ts",
    "content": "import { useQuery, useQueryClient } from '@tanstack/vue-query';\nimport { api } from '@/packages/api/src';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport type { Task } from '@/packages/api/src';\nimport { computed } from 'vue';\nimport { fetchAllPages } from '@/utils/fetchAllPages';\n\nexport async function fetchAllTasks(organizationId: string): Promise<Task[]> {\n    return fetchAllPages((page) =>\n        api.getTasks({\n            params: { organization: organizationId },\n            queries: { done: 'all', page },\n        })\n    );\n}\n\nexport function useTasksQuery() {\n    const queryClient = useQueryClient();\n\n    const query = useQuery({\n        queryKey: computed(() => ['tasks', getCurrentOrganizationId()]),\n        queryFn: async () => {\n            const organizationId = getCurrentOrganizationId();\n            if (!organizationId) throw new Error('No organization');\n            const data = await fetchAllTasks(organizationId);\n            return { data };\n        },\n        enabled: () => !!getCurrentOrganizationId(),\n        staleTime: 1000 * 30, // 30 seconds\n    });\n\n    const tasks = computed<Task[]>(() => query.data.value?.data ?? []);\n\n    const invalidateTasks = () => {\n        queryClient.invalidateQueries({ queryKey: ['tasks'] });\n    };\n\n    return {\n        ...query,\n        tasks,\n        invalidateTasks,\n    };\n}\n"
  },
  {
    "path": "resources/js/utils/useTimeEntriesCalendarQuery.ts",
    "content": "import { useQuery } from '@tanstack/vue-query';\nimport { api, type TimeEntryResponse, type TimeEntry } from '@/packages/api/src';\nimport { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';\nimport { computed, type Ref } from 'vue';\nimport { getDayJsInstance } from '@/packages/ui/src/utils/time';\nimport { getUserTimezone, getWeekStart } from '@/packages/ui/src/utils/settings';\n\nconst weekStartMap: Record<string, number> = {\n    sunday: 0,\n    monday: 1,\n    tuesday: 2,\n    wednesday: 3,\n    thursday: 4,\n    friday: 5,\n    saturday: 6,\n};\n\n/**\n * Calculate expanded date range to include previous and next periods with timezone transformations.\n * This allows smooth navigation between calendar views without loading delays.\n */\nexport function getExpandedCalendarDateRange(\n    calendarStart: Date,\n    calendarEnd: Date\n): { start: string; end: string } {\n    const dayjs = getDayJsInstance();\n    const duration = dayjs(calendarEnd).diff(dayjs(calendarStart), 'milliseconds');\n\n    // Calculate previous period\n    const previousStart = dayjs(calendarStart).subtract(duration, 'milliseconds');\n    // Calculate next period\n    const nextEnd = dayjs(calendarEnd).add(duration, 'milliseconds');\n\n    // Apply timezone transformations\n    const timezone = getUserTimezone();\n    const formattedStart = previousStart.utc().tz(timezone, true).utc().format();\n    const formattedEnd = nextEnd.utc().tz(timezone, true).utc().format();\n\n    return {\n        start: formattedStart,\n        end: formattedEnd,\n    };\n}\n\n/**\n * Get the initial week view date range based on user's week start preference.\n * Matches FullCalendar's timeGridWeek initial view.\n */\nexport function getInitialWeekRange(): { start: Date; end: Date } {\n    const dayjs = getDayJsInstance();\n    const weekStart = getWeekStart();\n    const firstDay = weekStartMap[weekStart] ?? 1;\n\n    const now = dayjs();\n    const currentDayOfWeek = now.day();\n    const daysFromWeekStart = (currentDayOfWeek - firstDay + 7) % 7;\n    const calendarStart = now.subtract(daysFromWeekStart, 'day').startOf('day');\n    const calendarEnd = calendarStart.add(7, 'day');\n\n    return {\n        start: calendarStart.toDate(),\n        end: calendarEnd.toDate(),\n    };\n}\n\n/**\n * Create the query key for calendar time entries.\n */\nexport function createCalendarQueryKey(\n    start: string | null,\n    end: string | null,\n    organizationId: string | null\n): readonly [\n    'timeEntries',\n    'calendar',\n    { start: string | null; end: string | null; organization: string | null },\n] {\n    return ['timeEntries', 'calendar', { start, end, organization: organizationId }] as const;\n}\n\n/**\n * Fetch all calendar entries with pagination.\n */\nexport async function fetchAllCalendarEntries(\n    organizationId: string,\n    memberId: string | undefined,\n    start: string,\n    end: string\n): Promise<TimeEntryResponse> {\n    const allEntries: TimeEntry[] = [];\n\n    while (true) {\n        const response = await api.getTimeEntries({\n            params: {\n                organization: organizationId,\n            },\n            queries: {\n                start,\n                end,\n                member_id: memberId,\n                offset: allEntries.length || undefined,\n            },\n        });\n\n        if (response.data.length === 0) {\n            return { data: allEntries, meta: response.meta };\n        }\n\n        allEntries.push(...response.data);\n\n        if (allEntries.length >= response.meta.total) {\n            return { data: allEntries, meta: response.meta };\n        }\n    }\n}\n\nexport function useTimeEntriesCalendarQuery(\n    calendarStart: Ref<Date | undefined>,\n    calendarEnd: Ref<Date | undefined>\n) {\n    const enableCalendarQuery = computed(() => {\n        return !!getCurrentOrganizationId() && !!calendarStart.value && !!calendarEnd.value;\n    });\n\n    const expandedDateRange = computed(() => {\n        if (!calendarStart.value || !calendarEnd.value) {\n            return { start: null, end: null };\n        }\n        return getExpandedCalendarDateRange(calendarStart.value, calendarEnd.value);\n    });\n\n    return useQuery<TimeEntryResponse>({\n        queryKey: computed(() =>\n            createCalendarQueryKey(\n                expandedDateRange.value.start,\n                expandedDateRange.value.end,\n                getCurrentOrganizationId()\n            )\n        ),\n        enabled: enableCalendarQuery,\n        placeholderData: (previousData) => previousData,\n        queryFn: async () => {\n            return fetchAllCalendarEntries(\n                getCurrentOrganizationId() || '',\n                getCurrentMembershipId(),\n                expandedDateRange.value.start!,\n                expandedDateRange.value.end!\n            );\n        },\n        staleTime: 1000 * 30, // 30 seconds\n    });\n}\n"
  },
  {
    "path": "resources/js/utils/useTimeEntriesInfiniteQuery.ts",
    "content": "import { useInfiniteQuery } from '@tanstack/vue-query';\nimport { api } from '@/packages/api/src';\nimport { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';\nimport dayjs from 'dayjs';\nimport { computed } from 'vue';\n\nexport function useTimeEntriesInfiniteQuery() {\n    const organizationId = computed(() => getCurrentOrganizationId());\n    const memberId = computed(() => getCurrentMembershipId());\n\n    return useInfiniteQuery({\n        queryKey: computed(() => [\n            'timeEntries',\n            'infinite',\n            { organizationId: organizationId.value, memberId: memberId.value },\n        ]),\n        queryFn: async ({ pageParam }) => {\n            const orgId = organizationId.value;\n            if (!orgId) return { data: [] };\n\n            const queries: Record<string, string | undefined> = {\n                only_full_dates: 'true',\n                member_id: memberId.value,\n                limit: '50',\n            };\n\n            if (pageParam) {\n                queries.end = pageParam;\n            }\n\n            const response = await api.getTimeEntries({\n                params: {\n                    organization: orgId,\n                },\n                queries: queries,\n            });\n\n            return response;\n        },\n        initialPageParam: undefined as string | undefined,\n        getNextPageParam: (lastPage) => {\n            if (!lastPage?.data || lastPage.data.length === 0) {\n                return undefined;\n            }\n            const latestTimeEntry = lastPage.data[lastPage.data.length - 1]!;\n            return dayjs(latestTimeEntry.start).utc().format();\n        },\n        enabled: computed(() => !!organizationId.value),\n        staleTime: 1000 * 30, // 30 seconds\n        gcTime: 1000 * 60 * 10, // 10 minutes\n    });\n}\n"
  },
  {
    "path": "resources/js/utils/useTimeEntriesMutations.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/vue-query';\nimport {\n    api,\n    type CreateTimeEntryBody,\n    type TimeEntry,\n    type UpdateMultipleTimeEntriesChangeset,\n} from '@/packages/api/src';\nimport { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';\nimport { useNotificationsStore } from '@/utils/notification';\n\nexport function useTimeEntriesMutations() {\n    const queryClient = useQueryClient();\n    const { handleApiRequestNotifications } = useNotificationsStore();\n\n    const { mutateAsync: createTimeEntry } = useMutation({\n        mutationFn: async (timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) => {\n            const organizationId = getCurrentOrganizationId();\n            const memberId = getCurrentMembershipId();\n            if (organizationId && memberId !== undefined) {\n                const newTimeEntry = {\n                    ...timeEntry,\n                    member_id: memberId,\n                } as CreateTimeEntryBody;\n\n                return await handleApiRequestNotifications(\n                    () =>\n                        api.createTimeEntry(newTimeEntry, {\n                            params: {\n                                organization: organizationId,\n                            },\n                        }),\n                    'Time entry created successfully',\n                    'Failed to create time entry'\n                );\n            }\n        },\n        onSuccess: () => {\n            queryClient.invalidateQueries({ queryKey: ['timeEntries'] });\n        },\n    });\n\n    const { mutateAsync: updateTimeEntry } = useMutation({\n        mutationFn: async (timeEntry: TimeEntry) => {\n            const organizationId = getCurrentOrganizationId();\n            if (organizationId) {\n                return await handleApiRequestNotifications(\n                    () =>\n                        api.updateTimeEntry(timeEntry, {\n                            params: {\n                                organization: organizationId,\n                                timeEntry: timeEntry.id,\n                            },\n                        }),\n                    'Time entry updated successfully',\n                    'Failed to update time entry'\n                );\n            }\n        },\n        onSuccess: () => {\n            queryClient.invalidateQueries({ queryKey: ['timeEntries'] });\n        },\n    });\n\n    const { mutateAsync: updateTimeEntries } = useMutation({\n        mutationFn: async ({\n            ids,\n            changes,\n        }: {\n            ids: string[];\n            changes: UpdateMultipleTimeEntriesChangeset;\n        }) => {\n            const organizationId = getCurrentOrganizationId();\n            if (organizationId) {\n                return await handleApiRequestNotifications(\n                    () =>\n                        api.updateMultipleTimeEntries(\n                            {\n                                ids: ids,\n                                changes: changes,\n                            },\n                            {\n                                params: {\n                                    organization: organizationId,\n                                },\n                            }\n                        ),\n                    'Time entries updated successfully',\n                    'Failed to update time entries'\n                );\n            }\n        },\n        onSuccess: () => {\n            queryClient.invalidateQueries({ queryKey: ['timeEntries'] });\n        },\n    });\n\n    const { mutateAsync: deleteTimeEntry } = useMutation({\n        mutationFn: async (timeEntryId: string) => {\n            const organizationId = getCurrentOrganizationId();\n            if (organizationId) {\n                return await handleApiRequestNotifications(\n                    () =>\n                        api.deleteTimeEntry(undefined, {\n                            params: {\n                                organization: organizationId,\n                                timeEntry: timeEntryId,\n                            },\n                        }),\n                    'Time entry deleted successfully',\n                    'Failed to delete time entry'\n                );\n            }\n        },\n        onSuccess: () => {\n            queryClient.invalidateQueries({ queryKey: ['timeEntries'] });\n        },\n    });\n\n    const { mutateAsync: deleteTimeEntries } = useMutation({\n        mutationFn: async (timeEntries: TimeEntry[]) => {\n            const organizationId = getCurrentOrganizationId();\n            const timeEntryIds = timeEntries.map((entry) => entry.id);\n            if (organizationId) {\n                return await handleApiRequestNotifications(\n                    () =>\n                        api.deleteTimeEntries(undefined, {\n                            queries: {\n                                ids: timeEntryIds,\n                            },\n                            params: {\n                                organization: organizationId,\n                            },\n                        }),\n                    'Time entries deleted successfully',\n                    'Failed to delete time entries'\n                );\n            }\n        },\n        onSuccess: () => {\n            queryClient.invalidateQueries({ queryKey: ['timeEntries'] });\n        },\n    });\n\n    return {\n        createTimeEntry,\n        updateTimeEntry,\n        updateTimeEntries,\n        deleteTimeEntry,\n        deleteTimeEntries,\n    };\n}\n"
  },
  {
    "path": "resources/js/utils/useTimeEntriesReportQuery.ts",
    "content": "import { useQuery } from '@tanstack/vue-query';\nimport { api, type TimeEntryResponse } from '@/packages/api/src';\nimport { getCurrentOrganizationId } from '@/utils/useUser';\nimport { computed, type Ref, type ComputedRef, unref } from 'vue';\n\nexport function useTimeEntriesReportQuery(\n    filterParams: Ref<Record<string, unknown>> | ComputedRef<Record<string, unknown>>\n) {\n    return useQuery<TimeEntryResponse>({\n        queryKey: computed(() => [\n            'timeEntries',\n            'detailed-report',\n            getCurrentOrganizationId(),\n            unref(filterParams),\n        ]),\n        enabled: computed(() => !!getCurrentOrganizationId()),\n        queryFn: () =>\n            api.getTimeEntries({\n                params: {\n                    organization: getCurrentOrganizationId() || '',\n                },\n                queries: { ...unref(filterParams) },\n            }),\n        staleTime: 1000 * 30, // 30 seconds\n    });\n}\n"
  },
  {
    "path": "resources/js/utils/useUser.ts",
    "content": "import { usePage } from '@inertiajs/vue3';\nimport type { User } from '@/types/models';\n\nconst page = usePage<{\n    auth: {\n        user: User;\n    };\n}>();\nfunction getCurrentUserId() {\n    return page.props.auth.user.id;\n}\n\nfunction getCurrentUser() {\n    return page.props.auth.user;\n}\n\nfunction getCurrentOrganizationId() {\n    return page.props.auth.user.current_team_id;\n}\n\nfunction getCurrentMembershipId() {\n    return page.props.auth.user.all_teams.find((team) => team.id === getCurrentOrganizationId())\n        ?.membership.id;\n}\n\nfunction getCurrentRole() {\n    return page.props.auth.user.all_teams.find((team) => team.id === getCurrentOrganizationId())\n        ?.membership.role;\n}\n\nexport {\n    getCurrentOrganizationId,\n    getCurrentUserId,\n    getCurrentMembershipId,\n    getCurrentRole,\n    getCurrentUser,\n};\n"
  },
  {
    "path": "resources/js/ziggy.d.ts",
    "content": "/* This file is generated by Ziggy. */\ndeclare module 'ziggy-js' {\n    interface RouteList {\n        'scramble.docs.index': [];\n        'scramble.docs.api': [];\n        'filament.exports.download': [\n            {\n                'name': 'export';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'filament.imports.failed-rows.download': [\n            {\n                'name': 'import';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'filament.admin.auth.logout': [];\n        'filament.admin.pages.dashboard': [];\n        'filament.admin.resources.clients.index': [];\n        'filament.admin.resources.clients.create': [];\n        'filament.admin.resources.clients.edit': [\n            {\n                'name': 'record';\n                'required': true;\n            },\n        ];\n        'filament.admin.resources.organizations.index': [];\n        'filament.admin.resources.organizations.create': [];\n        'filament.admin.resources.organizations.edit': [\n            {\n                'name': 'record';\n                'required': true;\n            },\n        ];\n        'filament.admin.resources.projects.index': [];\n        'filament.admin.resources.projects.create': [];\n        'filament.admin.resources.projects.edit': [\n            {\n                'name': 'record';\n                'required': true;\n            },\n        ];\n        'filament.admin.resources.tags.index': [];\n        'filament.admin.resources.tags.create': [];\n        'filament.admin.resources.tags.edit': [\n            {\n                'name': 'record';\n                'required': true;\n            },\n        ];\n        'filament.admin.resources.tasks.index': [];\n        'filament.admin.resources.tasks.create': [];\n        'filament.admin.resources.tasks.edit': [\n            {\n                'name': 'record';\n                'required': true;\n            },\n        ];\n        'filament.admin.resources.time-entries.index': [];\n        'filament.admin.resources.time-entries.create': [];\n        'filament.admin.resources.time-entries.edit': [\n            {\n                'name': 'record';\n                'required': true;\n            },\n        ];\n        'filament.admin.resources.users.index': [];\n        'filament.admin.resources.users.create': [];\n        'filament.admin.resources.users.edit': [\n            {\n                'name': 'record';\n                'required': true;\n            },\n        ];\n        'login': [];\n        'logout': [];\n        'password.request': [];\n        'password.reset': [\n            {\n                'name': 'token';\n                'required': true;\n            },\n        ];\n        'password.email': [];\n        'password.update': [];\n        'register': [];\n        'verification.notice': [];\n        'verification.verify': [\n            {\n                'name': 'id';\n                'required': true;\n            },\n            {\n                'name': 'hash';\n                'required': true;\n            },\n        ];\n        'verification.send': [];\n        'user-profile-information.update': [];\n        'user-password.update': [];\n        'password.confirmation': [];\n        'password.confirm': [];\n        'two-factor.login': [];\n        'two-factor.enable': [];\n        'two-factor.confirm': [];\n        'two-factor.disable': [];\n        'two-factor.qr-code': [];\n        'two-factor.secret-key': [];\n        'two-factor.recovery-codes': [];\n        'profile.show': [];\n        'other-browser-sessions.destroy': [];\n        'current-user-photo.destroy': [];\n        'current-user.destroy': [];\n        'teams.create': [];\n        'teams.store': [];\n        'teams.show': [\n            {\n                'name': 'team';\n                'required': true;\n            },\n        ];\n        'teams.update': [\n            {\n                'name': 'team';\n                'required': true;\n            },\n        ];\n        'teams.destroy': [\n            {\n                'name': 'team';\n                'required': true;\n            },\n        ];\n        'current-team.update': [];\n        'team-members.store': [\n            {\n                'name': 'team';\n                'required': true;\n            },\n        ];\n        'team-members.update': [\n            {\n                'name': 'team';\n                'required': true;\n            },\n            {\n                'name': 'user';\n                'required': true;\n            },\n        ];\n        'team-members.destroy': [\n            {\n                'name': 'team';\n                'required': true;\n            },\n            {\n                'name': 'user';\n                'required': true;\n            },\n        ];\n        'team-invitations.accept': [\n            {\n                'name': 'invitation';\n                'required': true;\n            },\n        ];\n        'team-invitations.destroy': [\n            {\n                'name': 'invitation';\n                'required': true;\n            },\n        ];\n        'passport.token': [];\n        'passport.authorizations.authorize': [];\n        'passport.token.refresh': [];\n        'passport.authorizations.approve': [];\n        'passport.authorizations.deny': [];\n        'passport.tokens.index': [];\n        'passport.tokens.destroy': [\n            {\n                'name': 'token_id';\n                'required': true;\n            },\n        ];\n        'passport.clients.index': [];\n        'passport.clients.store': [];\n        'passport.clients.update': [\n            {\n                'name': 'client_id';\n                'required': true;\n            },\n        ];\n        'passport.clients.destroy': [\n            {\n                'name': 'client_id';\n                'required': true;\n            },\n        ];\n        'passport.scopes.index': [];\n        'passport.personal.tokens.index': [];\n        'passport.personal.tokens.store': [];\n        'passport.personal.tokens.destroy': [\n            {\n                'name': 'token_id';\n                'required': true;\n            },\n        ];\n        'livewire.update': [];\n        'livewire.upload-file': [];\n        'livewire.preview-file': [\n            {\n                'name': 'filename';\n                'required': true;\n            },\n        ];\n        'ignition.healthCheck': [];\n        'ignition.executeSolution': [];\n        'ignition.updateConfig': [];\n        'api.v1.organizations.show': [\n            {\n                'name': 'organization';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'api.v1.organizations.update': [\n            {\n                'name': 'organization';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'api.v1.users.index': [\n            {\n                'name': 'organization';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'api.v1.users.invite-placeholder': [\n            {\n                'name': 'organization';\n                'required': true;\n                'binding': 'id';\n            },\n            {\n                'name': 'user';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'api.v1.projects.index': [\n            {\n                'name': 'organization';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'api.v1.projects.show': [\n            {\n                'name': 'organization';\n                'required': true;\n                'binding': 'id';\n            },\n            {\n                'name': 'project';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'api.v1.projects.store': [\n            {\n                'name': 'organization';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'api.v1.projects.update': [\n            {\n                'name': 'organization';\n                'required': true;\n                'binding': 'id';\n            },\n            {\n                'name': 'project';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'api.v1.projects.destroy': [\n            {\n                'name': 'organization';\n                'required': true;\n                'binding': 'id';\n            },\n            {\n                'name': 'project';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'api.v1.time-entries.index': [\n            {\n                'name': 'organization';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'api.v1.time-entries.store': [\n            {\n                'name': 'organization';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'api.v1.time-entries.update': [\n            {\n                'name': 'organization';\n                'required': true;\n                'binding': 'id';\n            },\n            {\n                'name': 'timeEntry';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'api.v1.time-entries.destroy': [\n            {\n                'name': 'organization';\n                'required': true;\n                'binding': 'id';\n            },\n            {\n                'name': 'timeEntry';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'api.v1.tags.index': [\n            {\n                'name': 'organization';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'api.v1.tags.store': [\n            {\n                'name': 'organization';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'api.v1.tags.update': [\n            {\n                'name': 'organization';\n                'required': true;\n                'binding': 'id';\n            },\n            {\n                'name': 'tag';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'api.v1.tags.destroy': [\n            {\n                'name': 'organization';\n                'required': true;\n                'binding': 'id';\n            },\n            {\n                'name': 'tag';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'api.v1.clients.index': [\n            {\n                'name': 'organization';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'api.v1.clients.store': [\n            {\n                'name': 'organization';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'api.v1.clients.update': [\n            {\n                'name': 'organization';\n                'required': true;\n                'binding': 'id';\n            },\n            {\n                'name': 'client';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'api.v1.clients.destroy': [\n            {\n                'name': 'organization';\n                'required': true;\n                'binding': 'id';\n            },\n            {\n                'name': 'client';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'api.v1.tasks.index': [\n            {\n                'name': 'organization';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'api.v1.tasks.store': [\n            {\n                'name': 'organization';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'api.v1.tasks.update': [\n            {\n                'name': 'organization';\n                'required': true;\n                'binding': 'id';\n            },\n            {\n                'name': 'task';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'api.v1.tasks.destroy': [\n            {\n                'name': 'organization';\n                'required': true;\n                'binding': 'id';\n            },\n            {\n                'name': 'task';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'api.v1.import.import': [\n            {\n                'name': 'organization';\n                'required': true;\n                'binding': 'id';\n            },\n        ];\n        'dashboard': [];\n        'telescope': [\n            {\n                'name': 'view';\n                'required': false;\n            },\n        ];\n        'api.': [\n            {\n                'name': 'fallbackPlaceholder';\n                'required': true;\n            },\n        ];\n    }\n}\nexport {};\n"
  },
  {
    "path": "resources/js/ziggy.js",
    "content": "const Ziggy = {\n    'url': 'http://solidtime.test',\n    'port': null,\n    'defaults': {},\n    'routes': {\n        'scramble.docs.index': {\n            'uri': 'docs/api.json',\n            'methods': ['GET', 'HEAD'],\n        },\n        'scramble.docs.api': { 'uri': 'docs/api', 'methods': ['GET', 'HEAD'] },\n        'filament.exports.download': {\n            'uri': 'filament/exports/{export}/download',\n            'methods': ['GET', 'HEAD'],\n            'parameters': ['export'],\n            'bindings': { 'export': 'id' },\n        },\n        'filament.imports.failed-rows.download': {\n            'uri': 'filament/imports/{import}/failed-rows/download',\n            'methods': ['GET', 'HEAD'],\n            'parameters': ['import'],\n            'bindings': { 'import': 'id' },\n        },\n        'filament.admin.auth.logout': {\n            'uri': 'admin/logout',\n            'methods': ['POST'],\n        },\n        'filament.admin.pages.dashboard': {\n            'uri': 'admin',\n            'methods': ['GET', 'HEAD'],\n        },\n        'filament.admin.resources.clients.index': {\n            'uri': 'admin/clients',\n            'methods': ['GET', 'HEAD'],\n        },\n        'filament.admin.resources.clients.create': {\n            'uri': 'admin/clients/create',\n            'methods': ['GET', 'HEAD'],\n        },\n        'filament.admin.resources.clients.edit': {\n            'uri': 'admin/clients/{record}/edit',\n            'methods': ['GET', 'HEAD'],\n            'parameters': ['record'],\n        },\n        'filament.admin.resources.organizations.index': {\n            'uri': 'admin/organizations',\n            'methods': ['GET', 'HEAD'],\n        },\n        'filament.admin.resources.organizations.create': {\n            'uri': 'admin/organizations/create',\n            'methods': ['GET', 'HEAD'],\n        },\n        'filament.admin.resources.organizations.edit': {\n            'uri': 'admin/organizations/{record}/edit',\n            'methods': ['GET', 'HEAD'],\n            'parameters': ['record'],\n        },\n        'filament.admin.resources.projects.index': {\n            'uri': 'admin/projects',\n            'methods': ['GET', 'HEAD'],\n        },\n        'filament.admin.resources.projects.create': {\n            'uri': 'admin/projects/create',\n            'methods': ['GET', 'HEAD'],\n        },\n        'filament.admin.resources.projects.edit': {\n            'uri': 'admin/projects/{record}/edit',\n            'methods': ['GET', 'HEAD'],\n            'parameters': ['record'],\n        },\n        'filament.admin.resources.tags.index': {\n            'uri': 'admin/tags',\n            'methods': ['GET', 'HEAD'],\n        },\n        'filament.admin.resources.tags.create': {\n            'uri': 'admin/tags/create',\n            'methods': ['GET', 'HEAD'],\n        },\n        'filament.admin.resources.tags.edit': {\n            'uri': 'admin/tags/{record}/edit',\n            'methods': ['GET', 'HEAD'],\n            'parameters': ['record'],\n        },\n        'filament.admin.resources.tasks.index': {\n            'uri': 'admin/tasks',\n            'methods': ['GET', 'HEAD'],\n        },\n        'filament.admin.resources.tasks.create': {\n            'uri': 'admin/tasks/create',\n            'methods': ['GET', 'HEAD'],\n        },\n        'filament.admin.resources.tasks.edit': {\n            'uri': 'admin/tasks/{record}/edit',\n            'methods': ['GET', 'HEAD'],\n            'parameters': ['record'],\n        },\n        'filament.admin.resources.time-entries.index': {\n            'uri': 'admin/time-entries',\n            'methods': ['GET', 'HEAD'],\n        },\n        'filament.admin.resources.time-entries.create': {\n            'uri': 'admin/time-entries/create',\n            'methods': ['GET', 'HEAD'],\n        },\n        'filament.admin.resources.time-entries.edit': {\n            'uri': 'admin/time-entries/{record}/edit',\n            'methods': ['GET', 'HEAD'],\n            'parameters': ['record'],\n        },\n        'filament.admin.resources.users.index': {\n            'uri': 'admin/users',\n            'methods': ['GET', 'HEAD'],\n        },\n        'filament.admin.resources.users.create': {\n            'uri': 'admin/users/create',\n            'methods': ['GET', 'HEAD'],\n        },\n        'filament.admin.resources.users.edit': {\n            'uri': 'admin/users/{record}/edit',\n            'methods': ['GET', 'HEAD'],\n            'parameters': ['record'],\n        },\n        'login': { 'uri': 'login', 'methods': ['GET', 'HEAD'] },\n        'logout': { 'uri': 'logout', 'methods': ['POST'] },\n        'password.request': {\n            'uri': 'forgot-password',\n            'methods': ['GET', 'HEAD'],\n        },\n        'password.reset': {\n            'uri': 'reset-password/{token}',\n            'methods': ['GET', 'HEAD'],\n            'parameters': ['token'],\n        },\n        'password.email': { 'uri': 'forgot-password', 'methods': ['POST'] },\n        'password.update': { 'uri': 'reset-password', 'methods': ['POST'] },\n        'register': { 'uri': 'register', 'methods': ['GET', 'HEAD'] },\n        'verification.notice': {\n            'uri': 'email/verify',\n            'methods': ['GET', 'HEAD'],\n        },\n        'verification.verify': {\n            'uri': 'email/verify/{id}/{hash}',\n            'methods': ['GET', 'HEAD'],\n            'parameters': ['id', 'hash'],\n        },\n        'verification.send': {\n            'uri': 'email/verification-notification',\n            'methods': ['POST'],\n        },\n        'user-profile-information.update': {\n            'uri': 'user/profile-information',\n            'methods': ['PUT'],\n        },\n        'user-password.update': { 'uri': 'user/password', 'methods': ['PUT'] },\n        'password.confirmation': {\n            'uri': 'user/confirmed-password-status',\n            'methods': ['GET', 'HEAD'],\n        },\n        'password.confirm': {\n            'uri': 'user/confirm-password',\n            'methods': ['POST'],\n        },\n        'two-factor.login': {\n            'uri': 'two-factor-challenge',\n            'methods': ['GET', 'HEAD'],\n        },\n        'two-factor.enable': {\n            'uri': 'user/two-factor-authentication',\n            'methods': ['POST'],\n        },\n        'two-factor.confirm': {\n            'uri': 'user/confirmed-two-factor-authentication',\n            'methods': ['POST'],\n        },\n        'two-factor.disable': {\n            'uri': 'user/two-factor-authentication',\n            'methods': ['DELETE'],\n        },\n        'two-factor.qr-code': {\n            'uri': 'user/two-factor-qr-code',\n            'methods': ['GET', 'HEAD'],\n        },\n        'two-factor.secret-key': {\n            'uri': 'user/two-factor-secret-key',\n            'methods': ['GET', 'HEAD'],\n        },\n        'two-factor.recovery-codes': {\n            'uri': 'user/two-factor-recovery-codes',\n            'methods': ['GET', 'HEAD'],\n        },\n        'profile.show': { 'uri': 'user/profile', 'methods': ['GET', 'HEAD'] },\n        'other-browser-sessions.destroy': {\n            'uri': 'user/other-browser-sessions',\n            'methods': ['DELETE'],\n        },\n        'current-user-photo.destroy': {\n            'uri': 'user/profile-photo',\n            'methods': ['DELETE'],\n        },\n        'current-user.destroy': { 'uri': 'user', 'methods': ['DELETE'] },\n        'teams.create': { 'uri': 'teams/create', 'methods': ['GET', 'HEAD'] },\n        'teams.store': { 'uri': 'teams', 'methods': ['POST'] },\n        'teams.show': {\n            'uri': 'teams/{team}',\n            'methods': ['GET', 'HEAD'],\n            'parameters': ['team'],\n        },\n        'teams.update': {\n            'uri': 'teams/{team}',\n            'methods': ['PUT'],\n            'parameters': ['team'],\n        },\n        'teams.destroy': {\n            'uri': 'teams/{team}',\n            'methods': ['DELETE'],\n            'parameters': ['team'],\n        },\n        'current-team.update': { 'uri': 'current-team', 'methods': ['PUT'] },\n        'team-members.store': {\n            'uri': 'teams/{team}/members',\n            'methods': ['POST'],\n            'parameters': ['team'],\n        },\n        'team-members.update': {\n            'uri': 'teams/{team}/members/{user}',\n            'methods': ['PUT'],\n            'parameters': ['team', 'user'],\n        },\n        'team-members.destroy': {\n            'uri': 'teams/{team}/members/{user}',\n            'methods': ['DELETE'],\n            'parameters': ['team', 'user'],\n        },\n        'team-invitations.accept': {\n            'uri': 'team-invitations/{invitation}',\n            'methods': ['GET', 'HEAD'],\n            'parameters': ['invitation'],\n        },\n        'team-invitations.destroy': {\n            'uri': 'team-invitations/{invitation}',\n            'methods': ['DELETE'],\n            'parameters': ['invitation'],\n        },\n        'passport.token': { 'uri': 'oauth/token', 'methods': ['POST'] },\n        'passport.authorizations.authorize': {\n            'uri': 'oauth/authorize',\n            'methods': ['GET', 'HEAD'],\n        },\n        'passport.token.refresh': {\n            'uri': 'oauth/token/refresh',\n            'methods': ['POST'],\n        },\n        'passport.authorizations.approve': {\n            'uri': 'oauth/authorize',\n            'methods': ['POST'],\n        },\n        'passport.authorizations.deny': {\n            'uri': 'oauth/authorize',\n            'methods': ['DELETE'],\n        },\n        'passport.tokens.index': {\n            'uri': 'oauth/tokens',\n            'methods': ['GET', 'HEAD'],\n        },\n        'passport.tokens.destroy': {\n            'uri': 'oauth/tokens/{token_id}',\n            'methods': ['DELETE'],\n            'parameters': ['token_id'],\n        },\n        'passport.clients.index': {\n            'uri': 'oauth/clients',\n            'methods': ['GET', 'HEAD'],\n        },\n        'passport.clients.store': {\n            'uri': 'oauth/clients',\n            'methods': ['POST'],\n        },\n        'passport.clients.update': {\n            'uri': 'oauth/clients/{client_id}',\n            'methods': ['PUT'],\n            'parameters': ['client_id'],\n        },\n        'passport.clients.destroy': {\n            'uri': 'oauth/clients/{client_id}',\n            'methods': ['DELETE'],\n            'parameters': ['client_id'],\n        },\n        'passport.scopes.index': {\n            'uri': 'oauth/scopes',\n            'methods': ['GET', 'HEAD'],\n        },\n        'passport.personal.tokens.index': {\n            'uri': 'oauth/personal-access-tokens',\n            'methods': ['GET', 'HEAD'],\n        },\n        'passport.personal.tokens.store': {\n            'uri': 'oauth/personal-access-tokens',\n            'methods': ['POST'],\n        },\n        'passport.personal.tokens.destroy': {\n            'uri': 'oauth/personal-access-tokens/{token_id}',\n            'methods': ['DELETE'],\n            'parameters': ['token_id'],\n        },\n        'livewire.update': { 'uri': 'livewire/update', 'methods': ['POST'] },\n        'livewire.upload-file': {\n            'uri': 'livewire/upload-file',\n            'methods': ['POST'],\n        },\n        'livewire.preview-file': {\n            'uri': 'livewire/preview-file/{filename}',\n            'methods': ['GET', 'HEAD'],\n            'parameters': ['filename'],\n        },\n        'ignition.healthCheck': {\n            'uri': '_ignition/health-check',\n            'methods': ['GET', 'HEAD'],\n        },\n        'ignition.executeSolution': {\n            'uri': '_ignition/execute-solution',\n            'methods': ['POST'],\n        },\n        'ignition.updateConfig': {\n            'uri': '_ignition/update-config',\n            'methods': ['POST'],\n        },\n        'api.v1.organizations.show': {\n            'uri': 'api/v1/organizations/{organization}',\n            'methods': ['GET', 'HEAD'],\n            'parameters': ['organization'],\n            'bindings': { 'organization': 'id' },\n        },\n        'api.v1.organizations.update': {\n            'uri': 'api/v1/organizations/{organization}',\n            'methods': ['PUT'],\n            'parameters': ['organization'],\n            'bindings': { 'organization': 'id' },\n        },\n        'api.v1.users.index': {\n            'uri': 'api/v1/organizations/{organization}/members',\n            'methods': ['GET', 'HEAD'],\n            'parameters': ['organization'],\n            'bindings': { 'organization': 'id' },\n        },\n        'api.v1.users.invite-placeholder': {\n            'uri': 'api/v1/organizations/{organization}/members/{user}/invite-placeholder',\n            'methods': ['POST'],\n            'parameters': ['organization', 'user'],\n            'bindings': { 'organization': 'id', 'user': 'id' },\n        },\n        'api.v1.projects.index': {\n            'uri': 'api/v1/organizations/{organization}/projects',\n            'methods': ['GET', 'HEAD'],\n            'parameters': ['organization'],\n            'bindings': { 'organization': 'id' },\n        },\n        'api.v1.projects.show': {\n            'uri': 'api/v1/organizations/{organization}/projects/{project}',\n            'methods': ['GET', 'HEAD'],\n            'parameters': ['organization', 'project'],\n            'bindings': { 'organization': 'id', 'project': 'id' },\n        },\n        'api.v1.projects.store': {\n            'uri': 'api/v1/organizations/{organization}/projects',\n            'methods': ['POST'],\n            'parameters': ['organization'],\n            'bindings': { 'organization': 'id' },\n        },\n        'api.v1.projects.update': {\n            'uri': 'api/v1/organizations/{organization}/projects/{project}',\n            'methods': ['PUT'],\n            'parameters': ['organization', 'project'],\n            'bindings': { 'organization': 'id', 'project': 'id' },\n        },\n        'api.v1.projects.destroy': {\n            'uri': 'api/v1/organizations/{organization}/projects/{project}',\n            'methods': ['DELETE'],\n            'parameters': ['organization', 'project'],\n            'bindings': { 'organization': 'id', 'project': 'id' },\n        },\n        'api.v1.time-entries.index': {\n            'uri': 'api/v1/organizations/{organization}/time-entries',\n            'methods': ['GET', 'HEAD'],\n            'parameters': ['organization'],\n            'bindings': { 'organization': 'id' },\n        },\n        'api.v1.time-entries.store': {\n            'uri': 'api/v1/organizations/{organization}/time-entries',\n            'methods': ['POST'],\n            'parameters': ['organization'],\n            'bindings': { 'organization': 'id' },\n        },\n        'api.v1.time-entries.update': {\n            'uri': 'api/v1/organizations/{organization}/time-entries/{timeEntry}',\n            'methods': ['PUT'],\n            'parameters': ['organization', 'timeEntry'],\n            'bindings': { 'organization': 'id', 'timeEntry': 'id' },\n        },\n        'api.v1.time-entries.destroy': {\n            'uri': 'api/v1/organizations/{organization}/time-entries/{timeEntry}',\n            'methods': ['DELETE'],\n            'parameters': ['organization', 'timeEntry'],\n            'bindings': { 'organization': 'id', 'timeEntry': 'id' },\n        },\n        'api.v1.tags.index': {\n            'uri': 'api/v1/organizations/{organization}/tags',\n            'methods': ['GET', 'HEAD'],\n            'parameters': ['organization'],\n            'bindings': { 'organization': 'id' },\n        },\n        'api.v1.tags.store': {\n            'uri': 'api/v1/organizations/{organization}/tags',\n            'methods': ['POST'],\n            'parameters': ['organization'],\n            'bindings': { 'organization': 'id' },\n        },\n        'api.v1.tags.update': {\n            'uri': 'api/v1/organizations/{organization}/tags/{tag}',\n            'methods': ['PUT'],\n            'parameters': ['organization', 'tag'],\n            'bindings': { 'organization': 'id', 'tag': 'id' },\n        },\n        'api.v1.tags.destroy': {\n            'uri': 'api/v1/organizations/{organization}/tags/{tag}',\n            'methods': ['DELETE'],\n            'parameters': ['organization', 'tag'],\n            'bindings': { 'organization': 'id', 'tag': 'id' },\n        },\n        'api.v1.clients.index': {\n            'uri': 'api/v1/organizations/{organization}/clients',\n            'methods': ['GET', 'HEAD'],\n            'parameters': ['organization'],\n            'bindings': { 'organization': 'id' },\n        },\n        'api.v1.clients.store': {\n            'uri': 'api/v1/organizations/{organization}/clients',\n            'methods': ['POST'],\n            'parameters': ['organization'],\n            'bindings': { 'organization': 'id' },\n        },\n        'api.v1.clients.update': {\n            'uri': 'api/v1/organizations/{organization}/clients/{client}',\n            'methods': ['PUT'],\n            'parameters': ['organization', 'client'],\n            'bindings': { 'organization': 'id', 'client': 'id' },\n        },\n        'api.v1.clients.destroy': {\n            'uri': 'api/v1/organizations/{organization}/clients/{client}',\n            'methods': ['DELETE'],\n            'parameters': ['organization', 'client'],\n            'bindings': { 'organization': 'id', 'client': 'id' },\n        },\n        'api.v1.tasks.index': {\n            'uri': 'api/v1/organizations/{organization}/tasks',\n            'methods': ['GET', 'HEAD'],\n            'parameters': ['organization'],\n            'bindings': { 'organization': 'id' },\n        },\n        'api.v1.tasks.store': {\n            'uri': 'api/v1/organizations/{organization}/tasks',\n            'methods': ['POST'],\n            'parameters': ['organization'],\n            'bindings': { 'organization': 'id' },\n        },\n        'api.v1.tasks.update': {\n            'uri': 'api/v1/organizations/{organization}/tasks/{task}',\n            'methods': ['PUT'],\n            'parameters': ['organization', 'task'],\n            'bindings': { 'organization': 'id', 'task': 'id' },\n        },\n        'api.v1.tasks.destroy': {\n            'uri': 'api/v1/organizations/{organization}/tasks/{task}',\n            'methods': ['DELETE'],\n            'parameters': ['organization', 'task'],\n            'bindings': { 'organization': 'id', 'task': 'id' },\n        },\n        'api.v1.import.import': {\n            'uri': 'api/v1/organizations/{organization}/import',\n            'methods': ['POST'],\n            'parameters': ['organization'],\n            'bindings': { 'organization': 'id' },\n        },\n        'dashboard': { 'uri': 'dashboard', 'methods': ['GET', 'HEAD'] },\n        'telescope': {\n            'uri': 'telescope/{view?}',\n            'methods': ['GET', 'HEAD'],\n            'wheres': { 'view': '(.*)' },\n            'parameters': ['view'],\n        },\n        'api.': {\n            'uri': 'api/{fallbackPlaceholder}',\n            'methods': ['GET', 'HEAD'],\n            'wheres': { 'fallbackPlaceholder': '.*' },\n            'parameters': ['fallbackPlaceholder'],\n        },\n    },\n};\nif (typeof window !== 'undefined' && typeof window.Ziggy !== 'undefined') {\n    Object.assign(Ziggy.routes, window.Ziggy.routes);\n}\nexport { Ziggy };\n"
  },
  {
    "path": "resources/markdown/policy.md",
    "content": "# Privacy Policy\n\nEdit this file to define the privacy policy for your application.\n"
  },
  {
    "path": "resources/markdown/terms.md",
    "content": "# Terms of Service\n\nEdit this file to define the terms of service for your application.\n"
  },
  {
    "path": "resources/testfiles/clockify_projects_import_test_1.csv",
    "content": "\"Project\",\"Client\",\"Status\",\"Visibility\",\"Billability\",\"Task\",\"Tracked (h)\",\"Estimated (h)\",\"Remaining (h)\",\"Overage (h)\",\"Progress(%)\",\"Billable (h)\",\"Non-billable (h)\",\"Billable Rate (USD)\",\"Amount (USD)\",\"Project members\",\"Project manager\",\"Note\"\n\"Project for Big Company\",\"Big Company\",\"Active\",\"Public\",\"Yes\",\"Task 1, Task 2, Task 3\",\"0.00\",\"1001.11\",\"\",\"\",\"\",\"0.00\",\"0.00\",\"100.01\",\"0.00\",\"Constantin Graf\",\"\",\"\"\n\"Project without Client\",\"\",\"Active\",\"Public\",\"Yes\",\"\",\"0.00\",\"\",\"\",\"\",\"\",\"0.00\",\"0.00\",\"\",\"0.00\",\"Constantin Graf\",\"\",\"\"\n"
  },
  {
    "path": "resources/testfiles/clockify_time_entries_import_test_1.csv",
    "content": "\"Project\",\"Client\",\"Description\",\"Task\",\"User\",\"Group\",\"Email\",\"Tags\",\"Billable\",\"Start Date\",\"Start Time\",\"End Date\",\"End Time\",\"Duration (h)\",\"Duration (decimal)\",\"Billable Rate (USD)\",\"Billable Amount (USD)\"\n\"Project without Client\",\"\",\"\",\"\",\"Peter Tester\",\"\",\"peter.test@email.test\",\"Development, Backend\",\"No\",\"03/04/2024\",\"10:23:52 AM\",\"03/04/2024\",\"10:23:52 AM\",\"00:00:00\",\"0.00\",\"0.00\",\"0.00\"\n\"Project for Big Company\",\"Big Company\",\"Working hard\",\"Task 1\",\"Peter Tester\",\"\",\"peter.test@email.test\",\"\",\"Yes\",\"03/04/2024\",\"10:23 AM\",\"03/04/2024\",\"11:23:01 AM\",\"01:00:01\",\"0.00\",\"0.00\",\"0.00\"\n"
  },
  {
    "path": "resources/testfiles/clockify_time_entries_import_test_2.csv",
    "content": "\"Project\",\"Client\",\"Description\",\"Task\",\"User\",\"Group\",\"Email\",\"Tags\",\"Type\",\"Billable\",\"Invoiced\",\"Invoice ID\",\"Start Date\",\"Start Time\",\"End Date\",\"End Time\",\"Duration (h)\",\"Duration (decimal)\",\"Billable Rate (EUR)\",\"Billable Amount (EUR)\",\"Date of creation\"\n\"Real World Project\",\"Real World Client\",\"\\\\ 🔥 Special characters  ''''''`!@#$%^&*()_+\\-=\\[\\]{};':''\\\\|,.''<>\\/?~ \\\\\\\",\"A giant task\",\"Peter Tester\",\"Group1, Group2\",\"peter.test@email.test\",\"\",\"Regular\",\"Yes\",\"Yes\",\"Invoice100\",\"10/15/2024\",\"11:00:00 AM\",\"10/15/2024\",\"11:30:00 AM\",\"00:30:00\",\"0.50\",\"1000.00\",\"500.00\",\"10/15/2024\"\n"
  },
  {
    "path": "resources/testfiles/clockify_time_entries_import_test_3.csv",
    "content": "\"Project\",\"Client\",\"Description\",\"Task\",\"User\",\"Group\",\"Email\",\"Tags\",\"Type\",\"Billable\",\"Invoiced\",\"Invoice ID\",\"Start Date\",\"Start Time\",\"End Date\",\"End Time\",\"Duration (h)\",\"Duration (decimal)\",\"Billable Rate (EUR)\",\"Billable Amount (EUR)\",\"Date of creation\"\n\"Real World Project\",\"Real World Client\",\"\\\\ 🔥 Special characters  ''''''`!@#$%^&*()_+\\-=\\[\\]{};':''\\\\|,.''<>\\/?~ \\\\\\\",\"A giant task\",\"Peter Tester\",\"Group1, Group2\",\"peter.test@email.test\",\"\",\"Regular\",\"Yes\",\"Yes\",\"Invoice100\",\"13/15/2024\",\"11:00:00 AM\",\"10/15/2024\",\"11:30:00 AM\",\"00:30:00\",\"0.50\",\"1000.00\",\"500.00\",\"10/15/2024\"\n"
  },
  {
    "path": "resources/testfiles/generic_projects_import_test_1.csv",
    "content": "name,color,billable_rate,is_public,client,billable_default,estimated_time,archived_at\n\"Project for Big Company\",,10001,false,\"Big Company\",true,,\n\"Project without Client\",#ef5350,,false,,false,1000,\n\"Project (Archived)\",#6a407f,,true,\"Some client\",true,0,2024-08-25T10:00:00Z\n"
  },
  {
    "path": "resources/testfiles/generic_time_entries_import_test_1.csv",
    "content": "description,billable,client,project,tags,start,end,task,user_name,user_email\n\"\",\"false\",\"\",\"Project without Client\",\"Development, Backend\",\"2024-03-04T09:23:52Z\",\"2024-03-04T09:23:52Z\",\"\",\"Peter Tester\",\"peter.test@email.test\"\n\"Working hard\",\"true\",\"Big Company\",\"Project for Big Company\",\"\",\"2024-03-04T09:23:00Z\",\"2024-03-04T10:23:01Z\",\"Task 1\",\"Peter Tester\",\"peter.test@email.test\"\n"
  },
  {
    "path": "resources/testfiles/harvest_clients_import_test_1.csv",
    "content": "Client Name,Address\nExample Client,\"\"\n\"\\\\ 🔥 Special characters  \"\"\"\"\"\"`!@#$%^&*()_+\\-=\\[\\]{};':\"\"\\\\|,.''<>\\/?~ \\\\\\\",\"\"\n"
  },
  {
    "path": "resources/testfiles/harvest_projects_import_test_1.csv",
    "content": "Client,Project,Project Code,Start Date,End Date,Project Notes,Total Hours,Billable Hours,Billable Amount,Budget By,Budget,Budget Spent,Budget Remaining,Total Costs,Team Costs,Expenses\nExample Client,Example Project,,\"\",\"\",This is an example project to help you trial Harvest. You can track time to this project and see what insights you can get from our reports! Feel free to make any edits you want to this project or even delete it.,\"20,01\",\"20,01\",\"2.001,0\",Hours,\"50,0\",\"20,01\",\"29,99\",\"0,0\",\"0,0\",\"0,0\"\n\"\\\\ 🔥 Special characters client \"\"\"\"\"\"`!@#$%^&*()_+\\-=\\[\\]{};':\"\"\\\\|,.''<>\\/?~ \\\\\\\",\"\\\\ 🔥 Special characters project \"\"\"\"\"\"`!@#$%^&*()_+\\-=\\[\\]{};':\"\"\\\\|,.''<>\\/?~ \\\\\\\",,\"\",\"\",,\"0,0\",\"0,0\",\"0,0\",Hours,\"0,0\",\"0,0\",\"50,0\",\"0,0\",\"0,0\",\"0,0\"\n"
  },
  {
    "path": "resources/testfiles/harvest_time_entries_import_test_1.csv",
    "content": "Date,Client,Project,Project Code,Task,Notes,Hours,Billable?,Invoiced?,Approved?,First Name,Last Name,Roles,Employee?,Billable Rate,Billable Amount,Cost Rate,Cost Amount,Currency,External Reference URL\n2024-03-04,,Project without Client,,,\"\",\"20,0\",No,No,No,Peter,Tester,,Yes,\"100,0\",\"2.000,0\",\"0,0\",\"0,0\",Euro - EUR,\n2024-03-04,Big Company,Project for Big Company,,Task 1,Working hard,\"0,01\",Yes,No,No,Peter,Tester,,Yes,\"100,0\",\"1,0\",\"0,0\",\"0,0\",Euro - EUR,\n"
  },
  {
    "path": "resources/testfiles/solidtime_import_test_1/clients.csv",
    "content": "id,name,organization_id,archived_at,created_at,updated_at\nb4187a44-41f4-46d7-8460-f15a25b3aad6,\"Big Company\",ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z\ne5a4d8f5-81ae-4606-8e84-6ab1ffa58b72,\"Other Company (Archived)\",ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z\n"
  },
  {
    "path": "resources/testfiles/solidtime_import_test_1/members.csv",
    "content": "id,user_id,name,email,organization_id,billable_rate,role,created_at,updated_at\n06e6e605-86bd-417b-b75d-02f671e5d520,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,\"Peter Tester\",peter.test@email.test,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,admin,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z\n"
  },
  {
    "path": "resources/testfiles/solidtime_import_test_1/meta.json",
    "content": "{\"id\":\"d6a324ee-58d5-4096-8069-c63bd55608f7\",\"version\":\"1.0\",\"organizations\":[\"ee5a8cd6-312f-4ae6-b044-e2014f09ecc2\"],\"exported_at\":\"2024-08-26T18:21:59Z\"}"
  },
  {
    "path": "resources/testfiles/solidtime_import_test_1/organization_invitations.csv",
    "content": "id,email,organization_id,role,created_at,updated_at\n"
  },
  {
    "path": "resources/testfiles/solidtime_import_test_1/organizations.csv",
    "content": "id,name,billable_rate,currency,created_at,updated_at\nee5a8cd6-312f-4ae6-b044-e2014f09ecc2,\"ACME Corp\",,EUR,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z\n"
  },
  {
    "path": "resources/testfiles/solidtime_import_test_1/project_members.csv",
    "content": "id,billable_rate,project_id,user_id,member_id,created_at,updated_at\n180a1a98-2f1c-4596-86e4-63a6be0d7b1d,10002,06e79ec4-33f8-4730-804c-d03c014991d1,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,06e6e605-86bd-417b-b75d-02f671e5d520,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z\n"
  },
  {
    "path": "resources/testfiles/solidtime_import_test_1/projects.csv",
    "content": "id,name,color,billable_rate,is_public,client_id,organization_id,is_billable,archived_at,created_at,updated_at\n06e79ec4-33f8-4730-804c-d03c014991d1,\"Project for Big Company\",#ec407a,10001,false,b4187a44-41f4-46d7-8460-f15a25b3aad6,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,true,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z\n622c74a9-7e64-44c2-9426-2a37ac738206,\"Project without Client\",#ef5350,,false,,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,false,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z\naa831162-dbb2-4cfe-bfe0-5e3a252c66f0,\"Project (Archived)\",#6a407f,,true,e5a4d8f5-81ae-4606-8e84-6ab1ffa58b72,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,true,2024-08-25T10:00:00Z,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z\n"
  },
  {
    "path": "resources/testfiles/solidtime_import_test_1/tags.csv",
    "content": "id,name,organization_id,created_at,updated_at\n2c5c2da7-9ef8-4410-bb8f-6e0a90f9d2c7,Development,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z\nbf6c0ac5-2587-474b-8983-40bb3ea8002f,Backend,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z\n"
  },
  {
    "path": "resources/testfiles/solidtime_import_test_1/tasks.csv",
    "content": "id,name,project_id,organization_id,done_at,created_at,updated_at\nb49688a0-94f3-4cb3-9ca1-5003de955fb0,\"Task 1\",06e79ec4-33f8-4730-804c-d03c014991d1,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z\nb49688a0-94f3-4cb3-9ca1-5003de955fb0,\"Task 2\",06e79ec4-33f8-4730-804c-d03c014991d1,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,2024-08-24T10:00:00Z,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z\n"
  },
  {
    "path": "resources/testfiles/solidtime_import_test_1/time_entries.csv",
    "content": "id,description,start,end,billable_rate,billable,member_id,user_id,organization_id,client_id,project_id,task_id,tags,is_imported,still_active_email_sent_at,created_at,updated_at\n00aae3be-18fc-462d-bee4-350fb605b2f3,,2024-03-04T09:23:52Z,2024-03-04T09:23:52Z,,false,06e6e605-86bd-417b-b75d-02f671e5d520,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,,,\"[\"\"2c5c2da7-9ef8-4410-bb8f-6e0a90f9d2c7\"\",\"\"bf6c0ac5-2587-474b-8983-40bb3ea8002f\"\"]\",false,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z\n1c7a905d-aa12-4d08-bc41-7e92577e7cdf,\"Working hard\",2024-03-04T09:23:00Z,2024-03-04T10:23:01Z,,true,06e6e605-86bd-417b-b75d-02f671e5d520,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,b4187a44-41f4-46d7-8460-f15a25b3aad6,06e79ec4-33f8-4730-804c-d03c014991d1,b49688a0-94f3-4cb3-9ca1-5003de955fb0,[],false,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z\n"
  },
  {
    "path": "resources/testfiles/toggl_data_import_test_1/clients.json",
    "content": "[\n    {\n        \"archived\": false,\n        \"creator_id\": 201,\n        \"id\": 301,\n        \"name\": \"Big Company\",\n        \"wid\": 0\n    },\n    {\n        \"archived\": true,\n        \"creator_id\": 201,\n        \"id\": 302,\n        \"name\": \"Other Company (Archived)\",\n        \"wid\": 0\n    }\n]\n"
  },
  {
    "path": "resources/testfiles/toggl_data_import_test_1/projects.json",
    "content": "[\n    {\n        \"active\": true,\n        \"actual_hours\": null,\n        \"actual_seconds\": null,\n        \"auto_estimates\": false,\n        \"billable\": false,\n        \"cid\": null,\n        \"client_id\": null,\n        \"color\": \"#ef5350\",\n        \"currency\": \"EUR\",\n        \"estimated_hours\": null,\n        \"estimated_seconds\": null,\n        \"fixed_fee\": null,\n        \"guid\": \"\",\n        \"id\": 401,\n        \"is_private\": true,\n        \"name\": \"Project without Client\",\n        \"rate\": null,\n        \"rate_last_updated\": null,\n        \"recurring\": false,\n        \"recurring_parameters\": null,\n        \"start_date\": \"2020-01-01\",\n        \"status\": \"active\",\n        \"template\": false,\n        \"template_id\": null,\n        \"wid\": 0,\n        \"workspace_id\": 0\n    },\n    {\n        \"active\": true,\n        \"actual_hours\": null,\n        \"actual_seconds\": null,\n        \"auto_estimates\": false,\n        \"billable\": true,\n        \"cid\": 301,\n        \"client_id\": 301,\n        \"color\": \"#ec407a\",\n        \"currency\": null,\n        \"estimated_hours\": null,\n        \"estimated_seconds\": null,\n        \"fixed_fee\": null,\n        \"guid\": \"\",\n        \"id\": 402,\n        \"is_private\": true,\n        \"name\": \"Project for Big Company\",\n        \"rate\": 100.01,\n        \"rate_last_updated\": null,\n        \"recurring\": false,\n        \"recurring_parameters\": null,\n        \"start_date\": \"2020-01-01\",\n        \"status\": \"active\",\n        \"template\": false,\n        \"template_id\": null,\n        \"wid\": 0,\n        \"workspace_id\": 0\n    },\n    {\n        \"active\": false,\n        \"actual_hours\": null,\n        \"actual_seconds\": null,\n        \"auto_estimates\": false,\n        \"billable\": true,\n        \"cid\": 302,\n        \"client_id\": 302,\n        \"color\": \"#6a407f\",\n        \"currency\": null,\n        \"estimated_hours\": null,\n        \"estimated_seconds\": null,\n        \"fixed_fee\": null,\n        \"guid\": \"\",\n        \"id\": 403,\n        \"is_private\": false,\n        \"name\": \"Project (Archived)\",\n        \"rate\": null,\n        \"rate_last_updated\": null,\n        \"recurring\": false,\n        \"recurring_parameters\": null,\n        \"start_date\": \"2020-01-01\",\n        \"status\": \"active\",\n        \"template\": false,\n        \"template_id\": null,\n        \"wid\": 0,\n        \"workspace_id\": 0\n    }\n]\n"
  },
  {
    "path": "resources/testfiles/toggl_data_import_test_1/projects_users/401.json",
    "content": "[]\n"
  },
  {
    "path": "resources/testfiles/toggl_data_import_test_1/projects_users/402.json",
    "content": "[\n    {\n        \"gid\": null,\n        \"group_id\": null,\n        \"id\": 801,\n        \"labour_cost\": null,\n        \"manager\": true,\n        \"project_id\": 402,\n        \"rate\": 100.02,\n        \"rate_last_updated\": null,\n        \"user_id\": 2001,\n        \"workspace_id\": 0\n    }\n]\n"
  },
  {
    "path": "resources/testfiles/toggl_data_import_test_1/projects_users/403.json",
    "content": "[]\n"
  },
  {
    "path": "resources/testfiles/toggl_data_import_test_1/tags.json",
    "content": "[\n    {\n        \"creator_id\": 0,\n        \"id\": 501,\n        \"name\": \"Development\",\n        \"workspace_id\": 0\n    },\n    {\n        \"creator_id\": 0,\n        \"id\": 502,\n        \"name\": \"Backend\",\n        \"workspace_id\": 0\n    }\n]\n"
  },
  {
    "path": "resources/testfiles/toggl_data_import_test_1/tasks/401.json",
    "content": "[]\n"
  },
  {
    "path": "resources/testfiles/toggl_data_import_test_1/tasks/402.json",
    "content": "[\n    {\n        \"active\": true,\n        \"estimated_seconds\": 0,\n        \"id\": 601,\n        \"name\": \"Task 1\",\n        \"project_id\": 402,\n        \"recurring\": false,\n        \"tracked_seconds\": 0,\n        \"user_id\": null,\n        \"workspace_id\": 0\n    },\n    {\n        \"active\": false,\n        \"estimated_seconds\": 0,\n        \"id\": 602,\n        \"name\": \"Task 2\",\n        \"project_id\": 403,\n        \"recurring\": false,\n        \"tracked_seconds\": 0,\n        \"user_id\": null,\n        \"workspace_id\": 0\n    }\n]\n"
  },
  {
    "path": "resources/testfiles/toggl_data_import_test_1/tasks/403.json",
    "content": "[]\n"
  },
  {
    "path": "resources/testfiles/toggl_data_import_test_1/workspace_users.json",
    "content": "[\n    {\n        \"active\": true,\n        \"admin\": true,\n        \"email\": \"peter.test@email.test\",\n        \"group_ids\": [],\n        \"id\": 201,\n        \"inactive\": false,\n        \"labour_cost\": null,\n        \"name\": \"Peter Tester\",\n        \"rate\": null,\n        \"rate_last_updated\": null,\n        \"role\": \"admin\",\n        \"timezone\": \"Europe/Vienna\",\n        \"uid\": 2001,\n        \"wid\": 0,\n        \"working_hours_in_minutes\": null\n    }\n]\n"
  },
  {
    "path": "resources/testfiles/toggl_data_import_test_2/clients.json",
    "content": "[\n    {\n        \"archived\": false,\n        \"creator_id\": 201,\n        \"id\": 301,\n        \"name\": \"Big Company\",\n        \"wid\": 0\n    },\n    {\n        \"archived\": true,\n        \"creator_id\": 201,\n        \"id\": 302,\n        \"name\": \"Other Company (Archived)\",\n        \"wid\": 0\n    }\n]\n"
  },
  {
    "path": "resources/testfiles/toggl_data_import_test_2/projects.json",
    "content": "[\n    {\n        \"active\": true,\n        \"actual_hours\": null,\n        \"actual_seconds\": null,\n        \"auto_estimates\": false,\n        \"billable\": false,\n        \"cid\": null,\n        \"client_id\": null,\n        \"color\": \"#ef5350\",\n        \"currency\": \"EUR\",\n        \"estimated_hours\": null,\n        \"estimated_seconds\": null,\n        \"fixed_fee\": null,\n        \"guid\": \"\",\n        \"id\": 401,\n        \"is_private\": true,\n        \"name\": \"Project without Client\",\n        \"rate\": null,\n        \"rate_last_updated\": null,\n        \"recurring\": false,\n        \"recurring_parameters\": null,\n        \"start_date\": \"2020-01-01\",\n        \"status\": \"active\",\n        \"template\": false,\n        \"template_id\": null,\n        \"wid\": 0,\n        \"workspace_id\": 0\n    },\n    {\n        \"active\": true,\n        \"actual_hours\": null,\n        \"actual_seconds\": null,\n        \"auto_estimates\": false,\n        \"billable\": true,\n        \"cid\": 301,\n        \"client_id\": 301,\n        \"color\": \"#ec407a\",\n        \"currency\": null,\n        \"estimated_hours\": null,\n        \"estimated_seconds\": null,\n        \"fixed_fee\": null,\n        \"guid\": \"\",\n        \"id\": 402,\n        \"is_private\": true,\n        \"name\": \"Project for Big Company\",\n        \"rate\": 100.01,\n        \"rate_last_updated\": null,\n        \"recurring\": false,\n        \"recurring_parameters\": null,\n        \"start_date\": \"2020-01-01\",\n        \"status\": \"active\",\n        \"template\": false,\n        \"template_id\": null,\n        \"wid\": 0,\n        \"workspace_id\": 0\n    },\n    {\n        \"active\": false,\n        \"actual_hours\": null,\n        \"actual_seconds\": null,\n        \"auto_estimates\": false,\n        \"billable\": true,\n        \"cid\": 302,\n        \"client_id\": 302,\n        \"color\": \"#6a407f\",\n        \"currency\": null,\n        \"estimated_hours\": null,\n        \"estimated_seconds\": null,\n        \"fixed_fee\": null,\n        \"guid\": \"\",\n        \"id\": 403,\n        \"is_private\": false,\n        \"name\": \"Project (Archived)\",\n        \"rate\": null,\n        \"rate_last_updated\": null,\n        \"recurring\": false,\n        \"recurring_parameters\": null,\n        \"start_date\": \"2020-01-01\",\n        \"status\": \"active\",\n        \"template\": false,\n        \"template_id\": null,\n        \"wid\": 0,\n        \"workspace_id\": 0\n    }\n]\n"
  },
  {
    "path": "resources/testfiles/toggl_data_import_test_2/projects_users/401.json",
    "content": "[]\n"
  },
  {
    "path": "resources/testfiles/toggl_data_import_test_2/projects_users/402.json",
    "content": "[\n    {\n        \"gid\": null,\n        \"group_id\": null,\n        \"id\": 801,\n        \"labour_cost\": null,\n        \"manager\": true,\n        \"project_id\": 402,\n        \"rate\": 100.02,\n        \"rate_last_updated\": null,\n        \"user_id\": 2001,\n        \"workspace_id\": 0\n    }\n]\n"
  },
  {
    "path": "resources/testfiles/toggl_data_import_test_2/projects_users/403.json",
    "content": "[]\n"
  },
  {
    "path": "resources/testfiles/toggl_data_import_test_2/tags.json",
    "content": "[\n    {\n        \"creator_id\": 0,\n        \"id\": 501,\n        \"name\": \"Development\",\n        \"workspace_id\": 0\n    },\n    {\n        \"creator_id\": 0,\n        \"id\": 502,\n        \"name\": \"Backend\",\n        \"workspace_id\": 0\n    }\n]\n"
  },
  {
    "path": "resources/testfiles/toggl_data_import_test_2/tasks/401.json",
    "content": "[]\n"
  },
  {
    "path": "resources/testfiles/toggl_data_import_test_2/tasks/402.json",
    "content": "[\n    {\n        \"active\": true,\n        \"estimated_seconds\": 0,\n        \"id\": 601,\n        \"name\": \"Task 1\",\n        \"project_id\": 402,\n        \"recurring\": false,\n        \"tracked_seconds\": 0,\n        \"user_id\": null,\n        \"workspace_id\": 0\n    },\n    {\n        \"active\": false,\n        \"estimated_seconds\": 0,\n        \"id\": 602,\n        \"name\": \"Task 2\",\n        \"project_id\": 403,\n        \"recurring\": false,\n        \"tracked_seconds\": 0,\n        \"user_id\": null,\n        \"workspace_id\": 0\n    }\n]\n"
  },
  {
    "path": "resources/testfiles/toggl_data_import_test_2/tasks/403.json",
    "content": "[]\n"
  },
  {
    "path": "resources/testfiles/toggl_data_import_test_2/workspace_users.json",
    "content": "[\n    {\n        \"active\": true,\n        \"admin\": true,\n        \"email\": \"peter.test@email.test\",\n        \"group_ids\": [],\n        \"id\": 201,\n        \"inactive\": false,\n        \"labour_cost\": null,\n        \"name\": \"Peter Tester\",\n        \"rate\": null,\n        \"rate_last_updated\": null,\n        \"role\": \"admin\",\n        \"timezone\": \"Etc/UTC\",\n        \"uid\": 2001,\n        \"wid\": 0,\n        \"working_hours_in_minutes\": null\n    }\n]\n"
  },
  {
    "path": "resources/testfiles/toggl_time_entries_import_test_1.csv",
    "content": "﻿User,Email,Client,Project,Task,Description,Billable,Start date,Start time,End date,End time,Duration,Tags,Amount (EUR)\nPeter Tester,peter.test@email.test,,Project without Client,,\"\",No,2024-03-04,10:23:52,2024-03-04,10:23:52,00:00:00,\"Development, Backend\",\nPeter Tester,peter.test@email.test,Big Company,Project for Big Company,Task 1,Working hard,Yes,2024-03-04,10:23:00,2024-03-04,11:23:01,01:00:01,,111.11\n"
  },
  {
    "path": "resources/testfiles/toggl_time_entries_import_test_2.csv",
    "content": "﻿\"User\",\"Email\",\"Client\",\"Project\",\"Task\",\"Description\",\"Billable\",\"Start date\",\"Start time\",\"End date\",\"End time\",\"Duration\",\"Tags\"\n\"Peter Tester\",\"peter.test@email.test\",\"Real World Client\",\"Real World Project\",\"A giant task\",\"\\\\ 🔥 Special characters  \"\"\"\"\"\"`!@#$%^&*()_+\\-=\\[\\]{};':\"\"\\\\|,.''<>\\/?~ \\\\\\\",\"No\",\"2024-10-15\",\"12:02:17\",\"2024-10-15\",\"12:02:19\",\"00:00:02\",\"\"\n"
  },
  {
    "path": "resources/views/app.blade.php",
    "content": "<!DOCTYPE html>\n<html lang=\"{{ str_replace('_', '-', app()->getLocale()) }}\">\n    <head>\n        <meta charset=\"utf-8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1\">\n\n        <title inertia>{{ config('app.name', 'Laravel') }}</title>\n\n        <!-- Favicons -->\n        <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/favicons/apple-touch-icon.png\">\n        <link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/favicons/favicon-32x32.png\">\n        <link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/favicons/favicon-16x16.png\">\n        <link rel=\"manifest\" href=\"/favicons/site.webmanifest\">\n        <link rel=\"mask-icon\" href=\"/favicons/safari-pinned-tab.svg\" color=\"#000000\">\n        <link rel=\"shortcut icon\" href=\"/favicons/favicon.ico\">\n        <meta name=\"msapplication-TileColor\" content=\"#000000\">\n        <meta name=\"msapplication-config\" content=\"/favicons/browserconfig.xml\">\n        <meta name=\"theme-color\" content=\"#000000\">\n\n        <!-- Scripts -->\n        @routes\n        @vite(array_filter(\\Nwidart\\Modules\\Module::getAssets(), fn($asset) => $asset !== 'resources/css/filament/admin/theme.css'))\n        @inertiaHead\n    </head>\n    <body class=\"font-sans antialiased\">\n        @inertia\n    </body>\n</html>\n"
  },
  {
    "path": "resources/views/auth/oauth/authorize.blade.php",
    "content": "<!DOCTYPE html>\n<html lang=\"en\" class=\"light\">\n<head>\n    <meta charset=\"utf-8\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\n    <title>{{ config('app.name') }} - Authorization</title>\n    @vite('resources/css/app.css')\n    <script>\n        // Theme evaluation script - similar to theme.ts\n        (function() {\n            // Get theme setting from localStorage, default to 'system'\n            const themeSetting = localStorage.getItem('theme') || 'system';\n\n            // Get user's preferred color scheme\n            const preferredColorScheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';\n\n            // Determine current theme\n            let currentTheme;\n            if (themeSetting === 'system') {\n                // If user has no preference, default to dark (matching theme.ts logic)\n                currentTheme = preferredColorScheme === 'no-preference' ? 'dark' : preferredColorScheme;\n            } else {\n                currentTheme = themeSetting;\n            }\n\n            // Apply theme class to html element\n            document.documentElement.classList.remove('light', 'dark');\n            document.documentElement.classList.add(currentTheme);\n\n            // Listen for changes in color scheme preference\n            window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {\n                if (localStorage.getItem('theme') === 'system') {\n                    const newTheme = e.matches ? 'dark' : 'light';\n                    document.documentElement.classList.remove('light', 'dark');\n                    document.documentElement.classList.add(newTheme);\n                }\n            });\n\n            // Listen for localStorage changes (in case theme is changed in another tab)\n            window.addEventListener('storage', function(e) {\n                if (e.key === 'theme') {\n                    const newThemeSetting = e.newValue || 'system';\n                    let newTheme;\n\n                    if (newThemeSetting === 'system') {\n                        const preferredColorScheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';\n                        newTheme = preferredColorScheme === 'no-preference' ? 'dark' : preferredColorScheme;\n                    } else {\n                        newTheme = newThemeSetting;\n                    }\n\n                    document.documentElement.classList.remove('light', 'dark');\n                    document.documentElement.classList.add(newTheme);\n                }\n            });\n        })();\n    </script>\n</head>\n<body class=\"passport-authorize\">\n<div\n    class=\"min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-default-background\">\n    <div>\n        <svg\n            class=\"h-12 py-2 fill-text-primary\"\n            viewBox=\"0 0 168 30\"\n            fill=\"none\"\n            xmlns=\"http://www.w3.org/2000/svg\">\n            <path\n                d=\"M54.4081 6.78783C55.0812 7.46093 55.9225 7.79748 56.9322 7.79748C57.9936 7.79748 58.8479 7.46093 59.4951 6.78783C60.1682 6.08885 60.5048 5.22159 60.5048 4.18606C60.5048 3.17642 60.1682 2.3221 59.4951 1.62312C58.8479 0.924138 57.9936 0.574646 56.9322 0.574646C55.9225 0.574646 55.0812 0.924138 54.4081 1.62312C53.735 2.3221 53.3984 3.17642 53.3984 4.18606C53.3984 5.22159 53.735 6.08885 54.4081 6.78783Z\" />\n            <path\n                d=\"M158.028 29.4272C155.905 29.4272 154.028 29.0129 152.397 28.1845C150.766 27.3302 149.485 26.1523 148.553 24.6508C147.621 23.1492 147.155 21.4277 147.155 19.4861C147.155 17.5703 147.608 15.8746 148.514 14.399C149.42 12.8975 150.65 11.7196 152.203 10.8653C153.782 9.98505 155.556 9.54495 157.523 9.54495C159.439 9.54495 161.134 9.95916 162.61 10.7876C164.112 11.5901 165.277 12.7163 166.105 14.166C166.959 15.5899 167.386 17.2208 167.386 19.0589C167.386 19.4472 167.361 19.8485 167.309 20.2627C167.283 20.651 167.205 21.1041 167.076 21.6218L150.339 21.6995V17.3503L164.396 17.2338L161.367 19.1366C161.342 18.0751 161.186 17.2079 160.901 16.5348C160.617 15.8358 160.202 15.3051 159.659 14.9427C159.115 14.5802 158.429 14.399 157.601 14.399C156.746 14.399 156.009 14.6061 155.387 15.0203C154.766 15.4345 154.287 16.017 153.95 16.7678C153.614 17.5185 153.446 18.4246 153.446 19.4861C153.446 20.5734 153.627 21.5053 153.989 22.282C154.352 23.0327 154.869 23.6023 155.543 23.9906C156.216 24.3789 157.044 24.5731 158.028 24.5731C158.96 24.5731 159.775 24.4178 160.474 24.1071C161.199 23.7964 161.846 23.3175 162.416 22.6703L165.95 26.2041C165.018 27.2655 163.879 28.068 162.532 28.6117C161.212 29.1553 159.711 29.4272 158.028 29.4272Z\" />\n            <path\n                d=\"M114.306 29V10.0109H121.063V29H114.306ZM126.228 29V18.0104C126.228 17.2079 125.982 16.5866 125.49 16.1465C124.998 15.6805 124.39 15.4475 123.665 15.4475C123.147 15.4475 122.694 15.551 122.306 15.7581C121.917 15.9652 121.607 16.263 121.374 16.6513C121.167 17.0137 121.063 17.4668 121.063 18.0104L118.422 16.9619C118.422 15.4345 118.759 14.1272 119.432 13.0399C120.105 11.9526 121.011 11.1112 122.15 10.5158C123.289 9.92034 124.584 9.62262 126.034 9.62262C127.328 9.62262 128.493 9.93328 129.528 10.5546C130.59 11.15 131.431 11.9914 132.053 13.0787C132.674 14.166 132.985 15.4475 132.985 16.9231V29H126.228ZM138.149 29V18.0104C138.149 17.2079 137.903 16.5866 137.411 16.1465C136.92 15.6805 136.311 15.4475 135.586 15.4475C135.094 15.4475 134.641 15.551 134.227 15.7581C133.839 15.9652 133.528 16.263 133.295 16.6513C133.088 17.0137 132.985 17.4668 132.985 18.0104L129.024 17.8163C129.075 16.1076 129.451 14.6449 130.15 13.4282C130.849 12.2114 131.807 11.2795 133.023 10.6323C134.266 9.95917 135.664 9.62262 137.217 9.62262C138.693 9.62262 140.013 9.93328 141.178 10.5546C142.343 11.1759 143.249 12.082 143.896 13.2729C144.57 14.4378 144.906 15.8358 144.906 17.4668V29H138.149Z\" />\n            <path d=\"M103.573 29V10.011H110.369V29H103.573Z\" />\n            <path\n                d=\"M104.428 6.78783C105.101 7.46093 105.942 7.79748 106.952 7.79748C108.013 7.79748 108.867 7.46093 109.515 6.78783C110.188 6.08885 110.524 5.22159 110.524 4.18606C110.524 3.17642 110.188 2.3221 109.515 1.62312C108.867 0.924138 108.013 0.574646 106.952 0.574646C105.942 0.574646 105.101 0.924138 104.428 1.62312C103.755 2.3221 103.418 3.17642 103.418 4.18606C103.418 5.22159 103.755 6.08885 104.428 6.78783Z\" />\n            <path\n                d=\"M90.2867 29V2.16681H97.0435V29H90.2867ZM86.0928 15.6417V10.011H101.237V15.6417H86.0928Z\" />\n            <path\n                d=\"M72.4414 29.3883C70.6033 29.3883 68.9853 28.9612 67.5873 28.1068C66.1893 27.2525 65.0891 26.0876 64.2866 24.6119C63.5099 23.1104 63.1216 21.4147 63.1216 19.5249C63.1216 17.6091 63.5099 15.9005 64.2866 14.399C65.0891 12.8975 66.1764 11.7325 67.5485 10.9041C68.9464 10.0498 70.5774 9.62262 72.4414 9.62262C73.6322 9.62262 74.7454 9.84267 75.781 10.2828C76.8165 10.697 77.6837 11.2924 78.3827 12.0691C79.0817 12.8457 79.4959 13.7259 79.6254 14.7097V23.9906C79.4959 24.9744 79.0817 25.8805 78.3827 26.7089C77.6837 27.5373 76.8165 28.1975 75.781 28.6893C74.7454 29.1553 73.6322 29.3883 72.4414 29.3883ZM73.6452 23.3693C74.3959 23.3693 75.0431 23.214 75.5868 22.9033C76.1304 22.5668 76.5576 22.1137 76.8683 21.5442C77.2048 20.9487 77.3731 20.2627 77.3731 19.4861C77.3731 18.7353 77.2177 18.0751 76.9071 17.5056C76.5964 16.9361 76.1563 16.483 75.5868 16.1465C75.0431 15.8099 74.4089 15.6416 73.684 15.6416C72.9591 15.6416 72.3119 15.8099 71.7424 16.1465C71.1987 16.483 70.7586 16.949 70.4221 17.5444C70.1114 18.114 69.9561 18.7612 69.9561 19.4861C69.9561 20.2368 70.1114 20.9099 70.4221 21.5053C70.7327 22.0749 71.1728 22.5279 71.7424 22.8645C72.3119 23.201 72.9462 23.3693 73.6452 23.3693ZM83.7416 29H77.1012V23.9129L78.0721 19.2531L76.9848 14.6708V0.691162H83.7416V29Z\" />\n            <path d=\"M53.5537 29V10.011H60.3494V29H53.5537Z\" />\n            <path d=\"M42.8608 29V0.691162H49.6177V29H42.8608Z\" />\n            <path\n                d=\"M29.6176 29.4272C27.5724 29.4272 25.7473 29 24.1423 28.1457C22.5631 27.2655 21.3075 26.0746 20.3755 24.5731C19.4435 23.0457 18.9775 21.3371 18.9775 19.4472C18.9775 17.5574 19.4306 15.8746 20.3367 14.399C21.2687 12.8975 22.5372 11.7196 24.1423 10.8653C25.7473 9.98505 27.5595 9.54495 29.5788 9.54495C31.5981 9.54495 33.3973 9.98505 34.9765 10.8653C36.5816 11.7196 37.8501 12.8975 38.7821 14.399C39.714 15.8746 40.18 17.5574 40.18 19.4472C40.18 21.3371 39.714 23.0457 38.7821 24.5731C37.876 26.0746 36.6204 27.2655 35.0153 28.1457C33.4361 29 31.6369 29.4272 29.6176 29.4272ZM29.5788 23.4081C30.3295 23.4081 30.9768 23.2528 31.5204 22.9421C32.09 22.6056 32.5301 22.1396 32.8407 21.5442C33.1514 20.9487 33.3067 20.2627 33.3067 19.4861C33.3067 18.7094 33.1384 18.0363 32.8019 17.4668C32.4912 16.8713 32.0641 16.4183 31.5204 16.1076C30.9768 15.7711 30.3295 15.6028 29.5788 15.6028C28.8539 15.6028 28.2067 15.7711 27.6372 16.1076C27.0676 16.4442 26.6275 16.9102 26.3169 17.5056C26.0062 18.0751 25.8509 18.7482 25.8509 19.5249C25.8509 20.2756 26.0062 20.9487 26.3169 21.5442C26.6275 22.1396 27.0676 22.6056 27.6372 22.9421C28.2067 23.2528 28.8539 23.4081 29.5788 23.4081Z\" />\n            <path\n                d=\"M9.20323 29.5437C8.03825 29.5437 6.88622 29.3883 5.74714 29.0777C4.63394 28.767 3.58547 28.3528 2.60172 27.835C1.64385 27.2914 0.828369 26.6701 0.155273 25.9711L3.84435 22.2043C4.46567 22.8515 5.20349 23.3564 6.0578 23.7188C6.938 24.0812 7.86998 24.2624 8.85373 24.2624C9.42328 24.2624 9.85043 24.1848 10.1352 24.0295C10.4459 23.8741 10.6012 23.6541 10.6012 23.3693C10.6012 22.9551 10.3811 22.6444 9.94104 22.4373C9.52683 22.2043 8.97023 22.0102 8.27125 21.8548C7.59815 21.6736 6.88623 21.4665 6.13547 21.2335C5.38471 20.9746 4.65983 20.6381 3.96085 20.2239C3.26187 19.8097 2.69232 19.2272 2.25222 18.4764C1.83801 17.7257 1.63091 16.7678 1.63091 15.6028C1.63091 14.3861 1.95451 13.3247 2.60172 12.4186C3.27481 11.4866 4.20679 10.7617 5.39765 10.2439C6.58851 9.70029 7.98648 9.42847 9.59155 9.42847C11.2225 9.42847 12.7758 9.71324 14.2514 10.2828C15.7271 10.8264 16.9179 11.6549 17.824 12.7681L14.0961 16.5348C13.4748 15.8358 12.7888 15.3569 12.038 15.098C11.2872 14.8132 10.6012 14.6708 9.97987 14.6708C9.38444 14.6708 8.95729 14.7615 8.6984 14.9427C8.43952 15.098 8.31008 15.318 8.31008 15.6028C8.31008 15.9394 8.51719 16.2112 8.9314 16.4183C9.3715 16.6254 9.9281 16.8196 10.6012 17.0008C11.3002 17.1561 12.0121 17.3632 12.737 17.6221C13.4877 17.881 14.1997 18.2434 14.8728 18.7094C15.5717 19.1495 16.1283 19.7449 16.5426 20.4957C16.9827 21.2465 17.2027 22.2173 17.2027 23.4081C17.2027 25.298 16.4778 26.7995 15.0281 27.9127C13.5783 29 11.6367 29.5437 9.20323 29.5437Z\" />\n        </svg>\n    </div>\n\n    <div\n        class=\"w-full sm:max-w-md mt-6 px-6 py-4 bg-card-background shadow-md border border-card-border overflow-hidden sm:rounded-lg\">\n\n        <!-- Introduction -->\n        <p class=\"text-center pb-4 text-text-primary\"><strong class=\"text-text-primary\">{{ $client->name }}</strong> is requesting permission\n            to access your\n            account.</p>\n\n        <!-- Scope List -->\n        @if (count($scopes) > 0)\n            <div class=\"pb-4\">\n                <p class=\"text-text-primary\"><strong>This application will be able to:</strong></p>\n\n                <ul class=\"list-disc pl-5 py-2 text-text-primary\">\n                    @foreach ($scopes as $scope)\n                        <li>{{ $scope->description }}</li>\n                    @endforeach\n                </ul>\n            </div>\n        @endif\n\n        <div class=\"flex flex-col sm:flex-row sm:space-x-5 space-x-0 space-y-3 sm:space-y-0\">\n            <!-- Authorize Button -->\n            <form method=\"post\" class=\"flex-1\" action=\"{{ route('passport.authorizations.approve') }}\">\n                @csrf\n\n                <input type=\"hidden\" name=\"state\" value=\"{{ $request->state }}\">\n                <input type=\"hidden\" name=\"client_id\" value=\"{{ $client->getKey() }}\">\n                <input type=\"hidden\" name=\"auth_token\" value=\"{{ $authToken }}\">\n                <button type=\"submit\"\n                        class=\"w-full text-center items-center px-2 sm:px-3 py-2 bg-accent-300/10 border border-accent-300/20 rounded-md font-semibold text-xs sm:text-sm text-text-primary hover:bg-accent-300/20 active:bg-accent-300/20 focus:outline-none focus:ring-2 focus:ring-accent-300 focus:ring-offset-2 transition ease-in-out duration-150\">\n                    Authorize\n                </button>\n            </form>\n\n            <!-- Cancel Button -->\n            <form method=\"post\" class=\"flex-1\" action=\"{{ route('passport.authorizations.deny') }}\">\n                @csrf\n                @method('DELETE')\n\n                <input type=\"hidden\" name=\"state\" value=\"{{ $request->state }}\">\n                <input type=\"hidden\" name=\"client_id\" value=\"{{ $client->getKey() }}\">\n                <input type=\"hidden\" name=\"auth_token\" value=\"{{ $authToken }}\">\n                <button\n                    class=\"w-full text-center text-xs sm:text-sm px-2 sm:px-3 py-2 bg-button-secondary-background border border-button-secondary-border hover:bg-button-secondary-background-hover shadow-sm transition text-text-primary rounded-lg font-medium items-center space-x-1.5 focus-visible:border-input-border-active focus:outline-none focus:ring-0 disabled:opacity-25 ease-in-out\">\n                    Cancel\n                </button>\n            </form>\n        </div>\n\n    </div>\n\n</div>\n</body>\n</html>\n"
  },
  {
    "path": "resources/views/emails/auth-api-expiration-reminder.blade.php",
    "content": "@component('mail::message')\n\n{{ __('The API token \":token\" expired.', ['token' => $tokenName]) }}\n\n\n{{ __('You can create a new API token in your profile:') }}\n\n@component('mail::button', ['url' => $profileUrl])\n    {{ __('Go to your profile') }}\n@endcomponent\n\n@endcomponent\n"
  },
  {
    "path": "resources/views/emails/auth-api-token-expired.blade.php",
    "content": "@component('mail::message')\n\n{{ __('The API token \":token\" will expire in 7 days!', ['token' => $tokenName]) }}\n\n{{ __('Please make sure to create a new API token and use the new one instead before it expires to avoid any disruptions in service.') }}\n\n{{ __('You can create a new API token in your profile:') }}\n\n@component('mail::button', ['url' => $profileUrl])\n    {{ __('Go to your profile') }}\n@endcomponent\n\n@endcomponent\n"
  },
  {
    "path": "resources/views/emails/organization-invitation.blade.php",
    "content": "@component('mail::message')\n{{ __('You have been invited to join the :organization organization!', ['organization' => $invitation->organization->name]) }}\n\n@if (Laravel\\Fortify\\Features::enabled(Laravel\\Fortify\\Features::registration()))\n{{ __('If you do not have an account, you may create one by clicking the button below. After creating an account, you may click the invitation acceptance button in this email to accept the team invitation:') }}\n\n@component('mail::button', ['url' => route('register')])\n{{ __('Create Account') }}\n@endcomponent\n\n{{ __('If you already have an account, you may accept this invitation by clicking the button below:') }}\n\n@else\n{{ __('You may accept this invitation by clicking the button below:') }}\n@endif\n\n\n@component('mail::button', ['url' => $acceptUrl])\n{{ __('Accept Invitation') }}\n@endcomponent\n\n{{ __('If you did not expect to receive an invitation to this organization, you may discard this email.') }}\n@endcomponent\n"
  },
  {
    "path": "resources/views/emails/time-entry-still-running.blade.php",
    "content": "@component('mail::message')\n@if(empty($timeEntry->description))\n{{ __('Your currently running time entry is now running for more than 8 hours!') }}\n@else\n{{ __('Your currently running time entry \":description\" is now running for more than 8 hours!', ['description' => $timeEntry->description]) }}\n@endif\n\n{{ __('If you forgot to stop the Time Tracker you do that in solidtime:') }}\n\n@component('mail::button', ['url' => $dashboardUrl])\n{{ __('Go to solidtime') }}\n@endcomponent\n\n@endcomponent\n"
  },
  {
    "path": "resources/views/filament/widgets/server-overview.blade.php",
    "content": "<x-filament-widgets::widget>\n    <x-filament::section>\n        <div>\n            <span class=\"text-gray-950 font-bold dark:text-white\">Version</span>\n            @if($version !== null)\n                <span>v{{ $version }}</span>\n            @else\n                <span>-</span>\n            @endif\n            <br>\n            <span class=\"text-gray-950 font-bold dark:text-white\">Build</span>\n            @if($build !== null)\n                <span>{{ $build }}</span>\n            @else\n                <span>-</span>\n            @endif\n\n        </div>\n\n        @if ($currentVersion !== null && $version !== null)\n        <div class=\"mt-4 inline-flex items-center justify-center gap-1\">\n            @if ($needsUpdate)\n                <span>\n                    <x-filament::icon\n                        icon=\"heroicon-o-exclamation-triangle\"\n                        class=\"h-5 w-5 text-orange-500 dark:text-orange-400\"\n                    />\n                </span>\n                <span>Update available (v{{ $currentVersion }})</span>\n            @else\n                <x-filament::icon\n                    icon=\"heroicon-o-check-circle\"\n                    class=\"h-5 w-5 text-green-500 dark:text-green-400\"\n                />\n                <span>Current version</span>\n            @endif\n        </div>\n        @endif\n    </x-filament::section>\n</x-filament-widgets::widget>\n"
  },
  {
    "path": "resources/views/reports/time-entry-aggregate/pdf-footer.blade.php",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <style>\n            .page-number {\n                position: absolute;\n                bottom: 0;\n                left: 0;\n                font-size: 12px;\n                margin-left: 46px;\n                margin-bottom: 40px;\n            }\n        </style>\n    </head>\n    <body>\n        <div class=\"page-number\">\n            Page <span class=\"pageNumber\"></span> of <span class=\"totalPages\"></span>\n        </div>\n    </body>\n</html>\n"
  },
  {
    "path": "resources/views/reports/time-entry-aggregate/pdf.blade.php",
    "content": "@use('Brick\\Math\\BigDecimal')\n@use('Brick\\Money\\Money')\n@use('Illuminate\\Support\\Carbon')\n@use('Carbon\\CarbonInterval')\n@inject('colorService', 'App\\Service\\ColorService')\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"utf-8\"/>\n    <title>Report</title>\n    <style>\n        html, body, div, span, applet, object, iframe,\n        h1, h2, h3, h4, h5, h6, p, blockquote, pre,\n        a, abbr, acronym, address, big, cite, code,\n        del, dfn, em, img, ins, kbd, q, s, samp,\n        small, strike, strong, sub, sup, tt, var,\n        b, u, i, center,\n        dl, dt, dd, ol, ul, li,\n        fieldset, form, label, legend,\n        table, caption, tbody, tfoot, thead, tr, th, td,\n        article, aside, canvas, details, embed,\n        figure, figcaption, footer, header, hgroup,\n        menu, nav, output, ruby, section, summary,\n        time, mark, audio, video {\n            margin: 0;\n            padding: 0;\n            border: 0;\n            font-size: 100%;\n            vertical-align: baseline;\n            box-sizing: border-box;\n        }\n\n\n        /* HTML5 display-role reset for older browsers */\n        article, aside, details, figcaption, figure,\n        footer, header, hgroup, menu, nav, section {\n            display: block;\n        }\n\n        body {\n            line-height: 1;\n        }\n\n        ol, ul {\n            list-style: none;\n        }\n\n        blockquote, q {\n            quotes: none;\n        }\n\n        blockquote:before, blockquote:after,\n        q:before, q:after {\n            content: '';\n            content: none;\n        }\n\n        table {\n            border-collapse: collapse;\n            border-spacing: 0;\n            text-align: left;\n        }\n\n        @font-face {\n            font-family: 'Outfit';\n            src: url('outfit.ttf');\n        }\n\n        body {\n            font-family: 'Outfit', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif;\n            color: #18181b\n        }\n\n        table {\n            font-size: 14px;\n        }\n\n        thead {\n            border-bottom: 1px #d4d4d8 solid;\n        }\n\n        tfoot {\n            border-top: 1px #d4d4d8 solid;\n        }\n\n        table th, table tfoot td {\n            font-weight: 500;\n            padding: 6px 12px;\n            color: #18181b;\n        }\n\n        .table-wrapper table th {\n            background-color: #fafafa;\n        }\n\n        .table-wrapper {\n            border: 1px solid #d4d4d8;\n            border-radius: 8px;\n            overflow: hidden;\n            width: calc(100% - 2px)\n        }\n\n        table tr {\n            border-bottom: 1px #e4e4e7 solid;\n        }\n\n        table tr:last-of-type {\n            border-bottom: none;\n        }\n\n        table tr td {\n            font-weight: 400;\n            color: #3f3f46;\n            padding: 6px 12px;\n        }\n\n        .data-table {\n            break-after: auto;\n        }\n\n        .no-break {\n            break-after: avoid-page;\n        }\n    </style>\n    <script>\n        window.status = \"processing\";\n    </script>\n    <script\n        src=\"{{ $debug ? 'https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js' : 'echarts.min.js' }}\"></script>\n\n    @if($debug)\n        <link rel=\"preconnect\" href=\"https://fonts.bunny.net\">\n        <link href=\"https://fonts.bunny.net/css?family=outfit:200,300,400,500,600,700,800\" rel=\"stylesheet\"/>\n    @endif\n\n</head>\n<body>\n<div>\n    <p style=\"font-size: 32px; font-weight: 600; margin-bottom: 5px;\">Report</p>\n    <div style=\"font-size: 16px; font-weight: 600; color: #71717a;\">\n        <span>{{ $localization->formatDate($start->timezone($timezone)) }} - {{ $localization->formatDate($end->timezone($timezone)) }}</span><br><br>\n    </div>\n\n</div>\n\n\n<div class=\"table-wrapper\">\n    <div\n        style=\"background-color: #fafafa; padding: 5px 14px; border-bottom: 1px #d4d4d8 solid; display: flex; gap: 20px;\">\n        <div style=\"padding: 8px 12px; border-radius: 8px;\">\n            <div style=\"color: #71717a; font-weight: 600;\">Duration</div>\n            <div\n                style=\"font-size: 24px; font-weight: 500; margin-top: 2px;\">{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div>\n        </div>\n        @if($showBillableRate)\n        <div style=\"padding: 8px 12px; border-radius: 8px;\">\n            <div style=\"color: #71717a; font-weight: 600;\">Total cost</div>\n            <div\n                style=\"font-size: 24px; font-weight: 500; margin-top: 2px;\">{{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)) }} </div>\n        </div>\n        @endif\n    </div>\n    <div id=\"main-chart\" style=\"width: 700px; height: 300px; margin: 20px auto;\"></div>\n\n</div>\n\n\n<div style=\"display: flex; align-items: center; padding-top: 40px;\">\n    <div style=\"padding: 10px 0;\">\n        <div id=\"pie-chart\" style=\"width: 300px; height: 180px; margin-bottom: 20px;\"></div>\n    </div>\n    <div style=\"flex: 1 1 0%;\">\n        <div class=\"\">\n            <table style=\"width: 100%; \">\n                <thead>\n                <tr>\n                    <th>\n                        {{ $group->description() }}\n                    </th>\n                    <th>Duration</th>\n                    @if($showBillableRate)\n                    <th style=\"text-align: right;\">Cost</th>\n                    @endif\n                </tr>\n                </thead>\n                @foreach($aggregatedData['grouped_data'] as $group1Entry)\n                    <tr>\n                        <td style=\"display: flex; align-items: center;\">\n                            <div style=\"width: 12px; height: 12px; border-radius: 50%; background-color: {{\n                        $group1Entry['color'] ?? ($group1Entry['key'] ? $colorService->getRandomColor($group1Entry['key']) : '#CCCCCC')\n }};\">\n                            </div>\n                            <span style=\"padding-left: 8px;\">\n                                @if($group->is(\\App\\Enums\\TimeEntryAggregationType::Billable))\n                                    {{ $group1Entry['key'] === '1' ? 'Billable' : 'Non-billable' }}\n                                @else\n                                    {{ $group1Entry['description'] ?? $group1Entry['key'] ?? 'No '.Str::lower($group->description()) }}\n                                @endif\n                            </span>\n                        </td>\n                        <td style=\"text-align: left;\">\n                            {{ $localization->formatInterval(CarbonInterval::seconds($group1Entry['seconds'])) }}\n                        </td>\n                        @if($showBillableRate)\n                        <td style=\"text-align: right;\">\n                            {{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($group1Entry['cost'], 2)->__toString(), $currency)) }}\n                        </td>\n                        @endif\n                    </tr>\n                @endforeach\n                <tfoot>\n                <tr>\n                    <td style=\"font-weight: 500;color: #18181b;\">\n                        Total\n                    </td>\n                    <td style=\"font-weight: 500;color: #18181b;\">\n                        {{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }}\n                    </td>\n                    @if($showBillableRate)\n                    <td style=\"text-align: right; font-weight: 500;color: #18181b;\">\n                        {{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)) }}\n                    </td>\n                    @endif\n                </tr>\n                </tfoot>\n            </table>\n        </div>\n\n    </div>\n</div>\n\n@foreach($aggregatedData['grouped_data'] as $group1Entry)\n    <div class=\"data-table\">\n        <h2 class=\"no-break\"\n            style=\"padding-top: 16px; padding-bottom: 8px; font-size: 16px; font-weight: 600; padding-left: 6px; color: #3f3f46;\">\n            @if($group->is(\\App\\Enums\\TimeEntryAggregationType::Billable))\n                {{ $group1Entry['key'] === '1' ? 'Billable' : 'Non-billable' }}\n            @else\n                <span style=\"color: #a1a1aa;\">\n                    {{ $group->description() }}:\n                    </span>\n                {{ $group1Entry['description'] ?? $group1Entry['key'] ?? 'No '.Str::lower($group->description()) }}\n            @endif\n        </h2>\n\n        <div class=\"table-wrapper\">\n            <table style=\"width: 100%;\">\n                <thead>\n                <tr>\n                    <th>\n                        {{ $subGroup->description() }}\n                    </th>\n                    <th>\n                        Duration\n                    </th>\n                    <th>\n                        Duration (h)\n                    </th>\n                    @if($showBillableRate)\n                    <th>\n                        Cost\n                    </th>\n                    @endif\n                </tr>\n                </thead>\n                <tbody>\n                @php\n                    $counter = 1;\n                    $totalDuration = 0;\n                    $totalCost = 0;\n                @endphp\n                @foreach($group1Entry['grouped_data'] as $group2Entry)\n                    @php\n                        $duration = CarbonInterval::seconds($group2Entry['seconds']);\n                    @endphp\n                    <tr>\n                        <td>\n                            @if($subGroup->is(\\App\\Enums\\TimeEntryAggregationType::Billable))\n                                {{ $group2Entry['key'] === '1' ? 'Billable' : 'Non-billable' }}\n                            @else\n                                {{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}\n                            @endif\n                        </td>\n                        <td>\n                            {{ $localization->formatInterval($duration) }}\n                        </td>\n                        <td>\n                            {{ $localization->formatNumber($duration->totalHours) }}\n                        </td>\n                        @if($showBillableRate)\n                        <td>\n                            {{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->__toString(), $currency)) }}\n                        </td>\n                        @endif\n                    </tr>\n                    @php\n                        $totalDuration += $group2Entry['seconds'];\n                        if ($showBillableRate) {\n                            $totalCost += $group2Entry['cost'];\n                        }\n                    @endphp\n                @endforeach\n                </tbody>\n            </table>\n        </div>\n\n    </div>\n@endforeach\n\n<script>\n    let elementPieChart = document.getElementById(\"pie-chart\");\n    let pieChart = echarts.init(elementPieChart, null, {\n        renderer: \"svg\"\n    });\n    let pieChartOptions = {\n        animation: false,\n        backgroundColor: \"transparent\",\n\n        series: [\n            {\n                data: {!! json_encode(collect($aggregatedData['grouped_data'])->map(function (array $data) use (&$colorService, $group): object {\n                    $color = $data['color'];\n                    if ($color === null) {\n                        $color = $colorService->getRandomColor($data['key']);\n                    }\n                    if ($data['key'] === null) {\n                       $color = '#CCCCCC';\n                    }\n                    return (object)[\n                        'value' => $data['seconds'],\n                        'name' => $data['description'] ?? $data['key'] ?? 'No '.Str::lower($group->description()),\n                        'color' => $color,\n                        'itemStyle' => (object) [\n                            'color' => $color,\n                        ],\n                        'emphasis' => (object) [\n                            'itemStyle' => (object) [\n                                'color' => $color,\n                            ],\n                        ],\n                    ];\n                })->toArray()) !!},\n                radius: [\"40%\", \"80%\"],\n                type: \"pie\",\n                label: {\n                    formatter: \"{d}%\",\n                    overflow: \"truncate\"\n                }\n            }\n        ]\n    };\n    pieChart.on(\"finished\", () => {\n        window.pieChartFinished = true;\n        if (window.mainChartFinished && window.pieChartFinished) {\n            window.status = \"ready\";\n        }\n    });\n    pieChart.setOption(pieChartOptions);\n\n    let elementMainChart = document.getElementById(\"main-chart\");\n    let mainChart = echarts.init(elementMainChart, null, {\n        renderer: \"svg\"\n    });\n    let mainChartOptions = {\n        animation: false,\n        tooltip: {},\n        xAxis: {\n            data: {!!\n                json_encode(collect($dataHistoryChart['grouped_data'])\n                    ->pluck('key')\n                    ->map(fn($value) => $localization->formatDate(Carbon::parse($value)))\n                    ->toArray())\n            !!},\n            axisLabel: {\n                fontSize: 10,\n                fontWeight: 400,\n                color: \"rgb(120, 120, 120)\",\n                margin: 16,\n                fontFamily: \"Outfit, sans-serif\"\n            },\n            axisTick: {\n                interval: 0,\n                alignWithLabel: true\n            }\n        },\n        grid: {\n            containLabel: true,\n            left: 15,\n            top: 15,\n            right: 15,\n            bottom: 0\n        },\n        yAxis: {\n            minInterval: 1,\n            axisLabel: {\n                show: false,\n                inside: true,\n            }\n        },\n        series: [\n            {\n                name: \"time\",\n                type: \"bar\",\n                data: {!! json_encode(collect($dataHistoryChart['grouped_data'])->map(fn($value) => (object) [\n                            'value' => $value['seconds'],\n                            'name' => ((int) $value['seconds']) === 0 ? '' : $localization->formatInterval(CarbonInterval::seconds((int) $value['seconds']))\n                        ])->toArray()) !!},\n                itemStyle: {\n                    borderColor: \"#7dd3fc\",\n                    color: \"#7dd3fc\"\n                },\n                label: {\n                    show: true,\n                    @if(count($dataHistoryChart['grouped_data']) > 15)\n                    rotate: 90,\n                    offset: [10, 5],\n                    @endif\n                    fontSize: 10,\n                    position: \"top\",\n                    formatter: function (params) {\n                        return params.name;\n                    }\n                }\n            }\n        ]\n    };\n    mainChart.on(\"finished\", () => {\n        window.mainChartFinished = true;\n        if (window.mainChartFinished && window.pieChartFinished) {\n            window.status = \"ready\";\n        }\n    });\n    mainChart.setOption(mainChartOptions);\n</script>\n</body>\n</html>\n"
  },
  {
    "path": "resources/views/reports/time-entry-aggregate/spreadsheet.blade.php",
    "content": "@use('App\\Enums\\ExportFormat')\n@use('Brick\\Math\\BigDecimal')\n@use('PhpOffice\\PhpSpreadsheet\\Cell\\DataType')\n@use('PhpOffice\\PhpSpreadsheet\\Style\\NumberFormat')\n@use('Carbon\\CarbonInterval')\n@use('App\\Enums\\TimeEntryAggregationType')\n@inject('interval', 'App\\Service\\IntervalService')\n<table>\n    <thead>\n    <tr>\n        <th style=\"border: 1px solid black; font-weight: bold;\" data-type=\"{{ DataType::TYPE_STRING }}\">\n            {{ $group->description() }}\n        </th>\n        <th style=\"border: 1px solid black; font-weight: bold;\" data-type=\"{{ DataType::TYPE_STRING }}\">\n            {{ $subGroup->description() }}\n        </th>\n        <th style=\"border: 1px solid black; font-weight: bold;\" data-type=\"{{ DataType::TYPE_STRING }}\">\n            Duration\n        </th>\n        <th style=\"border: 1px solid black; font-weight: bold;\" data-type=\"{{ DataType::TYPE_STRING }}\">\n            Duration (decimal)\n        </th>\n        <th style=\"border: 1px solid black; font-weight: bold;\" data-type=\"{{ DataType::TYPE_STRING }}\">\n            Amount ({{ Str::upper($currency) }})\n        </th>\n    </tr>\n    </thead>\n    <tbody>\n    @php\n        $counter = 1;\n        $totalDuration = 0;\n        $totalCost = 0;\n    @endphp\n    @foreach($data['grouped_data'] as $group1Entry)\n        @foreach($group1Entry['grouped_data'] as $group2Entry)\n            @php\n                $duration = CarbonInterval::seconds($group2Entry['seconds']);\n            @endphp\n            <tr>\n                @if($exportFormat === ExportFormat::ODS || $exportFormat === ExportFormat::CSV)\n                    @if ($group === TimeEntryAggregationType::Billable)\n                        <td style=\"border: 1px solid black;\" data-type=\"{{ DataType::TYPE_STRING }}\">\n                            {{ $group1Entry['key'] ? 'Yes' : 'No' }}\n                        </td>\n                    @else\n                        <td style=\"border: 1px solid black;\" data-type=\"{{ DataType::TYPE_STRING }}\">\n                            {{ $group1Entry['description'] ?? $group1Entry['key'] ?? '-' }}\n                        </td>\n                    @endif\n                    @if ($subGroup === TimeEntryAggregationType::Billable)\n                        <td style=\"border: 1px solid black;\" data-type=\"{{ DataType::TYPE_STRING }}\">\n                            {{ $group2Entry['key'] ? 'Yes' : 'No' }}\n                        </td>\n                    @else\n                        <td style=\"border: 1px solid black;\" data-type=\"{{ DataType::TYPE_STRING }}\">\n                            {{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}\n                        </td>\n                    @endif\n                    <td style=\"border: 1px solid black;\" data-type=\"{{ DataType::TYPE_STRING }}\">\n                        {{ $interval->format($duration) }}\n                    </td>\n                    <td style=\"border: 1px solid black;\" data-type=\"{{ DataType::TYPE_STRING }}\">\n                        {{ round($duration->totalHours, 2) }}\n                    </td>\n                    @if($showBillableRate)\n                    <td style=\"border: 1px solid black;\" data-type=\"{{ DataType::TYPE_STRING }}\">\n                        {{ round(BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->toFloat(), 2) }}\n                    </td>\n                    @endif\n                @else\n                    @if ($group === TimeEntryAggregationType::Billable)\n                        <td style=\"border: 1px solid black;\" data-type=\"{{ DataType::TYPE_STRING }}\">\n                            {{ $group1Entry['key'] ? 'Yes' : 'No' }}\n                        </td>\n                    @else\n                        <td style=\"border: 1px solid black;\" data-type=\"{{ DataType::TYPE_STRING }}\">\n                            {{ $group1Entry['description'] ?? $group1Entry['key'] ?? '-' }}\n                        </td>\n                    @endif\n                    @if ($subGroup === TimeEntryAggregationType::Billable)\n                        <td style=\"border: 1px solid black;\" data-type=\"{{ DataType::TYPE_STRING }}\">\n                            {{ $group2Entry['key'] ? 'Yes' : 'No' }}\n                        </td>\n                    @else\n                        <td style=\"border: 1px solid black;\" data-type=\"{{ DataType::TYPE_STRING }}\">\n                            {{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}\n                        </td>\n                    @endif\n                    <td style=\"border: 1px solid black;\" data-type=\"{{ DataType::TYPE_NUMERIC }}\"\n                        data-format=\"[hh]:mm:ss\">\n                        {{ $duration->totalDays }}\n                    </td>\n                    <td style=\"border: 1px solid black;\" data-type=\"{{ DataType::TYPE_NUMERIC }}\"\n                        data-format=\"{{ NumberFormat::FORMAT_NUMBER_00 }}\">\n                        {{ $duration->totalHours }}\n                    </td>\n                    @if($showBillableRate)\n                    <td style=\"border: 1px solid black;\" data-type=\"{{ DataType::TYPE_NUMERIC }}\"\n                        data-format=\"{{ NumberFormat::FORMAT_NUMBER_COMMA_SEPARATED1 }}\">\n                        {{ BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->__toString() }}\n                    </td>\n                    @endif\n                @endif\n            </tr>\n            @php\n                ++$counter;\n                $totalDuration += $group2Entry['seconds'];\n                if ($showBillableRate) {\n                    $totalCost += $group2Entry['cost'];\n                }\n            @endphp\n        @endforeach\n    @endforeach\n    @php\n        $totalDurationInterval = CarbonInterval::seconds($totalDuration);\n    @endphp\n    <tr style=\"border: 1px solid black;\">\n        <td style=\"border: 1px solid black; font-weight: bold;\" data-type=\"{{ DataType::TYPE_STRING }}\"></td>\n        <td style=\"border: 1px solid black; font-weight: bold;\" data-type=\"{{ DataType::TYPE_STRING }}\">\n            Total\n        </td>\n        @if($exportFormat === ExportFormat::ODS || $exportFormat === ExportFormat::CSV)\n            <td style=\"border: 1px solid black; font-weight: bold;\" data-type=\"{{ DataType::TYPE_STRING }}\">\n                {{ $interval->format($totalDurationInterval) }}\n            </td>\n            <td style=\"border: 1px solid black; font-weight: bold;\" data-type=\"{{ DataType::TYPE_STRING }}\">\n                {{ round($totalDurationInterval->totalHours, 2) }}\n            </td>\n            @if($showBillableRate)\n            <td style=\"border: 1px solid black; font-weight: bold;\" data-type=\"{{ DataType::TYPE_STRING }}\">\n                {{ round(BigDecimal::ofUnscaledValue($totalCost, 2)->toFloat(), 2) }}\n            </td>\n            @endif\n        @else\n            <td style=\"border: 1px solid black; font-weight: bold;\" data-type=\"{{ DataType::TYPE_FORMULA }}\"\n                data-format=\"[hh]:mm:ss\">\n                @if($counter > 1)\n                    =SUM(C2:C{{ $counter }})\n                @else\n                    =0\n                @endif\n            </td>\n            <td style=\"border: 1px solid black; font-weight: bold;\" data-type=\"{{ DataType::TYPE_FORMULA }}\"\n                data-format=\"{{ NumberFormat::FORMAT_NUMBER_00 }}\">\n                @if($counter > 1)\n                    =SUM(D2:D{{ $counter }})\n                @else\n                    =0\n                @endif\n            </td>\n            <td style=\"border: 1px solid black; font-weight: bold;\" data-type=\"{{ DataType::TYPE_FORMULA }}\"\n                data-format=\"{{ NumberFormat::FORMAT_NUMBER_COMMA_SEPARATED1 }}\">\n                @if($counter > 1)\n                    =SUM(E2:E{{ $counter }})\n                @else\n                    =0\n                @endif\n            </td>\n        @endif\n    </tr>\n    </tbody>\n</table>\n"
  },
  {
    "path": "resources/views/reports/time-entry-index/pdf-footer.blade.php",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <style>\n            .page-number {\n                position: absolute;\n                bottom: 0;\n                left: 0;\n                font-size: 12px;\n                margin-left: 46px;\n                margin-bottom: 40px;\n            }\n        </style>\n    </head>\n    <body>\n    <div class=\"page-number\">\n        Page <span class=\"pageNumber\"></span> of <span class=\"totalPages\"></span>\n    </div>\n    </body>\n</html>\n"
  },
  {
    "path": "resources/views/reports/time-entry-index/pdf.blade.php",
    "content": "@use('Brick\\Math\\BigDecimal')\n@use('Brick\\Money\\Money')\n@use('Carbon\\CarbonInterval')\n@inject('interval', 'App\\Service\\IntervalService')\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"utf-8\" />\n    <title>Report</title>\n    <style>\n\n        html, body, div, span, applet, object, iframe,\n        h1, h2, h3, h4, h5, h6, p, blockquote, pre,\n        a, abbr, acronym, address, big, cite, code,\n        del, dfn, em, img, ins, kbd, q, s, samp,\n        small, strike, strong, sub, sup, tt, var,\n        b, u, i, center,\n        dl, dt, dd, ol, ul, li,\n        fieldset, form, label, legend,\n        table, caption, tbody, tfoot, thead, tr, th, td,\n        article, aside, canvas, details, embed,\n        figure, figcaption, footer, header, hgroup,\n        menu, nav, output, ruby, section, summary,\n        time, mark, audio, video {\n            margin: 0;\n            padding: 0;\n            border: 0;\n            font-size: 100%;\n            vertical-align: baseline;\n            box-sizing: border-box;\n        }\n\n\n        /* HTML5 display-role reset for older browsers */\n        article, aside, details, figcaption, figure,\n        footer, header, hgroup, menu, nav, section {\n            display: block;\n        }\n\n        body {\n            line-height: 1;\n        }\n\n        ol, ul {\n            list-style: none;\n        }\n\n        blockquote, q {\n            quotes: none;\n        }\n\n        blockquote:before, blockquote:after,\n        q:before, q:after {\n            content: '';\n            content: none;\n        }\n\n        @font-face {\n            font-family: 'Outfit';\n            src: url('outfit.ttf');\n        }\n\n        body {\n            font-family: 'Outfit', 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif;\n            color: #18181b\n        }\n\n        table {\n            font-size: 10px;\n        }\n\n        table thead {\n            background-color: #eee;\n        }\n\n\n        .table-wrapper table th {\n            background-color: #fafafa;\n        }\n\n        .table-wrapper {\n            border: 1px solid #d4d4d8;\n            border-radius: 8px;\n            overflow: hidden;\n            width: calc(100% - 2px)\n        }\n\n        table {\n            border-collapse: collapse;\n            border-spacing: 0;\n            text-align: left;\n        }\n\n        thead {\n            border-bottom: 1px #d4d4d8 solid;\n        }\n\n        tfoot {\n            border-top: 1px #d4d4d8 solid;\n        }\n\n        table th, table tfoot td {\n            font-weight: 500;\n            padding: 6px 12px;\n            color: #18181b;\n        }\n\n        table td, table th {\n            font-size: 12px;\n        }\n\n        table tr {\n            border-bottom: 1px #e4e4e7 solid;\n        }\n\n        table tr:last-of-type {\n            border-bottom: none;\n        }\n\n        table tr td {\n            font-weight: 400;\n            color: #3f3f46;\n            padding: 6px 12px;\n        }\n\n    </style>\n</head>\n<body>\n<div>\n    <p style=\"font-size: 32px; font-weight: 600; margin-bottom: 5px;\">Detailed Report</p>\n    <div style=\"font-size: 16px; font-weight: 600; color: #71717a;\">\n        <span>{{ $localization->formatDate($start->timezone($timezone)) }} - {{ $localization->formatDate($end->timezone($timezone)) }}</span><br><br>\n    </div>\n</div>\n<div class=\"table-wrapper\">\n    <div\n        style=\"background-color: #fafafa; padding: 5px 14px; display: flex; gap: 20px;\">\n        <div style=\"padding: 8px 12px; border-radius: 8px;\">\n            <div style=\"color: #71717a; font-weight: 600;\">Duration</div>\n            <div\n                style=\"font-size: 24px; font-weight: 500; margin-top: 2px;\">{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div>\n        </div>\n        @if($showBillableRate)\n        <div style=\"padding: 8px 12px; border-radius: 8px;\">\n            <div style=\"color: #71717a; font-weight: 600;\">Total cost</div>\n            <div style=\"font-size: 24px; font-weight: 500; margin-top: 2px;\">\n                {{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)) }}\n            </div>\n        </div>\n        @endif\n    </div>\n    <div>\n        <table style=\"width: 100%;\">\n            <thead>\n            <tr style=\"border-top: 1px #d4d4d8 solid;\">\n                <th>Time Entry</th>\n                <th>User</th>\n                <th style=\"text-align: center;\">Time</th>\n                <th>Duration</th>\n                <th>Billable</th>\n                <th>Tags</th>\n            </tr>\n            </thead>\n            <tbody>\n            @foreach($timeEntries as $timeEntry)\n                <tr>\n                    <td style=\"overflow-wrap: break-word; max-width: 250px;\">\n                        {{ $timeEntry->description === '' ? '-' : $timeEntry->description }} <br>\n                        @if($timeEntry->task?->name)\n                            <span style=\"font-weight: 600;\">Task:</span> {{ $timeEntry->task?->name ?? '-' }} <br>\n                        @endif\n                        @if($timeEntry->project?->name)\n                            <span style=\"font-weight: 600;\">Project:</span> {{ $timeEntry->project?->name }} <br>\n                        @endif\n                        @if($timeEntry->client?->name)\n                            <span style=\"font-weight: 600;\">\n                                    Client:\n                                </span>{{ $timeEntry->client?->name }} <br>\n                        @endif\n                    </td>\n                    <td style=\"overflow-wrap: break-word; min-width: 75px;\">{{ $timeEntry->user->name }}</td>\n                    <td style=\"overflow-wrap: break-word; min-width: 150px; text-align: center;\">\n                        @if($timeEntry->start->timezone($timezone)->format('Y-m-d') === $timeEntry->end->timezone($timezone)->format('Y-m-d'))\n                            {{ $localization->formatDate($timeEntry->start->timezone($timezone)) }}\n                        @else\n                            {{ $localization->formatDate($timeEntry->start->timezone($timezone)) }} - <br> {{ $localization->formatDate($timeEntry->end->timezone($timezone)) }}\n                        @endif\n                        <br>\n                        {{ $localization->formatTime($timeEntry->start->timezone($timezone)) }} - {{ $localization->formatTime($timeEntry->end->timezone($timezone)) }}\n                    </td>\n                    <td style=\"overflow-wrap: break-word; min-width: 75px;\">\n                        {{ $localization->formatInterval($timeEntry->getDuration()) }}\n                    </td>\n                    <td style=\"overflow-wrap: break-word;\">{{ $timeEntry->billable ? 'Yes' : 'No' }}</td>\n                    <td style=\"overflow-wrap: break-word; min-width: 75px;\">{{ count($timeEntry->tagsRelation) === 0 ? '-' : $timeEntry->tagsRelation->implode('name', ', ') }}</td>\n                </tr>\n            @endforeach\n            </tbody>\n        </table>\n    </div>\n</div>\n\n\n</body>\n</html>\n"
  },
  {
    "path": "resources/views/vendor/mail/html/button.blade.php",
    "content": "@props([\n    'url',\n    'color' => 'primary',\n    'align' => 'center',\n])\n<table class=\"action\" align=\"{{ $align }}\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\">\n<tr>\n<td align=\"{{ $align }}\">\n<table width=\"100%\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\">\n<tr>\n<td align=\"{{ $align }}\">\n<table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\">\n<tr>\n<td>\n<a href=\"{{ $url }}\" class=\"button button-{{ $color }}\" target=\"_blank\" rel=\"noopener\">{{ $slot }}</a>\n</td>\n</tr>\n</table>\n</td>\n</tr>\n</table>\n</td>\n</tr>\n</table>\n"
  },
  {
    "path": "resources/views/vendor/mail/html/footer.blade.php",
    "content": "<tr>\n<td>\n<table class=\"footer\" align=\"center\" width=\"570\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\">\n<tr>\n<td class=\"content-cell\" align=\"center\">\n{{ Illuminate\\Mail\\Markdown::parse($slot) }}\n</td>\n</tr>\n</table>\n</td>\n</tr>\n"
  },
  {
    "path": "resources/views/vendor/mail/html/header.blade.php",
    "content": "@props(['url'])\n<tr>\n<td class=\"header\">\n<a href=\"{{ $url }}\" style=\"display: inline-block;\">\n@if(trim($slot) === 'solidtime')\n<img src=\"{{ asset('images/solidtime-logo.png') }}\" srcset=\"{{ asset('images/solidtime-logo.svg') }}\" class=\"logo\" alt=\"solidtime Logo\">\n@else\n{{ $slot }}\n@endif\n</a>\n</td>\n</tr>\n"
  },
  {
    "path": "resources/views/vendor/mail/html/layout.blade.php",
    "content": "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">\n<html xmlns=\"http://www.w3.org/1999/xhtml\">\n<head>\n<title>{{ config('app.name') }}</title>\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n<meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\" />\n<meta name=\"color-scheme\" content=\"light\">\n<meta name=\"supported-color-schemes\" content=\"light\">\n<style>\n@media only screen and (max-width: 600px) {\n.inner-body {\nwidth: 100% !important;\n}\n\n.footer {\nwidth: 100% !important;\n}\n}\n\n@media only screen and (max-width: 500px) {\n.button {\nwidth: 100% !important;\n}\n}\n</style>\n</head>\n<body>\n\n<table class=\"wrapper\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\">\n<tr>\n<td align=\"center\">\n<table class=\"content\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\">\n{{ $header ?? '' }}\n\n<!-- Email Body -->\n<tr>\n<td class=\"body\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"border: hidden !important;\">\n<table class=\"inner-body\" align=\"center\" width=\"570\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\">\n<!-- Body content -->\n<tr>\n<td class=\"content-cell\">\n{{ Illuminate\\Mail\\Markdown::parse($slot) }}\n\n{{ $subcopy ?? '' }}\n</td>\n</tr>\n</table>\n</td>\n</tr>\n\n{{ $footer ?? '' }}\n</table>\n</td>\n</tr>\n</table>\n</body>\n</html>\n"
  },
  {
    "path": "resources/views/vendor/mail/html/message.blade.php",
    "content": "<x-mail::layout>\n{{-- Header --}}\n<x-slot:header>\n<x-mail::header :url=\"config('app.url')\">\n{{ config('app.name') }}\n</x-mail::header>\n</x-slot:header>\n\n{{-- Body --}}\n{{ $slot }}\n\n{{-- Subcopy --}}\n@isset($subcopy)\n<x-slot:subcopy>\n<x-mail::subcopy>\n{{ $subcopy }}\n</x-mail::subcopy>\n</x-slot:subcopy>\n@endisset\n\n{{-- Footer --}}\n<x-slot:footer>\n<x-mail::footer>\n© {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }}\n</x-mail::footer>\n</x-slot:footer>\n</x-mail::layout>\n"
  },
  {
    "path": "resources/views/vendor/mail/html/panel.blade.php",
    "content": "<table class=\"panel\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\">\n<tr>\n<td class=\"panel-content\">\n<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\">\n<tr>\n<td class=\"panel-item\">\n{{ Illuminate\\Mail\\Markdown::parse($slot) }}\n</td>\n</tr>\n</table>\n</td>\n</tr>\n</table>\n\n"
  },
  {
    "path": "resources/views/vendor/mail/html/subcopy.blade.php",
    "content": "<table class=\"subcopy\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\">\n<tr>\n<td>\n{{ Illuminate\\Mail\\Markdown::parse($slot) }}\n</td>\n</tr>\n</table>\n"
  },
  {
    "path": "resources/views/vendor/mail/html/table.blade.php",
    "content": "<div class=\"table\">\n{{ Illuminate\\Mail\\Markdown::parse($slot) }}\n</div>\n"
  },
  {
    "path": "resources/views/vendor/mail/html/themes/default.css",
    "content": "/* Base */\n\nbody,\nbody *:not(html):not(style):not(br):not(tr):not(code) {\n    box-sizing: border-box;\n    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif,\n        'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';\n    position: relative;\n}\n\nbody {\n    -webkit-text-size-adjust: none;\n    background-color: #ffffff;\n    color: #718096;\n    height: 100%;\n    line-height: 1.4;\n    margin: 0;\n    padding: 0;\n    width: 100% !important;\n}\n\np,\nul,\nol,\nblockquote {\n    line-height: 1.4;\n    text-align: left;\n}\n\na {\n    color: #3869d4;\n}\n\na img {\n    border: none;\n}\n\n/* Typography */\n\nh1 {\n    color: #3d4852;\n    font-size: 18px;\n    font-weight: bold;\n    margin-top: 0;\n    text-align: left;\n}\n\nh2 {\n    font-size: 16px;\n    font-weight: bold;\n    margin-top: 0;\n    text-align: left;\n}\n\nh3 {\n    font-size: 14px;\n    font-weight: bold;\n    margin-top: 0;\n    text-align: left;\n}\n\np {\n    font-size: 16px;\n    line-height: 1.5em;\n    margin-top: 0;\n    text-align: left;\n}\n\np.sub {\n    font-size: 12px;\n}\n\nimg {\n    max-width: 100%;\n}\n\n/* Layout */\n\n.wrapper {\n    -premailer-cellpadding: 0;\n    -premailer-cellspacing: 0;\n    -premailer-width: 100%;\n    background-color: #edf2f7;\n    margin: 0;\n    padding: 0;\n    width: 100%;\n}\n\n.content {\n    -premailer-cellpadding: 0;\n    -premailer-cellspacing: 0;\n    -premailer-width: 100%;\n    margin: 0;\n    padding: 0;\n    width: 100%;\n}\n\n/* Header */\n\n.header {\n    padding: 25px 0;\n    text-align: center;\n}\n\n.header a {\n    color: #3d4852;\n    font-size: 19px;\n    font-weight: bold;\n    text-decoration: none;\n}\n\n/* Logo */\n\n.logo {\n    height: 40px;\n    max-height: 40px;\n    width: 160px;\n}\n\n/* Body */\n\n.body {\n    -premailer-cellpadding: 0;\n    -premailer-cellspacing: 0;\n    -premailer-width: 100%;\n    background-color: #edf2f7;\n    border-bottom: 1px solid #edf2f7;\n    border-top: 1px solid #edf2f7;\n    margin: 0;\n    padding: 0;\n    width: 100%;\n}\n\n.inner-body {\n    -premailer-cellpadding: 0;\n    -premailer-cellspacing: 0;\n    -premailer-width: 570px;\n    background-color: #ffffff;\n    border-color: #e8e5ef;\n    border-radius: 2px;\n    border-width: 1px;\n    box-shadow: 0 2px 0 rgba(0, 0, 150, 0.025), 2px 4px 0 rgba(0, 0, 150, 0.015);\n    margin: 0 auto;\n    padding: 0;\n    width: 570px;\n}\n\n/* Subcopy */\n\n.subcopy {\n    border-top: 1px solid #e8e5ef;\n    margin-top: 25px;\n    padding-top: 25px;\n}\n\n.subcopy p {\n    font-size: 14px;\n}\n\n/* Footer */\n\n.footer {\n    -premailer-cellpadding: 0;\n    -premailer-cellspacing: 0;\n    -premailer-width: 570px;\n    margin: 0 auto;\n    padding: 0;\n    text-align: center;\n    width: 570px;\n}\n\n.footer p {\n    color: #b0adc5;\n    font-size: 12px;\n    text-align: center;\n}\n\n.footer a {\n    color: #b0adc5;\n    text-decoration: underline;\n}\n\n/* Tables */\n\n.table table {\n    -premailer-cellpadding: 0;\n    -premailer-cellspacing: 0;\n    -premailer-width: 100%;\n    margin: 30px auto;\n    width: 100%;\n}\n\n.table th {\n    border-bottom: 1px solid #edeff2;\n    margin: 0;\n    padding-bottom: 8px;\n}\n\n.table td {\n    color: #74787e;\n    font-size: 15px;\n    line-height: 18px;\n    margin: 0;\n    padding: 10px 0;\n}\n\n.content-cell {\n    max-width: 100vw;\n    padding: 32px;\n}\n\n/* Buttons */\n\n.action {\n    -premailer-cellpadding: 0;\n    -premailer-cellspacing: 0;\n    -premailer-width: 100%;\n    margin: 30px auto;\n    padding: 0;\n    text-align: center;\n    width: 100%;\n}\n\n.button {\n    -webkit-text-size-adjust: none;\n    border-radius: 4px;\n    color: #fff;\n    display: inline-block;\n    overflow: hidden;\n    text-decoration: none;\n}\n\n.button-blue,\n.button-primary {\n    background-color: #2d3748;\n    border-bottom: 8px solid #2d3748;\n    border-left: 18px solid #2d3748;\n    border-right: 18px solid #2d3748;\n    border-top: 8px solid #2d3748;\n}\n\n.button-green,\n.button-success {\n    background-color: #48bb78;\n    border-bottom: 8px solid #48bb78;\n    border-left: 18px solid #48bb78;\n    border-right: 18px solid #48bb78;\n    border-top: 8px solid #48bb78;\n}\n\n.button-red,\n.button-error {\n    background-color: #e53e3e;\n    border-bottom: 8px solid #e53e3e;\n    border-left: 18px solid #e53e3e;\n    border-right: 18px solid #e53e3e;\n    border-top: 8px solid #e53e3e;\n}\n\n/* Panels */\n\n.panel {\n    border-left: #2d3748 solid 4px;\n    margin: 21px 0;\n}\n\n.panel-content {\n    background-color: #edf2f7;\n    color: #718096;\n    padding: 16px;\n}\n\n.panel-content p {\n    color: #718096;\n}\n\n.panel-item {\n    padding: 0;\n}\n\n.panel-item p:last-of-type {\n    margin-bottom: 0;\n    padding-bottom: 0;\n}\n\n/* Utilities */\n\n.break-all {\n    word-break: break-all;\n}\n"
  },
  {
    "path": "resources/views/vendor/mail/text/button.blade.php",
    "content": "{{ $slot }}: {{ $url }}\n"
  },
  {
    "path": "resources/views/vendor/mail/text/footer.blade.php",
    "content": "{{ $slot }}\n"
  },
  {
    "path": "resources/views/vendor/mail/text/header.blade.php",
    "content": "{{ $slot }}: {{ $url }}\n"
  },
  {
    "path": "resources/views/vendor/mail/text/layout.blade.php",
    "content": "{!! strip_tags($header ?? '') !!}\n\n{!! strip_tags($slot) !!}\n@isset($subcopy)\n\n{!! strip_tags($subcopy) !!}\n@endisset\n\n{!! strip_tags($footer ?? '') !!}\n"
  },
  {
    "path": "resources/views/vendor/mail/text/message.blade.php",
    "content": "<x-mail::layout>\n    {{-- Header --}}\n    <x-slot:header>\n        <x-mail::header :url=\"config('app.url')\">\n            {{ config('app.name') }}\n        </x-mail::header>\n    </x-slot:header>\n\n    {{-- Body --}}\n    {{ $slot }}\n\n    {{-- Subcopy --}}\n    @isset($subcopy)\n        <x-slot:subcopy>\n            <x-mail::subcopy>\n                {{ $subcopy }}\n            </x-mail::subcopy>\n        </x-slot:subcopy>\n    @endisset\n\n    {{-- Footer --}}\n    <x-slot:footer>\n        <x-mail::footer>\n            © {{ date('Y') }} {{ config('app.name') }}. @lang('All rights reserved.')\n        </x-mail::footer>\n    </x-slot:footer>\n</x-mail::layout>\n"
  },
  {
    "path": "resources/views/vendor/mail/text/panel.blade.php",
    "content": "{{ $slot }}\n"
  },
  {
    "path": "resources/views/vendor/mail/text/subcopy.blade.php",
    "content": "{{ $slot }}\n"
  },
  {
    "path": "resources/views/vendor/mail/text/table.blade.php",
    "content": "{{ $slot }}\n"
  },
  {
    "path": "routes/api.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse App\\Http\\Controllers\\Api\\V1\\ApiTokenController;\nuse App\\Http\\Controllers\\Api\\V1\\ChartController;\nuse App\\Http\\Controllers\\Api\\V1\\ClientController;\nuse App\\Http\\Controllers\\Api\\V1\\CurrencyController;\nuse App\\Http\\Controllers\\Api\\V1\\ExportController;\nuse App\\Http\\Controllers\\Api\\V1\\ImportController;\nuse App\\Http\\Controllers\\Api\\V1\\InvitationController;\nuse App\\Http\\Controllers\\Api\\V1\\MemberController;\nuse App\\Http\\Controllers\\Api\\V1\\OrganizationController;\nuse App\\Http\\Controllers\\Api\\V1\\ProjectController;\nuse App\\Http\\Controllers\\Api\\V1\\ProjectMemberController;\nuse App\\Http\\Controllers\\Api\\V1\\Public\\ReportController as PublicReportController;\nuse App\\Http\\Controllers\\Api\\V1\\ReportController;\nuse App\\Http\\Controllers\\Api\\V1\\TagController;\nuse App\\Http\\Controllers\\Api\\V1\\TaskController;\nuse App\\Http\\Controllers\\Api\\V1\\TimeEntryController;\nuse App\\Http\\Controllers\\Api\\V1\\UserController;\nuse App\\Http\\Controllers\\Api\\V1\\UserMembershipController;\nuse App\\Http\\Controllers\\Api\\V1\\UserTimeEntryController;\nuse Illuminate\\Support\\Facades\\Route;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\n\n/*\n|--------------------------------------------------------------------------\n| API Routes\n|--------------------------------------------------------------------------\n|\n| Here is where you can register API routes for your application. These\n| routes are loaded by the RouteServiceProvider and all of them will\n| be assigned to the \"api\" middleware group. Make something great!\n|\n*/\n\nRoute::prefix('v1')->name('v1.')->group(static function (): void {\n    Route::middleware([\n        'auth:api',\n        'verified',\n    ])->group(static function (): void {\n        // Organization routes\n        Route::name('organizations.')->group(static function (): void {\n            Route::get('/organizations/{organization}', [OrganizationController::class, 'show'])->name('show');\n            Route::put('/organizations/{organization}', [OrganizationController::class, 'update'])->name('update');\n        });\n\n        // Member routes\n        Route::name('members.')->prefix('/organizations/{organization}')->group(static function (): void {\n            Route::get('/members', [MemberController::class, 'index'])->name('index');\n            Route::put('/members/{member}', [MemberController::class, 'update'])->name('update');\n            Route::delete('/members/{member}', [MemberController::class, 'destroy'])->name('destroy');\n            Route::post('/members/{member}/invite-placeholder', [MemberController::class, 'invitePlaceholder'])->name('invite-placeholder');\n            Route::post('/members/{member}/make-placeholder', [MemberController::class, 'makePlaceholder'])->name('make-placeholder');\n            Route::post('member/{member}/merge-into', [MemberController::class, 'mergeInto'])->name('merge-into');\n        });\n\n        // User routes\n        Route::name('users.')->group(static function (): void {\n            Route::get('/users/me', [UserController::class, 'me'])->name('me');\n        });\n\n        // Api token routes\n        Route::name('api-tokens.')->group(static function (): void {\n            Route::get('/users/me/api-tokens', [ApiTokenController::class, 'index'])->name('index');\n            Route::post('/users/me/api-tokens', [ApiTokenController::class, 'store'])->name('store');\n            Route::post('/users/me/api-tokens/{apiToken}/revoke', [ApiTokenController::class, 'revoke'])->name('revoke');\n            Route::delete('/users/me/api-tokens/{apiToken}', [ApiTokenController::class, 'destroy'])->name('destroy');\n        });\n\n        // User Member routes\n        Route::name('users.memberships.')->group(static function (): void {\n            Route::get('/users/me/memberships', [UserMembershipController::class, 'myMemberships'])->name('my-memberships');\n        });\n\n        // Invitation routes\n        Route::name('invitations.')->prefix('/organizations/{organization}')->group(static function (): void {\n            Route::get('/invitations', [InvitationController::class, 'index'])->name('index');\n            Route::post('/invitations', [InvitationController::class, 'store'])->name('store')->middleware('check-organization-blocked');\n            Route::post('/invitations/{invitation}/resend', [InvitationController::class, 'resend'])->name('resend')->middleware('check-organization-blocked');\n            Route::delete('/invitations/{invitation}', [InvitationController::class, 'destroy'])->name('destroy')->middleware('check-organization-blocked');\n        });\n\n        // Project routes\n        Route::name('projects.')->prefix('/organizations/{organization}')->group(static function (): void {\n            Route::get('/projects', [ProjectController::class, 'index'])->name('index');\n            Route::get('/projects/{project}', [ProjectController::class, 'show'])->name('show');\n            Route::post('/projects', [ProjectController::class, 'store'])->name('store')->middleware('check-organization-blocked');\n            Route::put('/projects/{project}', [ProjectController::class, 'update'])->name('update')->middleware('check-organization-blocked');\n            Route::delete('/projects/{project}', [ProjectController::class, 'destroy'])->name('destroy');\n        });\n\n        // Project member routes\n        Route::name('project-members.')->prefix('/organizations/{organization}')->group(static function (): void {\n            Route::get('/projects/{project}/project-members', [ProjectMemberController::class, 'index'])->name('index');\n            Route::post('/projects/{project}/project-members', [ProjectMemberController::class, 'store'])->name('store')->middleware('check-organization-blocked');\n            Route::put('/project-members/{projectMember}', [ProjectMemberController::class, 'update'])->name('update')->middleware('check-organization-blocked');\n            Route::delete('/project-members/{projectMember}', [ProjectMemberController::class, 'destroy'])->name('destroy');\n        });\n\n        // Time entry routes\n        Route::name('time-entries.')->prefix('/organizations/{organization}')->group(static function (): void {\n            Route::get('/time-entries', [TimeEntryController::class, 'index'])->name('index');\n            Route::get('/time-entries/export', [TimeEntryController::class, 'indexExport'])->name('index-export');\n            Route::get('/time-entries/aggregate', [TimeEntryController::class, 'aggregate'])->name('aggregate');\n            Route::get('/time-entries/aggregate/export', [TimeEntryController::class, 'aggregateExport'])->name('aggregate-export');\n            Route::post('/time-entries', [TimeEntryController::class, 'store'])->name('store')->middleware('check-organization-blocked');\n            Route::put('/time-entries/{timeEntry}', [TimeEntryController::class, 'update'])->name('update')->middleware('check-organization-blocked');\n            Route::patch('/time-entries', [TimeEntryController::class, 'updateMultiple'])->name('update-multiple')->middleware('check-organization-blocked');\n            Route::delete('/time-entries/{timeEntry}', [TimeEntryController::class, 'destroy'])->name('destroy');\n            Route::delete('/time-entries', [TimeEntryController::class, 'destroyMultiple'])->name('destroy-multiple');\n        });\n\n        Route::name('users.time-entries.')->group(static function (): void {\n            Route::get('/users/me/time-entries/active', [UserTimeEntryController::class, 'myActive'])->name('my-active');\n            Route::get('/users/me/time-entries', [UserTimeEntryController::class, 'my'])->name('my'); // TODO\n        });\n\n        // Report routes\n        Route::name('reports.')->prefix('/organizations/{organization}')->group(static function (): void {\n            Route::get('/reports', [ReportController::class, 'index'])->name('index');\n            Route::get('/reports/{report}', [ReportController::class, 'show'])->name('show');\n            Route::post('/reports', [ReportController::class, 'store'])->name('store');\n            Route::put('/reports/{report}', [ReportController::class, 'update'])->name('update');\n            Route::delete('/reports/{report}', [ReportController::class, 'destroy'])->name('destroy');\n        });\n\n        // Chart routes\n        Route::name('charts.')->prefix('/organizations/{organization}/charts')->group(static function (): void {\n            Route::get('/weekly-project-overview', [ChartController::class, 'weeklyProjectOverview'])->name('weekly-project-overview');\n            Route::get('/latest-tasks', [ChartController::class, 'latestTasks'])->name('latest-tasks');\n            Route::get('/last-seven-days', [ChartController::class, 'lastSevenDays'])->name('last-seven-days');\n            Route::get('/latest-team-activity', [ChartController::class, 'latestTeamActivity'])->name('latest-team-activity');\n            Route::get('/daily-tracked-hours', [ChartController::class, 'dailyTrackedHours'])->name('daily-tracked-hours');\n            Route::get('/total-weekly-time', [ChartController::class, 'totalWeeklyTime'])->name('total-weekly-time');\n            Route::get('/total-weekly-billable-time', [ChartController::class, 'totalWeeklyBillableTime'])->name('total-weekly-billable-time');\n            Route::get('/total-weekly-billable-amount', [ChartController::class, 'totalWeeklyBillableAmount'])->name('total-weekly-billable-amount');\n            Route::get('/weekly-history', [ChartController::class, 'weeklyHistory'])->name('weekly-history');\n        });\n\n        // Tag routes\n        Route::name('tags.')->prefix('/organizations/{organization}')->group(static function (): void {\n            Route::get('/tags', [TagController::class, 'index'])->name('index');\n            Route::post('/tags', [TagController::class, 'store'])->name('store')->middleware('check-organization-blocked');\n            Route::put('/tags/{tag}', [TagController::class, 'update'])->name('update')->middleware('check-organization-blocked');\n            Route::delete('/tags/{tag}', [TagController::class, 'destroy'])->name('destroy');\n        });\n\n        // Client routes\n        Route::name('clients.')->prefix('/organizations/{organization}')->group(static function (): void {\n            Route::get('/clients', [ClientController::class, 'index'])->name('index');\n            Route::post('/clients', [ClientController::class, 'store'])->name('store')->middleware('check-organization-blocked');\n            Route::put('/clients/{client}', [ClientController::class, 'update'])->name('update')->middleware('check-organization-blocked');\n            Route::delete('/clients/{client}', [ClientController::class, 'destroy'])->name('destroy');\n        });\n\n        // Task routes\n        Route::name('tasks.')->prefix('/organizations/{organization}')->group(static function (): void {\n            Route::get('/tasks', [TaskController::class, 'index'])->name('index');\n            Route::post('/tasks', [TaskController::class, 'store'])->name('store')->middleware('check-organization-blocked');\n            Route::put('/tasks/{task}', [TaskController::class, 'update'])->name('update')->middleware('check-organization-blocked');\n            Route::delete('/tasks/{task}', [TaskController::class, 'destroy'])->name('destroy');\n        });\n\n        // Import routes\n        Route::name('import.')->prefix('/organizations/{organization}')->group(static function (): void {\n            Route::get('/importers', [ImportController::class, 'index'])->name('index');\n            Route::post('/import', [ImportController::class, 'import'])->name('import')->middleware('check-organization-blocked');\n        });\n\n        // Export routes\n        Route::name('export.')->prefix('/organizations/{organization}')->group(static function (): void {\n            Route::post('/export', [ExportController::class, 'export'])->name('export');\n        });\n    });\n\n    Route::get('/currencies', [CurrencyController::class, 'index'])->name('currencies.index');\n\n    // Public routes\n    Route::name('public.')->prefix('/public')->group(static function (): void {\n        Route::get('/reports', [PublicReportController::class, 'show'])->name('reports.show');\n    });\n});\n\n/**\n * Fallback routes, to prevent a rendered HTML page in /api/* routes\n * The / route is also included since the fallback is not triggered on the root route\n */\nRoute::get('/', function (): void {\n    throw new NotFoundHttpException('API resource not found');\n});\nRoute::fallback(function (): void {\n    throw new NotFoundHttpException('API resource not found');\n});\n"
  },
  {
    "path": "routes/web.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse App\\Http\\Controllers\\Web\\DashboardController;\nuse App\\Http\\Controllers\\Web\\HomeController;\nuse Illuminate\\Support\\Facades\\Route;\nuse Inertia\\Inertia;\nuse Laravel\\Jetstream\\Jetstream;\n\n/*\n|--------------------------------------------------------------------------\n| Web Routes\n|--------------------------------------------------------------------------\n|\n| Here is where you can register web routes for your application. These\n| routes are loaded by the RouteServiceProvider within a group which\n| contains the \"web\" middleware group. Now create something great!\n|\n*/\n\nRoute::get('/', [HomeController::class, 'index']);\n\nRoute::get('/shared-report', function () {\n    return Inertia::render('SharedReport');\n})->name('shared-report');\n\nRoute::middleware([\n    'auth:web',\n    config('jetstream.auth_session'),\n    'verified',\n])->group(function (): void {\n    Route::get('/dashboard', [DashboardController::class, 'dashboard'])->name('dashboard');\n\n    Route::get('/time', function () {\n        return Inertia::render('Time');\n    })->name('time');\n\n    Route::get('/calendar', function () {\n        return Inertia::render('Calendar');\n    })->name('calendar');\n\n    Route::get('/timesheet', function () {\n        return Inertia::render('Timesheet');\n    })->name('timesheet');\n\n    Route::get('/reporting', function () {\n        return Inertia::render('Reporting');\n    })->name('reporting');\n\n    Route::get('/reporting/detailed', function () {\n        return Inertia::render('ReportingDetailed');\n    })->name('reporting.detailed');\n\n    Route::get('/reporting/shared', function () {\n        return Inertia::render('ReportingShared');\n    })->name('reporting.shared');\n\n    Route::get('/projects', function () {\n        return Inertia::render('Projects');\n    })->name('projects');\n\n    Route::get('/projects/{project}', function () {\n        return Inertia::render('ProjectShow');\n    })->name('projects.show');\n\n    Route::get('/clients', function () {\n        return Inertia::render('Clients');\n    })->name('clients');\n\n    Route::get('/members', function () {\n        return Inertia::render('Members', [\n            'availableRoles' => array_values(Jetstream::$roles),\n        ]);\n    })->name('members');\n\n    Route::get('/tags', function () {\n        return Inertia::render('Tags');\n    })->name('tags');\n\n    Route::get('/import', function () {\n        return Inertia::render('Import');\n    })->name('import');\n\n});\n"
  },
  {
    "path": "storage/app/.gitignore",
    "content": "*\n!public/\n!.gitignore\n"
  },
  {
    "path": "storage/framework/.gitignore",
    "content": "compiled.php\nconfig.php\ndown\nevents.scanned.php\nmaintenance.php\nroutes.php\nroutes.scanned.php\nschedule-*\nservices.json\n"
  },
  {
    "path": "storage/framework/cache/.gitignore",
    "content": "*\n!data/\n!.gitignore\n"
  },
  {
    "path": "storage/framework/sessions/.gitignore",
    "content": "*\n!.gitignore\n"
  },
  {
    "path": "storage/framework/testing/.gitignore",
    "content": "*\n!.gitignore\n"
  },
  {
    "path": "storage/framework/views/.gitignore",
    "content": "*\n!.gitignore\n"
  },
  {
    "path": "storage/logs/.gitignore",
    "content": "*\n!.gitignore\n"
  },
  {
    "path": "tailwind.config.js",
    "content": "import defaultTheme from 'tailwindcss/defaultTheme';\nimport forms from '@tailwindcss/forms';\nimport typography from '@tailwindcss/typography';\nimport { solidtimeTheme } from './resources/js/packages/ui/tailwind.theme.js';\n\n/** @type {import(\"tailwindcss\").Config} */\nexport default {\n    darkMode: ['selector', '.dark'],\n    content: [\n        './extensions/Invoicing/resources/js/**/*.vue',\n        './vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php',\n        './vendor/laravel/jetstream/**/*.blade.php',\n        './storage/framework/views/*.php',\n        './resources/views/**/*.blade.php',\n        './resources/js/**/*.vue',\n        './resources/js/**/*.ts',\n        '!./resources/js/**/node_modules',\n    ],\n    theme: {\n        extend: {\n            ...solidtimeTheme,\n            fontFamily: {\n                sans: ['Inter', ...defaultTheme.fontFamily.sans],\n            },\n        },\n    },\n\n    plugins: [\n        forms,\n        typography,\n        require('@tailwindcss/container-queries'),\n        require('tailwindcss-animate'),\n    ],\n};\n"
  },
  {
    "path": "tests/CreatesApplication.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests;\n\nuse Illuminate\\Contracts\\Console\\Kernel;\nuse Illuminate\\Foundation\\Application;\n\ntrait CreatesApplication\n{\n    /**\n     * Creates the application.\n     */\n    public function createApplication(): Application\n    {\n        $app = require __DIR__.'/../bootstrap/app.php';\n\n        $app->make(Kernel::class)->bootstrap();\n\n        return $app;\n    }\n}\n"
  },
  {
    "path": "tests/Feature/AuthenticationTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Feature;\n\nuse App\\Models\\User;\nuse App\\Providers\\RouteServiceProvider;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Tests\\TestCase;\n\nclass AuthenticationTest extends TestCase\n{\n    use RefreshDatabase;\n\n    public function test_login_screen_can_be_rendered(): void\n    {\n        $response = $this->get('/login');\n\n        $response->assertStatus(200);\n    }\n\n    public function test_users_can_authenticate_using_the_login_screen(): void\n    {\n        $user = User::factory()->create();\n\n        $response = $this->post('/login', [\n            'email' => $user->email,\n            'password' => 'password',\n        ]);\n\n        $this->assertAuthenticated();\n        $response->assertRedirect(RouteServiceProvider::HOME);\n    }\n\n    public function test_users_can_not_authenticate_with_invalid_password(): void\n    {\n        $user = User::factory()->create();\n\n        $this->post('/login', [\n            'email' => $user->email,\n            'password' => 'wrong-password',\n        ]);\n\n        $this->assertGuest();\n    }\n}\n"
  },
  {
    "path": "tests/Feature/BrowserSessionsTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Feature;\n\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Tests\\TestCase;\n\nclass BrowserSessionsTest extends TestCase\n{\n    use RefreshDatabase;\n\n    public function test_other_browser_sessions_can_be_logged_out(): void\n    {\n        $this->actingAs($user = User::factory()->create());\n\n        $response = $this->delete('/user/other-browser-sessions', [\n            'password' => 'password',\n        ]);\n\n        $response->assertSessionHasNoErrors();\n    }\n}\n"
  },
  {
    "path": "tests/Feature/CreateOrganizationTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Feature;\n\nuse App\\Enums\\Role;\nuse App\\Events\\AfterCreateOrganization;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Illuminate\\Support\\Facades\\Event;\nuse Tests\\TestCase;\n\nclass CreateOrganizationTest extends TestCase\n{\n    use RefreshDatabase;\n\n    public function test_organizations_can_be_created(): void\n    {\n        // Arrange\n        $user = User::factory()->withPersonalOrganization()->create();\n        $this->actingAs($user);\n        Event::fake([\n            AfterCreateOrganization::class,\n        ]);\n\n        // Act\n        $response = $this->post('/teams', [\n            'name' => 'Test Organization',\n        ]);\n\n        // Assert\n        $response->assertStatus(302);\n        /** @var Organization|null $newOrganization */\n        $ownedTeams = $user->fresh()->ownedTeams;\n        $this->assertCount(2, $ownedTeams);\n        $this->assertTrue($ownedTeams->contains('name', 'Test Organization'));\n        $newOrganization = $ownedTeams->firstWhere('name', 'Test Organization');\n        /** @var Member $member */\n        $member = Member::query()->whereBelongsTo($user, 'user')->whereBelongsTo($newOrganization, 'organization')->firstOrFail();\n        $this->assertSame(Role::Owner->value, $member->role);\n        Event::assertDispatched(AfterCreateOrganization::class, function (AfterCreateOrganization $event) use ($newOrganization): bool {\n            return $event->organization->is($newOrganization);\n        });\n    }\n}\n"
  },
  {
    "path": "tests/Feature/DeleteAccountTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Feature;\n\nuse App\\Enums\\Role;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Tests\\TestCase;\n\nclass DeleteAccountTest extends TestCase\n{\n    use RefreshDatabase;\n\n    public function test_user_accounts_can_be_deleted(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $this->actingAs($user);\n\n        // Act\n        $response = $this->delete('/user', [\n            'password' => 'password',\n        ]);\n\n        // Assert\n        $response->assertStatus(302);\n        $this->assertNull($user->fresh());\n    }\n\n    public function test_correct_password_must_be_provided_before_account_can_be_deleted(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $this->actingAs($user);\n\n        // Act\n        $response = $this->delete('/user', [\n            'password' => 'wrong-password',\n        ]);\n\n        // Assert\n        $this->assertNotNull($user->fresh());\n    }\n\n    public function test_user_account_can_not_be_deleted_if_attached_to_a_organization_with_multiple_users(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $organization = Organization::factory()->withOwner($user)->create();\n        $userMember = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Owner)->create();\n        $otherUser = User::factory()->create();\n        $otherMember = Member::factory()->forOrganization($organization)->forUser($otherUser)->role(Role::Admin)->create();\n        $this->actingAs($user);\n\n        // Act\n        $response = $this->delete('/user', [\n            'password' => 'password',\n        ]);\n\n        // Assert\n        $response->assertInvalid(['password']);\n        $this->assertNotNull($user->fresh());\n    }\n}\n"
  },
  {
    "path": "tests/Feature/DeleteOrganizationTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Feature;\n\nuse App\\Enums\\Role;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Tests\\TestCase;\n\nclass DeleteOrganizationTest extends TestCase\n{\n    use RefreshDatabase;\n\n    public function test_organizations_can_be_deleted_and_users_of_the_organization_that_have_no_organization_get_a_new_one(): void\n    {\n        // Arrange\n        $user = User::factory()->withPersonalOrganization()->create();\n        $this->actingAs($user);\n\n        $organization = Organization::factory()->withOwner($user)->create([\n            'personal_team' => false,\n        ]);\n        Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Owner)->create();\n\n        $otherUser = User::factory()->create();\n        $organization->users()->attach(\n            $otherUser, ['role' => 'test-role']\n        );\n\n        // Act\n        $response = $this->delete('/teams/'.$organization->getKey());\n\n        // Assert\n        $this->assertNull($organization->fresh());\n        $this->assertCount(1, $otherUser->fresh()->teams);\n        $this->assertFalse($otherUser->fresh()->teams->first()->is($organization));\n    }\n\n    public function test_personal_organizations_can_be_deleted_but_user_gets_an_new_one_if_this_is_the_only_one_left(): void\n    {\n        // Arrange\n        $user = User::factory()->withPersonalOrganization()->create();\n        $organization = $user->currentTeam;\n        $this->actingAs($user);\n\n        // Act\n        $response = $this->delete('/teams/'.$organization->getKey());\n\n        // Assert\n        $user->refresh();\n        $this->assertDatabaseMissing(Organization::class, [\n            'id' => $organization->getKey(),\n        ]);\n        $this->assertTrue($user->currentTeam->isNot($organization));\n    }\n\n    public function test_organization_can_not_be_deleted_if_user_is_not_owner(): void\n    {\n        // Arrange\n        $user = User::factory()->withPersonalOrganization()->create();\n        $organization = Organization::factory()->withOwner($user)->create([\n            'personal_team' => false,\n        ]);\n        $this->actingAs($user);\n\n        $otherUser = User::factory()->create();\n        $organization->users()->attach(\n            $otherUser, ['role' => Role::Admin->value]\n        );\n\n        // Act\n        $response = $this->delete('/teams/'.$organization->getKey());\n\n        // Assert\n        $response->assertForbidden();\n        $this->assertDatabaseHas(Organization::class, [\n            'id' => $organization->getKey(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "tests/Feature/EmailVerificationTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Feature;\n\nuse App\\Models\\User;\nuse App\\Providers\\RouteServiceProvider;\nuse Illuminate\\Auth\\Events\\Verified;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Illuminate\\Support\\Facades\\Event;\nuse Illuminate\\Support\\Facades\\URL;\nuse Laravel\\Fortify\\Features;\nuse Tests\\TestCase;\n\nclass EmailVerificationTest extends TestCase\n{\n    use RefreshDatabase;\n\n    public function test_email_verification_screen_can_be_rendered(): void\n    {\n        if (! Features::enabled(Features::emailVerification())) {\n            $this->markTestSkipped('Email verification not enabled.');\n        }\n\n        $user = User::factory()->withPersonalOrganization()->unverified()->create();\n\n        $response = $this->actingAs($user)->get('/email/verify');\n\n        $response->assertStatus(200);\n    }\n\n    public function test_email_can_be_verified(): void\n    {\n        if (! Features::enabled(Features::emailVerification())) {\n            $this->markTestSkipped('Email verification not enabled.');\n        }\n\n        Event::fake([\n            Verified::class,\n        ]);\n\n        $user = User::factory()->unverified()->create();\n\n        $verificationUrl = URL::temporarySignedRoute(\n            'verification.verify',\n            now()->addMinutes(60),\n            ['id' => $user->id, 'hash' => sha1($user->email)]\n        );\n\n        $response = $this->actingAs($user)->get($verificationUrl);\n\n        Event::assertDispatched(Verified::class);\n\n        $this->assertTrue($user->fresh()->hasVerifiedEmail());\n        $response->assertRedirect(RouteServiceProvider::HOME.'?verified=1');\n    }\n\n    public function test_email_can_not_verified_with_invalid_hash(): void\n    {\n        if (! Features::enabled(Features::emailVerification())) {\n            $this->markTestSkipped('Email verification not enabled.');\n        }\n\n        $user = User::factory()->unverified()->create();\n\n        $verificationUrl = URL::temporarySignedRoute(\n            'verification.verify',\n            now()->addMinutes(60),\n            ['id' => $user->id, 'hash' => sha1('wrong-email')]\n        );\n\n        $this->actingAs($user)->get($verificationUrl);\n\n        $this->assertFalse($user->fresh()->hasVerifiedEmail());\n    }\n}\n"
  },
  {
    "path": "tests/Feature/InviteTeamMemberTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Feature;\n\nuse App\\Enums\\Role;\nuse App\\Models\\Member;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Illuminate\\Support\\Facades\\Mail;\nuse Illuminate\\Support\\Facades\\URL;\nuse Tests\\TestCase;\n\nclass InviteTeamMemberTest extends TestCase\n{\n    use RefreshDatabase;\n\n    public function test_team_members_can_no_longer_be_invited_to_team_over_jetstream(): void\n    {\n        // Arrange\n        Mail::fake();\n        $this->actingAs($user = User::factory()->withPersonalOrganization()->create());\n\n        // Act\n        $response = $this->post('/teams/'.$user->currentTeam->id.'/members', [\n            'email' => 'test@example.com',\n            'role' => 'admin',\n        ]);\n\n        // Assert\n        $response->assertStatus(403);\n        $response->assertSee('Moved to API');\n        Mail::assertNothingSent();\n    }\n\n    public function test_team_member_invitations_can_no_longer_be_cancelled_over_jetstream(): void\n    {\n        // Arrange\n        Mail::fake();\n\n        $this->actingAs($user = User::factory()->withPersonalOrganization()->create());\n\n        $invitation = $user->currentTeam->teamInvitations()->create([\n            'email' => 'test@example.com',\n            'role' => 'admin',\n        ]);\n\n        // Act\n        $response = $this->delete('/team-invitations/'.$invitation->id);\n\n        // Assert\n        $response->assertStatus(403);\n        $this->assertCount(1, $user->currentTeam->fresh()->teamInvitations);\n    }\n\n    public function test_team_member_invitations_can_be_accepted(): void\n    {\n        // Arrange\n        Mail::fake();\n        $owner = User::factory()->withPersonalOrganization()->create();\n        $user = User::factory()->withPersonalOrganization()->create();\n        $invitation = $owner->currentTeam->teamInvitations()->create([\n            'email' => $user->email,\n            'role' => Role::Employee->value,\n        ]);\n        $this->actingAs($user);\n\n        // Act\n        $acceptUrl = URL::temporarySignedRoute(\n            'team-invitations.accept',\n            now()->addMinutes(60),\n            [$invitation->getKey()]\n        );\n        $response = $this->get($acceptUrl);\n\n        // Assert\n        $this->assertCount(0, $owner->currentTeam->fresh()->teamInvitations);\n        $user->refresh();\n        $this->assertCount(2, $user->organizations);\n        $this->assertContains($owner->currentTeam->getKey(), $user->organizations->pluck('id'));\n    }\n\n    public function test_team_member_invitations_of_placeholder_can_be_accepted_and_migrates_date_to_real_user(): void\n    {\n        // Arrange\n        Mail::fake();\n        $placeholder = User::factory()->placeholder()->create();\n        $owner = User::factory()->withPersonalOrganization()->create();\n        $placeholderMember = Member::factory()->forOrganization($owner->currentTeam)->forUser($placeholder)->create();\n\n        $timeEntries = TimeEntry::factory()->forOrganization($owner->currentTeam)->forMember($placeholderMember)->createMany(5);\n\n        $user = User::factory()->withPersonalOrganization()->create([\n            'email' => $placeholder->email,\n        ]);\n\n        $invitation = $owner->currentTeam->teamInvitations()->create([\n            'email' => $user->email,\n            'role' => Role::Employee->value,\n        ]);\n        $this->actingAs($user);\n\n        // Act\n        $acceptUrl = URL::temporarySignedRoute(\n            'team-invitations.accept',\n            now()->addMinutes(60),\n            [$invitation->getKey()]\n        );\n        $response = $this->get($acceptUrl);\n\n        // Assert\n        $response->assertRedirect();\n        $user->refresh();\n        $this->assertDatabaseMissing(User::class, ['id' => $placeholder->id]);\n        $this->assertCount(0, $owner->currentTeam->fresh()->teamInvitations);\n        $this->assertCount(2, $user->organizations);\n        $this->assertContains($owner->currentTeam->getKey(), $user->organizations->pluck('id'));\n        $this->assertCount(5, $user->timeEntries);\n    }\n\n    public function test_team_member_accept_fails_if_user_with_that_email_does_not_exist(): void\n    {\n        // Arrange\n        Mail::fake();\n        $owner = User::factory()->withPersonalOrganization()->create();\n        $user = User::factory()->withPersonalOrganization()->create();\n        $invitation = $owner->currentTeam->teamInvitations()->create([\n            'email' => 'firstname.lastname@mail.test',\n            'role' => Role::Employee->value,\n        ]);\n        $this->actingAs($user);\n\n        // Act\n        $acceptUrl = URL::temporarySignedRoute(\n            'team-invitations.accept',\n            now()->addMinutes(60),\n            [$invitation->getKey()]\n        );\n        $response = $this->get($acceptUrl);\n\n        // Assert\n        $this->assertCount(1, $owner->currentTeam->fresh()->teamInvitations);\n        $user->refresh();\n        $this->assertCount(1, $user->organizations);\n    }\n}\n"
  },
  {
    "path": "tests/Feature/LeaveTeamTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Feature;\n\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Tests\\TestCase;\n\nclass LeaveTeamTest extends TestCase\n{\n    use RefreshDatabase;\n\n    public function test_users_can_no_longer_leave_team_over_jetstream(): void\n    {\n        // Arrange\n        $user = User::factory()->withPersonalOrganization()->create();\n\n        $user->currentTeam->users()->attach(\n            $otherUser = User::factory()->create(), ['role' => 'admin']\n        );\n\n        $this->actingAs($otherUser);\n\n        // Act\n        $response = $this->delete('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id);\n\n        // Assert\n        $response->assertStatus(403);\n        $this->assertCount(2, $user->currentTeam->fresh()->users);\n    }\n}\n"
  },
  {
    "path": "tests/Feature/PasswordConfirmationTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Feature;\n\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Tests\\TestCase;\n\nclass PasswordConfirmationTest extends TestCase\n{\n    use RefreshDatabase;\n\n    public function test_confirm_password_screen_can_be_rendered(): void\n    {\n        $user = User::factory()->withPersonalOrganization()->create();\n\n        $response = $this->actingAs($user)->get('/user/confirm-password');\n\n        $response->assertStatus(200);\n    }\n\n    public function test_password_can_be_confirmed(): void\n    {\n        $user = User::factory()->create();\n\n        $response = $this->actingAs($user)->post('/user/confirm-password', [\n            'password' => 'password',\n        ]);\n\n        $response->assertRedirect();\n        $response->assertSessionHasNoErrors();\n    }\n\n    public function test_password_is_not_confirmed_with_invalid_password(): void\n    {\n        $user = User::factory()->create();\n\n        $response = $this->actingAs($user)->post('/user/confirm-password', [\n            'password' => 'wrong-password',\n        ]);\n\n        $response->assertSessionHasErrors();\n    }\n}\n"
  },
  {
    "path": "tests/Feature/PasswordResetTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Feature;\n\nuse App\\Models\\User;\nuse Illuminate\\Auth\\Notifications\\ResetPassword;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Illuminate\\Support\\Facades\\Notification;\nuse Laravel\\Fortify\\Features;\nuse Tests\\TestCase;\n\nclass PasswordResetTest extends TestCase\n{\n    use RefreshDatabase;\n\n    public function test_reset_password_link_screen_can_be_rendered(): void\n    {\n        if (! Features::enabled(Features::resetPasswords())) {\n            $this->markTestSkipped('Password updates are not enabled.');\n        }\n\n        $response = $this->get('/forgot-password');\n\n        $response->assertStatus(200);\n    }\n\n    public function test_reset_password_link_can_be_requested(): void\n    {\n        if (! Features::enabled(Features::resetPasswords())) {\n            $this->markTestSkipped('Password updates are not enabled.');\n        }\n\n        Notification::fake();\n\n        $user = User::factory()->create();\n\n        $response = $this->post('/forgot-password', [\n            'email' => $user->email,\n        ]);\n\n        Notification::assertSentTo($user, ResetPassword::class);\n    }\n\n    public function test_reset_password_screen_can_be_rendered(): void\n    {\n        if (! Features::enabled(Features::resetPasswords())) {\n            $this->markTestSkipped('Password updates are not enabled.');\n        }\n\n        Notification::fake();\n\n        $user = User::factory()->create();\n\n        $response = $this->post('/forgot-password', [\n            'email' => $user->email,\n        ]);\n\n        Notification::assertSentTo($user, ResetPassword::class, function (object $notification) {\n            $response = $this->get('/reset-password/'.$notification->token);\n\n            $response->assertStatus(200);\n\n            return true;\n        });\n    }\n\n    public function test_password_can_be_reset_with_valid_token(): void\n    {\n        if (! Features::enabled(Features::resetPasswords())) {\n            $this->markTestSkipped('Password updates are not enabled.');\n        }\n\n        Notification::fake();\n\n        $user = User::factory()->create();\n\n        $response = $this->post('/forgot-password', [\n            'email' => $user->email,\n        ]);\n\n        Notification::assertSentTo($user, ResetPassword::class, function (object $notification) use ($user) {\n            $response = $this->post('/reset-password', [\n                'token' => $notification->token,\n                'email' => $user->email,\n                'password' => 'password',\n                'password_confirmation' => 'password',\n            ]);\n\n            $response->assertSessionHasNoErrors();\n\n            return true;\n        });\n    }\n}\n"
  },
  {
    "path": "tests/Feature/ProfileInformationTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Feature;\n\nuse App\\Enums\\Weekday;\nuse App\\Models\\User;\nuse App\\Service\\TimezoneService;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Tests\\TestCase;\n\nclass ProfileInformationTest extends TestCase\n{\n    use RefreshDatabase;\n\n    public function test_show_profile_information_succeeds(): void\n    {\n        // Arrange\n        $user = User::factory()->withPersonalOrganization()->create();\n        $this->actingAs($user);\n\n        // Act\n        $response = $this->get('/user/profile');\n\n        // Assert\n        $response->assertSuccessful();\n    }\n\n    public function test_profile_information_can_be_updated(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $timezone = app(TimezoneService::class)->getTimezones()[0];\n        $this->actingAs($user);\n\n        // Act\n        $response = $this->put('/user/profile-information', [\n            'name' => 'Test Name',\n            'email' => 'test@example.com',\n            'timezone' => $timezone,\n            'week_start' => Weekday::Sunday->value,\n        ]);\n\n        // Assert\n        $response->assertValid(errorBag: 'updateProfileInformation');\n        $user = $user->fresh();\n        $this->assertEquals('Test Name', $user->name);\n        $this->assertEquals('test@example.com', $user->email);\n        $this->assertEquals($timezone, $user->timezone);\n        $this->assertEquals(Weekday::Sunday, $user->week_start);\n    }\n}\n"
  },
  {
    "path": "tests/Feature/RegistrationTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Feature;\n\nuse App\\Enums\\Role;\nuse App\\Enums\\Weekday;\nuse App\\Events\\NewsletterRegistered;\nuse App\\Models\\Member;\nuse App\\Models\\User;\nuse App\\Providers\\RouteServiceProvider;\nuse App\\Service\\IpLookup\\IpLookupResponseDto;\nuse App\\Service\\IpLookup\\IpLookupServiceContract;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Illuminate\\Support\\Facades\\Config;\nuse Illuminate\\Support\\Facades\\Event;\nuse Laravel\\Fortify\\Features;\nuse Laravel\\Jetstream\\Jetstream;\nuse Tests\\TestCase;\n\nclass RegistrationTest extends TestCase\n{\n    use RefreshDatabase;\n\n    public function test_registration_screen_can_be_rendered(): void\n    {\n        if (! Features::enabled(Features::registration())) {\n            $this->markTestSkipped('Registration support is not enabled.');\n        }\n\n        $response = $this->get('/register');\n\n        $response->assertStatus(200);\n    }\n\n    public function test_new_users_can_register(): void\n    {\n        // Arrange\n        Event::fake([\n            NewsletterRegistered::class,\n        ]);\n\n        // Act\n        $response = $this->post('/register', [\n            'name' => 'Test User',\n            'email' => 'test@example.com',\n            'password' => 'password',\n            'password_confirmation' => 'password',\n            'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),\n        ]);\n\n        // Assert\n        $response->assertValid();\n        $this->assertAuthenticated();\n        $response->assertRedirect(RouteServiceProvider::HOME);\n        $user = User::where('email', 'test@example.com')->firstOrFail();\n        $this->assertSame('Test User', $user->name);\n        $this->assertSame('UTC', $user->timezone);\n        $organization = $user->organizations()->firstOrFail();\n        $this->assertSame(true, $organization->personal_team);\n        $member = Member::query()->whereBelongsTo($user, 'user')->whereBelongsTo($organization, 'organization')->firstOrFail();\n        $this->assertSame(Role::Owner->value, $member->role);\n        Event::assertNotDispatched(NewsletterRegistered::class);\n    }\n\n    public function test_user_registration_fails_if_registration_is_deactivated(): void\n    {\n        // Arrange\n        Event::fake([\n            NewsletterRegistered::class,\n        ]);\n        Config::set('app.enable_registration', false);\n\n        // Act\n        $response = $this->post('/register', [\n            'name' => 'Test User',\n            'email' => 'test@example.com',\n            'password' => 'password',\n            'password_confirmation' => 'password',\n            'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),\n        ]);\n\n        // Assert\n        $response->assertInvalid([\n            'email' => 'Registration is disabled.',\n        ]);\n        $this->assertFalse(User::query()->where('email', 'test@example.com')->exists());\n        Event::assertNotDispatched(NewsletterRegistered::class);\n    }\n\n    public function test_new_user_can_not_register_with_likely_invalid_domain(): void\n    {\n        // Act\n        $response = $this->post('/register', [\n            'name' => 'Test User',\n            'email' => 'peter.test@gmail',\n            'password' => 'password',\n            'password_confirmation' => 'password',\n            'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),\n        ]);\n\n        // Assert\n        $response->assertInvalid(['email']);\n    }\n\n    public function test_new_user_can_register_with_uppercase_email(): void\n    {\n        // Act\n        $response = $this->post('/register', [\n            'name' => 'Test User',\n            'email' => 'PETER.test@gmail.com ',\n            'password' => 'password',\n            'password_confirmation' => 'password',\n            'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),\n        ]);\n\n        // Assert\n        $response->assertValid(['email']);\n    }\n\n    public function test_new_users_can_consent_to_newsletter_during_registration(): void\n    {\n        // Arrange\n        Event::fake([\n            NewsletterRegistered::class,\n        ]);\n\n        // Act\n        $response = $this->post('/register', [\n            'name' => 'Test User',\n            'email' => 'test@example.com',\n            'password' => 'password',\n            'password_confirmation' => 'password',\n            'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),\n            'newsletter_consent' => true,\n        ]);\n\n        // Assert\n        $response->assertValid();\n        $this->assertAuthenticated();\n        $response->assertRedirect(RouteServiceProvider::HOME);\n        $user = User::where('email', 'test@example.com')->firstOrFail();\n        $this->assertSame('Test User', $user->name);\n        $this->assertSame('UTC', $user->timezone);\n        Event::assertDispatched(NewsletterRegistered::class);\n    }\n\n    public function test_new_users_can_register_and_frontend_can_send_timezone_for_user(): void\n    {\n        // Act\n        $response = $this->post('/register', [\n            'name' => 'Test User',\n            'email' => 'test@example.com',\n            'password' => 'password',\n            'password_confirmation' => 'password',\n            'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),\n            'timezone' => 'Europe/Berlin',\n        ]);\n\n        // Assert\n        $this->assertAuthenticated();\n        $response->assertRedirect(RouteServiceProvider::HOME);\n        $user = User::where('email', 'test@example.com')->firstOrFail();\n        $this->assertSame('Europe/Berlin', $user->timezone);\n    }\n\n    public function test_new_users_can_register_and_uses_ip_lookup_service_to_get_information_about_currency_and_start_of_week(): void\n    {\n        // Arrange\n        $this->mock(IpLookupServiceContract::class, function ($mock): void {\n            $mock->shouldReceive('lookup')->andReturn(new IpLookupResponseDto(\n                'America/New_York',\n                Weekday::Sunday,\n                'USD',\n            ));\n        });\n\n        // Act\n        $response = $this->post('/register', [\n            'name' => 'Test User',\n            'email' => 'test@example.com',\n            'password' => 'password',\n            'password_confirmation' => 'password',\n            'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),\n            'timezone' => 'Europe/Berlin',\n        ]);\n\n        // Assert\n        $this->assertAuthenticated();\n        $response->assertRedirect(RouteServiceProvider::HOME);\n        /** @var User $user */\n        $user = User::where('email', 'test@example.com')->firstOrFail();\n        $this->assertSame('Europe/Berlin', $user->timezone);\n        $this->assertSame(Weekday::Sunday, $user->week_start);\n        $this->assertSame('USD', $user->organizations->first()->currency);\n    }\n\n    public function test_new_users_can_register_and_uses_ip_lookup_service_to_get_information_about_timezone_if_client_did_not_send_one(): void\n    {\n        // Arrange\n        $this->mock(IpLookupServiceContract::class, function ($mock): void {\n            $mock->shouldReceive('lookup')->andReturn(new IpLookupResponseDto(\n                'America/New_York',\n                Weekday::Sunday,\n                'USD',\n            ));\n        });\n\n        // Act\n        $response = $this->post('/register', [\n            'name' => 'Test User',\n            'email' => 'test@example.com',\n            'password' => 'password',\n            'password_confirmation' => 'password',\n            'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),\n            'timezone' => null,\n        ]);\n\n        // Assert\n        $this->assertAuthenticated();\n        $response->assertRedirect(RouteServiceProvider::HOME);\n        /** @var User $user */\n        $user = User::where('email', 'test@example.com')->firstOrFail();\n        $this->assertSame('America/New_York', $user->timezone);\n        $this->assertSame(Weekday::Sunday, $user->week_start);\n        $this->assertSame('USD', $user->organizations->first()->currency);\n    }\n\n    public function test_new_users_can_register_and_uses_ip_lookup_service_to_get_information_about_timezone_if_client_sends_invalid_one(): void\n    {\n        // Arrange\n        $this->mock(IpLookupServiceContract::class, function ($mock): void {\n            $mock->shouldReceive('lookup')->andReturn(new IpLookupResponseDto(\n                'America/New_York',\n                Weekday::Sunday,\n                'USD',\n            ));\n        });\n\n        // Act\n        $response = $this->post('/register', [\n            'name' => 'Test User',\n            'email' => 'test@example.com',\n            'password' => 'password',\n            'password_confirmation' => 'password',\n            'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),\n            'timezone' => 'Unknown timezone',\n        ]);\n\n        // Assert\n        $this->assertAuthenticated();\n        $response->assertRedirect(RouteServiceProvider::HOME);\n        /** @var User $user */\n        $user = User::where('email', 'test@example.com')->firstOrFail();\n        $this->assertSame('America/New_York', $user->timezone);\n        $this->assertSame(Weekday::Sunday, $user->week_start);\n        $this->assertSame('USD', $user->organizations->first()->currency);\n    }\n\n    public function test_new_users_can_register_and_legacy_timezone_from_client_is_mapped_to_new_timezone(): void\n    {\n        // Arrange\n        $this->mock(IpLookupServiceContract::class, function ($mock): void {\n            $mock->shouldReceive('lookup')->andReturn(new IpLookupResponseDto(\n                'America/New_York',\n                Weekday::Sunday,\n                'USD',\n            ));\n        });\n\n        // Act\n        $response = $this->post('/register', [\n            'name' => 'Test User',\n            'email' => 'test@example.com',\n            'password' => 'password',\n            'password_confirmation' => 'password',\n            'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),\n            'timezone' => 'Asia/Calcutta',\n        ]);\n\n        // Assert\n        $this->assertAuthenticated();\n        $response->assertRedirect(RouteServiceProvider::HOME);\n        /** @var User $user */\n        $user = User::where('email', 'test@example.com')->firstOrFail();\n        $this->assertSame('Asia/Kolkata', $user->timezone);\n        $this->assertSame(Weekday::Sunday, $user->week_start);\n        $this->assertSame('USD', $user->organizations->first()->currency);\n    }\n\n    public function test_new_users_can_register_and_ignores_invalid_timezones_from_frontend(): void\n    {\n        $response = $this->post('/register', [\n            'name' => 'Test User',\n            'email' => 'test@example.com',\n            'password' => 'password',\n            'password_confirmation' => 'password',\n            'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),\n            'timezone' => 'Unknown timezone',\n        ]);\n\n        $this->assertAuthenticated();\n        $response->assertRedirect(RouteServiceProvider::HOME);\n        $user = User::where('email', 'test@example.com')->firstOrFail();\n        $this->assertSame('UTC', $user->timezone);\n    }\n\n    public function test_new_users_can_not_register_if_user_with_email_already_exists(): void\n    {\n        // Arrange\n        $user = User::factory()->create([\n            'email' => 'test@example.com',\n        ]);\n\n        // Act\n        $response = $this->post('/register', [\n            'name' => 'Test User',\n            'email' => 'test@example.com',\n            'password' => 'password',\n            'password_confirmation' => 'password',\n            'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),\n        ]);\n\n        $this->assertFalse($this->isAuthenticated(), 'The user is authenticated');\n        $response->assertInvalid(['email']);\n    }\n\n    public function test_new_users_can_register_if_placeholder_user_with_email_already_exists(): void\n    {\n        // Arrange\n        $user = User::factory()->create([\n            'email' => 'test@example.com',\n            'is_placeholder' => true,\n        ]);\n\n        // Act\n        $response = $this->post('/register', [\n            'name' => 'Test User',\n            'email' => 'test@example.com',\n            'password' => 'password',\n            'password_confirmation' => 'password',\n            'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),\n        ]);\n\n        $this->assertAuthenticated();\n        $response->assertRedirect(RouteServiceProvider::HOME);\n    }\n}\n"
  },
  {
    "path": "tests/Feature/RemoveTeamMemberTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Feature;\n\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Tests\\TestCase;\n\nclass RemoveTeamMemberTest extends TestCase\n{\n    use RefreshDatabase;\n\n    public function test_team_members_can_no_longer_be_removed_from_teams_over_jetstream_endpoints(): void\n    {\n        // Arrange\n        $this->actingAs($user = User::factory()->withPersonalOrganization()->create());\n\n        $user->currentTeam->users()->attach(\n            $otherUser = User::factory()->create(), ['role' => 'admin']\n        );\n\n        // Act\n        $response = $this->delete('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id);\n\n        // Assert\n        $response->assertStatus(403);\n        $response->assertSee('Moved to API');\n    }\n}\n"
  },
  {
    "path": "tests/Feature/TwoFactorAuthenticationSettingsTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Feature;\n\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Laravel\\Fortify\\Features;\nuse Tests\\TestCase;\n\nclass TwoFactorAuthenticationSettingsTest extends TestCase\n{\n    use RefreshDatabase;\n\n    public function test_two_factor_authentication_can_be_enabled(): void\n    {\n        if (! Features::canManageTwoFactorAuthentication()) {\n            $this->markTestSkipped('Two factor authentication is not enabled.');\n        }\n\n        $this->actingAs($user = User::factory()->create());\n\n        $this->withSession(['auth.password_confirmed_at' => time()]);\n\n        $response = $this->post('/user/two-factor-authentication');\n\n        $this->assertNotNull($user->fresh()->two_factor_secret);\n        $this->assertCount(8, $user->fresh()->recoveryCodes());\n    }\n\n    public function test_recovery_codes_can_be_regenerated(): void\n    {\n        if (! Features::canManageTwoFactorAuthentication()) {\n            $this->markTestSkipped('Two factor authentication is not enabled.');\n        }\n\n        $this->actingAs($user = User::factory()->create());\n\n        $this->withSession(['auth.password_confirmed_at' => time()]);\n\n        $this->post('/user/two-factor-authentication');\n        $this->post('/user/two-factor-recovery-codes');\n\n        $user = $user->fresh();\n\n        $this->post('/user/two-factor-recovery-codes');\n\n        $this->assertCount(8, $user->recoveryCodes());\n        $this->assertCount(8, array_diff($user->recoveryCodes(), $user->fresh()->recoveryCodes()));\n    }\n\n    public function test_two_factor_authentication_can_be_disabled(): void\n    {\n        if (! Features::canManageTwoFactorAuthentication()) {\n            $this->markTestSkipped('Two factor authentication is not enabled.');\n        }\n\n        $this->actingAs($user = User::factory()->create());\n\n        $this->withSession(['auth.password_confirmed_at' => time()]);\n\n        $this->post('/user/two-factor-authentication');\n\n        $this->assertNotNull($user->fresh()->two_factor_secret);\n\n        $this->delete('/user/two-factor-authentication');\n\n        $this->assertNull($user->fresh()->two_factor_secret);\n    }\n}\n"
  },
  {
    "path": "tests/Feature/UpdatePasswordTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Feature;\n\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Illuminate\\Support\\Facades\\Hash;\nuse Tests\\TestCase;\n\nclass UpdatePasswordTest extends TestCase\n{\n    use RefreshDatabase;\n\n    public function test_password_can_be_updated(): void\n    {\n        $this->actingAs($user = User::factory()->create());\n\n        $response = $this->put('/user/password', [\n            'current_password' => 'password',\n            'password' => 'new-password',\n            'password_confirmation' => 'new-password',\n        ]);\n\n        $this->assertTrue(Hash::check('new-password', $user->fresh()->password));\n    }\n\n    public function test_current_password_must_be_correct(): void\n    {\n        $this->actingAs($user = User::factory()->create());\n\n        $response = $this->put('/user/password', [\n            'current_password' => 'wrong-password',\n            'password' => 'new-password',\n            'password_confirmation' => 'new-password',\n        ]);\n\n        $response->assertSessionHasErrors();\n\n        $this->assertTrue(Hash::check('password', $user->fresh()->password));\n    }\n\n    public function test_new_passwords_must_match(): void\n    {\n        $this->actingAs($user = User::factory()->create());\n\n        $response = $this->put('/user/password', [\n            'current_password' => 'password',\n            'password' => 'new-password',\n            'password_confirmation' => 'wrong-password',\n        ]);\n\n        $response->assertSessionHasErrors();\n\n        $this->assertTrue(Hash::check('password', $user->fresh()->password));\n    }\n}\n"
  },
  {
    "path": "tests/Feature/UpdateTeamMemberRoleTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Feature;\n\nuse App\\Enums\\Role;\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Tests\\TestCase;\n\nclass UpdateTeamMemberRoleTest extends TestCase\n{\n    use RefreshDatabase;\n\n    public function test_team_member_roles_can_no_longer_be_updated_over_jetstream(): void\n    {\n        // Arrange\n        $user = User::factory()->withPersonalOrganization()->create();\n        $this->actingAs($user);\n\n        $user->currentTeam->users()->attach(\n            $otherUser = User::factory()->create(), ['role' => 'admin']\n        );\n\n        // Act\n        $response = $this->put('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id, [\n            'role' => Role::Employee->value,\n        ]);\n\n        // Assert\n        $response->assertStatus(403);\n        $response->assertSee('Moved to API');\n    }\n}\n"
  },
  {
    "path": "tests/Feature/UpdateTeamTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Feature;\n\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Tests\\TestCase;\n\nclass UpdateTeamTest extends TestCase\n{\n    use RefreshDatabase;\n\n    public function test_team_update_page_shows_not_found_if_id_is_not_uuid(): void\n    {\n        // Arrange\n        $user = User::factory()->withPersonalOrganization()->create();\n        $this->actingAs($user);\n\n        // Act\n        $response = $this->get('/teams/1');\n\n        // Assert\n        $response->assertStatus(404);\n    }\n\n    public function test_team_names_can_be_updated(): void\n    {\n        // Arrange\n        $user = User::factory()->withPersonalOrganization()->create();\n        $this->actingAs($user);\n\n        // Act\n        $response = $this->put('/teams/'.$user->currentTeam->id, [\n            'name' => 'Test Organization',\n            'currency' => 'USD',\n        ]);\n\n        // Assert\n        $response->assertValid(errorBag: 'updateTeamName');\n        $this->assertCount(1, $user->fresh()->ownedTeams);\n        $organization = $user->currentTeam->fresh();\n        $this->assertEquals('Test Organization', $organization->name);\n        $this->assertEquals('USD', $organization->currency);\n    }\n}\n"
  },
  {
    "path": "tests/TestCase.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests;\n\nuse App\\Service\\BillableRateService;\nuse App\\Service\\BillingContract;\nuse App\\Service\\PermissionStore;\nuse Carbon\\CarbonImmutable;\nuse Illuminate\\Database\\Eloquent\\Collection;\nuse Illuminate\\Foundation\\Testing\\TestCase as BaseTestCase;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Mail;\nuse Illuminate\\Support\\Facades\\Storage;\nuse Mockery\\MockInterface;\nuse TiMacDonald\\Log\\LogFake;\n\nabstract class TestCase extends BaseTestCase\n{\n    use CreatesApplication;\n\n    protected bool $mockBillingContract = true;\n\n    protected function setUp(): void\n    {\n        parent::setUp();\n        Mail::fake();\n        LogFake::bind();\n        Http::preventStrayRequests();\n        if ($this->mockBillingContract) {\n            $this->actAsOrganizationWithoutSubscriptionAndWithoutTrial();\n        }\n        // Note: The following line can be used to test timezone edge cases.\n        // $this->travelTo(Carbon::now()->timezone('Europe/Vienna')->setHour(0)->setMinute(59)->setSecond(0));\n    }\n\n    protected function mockPrivateStorage(): void\n    {\n        Storage::fake(config('filesystems.default'));\n    }\n\n    protected function mockPublicStorage(): void\n    {\n        Storage::fake(config('filesystems.public'));\n    }\n\n    protected function tearDown(): void\n    {\n        // Note: It is necessary to clear the permission cache after each test, since the \"scoped singletons\" are not reset between tests.\n        app(PermissionStore::class)->clear();\n        parent::tearDown();\n    }\n\n    protected function assertEqualsIdsOfEloquentCollection(array $ids, Collection $models): void\n    {\n        $this->assertEqualsCanonicalizing($ids, $models->pluck('id')->toArray());\n    }\n\n    /**\n     * Set the current time to the given time.\n     * This method fixes a bug, that setting the test now with Carbon::setTestNow() with a Carbon instance that has a timezone set, will not work as expected.\n     * IT will also set the timezone for model casts with type \"datetime\" to the timezone and not use the timezone configured in the configuration \"app.timezone\".\n     *\n     * @param  Carbon|CarbonImmutable  $date\n     * @param  callable|null  $callback\n     */\n    public function travelTo($date, $callback = null): void\n    {\n        parent::travelTo($date->utc());\n    }\n\n    protected function assertBillableRateServiceIsUnused(): void\n    {\n        $this->mock(BillableRateService::class, function (MockInterface $mock): void {\n            $mock->shouldNotReceive('updateTimeEntriesBillableRateForProjectMember');\n            $mock->shouldNotReceive('updateTimeEntriesBillableRateForProject');\n            $mock->shouldNotReceive('updateTimeEntriesBillableRateForMember');\n            $mock->shouldNotReceive('updateTimeEntriesBillableRateForOrganization');\n        });\n    }\n\n    protected function actAsOrganizationWithSubscription(): void\n    {\n        $this->mock(BillingContract::class, function (MockInterface $mock): void {\n            $mock->shouldReceive('hasSubscription')->andReturn(true);\n            $mock->shouldReceive('hasTrial')->andReturn(false);\n            $mock->shouldReceive('getTrialUntil')->andReturn(null);\n            $mock->shouldReceive('isBlocked')->andReturn(false);\n        });\n    }\n\n    protected function actAsOrganizationWithoutSubscriptionAndWithoutTrial(): void\n    {\n        $this->mock(BillingContract::class, function (MockInterface $mock): void {\n            $mock->shouldReceive('hasSubscription')->andReturn(false);\n            $mock->shouldReceive('hasTrial')->andReturn(false);\n            $mock->shouldReceive('getTrialUntil')->andReturn(null);\n            $mock->shouldReceive('isBlocked')->andReturn(false);\n        });\n    }\n}\n"
  },
  {
    "path": "tests/TestCaseWithDatabase.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests;\n\nuse App\\Enums\\Role;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Support\\Str;\nuse Laravel\\Jetstream\\Jetstream;\n\nabstract class TestCaseWithDatabase extends TestCase\n{\n    use RefreshDatabase;\n\n    /**\n     * @param  array<string>  $permissions\n     * @return object{user: User, organization: Organization, member: Member, owner: User, ownerMember: Member}\n     */\n    protected function createUserWithPermission(array $permissions = [], bool $isOwner = false): object\n    {\n        $roleName = 'custom-test-'.Str::uuid();\n        Jetstream::role($roleName, 'Custom Test', $permissions)\n            ->description('Role custom for testing');\n        $user = User::factory()->create();\n        if ($isOwner) {\n            $organization = Organization::factory()->withOwner($user)->create();\n        } else {\n            $owner = User::factory()->create();\n            $organization = Organization::factory()->withOwner($owner)->create();\n            $ownerMember = Member::factory()->forUser($owner)->forOrganization($organization)->create([\n                'role' => Role::Owner->value,\n            ]);\n            $owner->currentOrganization()->associate($organization);\n            $owner->save();\n        }\n        $member = Member::factory()->forUser($user)->forOrganization($organization)->create([\n            'role' => $roleName,\n        ]);\n        $user->currentOrganization()->associate($organization);\n        $user->save();\n\n        return (object) [\n            'user' => $user,\n            'organization' => $organization,\n            'member' => $member,\n            'owner' => $isOwner ? $user : $owner,\n            'ownerMember' => $isOwner ? $member : $ownerMember,\n        ];\n    }\n\n    /**\n     * @return object{user: User, organization: Organization, member: Member, owner: User, ownerMember: Member}\n     */\n    public function createUserWithRole(Role $role, bool $employeesCanSeeBillableRates = false): object\n    {\n        $owner = User::factory()->create();\n        $organization = Organization::factory()->withOwner($owner)->create([\n            'employees_can_see_billable_rates' => $employeesCanSeeBillableRates,\n        ]);\n        $ownerMember = Member::factory()->forUser($owner)->forOrganization($organization)->role(Role::Owner)->create();\n        $owner->currentOrganization()->associate($organization);\n        $owner->save();\n\n        if ($role === Role::Owner) {\n            $user = $owner;\n            $member = $ownerMember;\n        } else {\n            $user = User::factory()->create();\n            $member = Member::factory()->forUser($user)->forOrganization($organization)->role($role)->create();\n            $user->currentOrganization()->associate($organization);\n        }\n\n        return (object) [\n            'user' => $user,\n            'organization' => $organization,\n            'member' => $member,\n            'owner' => $owner,\n            'ownerMember' => $ownerMember,\n        ];\n    }\n\n    protected function enableQueryLog(): void\n    {\n        DB::flushQueryLog();\n        DB::enableQueryLog();\n    }\n\n    protected function getQueryLog(): array\n    {\n        if (! DB::logging()) {\n            throw new \\LogicException('Query log is not enabled. Call enableQueryLog() before calling getQueryLog()');\n        }\n\n        return DB::getQueryLog();\n    }\n\n    protected function assertQueryCount(int $count, string $message = ''): void\n    {\n        $queryLog = $this->getQueryLog();\n        $this->assertCount($count, $queryLog, $message);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Console/Commands/Admin/OrganizationDeleteCommandTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Console\\Commands\\Admin;\n\nuse App\\Console\\Commands\\Admin\\OrganizationDeleteCommand;\nuse App\\Models\\Organization;\nuse App\\Service\\DeletionService;\nuse Illuminate\\Support\\Str;\nuse Mockery\\MockInterface;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCaseWithDatabase;\n\n#[CoversClass(OrganizationDeleteCommand::class)]\nclass OrganizationDeleteCommandTest extends TestCaseWithDatabase\n{\n    public function test_it_calls_the_deletion_service_with_the_organization(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $this->mock(DeletionService::class, function (MockInterface $mock) use ($organization): void {\n            $mock->shouldReceive('deleteOrganization')\n                ->withArgs(fn (Organization $organizationArg) => $organizationArg->is($organization))\n                ->once();\n        });\n\n        // Act\n        $command = $this->artisan('admin:organization:delete', ['organization' => $organization->getKey()]);\n\n        // Assert\n        $command->expectsOutput(\"Deleting organization with ID {$organization->getKey()}\")\n            ->expectsOutput(\"Organization with ID {$organization->getKey()} has been deleted.\")\n            ->assertExitCode(0);\n    }\n\n    public function test_it_fails_if_organization_does_not_exist(): void\n    {\n        // Arrange\n        $organizationId = Str::uuid()->toString();\n\n        // Act\n        $command = $this->artisan('admin:organization:delete', ['organization' => $organizationId]);\n\n        // Assert\n        $command->expectsOutput('Organization with ID '.$organizationId.' not found.');\n        $command->assertExitCode(1);\n    }\n\n    public function test_it_fails_if_organization_id_is_not_a_valid_uuid(): void\n    {\n        // Arrange\n        $organizationId = 'invalid-uuid';\n\n        // Act\n        $command = $this->artisan('admin:organization:delete', ['organization' => $organizationId]);\n\n        // Assert\n        $command->expectsOutput('Organization ID must be a valid UUID.')\n            ->assertExitCode(1);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Console/Commands/Admin/UserCreateCommandCommandTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Console\\Commands\\Admin;\n\nuse App\\Console\\Commands\\Admin\\UserCreateCommand;\nuse App\\Models\\User;\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Support\\Facades\\Artisan;\nuse Illuminate\\Support\\Facades\\Hash;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCaseWithDatabase;\n\n#[CoversClass(UserCreateCommand::class)]\nclass UserCreateCommandCommandTest extends TestCaseWithDatabase\n{\n    public function test_it_creates_user(): void\n    {\n        // Arrange\n        $email = 'mail@testuser.test';\n        $name = 'Test User';\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('admin:user:create', [\n            'name' => $name,\n            'email' => $email,\n        ]);\n\n        // Assert\n        $this->assertSame(Command::SUCCESS, $exitCode);\n        $output = Artisan::output();\n        $this->assertStringContainsString('Created user \"'.$name.'\" (\"'.$email.'\")', $output);\n        $this->assertDatabaseHas(User::class, [\n            'name' => $name,\n            'email' => $email,\n            'email_verified_at' => null,\n        ]);\n    }\n\n    public function test_created_user_is_verified_if_option_is_set(): void\n    {\n        // Arrange\n        $email = 'mail@testuser.test';\n        $name = 'Test User';\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('admin:user:create', [\n            'name' => $name,\n            'email' => $email,\n            '--verify-email' => true,\n        ]);\n\n        // Assert\n        $this->assertSame(Command::SUCCESS, $exitCode);\n        $output = Artisan::output();\n        $this->assertStringContainsString('Created user \"'.$name.'\" (\"'.$email.'\")', $output);\n        $this->assertDatabaseHas(User::class, [\n            'name' => $name,\n            'email' => $email,\n        ]);\n        $user = User::where('email', $email)->first();\n        $this->assertNotNull($user->email_verified_at);\n    }\n\n    public function test_it_fails_if_user_with_email_already_exists(): void\n    {\n        // Arrange\n        $email = 'mail@testuser.test';\n        $name = 'Test User';\n\n        User::factory()->create([\n            'email' => $email,\n        ]);\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('admin:user:create', [\n            'name' => $name,\n            'email' => $email,\n        ]);\n\n        // Assert\n        $this->assertSame(Command::FAILURE, $exitCode);\n        $output = Artisan::output();\n        $this->assertStringContainsString('User with email \"'.$email.'\" already exists.', $output);\n    }\n\n    public function test_it_asks_for_password_if_option_is_set(): void\n    {\n        // Arrange\n        $email = 'mail@testuser.test';\n        $name = 'Test User';\n\n        // Act\n        $this->artisan('admin:user:create', [\n            'name' => $name,\n            'email' => $email,\n            '--ask-for-password' => true,\n        ])\n            ->expectsQuestion('Enter the password', 'password')\n            ->assertExitCode(Command::SUCCESS);\n\n        $this->assertDatabaseHas(User::class, [\n            'name' => $name,\n            'email' => $email,\n            'email_verified_at' => null,\n        ]);\n        $user = User::where('email', $email)->first();\n        $this->assertNotNull($user->password);\n        $this->assertTrue(Hash::check('password', $user->password));\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Console/Commands/Admin/UserVerifyCommandTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Console\\Commands\\Admin;\n\nuse App\\Console\\Commands\\Admin\\UserVerifyCommand;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCaseWithDatabase;\n\n#[CoversClass(UserVerifyCommand::class)]\nclass UserVerifyCommandTest extends TestCaseWithDatabase\n{\n    public function test_it_verifies_user_email(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $user = User::factory()->create([\n            'email_verified_at' => null,\n        ]);\n        $member = Member::factory()->forUser($user)->forOrganization($organization)->create();\n\n        // Act\n        $command = $this->artisan('admin:user:verify', ['email' => $user->email]);\n\n        // Assert\n        $command->expectsOutput('Start verifying user with email \"'.$user->email.'\"')\n            ->expectsOutput('User with email \"'.$user->email.'\" has been verified.')\n            ->assertExitCode(0);\n    }\n\n    public function test_it_fails_if_user_does_not_exist(): void\n    {\n        // Arrange\n        $email = 'test@test.test';\n        $organization = Organization::factory()->create();\n        $user = User::factory()->create([\n            'email' => 'other@test.test',\n            'email_verified_at' => null,\n        ]);\n        $member = Member::factory()->forUser($user)->forOrganization($organization)->create();\n\n        // Act\n        $command = $this->artisan('admin:user:verify', ['email' => $email]);\n\n        // Assert\n        $command->expectsOutput('User with email \"'.$email.'\" not found.')\n            ->assertExitCode(1);\n    }\n\n    public function test_it_fails_if_user_email_is_already_verified(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $user = User::factory()->create([\n            'email_verified_at' => now(),\n        ]);\n        $member = Member::factory()->forUser($user)->forOrganization($organization)->create();\n\n        // Act\n        $command = $this->artisan('admin:user:verify', ['email' => $user->email]);\n\n        // Assert\n        $command->expectsOutput('User with email \"'.$user->email.'\" already verified.')\n            ->assertExitCode(1);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Console/Commands/Auth/AuthSendReminderForExpiringApiTokensCommandTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Console\\Commands\\Auth;\n\nuse App\\Console\\Commands\\Auth\\AuthSendReminderForExpiringApiTokensCommand;\nuse App\\Mail\\AuthApiTokenExpirationReminderMail;\nuse App\\Mail\\AuthApiTokenExpiredMail;\nuse App\\Models\\Passport\\Client;\nuse App\\Models\\Passport\\Token;\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Facades\\Artisan;\nuse Illuminate\\Support\\Facades\\Mail;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCaseWithDatabase;\n\n#[CoversClass(AuthSendReminderForExpiringApiTokensCommand::class)]\nclass AuthSendReminderForExpiringApiTokensCommandTest extends TestCaseWithDatabase\n{\n    public function test_sends_mail_for_expired_api_tokens_but_ignores_the_one_where_the_mail_was_already_sent_and_ignores_non_api_tokens(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $apiClient = Client::factory()->apiClient()->create();\n        $otherClient = Client::factory()->desktopClient()->create();\n        $expiredToken = Token::factory()->forUser($user->user)->forClient($apiClient)->create([\n            'reminder_sent_at' => Carbon::now()->subDays(8),\n            'expired_info_sent_at' => null,\n            'expires_at' => Carbon::now()->subDay(),\n        ]);\n        $expiredTokenWithMailSent = Token::factory()->forUser($user->user)->forClient($apiClient)->create([\n            'reminder_sent_at' => Carbon::now()->subDays(8),\n            'expired_info_sent_at' => Carbon::now(),\n            'expires_at' => Carbon::now()->subDay(),\n        ]);\n        $nonApiToken = Token::factory()->forUser($user->user)->forClient($otherClient)->create([\n            'reminder_sent_at' => null,\n            'expired_info_sent_at' => null,\n            'expires_at' => Carbon::now()->subDay(),\n        ]);\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('auth:send-mails-expiring-api-tokens');\n\n        // Assert\n        $this->assertSame(Command::SUCCESS, $exitCode);\n        $expiredToken->refresh();\n        $expiredTokenWithMailSent->refresh();\n        $nonApiToken->refresh();\n        $this->assertNotNull($expiredToken->expired_info_sent_at);\n        $this->assertNotNull($expiredTokenWithMailSent->expired_info_sent_at);\n        $this->assertNull($nonApiToken->reminder_sent_at);\n        $this->assertNull($nonApiToken->expired_info_sent_at);\n        Mail::assertNotQueued(AuthApiTokenExpirationReminderMail::class);\n        Mail::assertQueued(AuthApiTokenExpiredMail::class, function (AuthApiTokenExpiredMail $mail) use ($user, $expiredToken): bool {\n            return $mail->hasTo($user->user->email) &&\n                $mail->token->is($expiredToken) &&\n                $mail->user->is($user->user);\n        });\n\n        $output = Artisan::output();\n        $this->assertStringContainsString('Finished sending 0 expiring API token emails...', $output);\n        $this->assertStringContainsString('Finished sending 1 expired API token emails...', $output);\n        $this->assertStringContainsString(\n            'Start sending email to user \"'.$user->user->email.'\" ('.\n            $user->user->id.') about expired API token '.$expiredToken->getKey(), $output);\n    }\n\n    public function test_sends_mail_for_api_tokens_that_expire_soon_but_ignores_the_one_where_the_mail_was_already_sent_and_ignores_non_api_tokens(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $apiClient = Client::factory()->apiClient()->create();\n        $otherClient = Client::factory()->desktopClient()->create();\n        $expiringToken = Token::factory()->forUser($user->user)->forClient($apiClient)->create([\n            'reminder_sent_at' => null,\n            'expired_info_sent_at' => null,\n            'expires_at' => Carbon::now()->addDays(6),\n        ]);\n        $expiringTokenWithMailSent = Token::factory()->forUser($user->user)->forClient($apiClient)->create([\n            'reminder_sent_at' => Carbon::now(),\n            'expired_info_sent_at' => null,\n            'expires_at' => Carbon::now()->addDays(6),\n        ]);\n        $nonApiToken = Token::factory()->forUser($user->user)->forClient($otherClient)->create([\n            'reminder_sent_at' => null,\n            'expired_info_sent_at' => null,\n            'expires_at' => Carbon::now()->addDays(6),\n        ]);\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('auth:send-mails-expiring-api-tokens');\n\n        // Assert\n        $this->assertSame(Command::SUCCESS, $exitCode);\n        $expiringToken->refresh();\n        $expiringTokenWithMailSent->refresh();\n        $nonApiToken->refresh();\n        $this->assertNotNull($expiringToken->reminder_sent_at);\n        $this->assertNull($expiringToken->expired_info_sent_at);\n        $this->assertNotNull($expiringTokenWithMailSent->reminder_sent_at);\n        $this->assertNull($expiringTokenWithMailSent->expired_info_sent_at);\n        $this->assertNull($nonApiToken->reminder_sent_at);\n        $this->assertNull($nonApiToken->expired_info_sent_at);\n        Mail::assertNotQueued(AuthApiTokenExpiredMail::class);\n        Mail::assertQueued(AuthApiTokenExpirationReminderMail::class, function (AuthApiTokenExpirationReminderMail $mail) use ($user, $expiringToken): bool {\n            return $mail->hasTo($user->user->email) &&\n                $mail->token->is($expiringToken) &&\n                $mail->user->is($user->user);\n        });\n\n        $output = Artisan::output();\n        $this->assertStringContainsString('Finished sending 1 expiring API token emails...', $output);\n        $this->assertStringContainsString('Finished sending 0 expired API token emails...', $output);\n        $this->assertStringContainsString(\n            'Start sending email to user \"'.$user->user->email.'\" ('.\n            $user->user->id.') reminding about API token '.$expiringToken->getKey(), $output);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Console/Commands/Correction/CorrectionPlaceholderMembersCommandTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Console\\Commands\\Correction;\n\nuse App\\Console\\Commands\\Correction\\CorrectionPlaceholderMembersCommand;\nuse App\\Enums\\Role;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Support\\Facades\\Artisan;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCaseWithDatabase;\n\n#[CoversClass(CorrectionPlaceholderMembersCommand::class)]\nclass CorrectionPlaceholderMembersCommandTest extends TestCaseWithDatabase\n{\n    public function test_sets_member_role_to_placeholder_if_user_is_placeholder(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $user1 = User::factory()->placeholder()->create();\n        $member1 = Member::factory()->forOrganization($organization)->forUser($user1)->role(Role::Admin)->create();\n        $user2 = User::factory()->create();\n        $member2 = Member::factory()->forOrganization($organization)->forUser($user2)->role(Role::Admin)->create();\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('correction:placeholder-members');\n\n        // Assert\n        $this->assertSame(Command::SUCCESS, $exitCode);\n        $output = Artisan::output();\n        $member1->refresh();\n        $this->assertSame(Role::Placeholder->value, $member1->role);\n        $member2->refresh();\n        $this->assertSame(Role::Admin->value, $member2->role);\n        $this->assertSame(\"Sets all members who belong to a placeholder user to role placeholder...\\n\".\n            'Set role of member (id='.$member1->getKey().\") to placeholder\\n\", $output);\n    }\n\n    public function test_sets_member_role_to_placeholder_if_user_is_placeholder_dry_run(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $user1 = User::factory()->placeholder()->create();\n        $member1 = Member::factory()->forOrganization($organization)->forUser($user1)->role(Role::Admin)->create();\n        $user2 = User::factory()->create();\n        $member2 = Member::factory()->forOrganization($organization)->forUser($user2)->role(Role::Admin)->create();\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('correction:placeholder-members --dry-run');\n\n        // Assert\n        $this->assertSame(Command::SUCCESS, $exitCode);\n        $output = Artisan::output();\n        $member1->refresh();\n        $this->assertSame(Role::Admin->value, $member1->role);\n        $member2->refresh();\n        $this->assertSame(Role::Admin->value, $member2->role);\n        $this->assertSame(\"Sets all members who belong to a placeholder user to role placeholder...\\n\".\n            \"Running in dry-run mode. Nothing will be saved to the database.\\n\".\n            'Set role of member (id='.$member1->getKey().\") to placeholder\\n\", $output);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Console/Commands/Report/ReportSetExpiredToPrivateCommandTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Console\\Commands\\Report;\n\nuse App\\Console\\Commands\\Report\\ReportSetExpiredToPrivateCommand;\nuse App\\Models\\Report;\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Support\\Facades\\Artisan;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCaseWithDatabase;\n\n#[CoversClass(ReportSetExpiredToPrivateCommand::class)]\nclass ReportSetExpiredToPrivateCommandTest extends TestCaseWithDatabase\n{\n    public function test_command_sets_expired_reports_to_private(): void\n    {\n        // Arrange\n        $reportPrivateExpired = Report::factory()->private()->create([\n            'public_until' => now()->subDay(),\n        ]);\n        $reportPublicExpired = Report::factory()->public()->create([\n            'public_until' => now()->subDay(),\n        ]);\n        $reportPrivateNoExpiration = Report::factory()->private()->create([\n            'public_until' => null,\n        ]);\n        $reportPublicNoExpiration = Report::factory()->public()->create([\n            'public_until' => null,\n        ]);\n        $reportPrivateNotExpired = Report::factory()->private()->create([\n            'public_until' => now()->addDay(),\n        ]);\n        $reportPublicNotExpired = Report::factory()->public()->create([\n            'public_until' => now()->addDay(),\n        ]);\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('report:set-expired-to-private');\n\n        // Assert\n        $this->assertSame(Command::SUCCESS, $exitCode);\n        $output = Artisan::output();\n        $this->assertStringContainsString('Makes public reports private if the public_until date has passed...', $output);\n        $this->assertStringContainsString('Make report \"'.$reportPrivateExpired->name.'\" ('.$reportPrivateExpired->getKey().') private, expired: '.$reportPrivateExpired->public_until->toIso8601ZuluString().' ('.$reportPrivateExpired->public_until->diffForHumans().')', $output);\n        $this->assertStringContainsString('Make report \"'.$reportPublicExpired->name.'\" ('.$reportPublicExpired->getKey().') private, expired: '.$reportPublicExpired->public_until->toIso8601ZuluString().' ('.$reportPublicExpired->public_until->diffForHumans().')', $output);\n        $this->assertStringContainsString('Finished setting 2 expired reports to private...', $output);\n        $reportPrivateExpired->refresh();\n        $reportPublicExpired->refresh();\n        $reportPrivateNoExpiration->refresh();\n        $reportPublicNoExpiration->refresh();\n        $reportPrivateNotExpired->refresh();\n        $reportPublicNotExpired->refresh();\n        $this->assertFalse($reportPrivateExpired->is_public);\n        $this->assertNull($reportPrivateExpired->share_secret);\n        $this->assertFalse($reportPublicExpired->is_public);\n        $this->assertNull($reportPublicExpired->share_secret);\n        $this->assertFalse($reportPrivateNoExpiration->is_public);\n        $this->assertNull($reportPrivateNoExpiration->share_secret);\n        $this->assertTrue($reportPublicNoExpiration->is_public);\n        $this->assertNotNull($reportPublicNoExpiration->share_secret);\n        $this->assertFalse($reportPrivateNotExpired->is_public);\n        $this->assertNull($reportPrivateNotExpired->share_secret);\n        $this->assertTrue($reportPublicNotExpired->is_public);\n        $this->assertNotNull($reportPublicNotExpired->share_secret);\n    }\n\n    public function test_command_sets_expired_reports_to_private_in_dry_run_mode(): void\n    {\n        // Arrange\n        $reportPrivateExpired = Report::factory()->private()->create([\n            'public_until' => now()->subDay(),\n        ]);\n        $reportPublicExpired = Report::factory()->public()->create([\n            'public_until' => now()->subDay(),\n        ]);\n        $reportPrivateNoExpiration = Report::factory()->private()->create([\n            'public_until' => null,\n        ]);\n        $reportPublicNoExpiration = Report::factory()->public()->create([\n            'public_until' => null,\n        ]);\n        $reportPrivateNotExpired = Report::factory()->private()->create([\n            'public_until' => now()->addDay(),\n        ]);\n        $reportPublicNotExpired = Report::factory()->public()->create([\n            'public_until' => now()->addDay(),\n        ]);\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('report:set-expired-to-private', ['--dry-run' => true]);\n\n        // Assert\n        $this->assertSame(Command::SUCCESS, $exitCode);\n        $output = Artisan::output();\n        $this->assertStringContainsString('Makes public reports private if the public_until date has passed...', $output);\n        $this->assertStringContainsString('Running in dry-run mode. Nothing will be saved to the database.', $output);\n        $this->assertStringContainsString('Make report \"'.$reportPrivateExpired->name.'\" ('.$reportPrivateExpired->getKey().') private, expired: '.$reportPrivateExpired->public_until->toIso8601ZuluString().' ('.$reportPrivateExpired->public_until->diffForHumans().')', $output);\n        $this->assertStringContainsString('Make report \"'.$reportPublicExpired->name.'\" ('.$reportPublicExpired->getKey().') private, expired: '.$reportPublicExpired->public_until->toIso8601ZuluString().' ('.$reportPublicExpired->public_until->diffForHumans().')', $output);\n        $this->assertStringContainsString('Finished setting 2 expired reports to private...', $output);\n        $reportPrivateExpired->refresh();\n        $reportPublicExpired->refresh();\n        $reportPrivateNoExpiration->refresh();\n        $reportPublicNoExpiration->refresh();\n        $reportPrivateNotExpired->refresh();\n        $reportPublicNotExpired->refresh();\n        $this->assertFalse($reportPrivateExpired->is_public);\n        $this->assertNull($reportPrivateExpired->share_secret);\n        $this->assertTrue($reportPublicExpired->is_public);\n        $this->assertNotNull($reportPublicExpired->share_secret);\n        $this->assertFalse($reportPrivateNoExpiration->is_public);\n        $this->assertNull($reportPrivateNoExpiration->share_secret);\n        $this->assertTrue($reportPublicNoExpiration->is_public);\n        $this->assertNotNull($reportPublicNoExpiration->share_secret);\n        $this->assertFalse($reportPrivateNotExpired->is_public);\n        $this->assertNull($reportPrivateNotExpired->share_secret);\n        $this->assertTrue($reportPublicNotExpired->is_public);\n        $this->assertNotNull($reportPublicNotExpired->share_secret);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Console/Commands/SelfHost/SelfHostCheckForUpdateCommandTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Console\\Commands\\SelfHost;\n\nuse App\\Console\\Commands\\SelfHost\\SelfHostCheckForUpdateCommand;\nuse App\\Service\\ApiService;\nuse Cache;\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Support\\Facades\\Artisan;\nuse Illuminate\\Support\\Facades\\Http;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCase;\n\n#[CoversClass(SelfHostCheckForUpdateCommand::class)]\n#[CoversClass(ApiService::class)]\nclass SelfHostCheckForUpdateCommandTest extends TestCase\n{\n    public function test_checks_for_update_and_saves_version_in_cache(): void\n    {\n        // Arrange\n        Http::fake([\n            'https://app.solidtime.io/api/v1/ping/version' => Http::response(['version' => '1.2.3'], 200),\n        ]);\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:check-for-update');\n\n        // Assert\n        $this->assertSame(Command::SUCCESS, $exitCode);\n        $output = Artisan::output();\n        $this->assertSame('1.2.3', Cache::get('latest_version'));\n    }\n\n    public function test_checks_for_update_fails_gracefully_if_response_has_error_status_code(): void\n    {\n        // Arrange\n        Http::fake([\n            'https://app.solidtime.io/api/v1/ping/version' => Http::response(null, 500),\n        ]);\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:check-for-update');\n\n        // Assert\n        $this->assertSame(Command::FAILURE, $exitCode);\n        $output = Artisan::output();\n        $this->assertStringContainsString('Failed to check for update, check the logs for more information.', $output);\n    }\n\n    public function test_checks_for_update_fails_gracefully_if_timeout_happens(): void\n    {\n        // Arrange\n        Http::fake([\n            'https://app.solidtime.io/api/v1/ping/version' => function (): void {\n                throw new ConnectionException('Connection timed out');\n            },\n        ]);\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:check-for-update');\n\n        // Assert\n        $this->assertSame(Command::FAILURE, $exitCode);\n        $output = Artisan::output();\n        $this->assertStringContainsString('Failed to check for update, check the logs for more information.', $output);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Console/Commands/SelfHost/SelfHostDatabaseConsistencyCommandTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Console\\Commands\\SelfHost;\n\nuse App\\Console\\Commands\\SelfHost\\SelfHostDatabaseConsistency;\nuse App\\Enums\\Role;\nuse App\\Models\\Client;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\Task;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Support\\Facades\\Artisan;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCaseWithDatabase;\n\n#[CoversClass(SelfHostDatabaseConsistency::class)]\nclass SelfHostDatabaseConsistencyCommandTest extends TestCaseWithDatabase\n{\n    public function test_checks_that_task_need_to_be_part_of_project_in_time_entries(): void\n    {\n        // Arrange\n        $user = $this->createUserWithRole(Role::Owner);\n        $project1 = Project::factory()->forOrganization($user->organization)->create();\n        $project2 = Project::factory()->forOrganization($user->organization)->create();\n        $task = Task::factory()->forOrganization($user->organization)->forProject($project1)->create();\n        $timeEntry = TimeEntry::factory()->forMember($user->member)->forTask($task)->forProject($project2)->create();\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:database-consistency');\n\n        // Assert\n        $this->assertSame(Command::FAILURE, $exitCode);\n        $output = Artisan::output();\n        $this->assertSame(\"Consistency problem: Time entries have a task that does not belong to the project of the time entry\\n  - \".$timeEntry->getKey().\"\\n\", $output);\n    }\n\n    public function test_checks_that_client_id_is_the_client_id_of_the_project(): void\n    {\n        // Arrange\n        $user = $this->createUserWithRole(Role::Owner);\n        $client1 = Client::factory()->forOrganization($user->organization)->create();\n        $client2 = Client::factory()->forOrganization($user->organization)->create();\n        $project = Project::factory()->forOrganization($user->organization)->forClient($client1)->create();\n        $timeEntry = TimeEntry::factory()->forMember($user->member)->forProject($project)->create([\n            'client_id' => $client2->id,\n        ]);\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:database-consistency');\n\n        // Assert\n        $this->assertSame(Command::FAILURE, $exitCode);\n        $output = Artisan::output();\n        $this->assertSame(\"Consistency problem: Time entries have a client that does not match the client of the project\\n  - \".$timeEntry->getKey().\"\\n\", $output);\n    }\n\n    public function test_checks_that_client_id_is_the_client_id_of_the_project_with_no_client_in_time_entry(): void\n    {\n        // Arrange\n        $user = $this->createUserWithRole(Role::Owner);\n        $client1 = Client::factory()->forOrganization($user->organization)->create();\n        $client2 = Client::factory()->forOrganization($user->organization)->create();\n        $project = Project::factory()->forOrganization($user->organization)->forClient($client1)->create();\n        $timeEntry = TimeEntry::factory()->forMember($user->member)->forProject($project)->create([\n            'client_id' => null,\n        ]);\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:database-consistency');\n\n        // Assert\n        $this->assertSame(Command::FAILURE, $exitCode);\n        $output = Artisan::output();\n        $this->assertSame(\"Consistency problem: Time entries have a client that does not match the client of the project\\n  - \".$timeEntry->getKey().\"\\n\", $output);\n    }\n\n    public function test_checks_that_client_id_is_only_null_if_project_is_also_null(): void\n    {\n        // Arrange\n        $user = $this->createUserWithRole(Role::Owner);\n        $client1 = Client::factory()->forOrganization($user->organization)->create();\n        $project = Project::factory()->forOrganization($user->organization)->forClient($client1)->create();\n        $timeEntry = TimeEntry::factory()->forMember($user->member)->create([\n            'client_id' => $client1->getKey(),\n        ]);\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:database-consistency');\n\n        // Assert\n        $this->assertSame(Command::FAILURE, $exitCode);\n        $output = Artisan::output();\n        $this->assertSame(\"Consistency problem: Time entries have a client but no project\\n  - \".$timeEntry->getKey().\"\\n\", $output);\n    }\n\n    public function test_checks_that_every_user_needs_to_be_a_member_of_at_least_one_organization(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:database-consistency');\n\n        // Assert\n        $this->assertSame(Command::FAILURE, $exitCode);\n        $output = Artisan::output();\n        $this->assertSame(\"Consistency problem: Users are not member of any organization\\n  - \".$user->getKey().\"\\n\", $output);\n    }\n\n    public function test_checks_that_every_organization_needs_at_least_an_owner(): void\n    {\n        // Arrange\n        $user = $this->createUserWithRole(Role::Owner);\n        $organization = Organization::factory()->withOwner($user->user)->create();\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:database-consistency');\n\n        // Assert\n        $this->assertSame(Command::FAILURE, $exitCode);\n        $output = Artisan::output();\n        $this->assertSame(\"Consistency problem: Organizations without an owner\\n  - \".$organization->getKey().\"\\n\", $output);\n    }\n\n    public function test_checks_that_every_member_can_only_have_one_running_time_entry(): void\n    {\n        // Arrange\n        $user = $this->createUserWithRole(Role::Owner);\n        $timeEntry1 = TimeEntry::factory()->forMember($user->member)->active()->create();\n        $timeEntry2 = TimeEntry::factory()->forMember($user->member)->active()->create();\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:database-consistency');\n\n        // Assert\n        $this->assertSame(Command::FAILURE, $exitCode);\n        $output = Artisan::output();\n        $this->assertSame(\"Consistency problem: Users with more than one running time entry\\n  - \".$user->user->getKey().\"\\n\", $output);\n    }\n\n    public function test_checks_that_users_have_a_current_organization_that_they_are_not_a_member_of(): void\n    {\n        // Arrange\n        $user1 = $this->createUserWithRole(Role::Owner);\n        $user2 = $this->createUserWithRole(Role::Owner);\n        $user1->user->currentOrganization()->associate($user2->organization);\n        $user1->user->save();\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:database-consistency');\n\n        // Assert\n        $this->assertSame(Command::FAILURE, $exitCode);\n        $output = Artisan::output();\n        $this->assertSame(\"Consistency problem: Users have a current organization that they are not a member of\\n  - \".$user1->user->getKey().\"\\n\", $output);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Console/Commands/SelfHost/SelfHostGenerateKeysCommandTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Console\\Commands\\SelfHost;\n\nuse App\\Console\\Commands\\SelfHost\\SelfHostGenerateKeysCommand;\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Support\\Facades\\Artisan;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCase;\n\n#[CoversClass(SelfHostGenerateKeysCommand::class)]\nclass SelfHostGenerateKeysCommandTest extends TestCase\n{\n    public function test_generates_app_key_and_passport_keys_per_default_in_env_format(): void\n    {\n        // Arrange\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:generate-keys');\n\n        // Assert\n        $this->assertSame(Command::SUCCESS, $exitCode);\n        $output = Artisan::output();\n        $this->assertStringContainsString('APP_KEY=\"base64:', $output);\n        $this->assertStringContainsString('PASSPORT_PRIVATE_KEY=\"-----BEGIN PRIVATE KEY-----\\n', $output);\n        $this->assertStringContainsString('PASSPORT_PUBLIC_KEY=\"-----BEGIN PUBLIC KEY-----\\n', $output);\n    }\n\n    public function test_generates_app_key_and_passport_keys_in_env_format_in_multiline_if_requested(): void\n    {\n        // Arrange\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:generate-keys --multi-line');\n\n        // Assert\n        $this->assertSame(Command::SUCCESS, $exitCode);\n        $output = Artisan::output();\n        $this->assertStringContainsString('APP_KEY=\"base64:', $output);\n        $this->assertStringContainsString(\"PASSPORT_PRIVATE_KEY=\\\"-----BEGIN PRIVATE KEY-----\\n\", $output);\n        $this->assertStringContainsString(\"PASSPORT_PUBLIC_KEY=\\\"-----BEGIN PUBLIC KEY-----\\n\", $output);\n    }\n\n    public function test_generates_app_key_and_passport_keys_in_yaml_format_if_requested(): void\n    {\n        // Arrange\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:generate-keys --format=yaml');\n\n        // Assert\n        $this->assertSame(Command::SUCCESS, $exitCode);\n        $output = Artisan::output();\n        $this->assertStringContainsString('APP_KEY: \"base64:', $output);\n        $this->assertStringContainsString(\"PASSPORT_PRIVATE_KEY: |\\n  -----BEGIN PRIVATE KEY-----\", $output);\n        $this->assertStringContainsString(\"PASSPORT_PUBLIC_KEY: |\\n  -----BEGIN PUBLIC KEY-----\", $output);\n    }\n\n    public function test_generates_app_fail_if_attribute_format_is_invalid(): void\n    {\n        // Arrange\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:generate-keys --format=invalid');\n\n        // Assert\n        $this->assertSame(Command::FAILURE, $exitCode);\n        $output = Artisan::output();\n        $this->assertSame(\"Invalid format\\n\", $output);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Console/Commands/SelfHost/SelfHostTelemetryCommandTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Console\\Commands\\SelfHost;\n\nuse App\\Console\\Commands\\SelfHost\\SelfHostTelemetryCommand;\nuse App\\Service\\ApiService;\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Support\\Facades\\Artisan;\nuse Illuminate\\Support\\Facades\\Http;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCase;\n\n#[CoversClass(SelfHostTelemetryCommand::class)]\n#[CoversClass(ApiService::class)]\nclass SelfHostTelemetryCommandTest extends TestCase\n{\n    public function test_telemetry_sends_data_to_telemetry_endpoint_of_solidtime_cloud(): void\n    {\n        // Arrange\n        Http::fake([\n            'https://app.solidtime.io/api/v1/ping/telemetry' => Http::response(['success' => true], 200),\n        ]);\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:telemetry');\n\n        // Assert\n        $this->assertSame(Command::SUCCESS, $exitCode);\n        $output = Artisan::output();\n        $this->assertSame('', $output);\n    }\n\n    public function test_telemetry_sends_fails_gracefully_if_response_has_error_status_code(): void\n    {\n        // Arrange\n        Http::fake([\n            'https://app.solidtime.io/api/v1/ping/telemetry' => Http::response(null, 500),\n        ]);\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:telemetry');\n\n        // Assert\n        $this->assertSame(Command::FAILURE, $exitCode);\n        $output = Artisan::output();\n        $this->assertStringContainsString('Failed to send telemetry data, check the logs for more information.', $output);\n    }\n\n    public function test_telemetry_sends_fails_gracefully_if_timeout_happens(): void\n    {\n        // Arrange\n        Http::fake([\n            'https://app.solidtime.io/api/v1/ping/telemetry' => function (): void {\n                throw new ConnectionException('Connection timed out');\n            },\n        ]);\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:telemetry');\n\n        // Assert\n        $this->assertSame(Command::FAILURE, $exitCode);\n        $output = Artisan::output();\n        $this->assertStringContainsString('Failed to send telemetry data, check the logs for more information.', $output);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Console/Commands/TimeEntry/TimeEntrySendStillRunningMailsCommandTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Console\\Commands\\TimeEntry;\n\nuse App\\Console\\Commands\\TimeEntry\\TimeEntrySendStillRunningMailsCommand;\nuse App\\Mail\\TimeEntryStillRunningMail;\nuse App\\Models\\TimeEntry;\nuse Illuminate\\Console\\Command;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Facades\\Artisan;\nuse Illuminate\\Support\\Facades\\Mail;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCaseWithDatabase;\n\n#[CoversClass(TimeEntrySendStillRunningMailsCommand::class)]\nclass TimeEntrySendStillRunningMailsCommandTest extends TestCaseWithDatabase\n{\n    public function test_sends_mails_for_still_running_time_entries(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $timeEntryRunningLongerThanThreshold = TimeEntry::factory()->forMember($user->member)->create([\n            'start' => Carbon::now()->subHours(8)->subSecond(),\n            'end' => null,\n        ]);\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('time-entry:send-still-running-mails');\n\n        // Assert\n        Mail::assertQueued(TimeEntryStillRunningMail::class, function ($mail) use ($user, $timeEntryRunningLongerThanThreshold) {\n            return $mail->hasTo($user->user->email) &&\n                $mail->timeEntry->is($timeEntryRunningLongerThanThreshold) &&\n                $mail->user->is($user->user);\n        });\n        $timeEntryRunningLongerThanThreshold->refresh();\n        $this->assertNotNull($timeEntryRunningLongerThanThreshold->still_active_email_sent_at);\n        $this->assertSame(Command::SUCCESS, $exitCode);\n        $output = Artisan::output();\n        $this->assertSame(\"Sending still running time entry emails...\\n\".\n            'Start sending email to user \"'.$user->user->email.'\" ('.$user->user->getKey().') for time entry '.$timeEntryRunningLongerThanThreshold->getKey().\"\\n\".\n            \"Finished sending 1 still running time entry emails...\\n\", $output);\n\n    }\n\n    public function test_does_not_send_emails_for_not_running_time_entries(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $timeEntry = TimeEntry::factory()->forMember($user->member)->create([\n            'start' => Carbon::now()->subHours(8)->subSecond(),\n            'end' => Carbon::now(),\n        ]);\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('time-entry:send-still-running-mails');\n\n        // Assert\n        Mail::assertNothingOutgoing();\n        $timeEntry->refresh();\n        $this->assertNull($timeEntry->still_active_email_sent_at);\n        $this->assertSame(Command::SUCCESS, $exitCode);\n        $output = Artisan::output();\n        $this->assertSame(\"Sending still running time entry emails...\\n\".\n            \"Finished sending 0 still running time entry emails...\\n\", $output);\n    }\n\n    public function test_does_not_send_emails_for_running_time_entries_that_are_short_than_the_threshold(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $timeEntry = TimeEntry::factory()->forMember($user->member)->create([\n            'start' => Carbon::now()->subHours(8)->addMinute(),\n            'end' => null,\n        ]);\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('time-entry:send-still-running-mails');\n\n        // Assert\n        Mail::assertNothingOutgoing();\n        $timeEntry->refresh();\n        $this->assertNull($timeEntry->still_active_email_sent_at);\n        $this->assertSame(Command::SUCCESS, $exitCode);\n        $output = Artisan::output();\n        $this->assertSame(\"Sending still running time entry emails...\\n\".\n            \"Finished sending 0 still running time entry emails...\\n\", $output);\n    }\n\n    public function test_does_not_send_emails_for_running_time_entries_that_are_longer_than_the_threshold_but_already_received_the_email(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $timeEntry = TimeEntry::factory()->forMember($user->member)->create([\n            'start' => Carbon::now()->subHours(8)->subMinute(),\n            'end' => null,\n            'still_active_email_sent_at' => Carbon::now()->subMinute(),\n        ]);\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('time-entry:send-still-running-mails');\n\n        // Assert\n        Mail::assertNothingOutgoing();\n        $timeEntry->refresh();\n        $this->assertNotNull($timeEntry->still_active_email_sent_at);\n        $this->assertSame(Command::SUCCESS, $exitCode);\n        $output = Artisan::output();\n        $this->assertSame(\"Sending still running time entry emails...\\n\".\n            \"Finished sending 0 still running time entry emails...\\n\", $output);\n    }\n\n    public function test_dry_run_option_does_not_send_mails_but_outputs_what_would_happen(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $timeEntryRunningLongerThanThreshold = TimeEntry::factory()->forMember($user->member)->create([\n            'start' => Carbon::now()->subHours(8)->subSecond(),\n            'end' => null,\n        ]);\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('time-entry:send-still-running-mails --dry-run');\n\n        // Assert\n        Mail::assertNothingOutgoing();\n        $timeEntryRunningLongerThanThreshold->refresh();\n        $this->assertNull($timeEntryRunningLongerThanThreshold->still_active_email_sent_at);\n        $this->assertSame(Command::SUCCESS, $exitCode);\n        $output = Artisan::output();\n        $this->assertSame(\"Sending still running time entry emails...\\n\".\n            \"Running in dry-run mode. No emails will be sent and nothing will be saved to the database.\\n\".\n            'Start sending email to user \"'.$user->user->email.'\" ('.$user->user->getKey().') for time entry '.$timeEntryRunningLongerThanThreshold->getKey().\"\\n\".\n            \"Finished sending 1 still running time entry emails...\\n\", $output);\n    }\n\n    public function test_does_not_send_emails_for_placeholder_users(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $user->user->is_placeholder = true;\n        $user->user->save();\n        $timeEntryRunningLongerThanThreshold = TimeEntry::factory()->forMember($user->member)->create([\n            'start' => Carbon::now()->subHours(8)->subSecond(),\n            'end' => null,\n        ]);\n\n        // Act\n        $exitCode = $this->withoutMockingConsoleOutput()->artisan('time-entry:send-still-running-mails');\n\n        // Assert\n        Mail::assertNothingOutgoing();\n        $timeEntryRunningLongerThanThreshold->refresh();\n        $this->assertNull($timeEntryRunningLongerThanThreshold->still_active_email_sent_at);\n        $this->assertSame(Command::SUCCESS, $exitCode);\n        $output = Artisan::output();\n        $this->assertSame(\"Sending still running time entry emails...\\n\".\n            \"Finished sending 0 still running time entry emails...\\n\", $output);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Console/KernelTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Console;\n\nuse App\\Console\\Kernel;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCase;\n\n#[CoversClass(Kernel::class)]\nclass KernelTest extends TestCase\n{\n    public function test_self_host_commands_schedule_time_is_consistent_with_app_key(): void\n    {\n        // Arrange\n        config([\n            'app.key' => 'base64:cOXN4GLMXYjcdG0fKosnFogofXw1pNoXkLAViRH+a5Y=',\n        ]);\n\n        // Act\n        $schedule1 = app()->make(Kernel::class)->resolveConsoleSchedule();\n        $firstRunEvents = collect($schedule1->events())->filter(fn ($event) => str_contains($event->command, 'self-host:check-for-update') ||\n            str_contains($event->command, 'self-host:telemetry')\n        );\n\n        $schedule2 = app()->make(Kernel::class)->resolveConsoleSchedule();\n        $secondRunEvents = collect($schedule2->events())->filter(fn ($event) => str_contains($event->command, 'self-host:check-for-update') ||\n            str_contains($event->command, 'self-host:telemetry')\n        );\n        config([\n            'app.key' => 'base64:eP58hkQ8l3guqf8wvWJR7pB0weVQtnpjMdYpaVwX4Jw=',\n        ]);\n        $schedule3 = app()->make(Kernel::class)->resolveConsoleSchedule();\n        $thirdRunEvents = collect($schedule3->events())->filter(fn ($event) => str_contains($event->command, 'self-host:check-for-update') ||\n            str_contains($event->command, 'self-host:telemetry')\n        );\n\n        // Assert\n        $this->assertCount(2, $firstRunEvents);\n        $this->assertCount(2, $secondRunEvents);\n        $this->assertCount(2, $thirdRunEvents);\n\n        foreach ($firstRunEvents as $index => $event) {\n            $this->assertSame('52 9,21 * * *', $firstRunEvents[$index]->expression);\n            $this->assertSame('52 9,21 * * *', $secondRunEvents[$index]->expression);\n            $this->assertSame('48 13,1 * * *', $thirdRunEvents[$index]->expression);\n        }\n    }\n\n    public function test_self_hosting_telemetry_can_be_activated(): void\n    {\n        // Arrange\n        config([\n            'scheduling.tasks.self_hosting_telemetry' => true,\n        ]);\n\n        // Act\n        $schedule = app()->make(Kernel::class)->resolveConsoleSchedule();\n        $events = collect($schedule->events())->filter(fn ($event) => str_contains($event->command, 'self-host:telemetry')\n        );\n\n        // Assert\n        $this->assertCount(1, $events);\n    }\n\n    public function test_self_hosting_telemetry_can_be_deactivated(): void\n    {\n        // Arrange\n        config([\n            'scheduling.tasks.self_hosting_telemetry' => false,\n        ]);\n\n        // Act\n        $schedule = app()->make(Kernel::class)->resolveConsoleSchedule();\n        $events = collect($schedule->events())->filter(fn ($event) => str_contains($event->command, 'self-host:telemetry')\n        );\n\n        // Assert\n        $this->assertCount(0, $events);\n    }\n\n    public function test_self_hosting_check_for_update_can_be_activated(): void\n    {\n        // Arrange\n        config([\n            'scheduling.tasks.self_hosting_check_for_update' => true,\n        ]);\n\n        // Act\n        $schedule = app()->make(Kernel::class)->resolveConsoleSchedule();\n        $events = collect($schedule->events())->filter(fn ($event) => str_contains($event->command, 'self-host:check-for-update')\n        );\n\n        // Assert\n        $this->assertCount(1, $events);\n    }\n\n    public function test_self_hosting_check_for_update_can_be_deactivated(): void\n    {\n        // Arrange\n        config([\n            'scheduling.tasks.self_hosting_check_for_update' => false,\n        ]);\n\n        // Act\n        $schedule = app()->make(Kernel::class)->resolveConsoleSchedule();\n        $events = collect($schedule->events())->filter(fn ($event) => str_contains($event->command, 'self-host:check-for-update')\n        );\n\n        // Assert\n        $this->assertCount(0, $events);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Database/MigrationTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Database;\n\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Tests\\TestCase;\n\nclass MigrationTest extends TestCase\n{\n    use RefreshDatabase;\n\n    public function test_fresh_migration_and_rollback_runs_successfully(): void\n    {\n        $this->artisan('migrate:rollback')\n            ->assertSuccessful();\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Database/SeederTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Database;\n\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Illuminate\\Support\\Facades\\Config;\nuse Tests\\TestCase;\n\nclass SeederTest extends TestCase\n{\n    use RefreshDatabase;\n\n    public function test_running_the_seeder_multiple_times_runs_successfully(): void\n    {\n        $this->setupForSeeder();\n        $this->artisan('db:seed')\n            ->assertSuccessful();\n        $this->artisan('db:seed')\n            ->assertSuccessful();\n    }\n\n    public function test_fresh_migration_with_seeder_and_rollback_runs_successfully(): void\n    {\n        $this->setupForSeeder();\n        $this->artisan('db:seed')\n            ->assertSuccessful();\n        $this->artisan('migrate:rollback')\n            ->assertSuccessful();\n    }\n\n    private function setupForSeeder(): void\n    {\n        Config::set('passport.personal_access_client.id', '9e27f54d-5dfb-4dde-99d7-834518236c92');\n        Config::set('passport.personal_access_client.secret', 'EL5mXp3aF8ITjcwoOXRpbSK7zGrWhW4zTDpQXTkf');\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Endpoint/Api/V1/ApiEndpointTestAbstract.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Endpoint\\Api\\V1;\n\nuse Illuminate\\Testing\\TestResponse;\nuse Tests\\TestCaseWithDatabase;\n\nclass ApiEndpointTestAbstract extends TestCaseWithDatabase\n{\n    protected function assertResponseCode(TestResponse $response, int $statusCode): void\n    {\n        if ($response->getStatusCode() !== $statusCode) {\n            dump($response->getContent());\n        }\n        $response->assertStatus($statusCode);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Endpoint/Api/V1/ApiTokenEndpointTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Endpoint\\Api\\V1;\n\nuse App\\Http\\Controllers\\Api\\V1\\ApiTokenController;\nuse App\\Models\\Passport\\Client;\nuse App\\Models\\Passport\\Token;\nuse Laravel\\Passport\\ClientRepository;\nuse Laravel\\Passport\\Passport;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\n\n#[UsesClass(ApiTokenController::class)]\nclass ApiTokenEndpointTest extends ApiEndpointTestAbstract\n{\n    public function test_index_endpoint_returns_list_api_tokens(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([]);\n        $personalAccessClient = $this->createPersonalAccessClient();\n        $client = $this->createClient();\n        $token = Token::factory()->forUser($data->user)->forClient($personalAccessClient)->create();\n        $otherTokenType = Token::factory()->forUser($data->user)->forClient($client)->create();\n        $otherData = $this->createUserWithPermission([]);\n        $otherToken = Token::factory()->forUser($otherData->user)->forClient($personalAccessClient)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.api-tokens.index'));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $response->assertJsonCount(1, 'data');\n        $response->assertExactJson([\n            'data' => [\n                [\n                    'id' => $token->id,\n                    'name' => $token->name,\n                    'scopes' => $token->scopes,\n                    'revoked' => $token->revoked,\n                    'created_at' => $token->created_at->toIso8601ZuluString(),\n                    'expires_at' => $token->expires_at->toIso8601ZuluString(),\n                ],\n            ],\n        ]);\n    }\n\n    public function test_index_endpoint_returns_api_tokens_ordered_by_created_at_descending(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([]);\n        $personalAccessClient = $this->createPersonalAccessClient();\n        $tokenOldest = Token::factory()->forUser($data->user)->forClient($personalAccessClient)->create([\n            'created_at' => now()->subDays(3),\n        ]);\n        $tokenNewest = Token::factory()->forUser($data->user)->forClient($personalAccessClient)->create([\n            'created_at' => now()->subDay(),\n        ]);\n        $tokenMiddle = Token::factory()->forUser($data->user)->forClient($personalAccessClient)->create([\n            'created_at' => now()->subDays(2),\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.api-tokens.index'));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $ids = collect($response->json('data'))->pluck('id')->values()->toArray();\n        $this->assertSame([$tokenNewest->id, $tokenMiddle->id, $tokenOldest->id], $ids);\n    }\n\n    public function test_store_endpoint_creates_new_api_token(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([]);\n        $personalAccessClient = $this->createPersonalAccessClient();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.api-tokens.store'), [\n            'name' => 'Test Token',\n        ]);\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $response->assertJsonStructure([\n            'data' => [\n                'id',\n                'name',\n                'scopes',\n                'revoked',\n                'created_at',\n                'expires_at',\n                'access_token',\n            ],\n        ]);\n    }\n\n    public function test_store_fails_if_personal_access_client_is_not_configured(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.api-tokens.store'), [\n            'name' => 'Test Token',\n        ]);\n\n        // Assert\n        $this->assertResponseCode($response, 400);\n        $response->assertExactJson([\n            'error' => true,\n            'key' => 'personal_access_client_is_not_configured',\n            'message' => 'Personal access client is not configured',\n        ]);\n    }\n\n    public function test_revoke_endpoint_revokes_api_token(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([]);\n        $client = $this->createPersonalAccessClient();\n        $token = Token::factory()->forUser($data->user)->forClient($client)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.api-tokens.revoke', $token->id));\n\n        // Assert\n        $this->assertResponseCode($response, 204);\n        $this->assertDatabaseHas(Token::class, [\n            'id' => $token->id,\n            'revoked' => true,\n        ]);\n    }\n\n    public function test_revoke_fails_if_token_is_not_personal_access_token(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([]);\n        $personalAccessClient = $this->createPersonalAccessClient();\n        $client = $this->createClient();\n        $token = Token::factory()->forUser($data->user)->forClient($client)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.api-tokens.revoke', $token->id));\n\n        // Assert\n        $this->assertResponseCode($response, 403);\n        $this->assertDatabaseHas(Token::class, [\n            'id' => $token->id,\n            'revoked' => false,\n        ]);\n    }\n\n    public function test_revoke_fails_if_token_with_id_does_not_exist(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.api-tokens.revoke', 'not-valid'));\n\n        // Assert\n        $this->assertResponseCode($response, 404);\n    }\n\n    public function test_revoke_fails_if_the_token_does_not_belong_to_the_user(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([]);\n        $otherData = $this->createUserWithPermission([]);\n        $client = $this->createPersonalAccessClient();\n        $token = Token::factory()->forUser($otherData->user)->forClient($client)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.api-tokens.revoke', $token->id));\n\n        // Assert\n        $this->assertResponseCode($response, 403);\n        $this->assertDatabaseHas(Token::class, [\n            'id' => $token->id,\n            'revoked' => false,\n        ]);\n    }\n\n    public function test_destroy_endpoint_deletes_api_token(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([]);\n        $client = $this->createPersonalAccessClient();\n        $token = Token::factory()->forUser($data->user)->forClient($client)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.api-tokens.destroy', $token->id));\n\n        // Assert\n        $this->assertResponseCode($response, 204);\n        $this->assertDatabaseMissing(Token::class, ['id' => $token->id]);\n    }\n\n    public function test_destroy_fails_if_token_is_not_personal_access_token(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([]);\n        $personalAccessClient = $this->createPersonalAccessClient();\n        $client = $this->createClient();\n        $token = Token::factory()->forUser($data->user)->forClient($client)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.api-tokens.destroy', $token->id));\n\n        // Assert\n        $this->assertResponseCode($response, 403);\n        $this->assertDatabaseHas(Token::class, [\n            'id' => $token->id,\n        ]);\n    }\n\n    public function test_destroy_fails_if_token_with_id_does_not_exist(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.api-tokens.destroy', 'not-valid'));\n\n        // Assert\n        $this->assertResponseCode($response, 404);\n    }\n\n    public function test_destroy_fails_if_the_token_does_not_belong_to_the_user(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([]);\n        $otherData = $this->createUserWithPermission([]);\n        $client = $this->createPersonalAccessClient();\n        $token = Token::factory()->forUser($otherData->user)->forClient($client)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.api-tokens.destroy', $token->id));\n\n        // Assert\n        $this->assertResponseCode($response, 403);\n        $this->assertDatabaseHas(Token::class, [\n            'id' => $token->id,\n        ]);\n    }\n\n    private function createPersonalAccessClient(): Client\n    {\n        $clientRepository = new ClientRepository;\n        /** @var Client $client */\n        $client = $clientRepository->createPersonalAccessGrantClient('Test Personal Access Client');\n\n        return $client;\n    }\n\n    private function createClient(): Client\n    {\n        $clientRepository = new ClientRepository;\n        /** @var Client $client */\n        $client = $clientRepository->createAuthorizationCodeGrantClient(\n            name: 'Desktop App',\n            redirectUris: ['http://localhost']\n        );\n\n        return $client;\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Endpoint/Api/V1/ChartEndpointTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Endpoint\\Api\\V1;\n\nuse App\\Enums\\Role;\nuse Laravel\\Passport\\Passport;\nuse Tests\\Unit\\Endpoint\\Web\\EndpointTestAbstract;\n\nclass ChartEndpointTest extends EndpointTestAbstract\n{\n    public function test_weekly_project_overview_endpoint_fails_if_user_has_no_permission_to_view_chart(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.charts.weekly-project-overview', [\n            'organization' => $user->organization,\n        ]));\n\n        // Assert\n        $response->assertStatus(403);\n    }\n\n    public function test_weekly_project_overview_endpoint_returns_chart_data(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission(['charts:view:own']);\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.charts.weekly-project-overview', [\n            'organization' => $user->organization,\n        ]));\n\n        // Assert\n        $response->assertOk();\n    }\n\n    public function test_latest_tasks_endpoint_fails_if_user_has_no_permission_to_view_chart(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.charts.latest-tasks', [\n            'organization' => $user->organization,\n        ]));\n\n        // Assert\n        $response->assertStatus(403);\n    }\n\n    public function test_latest_tasks_endpoint_returns_chart_data(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission(['charts:view:own']);\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.charts.latest-tasks', [\n            'organization' => $user->organization,\n        ]));\n\n        // Assert\n        $response->assertOk();\n    }\n\n    public function test_last_seven_days_endpoint_fails_if_user_has_no_permission_to_view_chart(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.charts.last-seven-days', [\n            'organization' => $user->organization,\n        ]));\n\n        // Assert\n        $response->assertStatus(403);\n    }\n\n    public function test_last_seven_days_endpoint_returns_chart_data(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission(['charts:view:own']);\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.charts.last-seven-days', [\n            'organization' => $user->organization,\n        ]));\n\n        // Assert\n        $response->assertOk();\n    }\n\n    public function test_latest_team_activity_endpoint_fails_if_user_has_no_permission_to_view_chart_for_the_whole_orgnaization(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.charts.latest-team-activity', [\n            'organization' => $user->organization,\n        ]));\n\n        // Assert\n        $response->assertStatus(403);\n    }\n\n    public function test_latest_team_activity_endpoint_returns_chart_data(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission(['charts:view:all']);\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.charts.latest-team-activity', [\n            'organization' => $user->organization,\n        ]));\n\n        // Assert\n        $response->assertOk();\n    }\n\n    public function test_daily_tracked_hours_endpoint_fails_if_user_has_no_permission_to_view_chart(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.charts.daily-tracked-hours', [\n            'organization' => $user->organization,\n        ]));\n\n        // Assert\n        $response->assertStatus(403);\n    }\n\n    public function test_daily_tracked_hours_endpoint_returns_chart_data(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission(['charts:view:own']);\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.charts.daily-tracked-hours', [\n            'organization' => $user->organization,\n        ]));\n\n        // Assert\n        $response->assertOk();\n    }\n\n    public function test_total_weekly_time_endpoint_fails_if_user_has_no_permission_to_view_chart(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.charts.total-weekly-time', [\n            'organization' => $user->organization,\n        ]));\n\n        // Assert\n        $response->assertStatus(403);\n    }\n\n    public function test_total_weekly_time_endpoint_returns_chart_data(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission(['charts:view:own']);\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.charts.total-weekly-time', [\n            'organization' => $user->organization,\n        ]));\n\n        // Assert\n        $response->assertOk();\n    }\n\n    public function test_total_weekly_billable_time_endpoint_fails_if_user_has_no_permission_to_view_chart(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.charts.total-weekly-billable-time', [\n            'organization' => $user->organization,\n        ]));\n\n        // Assert\n        $response->assertStatus(403);\n    }\n\n    public function test_total_weekly_billable_time_endpoint_returns_chart_data(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission(['charts:view:own']);\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.charts.total-weekly-billable-time', [\n            'organization' => $user->organization,\n        ]));\n\n        // Assert\n        $response->assertOk();\n    }\n\n    public function test_total_weekly_billable_amount_endpoint_fails_if_user_has_no_permission_to_view_chart(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.charts.total-weekly-billable-amount', [\n            'organization' => $user->organization,\n        ]));\n\n        // Assert\n        $response->assertStatus(403);\n    }\n\n    public function test_total_weekly_billable_amount_endpoint_fails_if_the_user_is_an_employee_but_the_organization_does_not_allow_employees_to_view_billable_rates(): void\n    {\n        // Arrange\n        $user = $this->createUserWithRole(Role::Employee);\n        $organization = $user->organization;\n        $organization->employees_can_see_billable_rates = false;\n        $organization->save();\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.charts.total-weekly-billable-amount', [\n            'organization' => $organization,\n        ]));\n\n        // Assert\n        $response->assertStatus(403);\n    }\n\n    public function test_total_weekly_billable_amount_endpoint_returns_chart_data(): void\n    {\n        // Arrange\n        $user = $this->createUserWithRole(Role::Employee);\n        $organization = $user->organization;\n        $organization->employees_can_see_billable_rates = true;\n        $organization->save();\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.charts.total-weekly-billable-amount', [\n            'organization' => $user->organization,\n        ]));\n\n        // Assert\n        $response->assertOk();\n    }\n\n    public function test_weekly_history_endpoint_fails_if_user_has_no_permission_to_view_chart(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.charts.weekly-history', [\n            'organization' => $user->organization,\n        ]));\n\n        // Assert\n        $response->assertStatus(403);\n    }\n\n    public function test_weekly_history_endpoint_returns_chart_data(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission(['charts:view:own']);\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.charts.weekly-history', [\n            'organization' => $user->organization,\n        ]));\n\n        // Assert\n        $response->assertOk();\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Endpoint/Api/V1/ClientEndpointTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Endpoint\\Api\\V1;\n\nuse App\\Http\\Controllers\\Api\\V1\\ClientController;\nuse App\\Models\\Client;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse Illuminate\\Testing\\Fluent\\AssertableJson;\nuse Laravel\\Passport\\Passport;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\n\n#[UsesClass(ClientController::class)]\nclass ClientEndpointTest extends ApiEndpointTestAbstract\n{\n    public function test_index_endpoint_fails_if_user_has_no_permission_to_view_clients(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $clients = Client::factory()->forOrganization($data->organization)->createMany(4);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.clients.index', [$data->organization->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_index_endpoint_returns_list_of_all_clients_of_organization_ordered_by_created_at_desc_per_default(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'clients:view',\n            'clients:view:all',\n        ]);\n        $clients = Client::factory()->forOrganization($data->organization)->randomCreatedAt()->createMany(4);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.clients.index', [$data->organization->getKey()]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonCount(4, 'data');\n        $clients = Client::query()->orderBy('created_at', 'desc')->get();\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->has('links')\n            ->has('meta')\n            ->count('data', 4)\n            ->where('data.0.id', $clients->get(0)->getKey())\n            ->where('data.1.id', $clients->get(1)->getKey())\n            ->where('data.2.id', $clients->get(2)->getKey())\n            ->where('data.3.id', $clients->get(3)->getKey())\n        );\n    }\n\n    public function test_index_endpoint_returns_list_of_clients_assigned_to_employee_user(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'clients:view',\n        ]);\n\n        $clients = Client::factory()->forOrganization($data->organization)->createMany(2);\n        $projectWithMembership1 = Project::factory()->forOrganization($data->organization)->forClient($clients->get(0))->addMember($data->member)->isPrivate()->create();\n        $projectWithMembership2 = Project::factory()->forOrganization($data->organization)->forClient($clients->get(1))->addMember($data->member)->isPrivate()->create();\n\n        $otherClients = Client::factory()->forOrganization($data->organization)->createMany(2);\n        $projectWithoutMembership = Project::factory()->forOrganization($data->organization)->forClient($otherClients->get(0))->isPrivate()->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.clients.index', [$data->organization->getKey()]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonCount(2, 'data');\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->has('links')\n            ->has('meta')\n            ->count('data', 2)\n            ->where('data.0.id', $clients->get(0)->getKey())\n            ->where('data.1.id', $clients->get(1)->getKey())\n        );\n    }\n\n    public function test_index_endpoint_without_filter_archived_returns_only_non_archived_clients(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'clients:view',\n            'clients:view:all',\n        ]);\n        $archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);\n        $nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.clients.index', [$data->organization->getKey()]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonCount(2, 'data');\n        $this->assertEqualsCanonicalizing($nonArchivedClients->pluck('id')->toArray(), $response->json('data.*.id'));\n    }\n\n    public function test_index_endpoint_with_filter_archived_true_returns_only_archived_clients(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'clients:view',\n            'clients:view:all',\n        ]);\n        $archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);\n        $nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.clients.index', [\n            $data->organization->getKey(),\n            'archived' => 'true',\n        ]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonCount(2, 'data');\n        $this->assertEqualsCanonicalizing($archivedClients->pluck('id')->toArray(), $response->json('data.*.id'));\n    }\n\n    public function test_index_endpoint_with_filter_archived_false_returns_only_non_archived_clients(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'clients:view',\n            'clients:view:all',\n        ]);\n        $archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);\n        $nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.clients.index', [\n            $data->organization->getKey(),\n            'archived' => 'false',\n        ]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonCount(2, 'data');\n        $this->assertEqualsCanonicalizing($nonArchivedClients->pluck('id')->toArray(), $response->json('data.*.id'));\n    }\n\n    public function test_index_endpoint_with_filter_archived_all_returns_all_clients(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'clients:view',\n            'clients:view:all',\n        ]);\n        $archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);\n        $nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.clients.index', [\n            $data->organization->getKey(),\n            'archived' => 'all',\n        ]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonCount(4, 'data');\n        $this->assertEqualsCanonicalizing($archivedClients->merge($nonArchivedClients)->pluck('id')->toArray(), $response->json('data.*.id'));\n    }\n\n    public function test_store_endpoint_fails_if_user_has_no_permission_to_create_clients(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.clients.store', [$data->organization->getKey()]), [\n            'name' => 'Test Client',\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_store_endpoint_fails_if_client_with_same_name_already_exists(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'clients:create',\n        ]);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.clients.store', [$data->organization->getKey()]), [\n            'name' => $client->name,\n        ]);\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertJsonValidationErrors([\n            'name' => 'A client with the same name already exists in the organization.',\n        ]);\n        $this->assertDatabaseCount(Client::class, 1);\n    }\n\n    public function test_store_endpoint_fails_if_client_with_same_name_exists_in_different_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'clients:create',\n        ]);\n        $otherOrganization = Organization::factory()->create();\n        $client = Client::factory()->forOrganization($otherOrganization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.clients.store', [$data->organization->getKey()]), [\n            'name' => $client->name,\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        $this->assertDatabaseCount(Client::class, 2);\n    }\n\n    public function test_store_endpoint_creates_new_client(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'clients:create',\n        ]);\n        $clientFake = Client::factory()->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.clients.store', [$data->organization->getKey()]), [\n            'name' => $clientFake->name,\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.name', $clientFake->name)\n        );\n    }\n\n    public function test_update_endpoint_fails_if_user_has_no_permission_to_update_clients(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $clientFake = Client::factory()->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.clients.update', [$data->organization->getKey(), $client->getKey()]), [\n            'name' => $clientFake->name,\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_update_endpoint_fails_if_user_is_not_part_of_client_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'clients:update',\n        ]);\n        $otherOrganization = Organization::factory()->create();\n        $client = Client::factory()->forOrganization($otherOrganization)->create();\n        $clientFake = Client::factory()->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.clients.update', [$data->organization->getKey(), $client->getKey()]), [\n            'name' => $clientFake->name,\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n        $this->assertDatabaseHas(Client::class, [\n            'id' => $client->getKey(),\n            'name' => $client->name,\n            'organization_id' => $otherOrganization->getKey(),\n        ]);\n    }\n\n    public function test_update_endpoint_fails_if_client_if_client_with_same_name_already_exists(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'clients:update',\n        ]);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $clientFake = Client::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.clients.update', [$data->organization->getKey(), $client->getKey()]), [\n            'name' => $clientFake->name,\n        ]);\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertJsonValidationErrors([\n            'name' => 'A client with the same name already exists in the organization.',\n        ]);\n        $this->assertDatabaseHas(Client::class, [\n            'id' => $client->getKey(),\n            'name' => $client->name,\n            'organization_id' => $data->organization->getKey(),\n        ]);\n    }\n\n    public function test_update_endpoint_updates_client_name_even_if_client_with_same_name_exists_in_different_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'clients:update',\n        ]);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $otherOrganization = Organization::factory()->create();\n        $clientSameName = Client::factory()->forOrganization($otherOrganization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.clients.update', [$data->organization->getKey(), $client->getKey()]), [\n            'name' => $clientSameName->name,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $this->assertDatabaseHas(Client::class, [\n            'id' => $client->getKey(),\n            'name' => $clientSameName->name,\n            'organization_id' => $data->organization->getKey(),\n        ]);\n    }\n\n    public function test_update_endpoint_updates_client(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'clients:update',\n        ]);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $clientFake = Client::factory()->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.clients.update', [$data->organization->getKey(), $client->getKey()]), [\n            'name' => $clientFake->name,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.name', $clientFake->name)\n        );\n        $this->assertDatabaseHas(Client::class, [\n            'name' => $clientFake->name,\n            'organization_id' => $data->organization->getKey(),\n        ]);\n    }\n\n    public function test_update_endpoint_can_archive_a_client(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'clients:update',\n        ]);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $clientFake = Client::factory()->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.clients.update', [$data->organization->getKey(), $client->getKey()]), [\n            'name' => $clientFake->name,\n            'is_archived' => true,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.is_archived', true)\n        );\n        $client->refresh();\n        $this->assertTrue($client->is_archived);\n    }\n\n    public function test_update_endpoint_can_unarchive_a_client(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'clients:update',\n        ]);\n        $client = Client::factory()->forOrganization($data->organization)->archived()->create();\n        $clientFake = Client::factory()->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.clients.update', [$data->organization->getKey(), $client->getKey()]), [\n            'name' => $clientFake->name,\n            'is_archived' => false,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.is_archived', false)\n        );\n        $client->refresh();\n        $this->assertFalse($client->is_archived);\n    }\n\n    public function test_destroy_endpoint_fails_if_user_has_no_permission_to_delete_clients(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.clients.destroy', [$data->organization->getKey(), $client->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_destroy_endpoint_fails_if_user_is_not_part_of_client_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'clients:delete',\n        ]);\n        $otherOrganization = Organization::factory()->create();\n        $client = Client::factory()->forOrganization($otherOrganization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.clients.destroy', [$data->organization->getKey(), $client->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n        $this->assertDatabaseHas(Client::class, [\n            'id' => $client->getKey(),\n            'name' => $client->name,\n            'organization_id' => $otherOrganization->getKey(),\n        ]);\n    }\n\n    public function test_destroy_endpoint_fails_if_client_is_still_in_use_by_project(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'clients:delete',\n        ]);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.clients.destroy', [$data->organization->getKey(), $client->getKey()]));\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertJsonPath('message', 'The client is still used by a project and can not be deleted.');\n        $this->assertDatabaseHas(Client::class, [\n            'id' => $client->getKey(),\n        ]);\n    }\n\n    public function test_destroy_endpoint_deletes_client(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'clients:delete',\n        ]);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.clients.destroy', [$data->organization->getKey(), $client->getKey()]));\n\n        // Assert\n        $response->assertStatus(204);\n        $response->assertNoContent();\n        $this->assertDatabaseMissing(Client::class, [\n            'id' => $client->getKey(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Endpoint/Api/V1/CurrencyEndpointTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Endpoint\\Api\\V1;\n\nuse App\\Http\\Controllers\\Api\\V1\\CurrencyController;\nuse App\\Service\\CurrencyService;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(CurrencyController::class)]\n#[CoversClass(CurrencyService::class)]\nclass CurrencyEndpointTest extends ApiEndpointTestAbstract\n{\n    public function test_index_return_list_of_available_currencies_incl_symbol(): void\n    {\n        // Arrange\n\n        // Act\n        $response = $this->getJson(route('api.v1.currencies.index'));\n\n        // Assert\n        $response->assertOk();\n        $response->assertJsonCount(166);\n        $responseObj = collect($response->json());\n        $this->assertSame([\n            'code' => 'EUR',\n            'name' => 'Euro',\n            'symbol' => '€',\n        ], $responseObj->firstWhere('code', '=', 'EUR'));\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Endpoint/Api/V1/ExportEndpointTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Endpoint\\Api\\V1;\n\nuse App\\Http\\Controllers\\Api\\V1\\ExportController;\nuse App\\Models\\Organization;\nuse App\\Service\\Export\\ExportException;\nuse App\\Service\\Export\\ExportService;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Facades\\Storage;\nuse Laravel\\Passport\\Passport;\nuse Mockery\\MockInterface;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\n\n#[UsesClass(ExportController::class)]\nclass ExportEndpointTest extends ApiEndpointTestAbstract\n{\n    public function test_export_fails_if_user_does_not_have_permission(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $this->mock(ExportService::class, function (MockInterface $mock): void {\n            $mock->shouldNotReceive('export');\n        });\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.export.export', ['organization' => $data->organization->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_export_return_error_message_if_export_fails(): void\n    {\n        $user = $this->createUserWithPermission([\n            'export',\n        ]);\n        $this->mock(ExportService::class, function (MockInterface $mock) use (&$user): void {\n            $mock->shouldReceive('export')\n                ->withArgs(function (Organization $organization) use (&$user): bool {\n                    return $organization->is($user->organization);\n                })\n                ->andThrow(new ExportException)\n                ->once();\n        });\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.export.export', ['organization' => $user->organization->getKey()]));\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertExactJson([\n            'error' => true,\n            'key' => 'export',\n            'message' => 'Export failed, please try again later or contact support',\n        ]);\n    }\n\n    public function test_export_calls_export_service_if_user_has_permission(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission([\n            'export',\n        ]);\n        $filepath = 'exports/path.zip';\n        Storage::fake('local');\n        $now = Carbon::now();\n        $this->travelTo($now);\n        $this->mock(ExportService::class, function (MockInterface $mock) use (&$user, $filepath): void {\n            $mock->shouldReceive('export')\n                ->withArgs(function (Organization $organization) use (&$user): bool {\n                    return $organization->is($user->organization);\n                })\n                ->andReturn($filepath)\n                ->once();\n        });\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.export.export', [\n            'organization' => $user->organization->getKey(),\n        ]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonPath('success', true);\n        $this->assertStringContainsString($filepath, $response->json('download_url'));\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Endpoint\\Api\\V1;\n\nuse App\\Http\\Controllers\\Api\\V1\\ImportController;\nuse App\\Models\\Organization;\nuse App\\Service\\Import\\Importers\\ImportException;\nuse App\\Service\\Import\\Importers\\ReportDto;\nuse App\\Service\\Import\\ImportService;\nuse Laravel\\Passport\\Passport;\nuse Mockery\\MockInterface;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\n\n#[UsesClass(ImportController::class)]\nclass ImportEndpointTest extends ApiEndpointTestAbstract\n{\n    public function test_index_fails_if_user_does_not_have_permission(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.import.index', ['organization' => $data->organization->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_index_returns_importers_if_user_has_permission(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'import',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.import.index', ['organization' => $data->organization->getKey()]));\n\n        // Assert\n        $response->assertOk();\n        $response->assertJsonStructure([\n            'data' => [\n                [\n                    'key',\n                    'name',\n                    'description',\n                ],\n            ],\n        ]);\n        $toggleTimeEntries = collect($response->json('data'))->where('key', 'toggl_time_entries')->first();\n        $this->assertSame('toggl_time_entries', $toggleTimeEntries['key']);\n        $this->assertSame('Toggl Time Entries', $toggleTimeEntries['name']);\n        $this->assertSame(__('importer.toggl_time_entries.description'), $toggleTimeEntries['description']);\n    }\n\n    public function test_import_fails_if_user_does_not_have_permission(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.import.import', ['organization' => $data->organization->getKey()]), [\n            'type' => 'toggl_time_entries',\n            'data' => base64_encode('some data'),\n            'options' => [],\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_import_fails_if_data_can_not_be_base64_decoded(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission([\n            'import',\n        ]);\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.import.import', ['organization' => $user->organization->getKey()]), [\n            'type' => 'toggl_time_entries',\n            'data' => 'some invalid data ...',\n        ]);\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertExactJson([\n            'message' => 'Invalid base64 encoded data',\n        ]);\n    }\n\n    public function test_import_return_error_message_if_import_fails(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission([\n            'import',\n        ]);\n        $this->mock(ImportService::class, function (MockInterface $mock) use (&$user): void {\n            $mock->shouldReceive('import')\n                ->withArgs(function (Organization $organization, string $importerType, string $data) use (&$user): bool {\n                    return $organization->is($user->organization) && $importerType === 'toggl_time_entries' && $data === 'some data';\n                })\n                ->andThrow(new ImportException('This is a test error!'))\n                ->once();\n        });\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.import.import', ['organization' => $user->organization->getKey()]), [\n            'type' => 'toggl_time_entries',\n            'data' => base64_encode('some data'),\n        ]);\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertExactJson([\n            'message' => 'This is a test error!',\n        ]);\n    }\n\n    public function test_import_calls_import_service_if_user_has_permission(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission([\n            'import',\n        ]);\n        $this->mock(ImportService::class, function (MockInterface $mock) use (&$user): void {\n            $mock->shouldReceive('import')\n                ->withArgs(function (Organization $organization, string $importerType, string $data) use (&$user): bool {\n                    return $organization->is($user->organization) && $importerType === 'toggl_time_entries' && $data === 'some data';\n                })\n                ->andReturn(new ReportDto(\n                    clientsCreated: 1,\n                    projectsCreated: 2,\n                    tasksCreated: 3,\n                    timeEntriesCreated: 4,\n                    tagsCreated: 5,\n                    usersCreated: 6,\n                ))\n                ->once();\n        });\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.import.import', ['organization' => $user->organization->getKey()]), [\n            'type' => 'toggl_time_entries',\n            'data' => base64_encode('some data'),\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertExactJson([\n            'report' => [\n                'clients' => [\n                    'created' => 1,\n                ],\n                'projects' => [\n                    'created' => 2,\n                ],\n                'tasks' => [\n                    'created' => 3,\n                ],\n                'time_entries' => [\n                    'created' => 4,\n                ],\n                'tags' => [\n                    'created' => 5,\n                ],\n                'users' => [\n                    'created' => 6,\n                ],\n            ],\n        ]);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Endpoint\\Api\\V1;\n\nuse App\\Enums\\Role;\nuse App\\Http\\Controllers\\Api\\V1\\InvitationController;\nuse App\\Mail\\OrganizationInvitationMail;\nuse App\\Models\\Member;\nuse App\\Models\\OrganizationInvitation;\nuse App\\Models\\User;\nuse Illuminate\\Support\\Facades\\Mail;\nuse Laravel\\Passport\\Passport;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\n\n#[UsesClass(InvitationController::class)]\nclass InvitationEndpointTest extends ApiEndpointTestAbstract\n{\n    public function test_index_fails_if_user_has_no_permission_to_view_invitations(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.invitations.index', $data->organization->id));\n\n        // Assert\n        $response->assertStatus(403);\n    }\n\n    public function test_index_returns_invitations_of_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'invitations:view',\n        ]);\n        $invitation1 = OrganizationInvitation::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.invitations.index', $data->organization->getKey()));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJson([\n            'data' => [\n                [\n                    'id' => $invitation1->getKey(),\n                    'email' => $invitation1->email,\n                    'role' => $invitation1->role,\n                ],\n            ],\n        ]);\n    }\n\n    public function test_index_returns_invitations_ordered_by_created_at_descending(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'invitations:view',\n        ]);\n        $invitationOldest = OrganizationInvitation::factory()->forOrganization($data->organization)->create([\n            'created_at' => now()->subDays(3),\n        ]);\n        $invitationNewest = OrganizationInvitation::factory()->forOrganization($data->organization)->create([\n            'created_at' => now()->subDay(),\n        ]);\n        $invitationMiddle = OrganizationInvitation::factory()->forOrganization($data->organization)->create([\n            'created_at' => now()->subDays(2),\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.invitations.index', $data->organization->getKey()));\n\n        // Assert\n        $response->assertStatus(200);\n        $ids = collect($response->json('data'))->pluck('id')->values()->toArray();\n        $this->assertSame([$invitationNewest->getKey(), $invitationMiddle->getKey(), $invitationOldest->getKey()], $ids);\n    }\n\n    public function test_store_fails_if_user_has_no_permission_to_create_invitations(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.invitations.store', $data->organization->getKey()), [\n            'email' => 'test@mail.test',\n            'role' => Role::Employee->value,\n        ]);\n\n        // Assert\n        $response->assertStatus(403);\n    }\n\n    public function test_store_fails_if_user_invites_with_role_owner(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'invitations:create',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.invitations.store', $data->organization->getKey()), [\n            'email' => 'test@asdf.at',\n            'role' => Role::Owner->value,\n        ]);\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertJsonPath('message', 'The selected role is invalid.');\n    }\n\n    public function test_store_fails_if_user_invites_with_role_placeholder(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'invitations:create',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.invitations.store', $data->organization->getKey()), [\n            'email' => 'test@asdf.at',\n            'role' => Role::Placeholder->value,\n        ]);\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertJsonPath('message', 'The selected role is invalid.');\n    }\n\n    public function test_store_fails_if_user_invites_user_who_is_already_member_of_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'invitations:create',\n        ]);\n        Passport::actingAs($data->user);\n        $member = Member::factory()->forOrganization($data->organization)->create();\n\n        // Act\n        $response = $this->postJson(route('api.v1.invitations.store', $data->organization->getKey()), [\n            'email' => $member->user->email,\n            'role' => Role::Employee->value,\n        ]);\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertJsonPath('message', 'User is already a member of the organization');\n    }\n\n    public function test_store_fails_if_an_invitation_with_the_same_email_already_exists(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'invitations:create',\n        ]);\n        Passport::actingAs($data->user);\n        $email = 'user@email.test';\n        $invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create([\n            'email' => $email,\n        ]);\n\n        // Act\n        $response = $this->postJson(route('api.v1.invitations.store', $data->organization->getKey()), [\n            'email' => $email,\n            'role' => Role::Employee->value,\n        ]);\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertExactJson([\n            'error' => true,\n            'key' => 'invitation_for_the_email_already_exists',\n            'message' => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',\n        ]);\n    }\n\n    public function test_store_works_if_user_invites_user_who_is_also_a_placeholder(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'invitations:create',\n        ]);\n        $user = User::factory()->placeholder()->create();\n        $member = Member::factory()->forOrganization($data->organization)->forUser($user)->role(Role::Placeholder)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.invitations.store', $data->organization->getKey()), [\n            'email' => $user->email,\n            'role' => Role::Employee->value,\n        ]);\n\n        // Assert\n        $response->assertStatus(204);\n        $invitation = OrganizationInvitation::first();\n        $this->assertNotNull($invitation);\n        $this->assertEquals($user->email, $invitation->email);\n        $this->assertEquals(Role::Employee->value, $invitation->role);\n        Mail::assertQueued(fn (OrganizationInvitationMail $mail): bool => $mail->invitation->is($invitation));\n        Mail::assertNothingSent();\n    }\n\n    public function test_store_invites_user_to_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'invitations:create',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.invitations.store', $data->organization->getKey()), [\n            'email' => 'test@asdf.at',\n            'role' => Role::Employee->value,\n        ]);\n\n        // Assert\n        $response->assertStatus(204);\n        $invitation = OrganizationInvitation::first();\n        $this->assertNotNull($invitation);\n        $this->assertEquals('test@asdf.at', $invitation->email);\n        $this->assertEquals(Role::Employee->value, $invitation->role);\n        Mail::assertQueued(fn (OrganizationInvitationMail $mail): bool => $mail->invitation->is($invitation));\n        Mail::assertNothingSent();\n    }\n\n    public function test_resend_fails_if_user_has_no_permission_to_resend_the_invitation(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        Passport::actingAs($data->user);\n        $invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create();\n\n        // Act\n        $response = $this->postJson(route('api.v1.invitations.resend', [\n            $data->organization->getKey(),\n            $invitation->getKey(),\n        ]));\n\n        // Assert\n        Mail::assertNothingSent();\n        Mail::assertNothingQueued();\n        $response->assertStatus(403);\n    }\n\n    public function test_resend_fails_if_invitation_belongs_to_different_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'invitations:resend',\n        ]);\n        Passport::actingAs($data->user);\n        $invitation = OrganizationInvitation::factory()->create();\n\n        // Act\n        $response = $this->postJson(route('api.v1.invitations.resend', [$data->organization->getKey(), $invitation->getKey()]));\n\n        // Assert\n        Mail::assertNothingSent();\n        Mail::assertNothingQueued();\n        $response->assertStatus(403);\n    }\n\n    public function test_resend_resends_invitation_email(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'invitations:resend',\n        ]);\n        Passport::actingAs($data->user);\n        $invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create();\n\n        // Act\n        $response = $this->postJson(route('api.v1.invitations.resend', [\n            $data->organization->getKey(),\n            $invitation->getKey(),\n        ]));\n\n        // Assert\n        $response->assertStatus(204);\n        Mail::assertQueued(fn (OrganizationInvitationMail $mail): bool => $mail->invitation->is($invitation));\n        Mail::assertNothingSent();\n    }\n\n    public function test_delete_fails_if_user_has_no_permission_to_remove_invitations(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        Passport::actingAs($data->user);\n        $invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create();\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.invitations.destroy', [$data->organization->getKey(), $invitation->getKey()]));\n\n        // Assert\n        $response->assertStatus(403);\n    }\n\n    public function test_delete_fails_if_invitation_belongs_to_different_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'invitations:remove',\n        ]);\n        Passport::actingAs($data->user);\n        $invitation = OrganizationInvitation::factory()->create();\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.invitations.destroy', [$data->organization->getKey(), $invitation->getKey()]));\n\n        // Assert\n        $response->assertStatus(403);\n    }\n\n    public function test_delete_removes_invitation(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'invitations:remove',\n        ]);\n        Passport::actingAs($data->user);\n        $invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create();\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.invitations.destroy', [$data->organization->getKey(), $invitation->getKey()]));\n\n        // Assert\n        $response->assertStatus(204);\n        $this->assertNull(OrganizationInvitation::find($invitation->getKey()));\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Endpoint\\Api\\V1;\n\nuse App\\Enums\\Role;\nuse App\\Events\\MemberMadeToPlaceholder;\nuse App\\Events\\MemberRemoved;\nuse App\\Http\\Controllers\\Api\\V1\\MemberController;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\OrganizationInvitation;\nuse App\\Models\\Project;\nuse App\\Models\\ProjectMember;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse App\\Service\\BillableRateService;\nuse Illuminate\\Support\\Facades\\Event;\nuse Laravel\\Passport\\Passport;\nuse Mockery\\MockInterface;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\n\n#[UsesClass(MemberController::class)]\nclass MemberEndpointTest extends ApiEndpointTestAbstract\n{\n    public function test_index_fails_if_user_has_no_permission_to_view_members(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.members.index', $data->organization->id));\n\n        // Assert\n        $response->assertStatus(403);\n    }\n\n    public function test_index_returns_members_of_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:view',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.members.index', $data->organization->getKey()));\n\n        // Assert\n        $response->assertStatus(200);\n    }\n\n    public function test_index_returns_members_ordered_by_created_at_descending(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:view',\n        ]);\n        $memberOldest = Member::factory()->forOrganization($data->organization)->create([\n            'created_at' => now()->subDays(3),\n        ]);\n        $memberNewest = Member::factory()->forOrganization($data->organization)->create([\n            'created_at' => now()->subDay(),\n        ]);\n        $memberMiddle = Member::factory()->forOrganization($data->organization)->create([\n            'created_at' => now()->subDays(2),\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.members.index', $data->organization->getKey()));\n\n        // Assert\n        $response->assertStatus(200);\n        $ids = collect($response->json('data'))->pluck('id')->values()->toArray();\n        // Verify that the three explicitly created members appear in newest-first order\n        $createdMemberIds = array_values(array_filter($ids, fn ($id) => in_array($id, [\n            $memberOldest->getKey(),\n            $memberNewest->getKey(),\n            $memberMiddle->getKey(),\n        ], true)));\n        $this->assertSame([$memberNewest->getKey(), $memberMiddle->getKey(), $memberOldest->getKey()], $createdMemberIds);\n    }\n\n    public function test_update_member_fails_if_user_has_no_permission_to_update_members(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $data->member->getKey()]), [\n            'billable_rate' => 10001,\n            'role' => Role::Employee->value,\n        ]);\n\n        // Assert\n        $response->assertStatus(403);\n    }\n\n    public function test_update_member_fails_if_member_is_not_part_of_org(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:update',\n        ]);\n        $otherData = $this->createUserWithPermission([\n            'members:update',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $otherData->member->getKey()]), [\n            'billable_rate' => 10001,\n            'role' => Role::Employee->value,\n        ]);\n\n        // Assert\n        $response->assertStatus(403);\n    }\n\n    public function test_update_member_succeeds_if_data_is_valid(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:update',\n        ]);\n        $member = Member::factory()->forOrganization($data->organization)->withBillableRate()->role(Role::Admin)->create();\n        $this->assertBillableRateServiceIsUnused();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.members.update', [$data->organization->id, $member]), [\n            'billable_rate' => $member->billable_rate,\n            'role' => Role::Employee->value,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $oldBillableRate = $member->billable_rate;\n        $member->refresh();\n        $this->assertSame($oldBillableRate, $member->billable_rate);\n        $this->assertSame(Role::Employee->value, $member->role);\n    }\n\n    public function test_update_member_can_update_billable_rate_of_member_and_update_time_entries(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:update',\n        ]);\n        $this->mock(BillableRateService::class, function (MockInterface $mock) use ($data): void {\n            $mock->shouldReceive('updateTimeEntriesBillableRateForMember')\n                ->once()\n                ->withArgs(fn (Member $memberArg) => $memberArg->is($data->member) && $memberArg->billable_rate === 10001);\n        });\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $data->member]), [\n            'billable_rate' => 10001,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $member = $data->member;\n        $member->refresh();\n        $this->assertSame(10001, $member->billable_rate);\n    }\n\n    public function test_update_member_can_update_role(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:update',\n        ]);\n        $otherUser = User::factory()->create();\n        $otherMember = Member::factory()->forUser($otherUser)->forOrganization($data->organization)->role(Role::Employee)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $otherMember->getKey()]), [\n            'role' => Role::Admin->value,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $otherMember->refresh();\n        $this->assertSame(Role::Admin->value, $otherMember->role);\n    }\n\n    public function test_update_member_allows_role_owner_in_request_if_that_would_not_the_role(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:update',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $data->ownerMember->getKey()]), [\n            'role' => Role::Owner->value,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n    }\n\n    public function test_update_member_fails_if_user_tries_to_change_role_to_owner(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:update',\n        ]);\n        $member = Member::factory()->forOrganization($data->organization)->role(Role::Employee)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $member->getKey()]), [\n            'role' => Role::Owner->value,\n        ]);\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertJsonPath('message', 'Only owner can change ownership');\n    }\n\n    public function test_update_member_fails_if_user_tries_to_change_the_role_of_a_placeholder(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:update',\n        ]);\n        $user = User::factory()->placeholder()->create();\n        $member = Member::factory()->forOrganization($data->organization)->forUser($user)->role(Role::Placeholder)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $member->getKey()]), [\n            'role' => Role::Admin->value,\n        ]);\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertExactJson([\n            'error' => true,\n            'key' => 'changing_role_of_placeholder_is_not_allowed',\n            'message' => 'Changing role of placeholder is not allowed',\n        ]);\n    }\n\n    public function test_merge_into_fails_if_url_member_is_not_part_of_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:merge-into',\n        ]);\n        $userSource = User::factory()->placeholder()->create();\n        $memberSource = Member::factory()->forUser($userSource)->role(Role::Placeholder)->create();\n\n        $userDestination = User::factory()->create();\n        $memberDestination = Member::factory()->forUser($userDestination)->forOrganization($data->organization)->role(Role::Admin)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.members.merge-into', [$data->organization->getKey(), $memberSource->getKey()]), [\n            'member_id' => $memberDestination->getKey(),\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_merge_into_returns_validation_error_if_member_in_body_does_not_belong_to_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:merge-into',\n        ]);\n        $userSource = User::factory()->placeholder()->create();\n        $memberSource = Member::factory()->forUser($userSource)->forOrganization($data->organization)->role(Role::Placeholder)->create();\n\n        $userDestination = User::factory()->create();\n        $memberDestination = Member::factory()->forUser($userDestination)->role(Role::Admin)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.members.merge-into', [$data->organization->getKey(), $memberSource->getKey()]), [\n            'member_id' => $memberDestination->getKey(),\n        ]);\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertExactJson([\n            'errors' => [\n                'member_id' => [\n                    'The resource does not exist.',\n                ],\n            ],\n            'message' => 'The resource does not exist.',\n        ]);\n    }\n\n    public function test_merge_into_fails_if_from_member_is_not_a_placeholder(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:merge-into',\n        ]);\n        $userSource = User::factory()->placeholder()->create();\n        $memberSource = Member::factory()->forUser($userSource)->forOrganization($data->organization)->role(Role::Admin)->create();\n\n        $userDestination = User::factory()->create();\n        $memberDestination = Member::factory()->forUser($userDestination)->forOrganization($data->organization)->role(Role::Admin)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.members.merge-into', [$data->organization->getKey(), $memberSource->getKey()]), [\n            'member_id' => $memberDestination->getKey(),\n        ]);\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertExactJson([\n            'error' => true,\n            'key' => 'only_placeholders_can_be_merged_into_another_member',\n            'message' => 'Only placeholders can be merged into another member',\n        ]);\n    }\n\n    public function test_merge_into_fails_if_user_has_no_permission_to_merge_members(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([]);\n        $userSource = User::factory()->placeholder()->create();\n        $memberSource = Member::factory()->forUser($userSource)->forOrganization($data->organization)->role(Role::Placeholder)->create();\n\n        $userDestination = User::factory()->create();\n        $memberDestination = Member::factory()->forUser($userDestination)->forOrganization($data->organization)->role(Role::Admin)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.members.merge-into', [$data->organization->getKey(), $memberSource->getKey()]), [\n            'member_id' => $memberDestination->getKey(),\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_merge_into_assigns_resources_of_source_member_to_destination_member_and_deletes_member(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:merge-into',\n        ]);\n        $userSource = User::factory()->placeholder()->create();\n        $memberSource = Member::factory()->forUser($userSource)->forOrganization($data->organization)->role(Role::Placeholder)->create();\n        TimeEntry::factory()->forMember($memberSource)->createMany(3);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        ProjectMember::factory()->forMember($memberSource)->forProject($project)->create();\n\n        $userDestination = User::factory()->create();\n        $memberDestination = Member::factory()->forUser($userDestination)->forOrganization($data->organization)->role(Role::Admin)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.members.merge-into', [$data->organization->getKey(), $memberSource->getKey()]), [\n            'member_id' => $memberDestination->getKey(),\n        ]);\n\n        // Assert\n        $response->assertStatus(204);\n        $this->assertSame('', $response->getContent());\n        $this->assertDatabaseMissing(Member::class, [\n            'id' => $memberSource->getKey(),\n        ]);\n        $this->assertDatabaseMissing(User::class, [\n            'id' => $userSource->getKey(),\n        ]);\n        $memberDestination->refresh();\n        $this->assertCount(3, $memberDestination->timeEntries);\n        $this->assertCount(1, $memberDestination->projectMembers);\n        $this->assertDatabaseHas(ProjectMember::class, [\n            'project_id' => $project->getKey(),\n            'member_id' => $memberDestination->getKey(),\n            'user_id' => $userDestination->getKey(),\n        ]);\n    }\n\n    public function test_merge_into_assigns_resources_of_source_member_to_destination_member_and_deletes_member_with_existing_destination_resources(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:merge-into',\n        ]);\n        $userSource = User::factory()->placeholder()->create();\n        $memberSource = Member::factory()->forUser($userSource)->forOrganization($data->organization)->role(Role::Placeholder)->create();\n        TimeEntry::factory()->forMember($memberSource)->createMany(3);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        ProjectMember::factory()->forMember($memberSource)->forProject($project)->create([\n            'billable_rate' => 32100,\n        ]);\n\n        $userDestination = User::factory()->create();\n        $memberDestination = Member::factory()->forUser($userDestination)->forOrganization($data->organization)->role(Role::Admin)->create();\n        ProjectMember::factory()->forMember($memberDestination)->forProject($project)->create([\n            'billable_rate' => 12300,\n        ]);\n        TimeEntry::factory()->forMember($memberDestination)->createMany(3);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->withoutExceptionHandling()->postJson(route('api.v1.members.merge-into', [$data->organization->getKey(), $memberSource->getKey()]), [\n            'member_id' => $memberDestination->getKey(),\n        ]);\n\n        // Assert\n        $response->assertStatus(204);\n        $this->assertSame('', $response->getContent());\n        $this->assertDatabaseMissing(Member::class, [\n            'id' => $memberSource->getKey(),\n        ]);\n        $this->assertDatabaseMissing(User::class, [\n            'id' => $userSource->getKey(),\n        ]);\n        $memberDestination->refresh();\n        $this->assertCount(6, $memberDestination->timeEntries);\n        $this->assertCount(1, $memberDestination->projectMembers);\n        $this->assertDatabaseHas(ProjectMember::class, [\n            'project_id' => $project->getKey(),\n            'billable_rate' => 12300,\n            'member_id' => $memberDestination->getKey(),\n            'user_id' => $userDestination->getKey(),\n        ]);\n    }\n\n    public function test_update_member_fails_if_user_tries_to_change_role_of_the_current_owner(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:update',\n            'members:change-ownership',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $data->ownerMember->getKey()]), [\n            'role' => Role::Admin->value,\n        ]);\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertJsonPath('message', 'Organization needs at least one owner');\n    }\n\n    public function test_update_member_can_change_role_to_everything_expect_owner_with_the_member_update_permission(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:update',\n        ]);\n        $member = Member::factory()->forOrganization($data->organization)->role(Role::Employee)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $member->getKey()]), [\n            'role' => Role::Admin->value,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $member->refresh();\n        $this->assertSame(Role::Admin->value, $member->role);\n    }\n\n    public function test_update_member_can_change_role_to_owner_if_auth_user_has_change_ownership_permission(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:update',\n            'members:change-ownership',\n        ]);\n        $oldOwner = $data->ownerMember;\n        $organization = $data->organization;\n        $member = Member::factory()->forOrganization($data->organization)->role(Role::Employee)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $member->getKey()]), [\n            'role' => Role::Owner->value,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $member->refresh();\n        $organization->refresh();\n        $oldOwner->refresh();\n        $this->assertSame(Role::Owner->value, $member->role);\n        $this->assertSame($member->user_id, $organization->user_id);\n        $this->assertSame(Role::Admin->value, $oldOwner->role);\n    }\n\n    public function test_update_member_role_fails_if_role_is_placeholder(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:update',\n        ]);\n        $member = Member::factory()->forOrganization($data->organization)->role(Role::Employee)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $member->getKey()]), [\n            'role' => Role::Placeholder->value,\n        ]);\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertJsonPath('message', 'Changing role to placeholder is not allowed');\n    }\n\n    public function test_invite_placeholder_succeeds_if_data_is_valid(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:invite-placeholder',\n        ]);\n        $user = User::factory()->create([\n            'is_placeholder' => true,\n        ]);\n        $member = Member::factory()->forUser($user)->forOrganization($data->organization)->role(Role::Placeholder)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.members.invite-placeholder', [\n            'organization' => $data->organization->getKey(),\n            'member' => $member->getKey(),\n        ]));\n\n        // Assert\n        $response->assertValid();\n        $response->assertStatus(204);\n    }\n\n    public function test_invite_placeholder_fails_if_the_placeholder_has_a_invalid_email_from_an_import(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:invite-placeholder',\n        ]);\n        $user = User::factory()->create([\n            'is_placeholder' => true,\n            'email' => 'some.user@solidtime-import.test',\n        ]);\n        $member = Member::factory()\n            ->forUser($user)\n            ->forOrganization($data->organization)\n            ->role(Role::Placeholder)\n            ->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.members.invite-placeholder', [\n            'organization' => $data->organization->getKey(),\n            'member' => $member->getKey(),\n        ]));\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertExactJson([\n            'error' => true,\n            'key' => 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception',\n            'message' => 'This placeholder can not be invited use the merge tool instead',\n        ]);\n    }\n\n    public function test_destroy_member_fails_if_user_has_no_permission_to_delete_members(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        Passport::actingAs($data->user);\n        Event::fake([\n            MemberRemoved::class,\n        ]);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $data->member->getKey()]));\n\n        // Assert\n        $response->assertStatus(403);\n        Event::assertNotDispatched(MemberRemoved::class);\n    }\n\n    public function test_destroy_member_fails_if_member_is_owner(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:delete',\n        ]);\n        $memberToDelete = Member::factory()->forOrganization($data->organization)->role(Role::Owner)->create();\n        Passport::actingAs($data->user);\n        Event::fake([\n            MemberRemoved::class,\n        ]);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $memberToDelete->getKey()]));\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertJsonPath('message', 'Can not remove owner from organization');\n        Event::assertNotDispatched(MemberRemoved::class);\n    }\n\n    public function test_destroy_member_fails_if_member_is_not_part_of_org(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:delete',\n        ]);\n        $otherData = $this->createUserWithPermission([\n            'members:delete',\n        ]);\n        Passport::actingAs($data->user);\n        Event::fake([\n            MemberRemoved::class,\n        ]);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $otherData->member->getKey()]));\n\n        // Assert\n        $response->assertStatus(403);\n        Event::assertNotDispatched(MemberRemoved::class);\n    }\n\n    public function test_destroy_endpoint_fails_if_member_is_still_in_use_by_a_time_entry(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:delete',\n        ]);\n        TimeEntry::factory()->forMember($data->member)->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n        Event::fake([\n            MemberRemoved::class,\n        ]);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $data->member->getKey()]));\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertJsonPath('message', 'The member is still used by a time entry and can not be deleted.');\n        $this->assertDatabaseHas(Member::class, [\n            'id' => $data->member->getKey(),\n        ]);\n        Event::assertNotDispatched(MemberRemoved::class);\n    }\n\n    public function test_destroy_endpoint_fails_if_member_is_still_in_use_by_a_project_member(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:delete',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        ProjectMember::factory()->forProject($project)->forMember($data->member)->create();\n        Passport::actingAs($data->user);\n        Event::fake([\n            MemberRemoved::class,\n        ]);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $data->member->getKey()]));\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertJsonPath('message', 'The member is still used by a project member and can not be deleted.');\n        $this->assertDatabaseHas(Member::class, [\n            'id' => $data->member->getKey(),\n        ]);\n        Event::assertNotDispatched(MemberRemoved::class);\n    }\n\n    public function test_destroy_endpoint_also_deletes_user_if_member_is_placeholder(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:delete',\n        ]);\n        $user = User::factory()->placeholder()->create();\n        $member = Member::factory()->forUser($user)->forOrganization($data->organization)->role(Role::Placeholder)->create();\n        Passport::actingAs($data->user);\n        Event::fake([\n            MemberRemoved::class,\n        ]);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $member->getKey()]));\n\n        // Assert\n        $response->assertStatus(204);\n        $this->assertDatabaseMissing(Member::class, [\n            'id' => $member->getKey(),\n        ]);\n        $this->assertDatabaseMissing(User::class, [\n            'id' => $user->getKey(),\n        ]);\n        Event::assertDispatched(function (MemberRemoved $event) use ($data, $member): bool {\n            return $event->organization->is($data->organization) &&\n                $event->member->is($member);\n        }, 1);\n    }\n\n    public function test_destroy_endpoint_sets_current_organization_to_organization_the_user_is_still_member_of(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:delete',\n        ]);\n        $user = $data->user;\n        $otherOrganization = Organization::factory()->create();\n        $otherMember = Member::factory()->forOrganization($otherOrganization)->forUser($user)->role(Role::Employee)->create();\n        Passport::actingAs($user);\n        Event::fake([\n            MemberRemoved::class,\n        ]);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $data->member->getKey()]));\n\n        // Assert\n        $response->assertStatus(204);\n        $this->assertDatabaseMissing(Member::class, [\n            'id' => $data->member->getKey(),\n        ]);\n        $user->refresh();\n        $this->assertSame($otherOrganization->getKey(), $user->currentOrganization->getKey());\n        Event::assertDispatched(function (MemberRemoved $event) use ($data): bool {\n            return $event->organization->is($data->organization) &&\n                $event->member->is($data->member);\n        }, 1);\n    }\n\n    public function test_destroy_endpoint_creates_new_organization_and_sets_the_current_organization_to_it_if_user_is_not_member_of_any_other_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:delete',\n        ]);\n        $organization = $data->organization;\n        $user = $data->user;\n        Passport::actingAs($user);\n        Event::fake([\n            MemberRemoved::class,\n        ]);\n        $this->assertDatabaseCount(Organization::class, 1);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $data->member->getKey()]));\n\n        // Assert\n        $response->assertStatus(204);\n        $this->assertDatabaseCount(Organization::class, 2);\n        $newOrganization = Organization::where('id', '!=', $organization->getKey())->first();\n        $this->assertNotNull($newOrganization);\n        $this->assertDatabaseMissing(Member::class, [\n            'id' => $data->member->getKey(),\n        ]);\n        $this->assertDatabaseHas(Member::class, [\n            'organization_id' => $newOrganization->getKey(),\n            'user_id' => $user->getKey(),\n        ]);\n        $user->refresh();\n        $this->assertNotNull($user->currentOrganization);\n        Event::assertDispatched(function (MemberRemoved $event) use ($data): bool {\n            return $event->organization->is($data->organization) &&\n                $event->member->is($data->member);\n        }, 1);\n    }\n\n    public function test_destroy_endpoint_succeeds_if_member_is_still_in_use_by_a_project_member_and_delete_related_is_active(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:delete',\n        ]);\n        $otherMember = Member::factory()->forOrganization($data->organization)->role(Role::Employee)->create();\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $projectMember = ProjectMember::factory()->forProject($project)->forMember($data->member)->create();\n        $otherProjectMember = ProjectMember::factory()->forProject($project)->forMember($otherMember)->create();\n        Passport::actingAs($data->user);\n        Event::fake([\n            MemberRemoved::class,\n        ]);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.members.destroy', [\n            'organization' => $data->organization->getKey(),\n            'member' => $data->member->getKey(),\n            'delete_related' => 'true',\n        ]));\n\n        // Assert\n        $response->assertStatus(204);\n        $this->assertDatabaseMissing(Member::class, [\n            'id' => $data->member->getKey(),\n        ]);\n        $this->assertDatabaseHas(ProjectMember::class, [\n            'id' => $otherProjectMember->getKey(),\n            'member_id' => $otherMember->getKey(),\n            'user_id' => $otherMember->user_id,\n        ]);\n        $this->assertDatabaseMissing(ProjectMember::class, [\n            'id' => $projectMember->getKey(),\n        ]);\n        Event::assertDispatched(function (MemberRemoved $event) use ($data): bool {\n            return $event->organization->is($data->organization) &&\n                $event->member->is($data->member);\n        }, 1);\n    }\n\n    public function test_destroy_endpoint_succeeds_if_member_is_still_in_use_by_a_time_entry_and_delete_related_is_active(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:delete',\n        ]);\n        $otherMember = Member::factory()->forOrganization($data->organization)->role(Role::Employee)->create();\n        $timeEntry = TimeEntry::factory()->forMember($data->member)->forOrganization($data->organization)->create();\n        $otherTimeEntry = TimeEntry::factory()->forMember($otherMember)->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n        Event::fake([\n            MemberRemoved::class,\n        ]);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.members.destroy', [\n            'organization' => $data->organization->getKey(),\n            'member' => $data->member->getKey(),\n            'delete_related' => 'true',\n        ]));\n\n        // Assert\n        $response->assertStatus(204);\n        $this->assertDatabaseMissing(Member::class, [\n            'id' => $data->member->getKey(),\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $otherTimeEntry->getKey(),\n        ]);\n        $this->assertDatabaseMissing(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n        ]);\n        Event::assertDispatched(function (MemberRemoved $event) use ($data): bool {\n            return $event->organization->is($data->organization) &&\n                $event->member->is($data->member);\n        }, 1);\n    }\n\n    public function test_destroy_member_succeeds_if_data_is_valid(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:delete',\n        ]);\n        Passport::actingAs($data->user);\n        Event::fake([\n            MemberRemoved::class,\n        ]);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $data->member->getKey()]));\n\n        // Assert\n        $response->assertStatus(204);\n        $this->assertDatabaseMissing(Member::class, [\n            'id' => $data->member->getKey(),\n        ]);\n        Event::assertDispatched(function (MemberRemoved $event) use ($data): bool {\n            return $event->organization->is($data->organization) &&\n                $event->member->is($data->member);\n        }, 1);\n    }\n\n    public function test_make_placeholder_fails_if_user_has_no_permission(): void\n    {\n        // Arrange\n        Event::fake([\n            MemberMadeToPlaceholder::class,\n        ]);\n        $data = $this->createUserWithPermission();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.members.make-placeholder', [\n            'organization' => $data->organization->getKey(),\n            'member' => $data->member->getKey(),\n        ]));\n\n        // Assert\n        $response->assertForbidden();\n        Event::assertNotDispatched(MemberMadeToPlaceholder::class);\n    }\n\n    public function test_make_placeholder_fails_if_user_is_already_a_placeholder(): void\n    {\n        // Arrange\n        Event::fake([\n            MemberMadeToPlaceholder::class,\n        ]);\n        $data = $this->createUserWithPermission([\n            'members:make-placeholder',\n        ]);\n        $user = User::factory()->placeholder()->create();\n        $member = Member::factory()->forUser($user)->forOrganization($data->organization)->role(Role::Placeholder)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.members.make-placeholder', [\n            'organization' => $data->organization->getKey(),\n            'member' => $member->getKey(),\n        ]));\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertExactJson([\n            'error' => true,\n            'key' => 'changing_role_of_placeholder_is_not_allowed',\n            'message' => 'Changing role of placeholder is not allowed',\n        ]);\n    }\n\n    public function test_make_placeholder_fails_if_member_is_owner(): void\n    {\n        // Arrange\n        Event::fake([\n            MemberMadeToPlaceholder::class,\n        ]);\n        $data = $this->createUserWithPermission([\n            'members:make-placeholder',\n        ]);\n        $member = Member::factory()->forOrganization($data->organization)->role(Role::Owner)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.members.make-placeholder', [\n            'organization' => $data->organization->getKey(),\n            'member' => $member->getKey(),\n        ]));\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertJsonPath('message', 'Can not remove owner from organization');\n        Event::assertNotDispatched(MemberMadeToPlaceholder::class);\n    }\n\n    public function test_make_placeholder_fails_if_member_is_not_part_of_org(): void\n    {\n        // Arrange\n        Event::fake([\n            MemberMadeToPlaceholder::class,\n        ]);\n        $data = $this->createUserWithPermission([\n            'members:make-placeholder',\n        ]);\n        $otherData = $this->createUserWithPermission([\n            'members:make-placeholder',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.members.make-placeholder', [\n            'organization' => $data->organization->getKey(),\n            'member' => $otherData->member->getKey(),\n        ]));\n\n        // Assert\n        $response->assertStatus(403);\n    }\n\n    public function test_make_placeholder_creates_placeholder_and_attaches_resources_to_the_new_user(): void\n    {\n        // Arrange\n        Event::fake([\n            MemberMadeToPlaceholder::class,\n        ]);\n        $data = $this->createUserWithPermission([\n            'members:make-placeholder',\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($data->organization)->forUser($user)->role(Role::Admin)->create();\n        $timeEntry = TimeEntry::factory()->forMember($member)->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $projectMember = ProjectMember::factory()->forProject($project)->forMember($member)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.members.make-placeholder', [\n            'organization' => $data->organization->getKey(),\n            'member' => $member->getKey(),\n        ]));\n\n        // Assert\n        $response->assertStatus(204);\n        $member->refresh();\n        $this->assertSame(Role::Placeholder->value, $member->role);\n        $this->assertTrue($member->user->is_placeholder);\n        $this->assertCount(1, $user->organizations);\n        $this->assertCount(1, $member->user->organizations);\n        $this->assertNotEquals($user->getKey(), $member->user->getKey());\n        $timeEntry->refresh();\n        $this->assertSame($member->user_id, $timeEntry->user_id);\n        $projectMember->refresh();\n        $this->assertSame($member->user_id, $projectMember->user_id);\n        Event::assertDispatched(function (MemberMadeToPlaceholder $event) use ($data, $member): bool {\n            return $event->organization->is($data->organization) &&\n                $event->member->is($member);\n        }, 1);\n    }\n\n    public function test_invite_placeholder_fails_if_user_does_not_have_permission(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $user = User::factory()->create([\n            'is_placeholder' => true,\n        ]);\n        $member = Member::factory()->forUser($user)->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.members.invite-placeholder', [\n            'organization' => $data->organization->id,\n            'member' => $member->id,\n        ]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_invite_placeholder_fails_if_user_is_not_part_of_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:invite-placeholder',\n            'invitations:create',\n        ]);\n        $otherOrganization = Organization::factory()->create();\n        $user = User::factory()->create([\n            'is_placeholder' => true,\n        ]);\n        $member = Member::factory()->forUser($user)->forOrganization($otherOrganization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.members.invite-placeholder', [\n            'organization' => $data->organization->id,\n            'member' => $member->id,\n        ]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_invite_placeholder_fails_if_there_is_already_an_invitation_with_the_same_email(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:invite-placeholder',\n            'invitations:create',\n        ]);\n        $placeholder = User::factory()->placeholder()->create([\n            'email' => 'user@mail.test',\n        ]);\n        $placeholderMember = Member::factory()->forUser($placeholder)->forOrganization($data->organization)->role(Role::Placeholder)->create();\n        OrganizationInvitation::factory()->forOrganization($data->organization)->create([\n            'email' => $placeholder->email,\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.members.invite-placeholder', [\n            'organization' => $data->organization->id,\n            'member' => $placeholderMember->id,\n        ]));\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertExactJson([\n            'error' => true,\n            'key' => 'invitation_for_the_email_already_exists',\n            'message' => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',\n        ]);\n    }\n\n    public function test_invite_placeholder_returns_400_if_user_is_not_placeholder(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'members:invite-placeholder',\n            'invitations:create',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.members.invite-placeholder', [\n            'organization' => $data->organization->id,\n            'member' => $data->member->id,\n        ]));\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertExactJson([\n            'error' => true,\n            'key' => 'user_not_placeholder',\n            'message' => 'The given user is not a placeholder',\n        ]);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Endpoint\\Api\\V1;\n\nuse App\\Enums\\Role;\nuse App\\Http\\Controllers\\Api\\V1\\OrganizationController;\nuse App\\Models\\Organization;\nuse App\\Service\\BillableRateService;\nuse Laravel\\Passport\\Passport;\nuse Mockery\\MockInterface;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\n\n#[UsesClass(OrganizationController::class)]\nclass OrganizationEndpointTest extends ApiEndpointTestAbstract\n{\n    public function test_show_endpoint_fails_with_not_found_if_id_is_not_uuid(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'organizations:view',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.organizations.show', ['not-uuid']));\n\n        // Assert\n        $response->assertNotFound();\n    }\n\n    public function test_show_endpoint_fails_if_user_has_no_permission_to_view_organizations(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.organizations.show', [$data->organization->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_show_endpoint_returns_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'organizations:view',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.organizations.show', [$data->organization->getKey()]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonPath('data.id', $data->organization->getKey());\n    }\n\n    public function test_show_endpoint_shows_billable_rate_for_members_with_role_employee_if_organization_allows_it(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(Role::Employee);\n        $data->organization->employees_can_see_billable_rates = true;\n        $data->organization->billable_rate = 100;\n        $data->organization->save();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.organizations.show', [$data->organization->getKey()]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonPath('data.billable_rate', 100);\n    }\n\n    public function test_show_endpoint_does_not_show_billable_rate_for_members_with_role_employee_if_organization_does_not_allow_it(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(Role::Employee);\n        $data->organization->employees_can_see_billable_rates = false;\n        $data->organization->billable_rate = 100;\n        $data->organization->save();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.organizations.show', [$data->organization->getKey()]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonPath('data.billable_rate', null);\n    }\n\n    public function test_update_endpoint_fails_if_user_has_no_permission_to_update_organizations(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $this->assertBillableRateServiceIsUnused();\n        $organizationFake = Organization::factory()->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [\n            'name' => $organizationFake->name,\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_update_endpoint_can_update_the_organization_name(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'organizations:update',\n        ]);\n        $this->assertBillableRateServiceIsUnused();\n        $organizationFake = Organization::factory()->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [\n            'name' => $organizationFake->name,\n            'billable_rate' => null,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $this->assertDatabaseHas(Organization::class, [\n            'name' => $organizationFake->name,\n        ]);\n    }\n\n    public function test_update_endpoint_can_update_formats(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'organizations:update',\n        ]);\n        $this->assertBillableRateServiceIsUnused();\n        $organizationFake = Organization::factory()->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [\n            'name' => $organizationFake->name,\n            'number_format' => $organizationFake->number_format->value,\n            'currency_format' => $organizationFake->currency_format->value,\n            'date_format' => $organizationFake->date_format->value,\n            'interval_format' => $organizationFake->interval_format->value,\n            'time_format' => $organizationFake->time_format->value,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJson([\n            'data' => [\n                'id' => $data->organization->getKey(),\n                'number_format' => $organizationFake->number_format->value,\n                'currency_format' => $organizationFake->currency_format->value,\n                'date_format' => $organizationFake->date_format->value,\n                'interval_format' => $organizationFake->interval_format->value,\n                'time_format' => $organizationFake->time_format->value,\n            ],\n        ]);\n        $this->assertDatabaseHas(Organization::class, [\n            'name' => $organizationFake->name,\n            'number_format' => $organizationFake->number_format,\n            'currency_format' => $organizationFake->currency_format,\n            'date_format' => $organizationFake->date_format,\n            'interval_format' => $organizationFake->interval_format,\n            'time_format' => $organizationFake->time_format,\n        ]);\n    }\n\n    public function test_update_endpoint_can_update_billable_rate_of_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'organizations:update',\n        ]);\n        $this->assertBillableRateServiceIsUnused();\n        $organizationFake = Organization::factory()->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [\n            'billable_rate' => $organizationFake->billable_rate,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJson([\n            'data' => [\n                'id' => $data->organization->getKey(),\n                'name' => $data->organization->name,\n                'billable_rate' => $organizationFake->billable_rate,\n            ],\n        ]);\n        $this->assertDatabaseHas(Organization::class, [\n            'id' => $data->organization->getKey(),\n            'name' => $data->organization->name,\n            'billable_rate' => $organizationFake->billable_rate,\n        ]);\n    }\n\n    public function test_update_endpoint_can_update_the_setting_employees_can_see_billable_rates(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'organizations:update',\n        ]);\n        $this->assertBillableRateServiceIsUnused();\n        $data->organization->employees_can_see_billable_rates = false;\n        $data->organization->save();\n        $organizationFake = Organization::factory()->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [\n            'name' => $organizationFake->name,\n            'employees_can_see_billable_rates' => true,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $this->assertDatabaseHas(Organization::class, [\n            'name' => $organizationFake->name,\n            'employees_can_see_billable_rates' => true,\n        ]);\n    }\n\n    public function test_update_endpoint_can_update_billable_rate_of_organization_and_update_time_entries(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'organizations:update',\n        ]);\n        $billableRate = 111;\n        $organizationFake = Organization::factory()->billableRate($billableRate)->make();\n        $this->mock(BillableRateService::class, function (MockInterface $mock) use ($data, $billableRate): void {\n            $mock->shouldReceive('updateTimeEntriesBillableRateForOrganization')\n                ->once()\n                ->withArgs(fn (Organization $organization) => $organization->is($data->organization) && $organization->billable_rate === $billableRate);\n        });\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [\n            'name' => $organizationFake->name,\n            'billable_rate' => $organizationFake->billable_rate,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $this->assertDatabaseHas(Organization::class, [\n            'name' => $organizationFake->name,\n            'billable_rate' => $organizationFake->billable_rate,\n        ]);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Endpoint\\Api\\V1;\n\nuse App\\Enums\\Role;\nuse App\\Http\\Controllers\\Api\\V1\\ProjectController;\nuse App\\Models\\Client;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\ProjectMember;\nuse App\\Models\\Task;\nuse App\\Models\\TimeEntry;\nuse App\\Service\\BillableRateService;\nuse Illuminate\\Testing\\Fluent\\AssertableJson;\nuse Laravel\\Passport\\Passport;\nuse Mockery\\MockInterface;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\n\n#[UsesClass(ProjectController::class)]\nclass ProjectEndpointTest extends ApiEndpointTestAbstract\n{\n    public function test_index_endpoint_fails_if_user_has_no_permission_to_view_projects(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $projects = Project::factory()->forOrganization($data->organization)->createMany(4);\n        $projectsWithClients = Project::factory()->forOrganization($data->organization)->withClient()->createMany(4);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.projects.index', [$data->organization->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_index_endpoint_returns_list_of_all_projects_of_organization_for_user_with_all_projects_permission(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:view',\n            'projects:view:all',\n        ]);\n        $projects = Project::factory()->forOrganization($data->organization)->createMany(4);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.projects.index', [$data->organization->getKey()]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonCount(4, 'data');\n    }\n\n    public function test_index_endpoint_returns_projects_ordered_by_created_at_descending(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:view',\n            'projects:view:all',\n        ]);\n        $projectOldest = Project::factory()->forOrganization($data->organization)->create([\n            'created_at' => now()->subDays(3),\n        ]);\n        $projectNewest = Project::factory()->forOrganization($data->organization)->create([\n            'created_at' => now()->subDay(),\n        ]);\n        $projectMiddle = Project::factory()->forOrganization($data->organization)->create([\n            'created_at' => now()->subDays(2),\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.projects.index', [$data->organization->getKey()]));\n\n        // Assert\n        $response->assertStatus(200);\n        $ids = collect($response->json('data'))->pluck('id')->values()->toArray();\n        $this->assertSame([$projectNewest->getKey(), $projectMiddle->getKey(), $projectOldest->getKey()], $ids);\n    }\n\n    public function test_index_endpoint_without_filter_archived_returns_only_non_archived_projects(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:view',\n            'projects:view:all',\n        ]);\n        $archivedProjects = Project::factory()->forOrganization($data->organization)->archived()->createMany(2);\n        $nonArchivedProjects = Project::factory()->forOrganization($data->organization)->createMany(2);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.projects.index', [$data->organization->getKey()]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonCount(2, 'data');\n        $this->assertEqualsCanonicalizing($nonArchivedProjects->pluck('id')->toArray(), $response->json('data.*.id'));\n    }\n\n    public function test_index_endpoint_with_filter_archived_true_returns_only_archived_projects(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:view',\n            'projects:view:all',\n        ]);\n        $archivedProjects = Project::factory()->forOrganization($data->organization)->archived()->createMany(2);\n        $nonArchivedProjects = Project::factory()->forOrganization($data->organization)->createMany(2);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.projects.index', [\n            $data->organization->getKey(),\n            'archived' => 'true',\n        ]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonCount(2, 'data');\n        $this->assertEqualsCanonicalizing($archivedProjects->pluck('id')->toArray(), $response->json('data.*.id'));\n    }\n\n    public function test_index_endpoint_with_filter_archived_false_returns_only_non_archived_projects(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:view',\n            'projects:view:all',\n        ]);\n        $archivedProjects = Project::factory()->forOrganization($data->organization)->archived()->createMany(2);\n        $nonArchivedProjects = Project::factory()->forOrganization($data->organization)->createMany(2);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.projects.index', [\n            $data->organization->getKey(),\n            'archived' => 'false',\n        ]));\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonCount(2, 'data');\n        $this->assertEqualsCanonicalizing($nonArchivedProjects->pluck('id')->toArray(), $response->json('data.*.id'));\n    }\n\n    public function test_index_endpoint_with_filter_archived_all_returns_all_projects(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:view',\n            'projects:view:all',\n        ]);\n        $archivedProjects = Project::factory()->forOrganization($data->organization)->archived()->createMany(2);\n        $nonArchivedProjects = Project::factory()->forOrganization($data->organization)->createMany(2);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.projects.index', [\n            $data->organization->getKey(),\n            'archived' => 'all',\n        ]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonCount(4, 'data');\n    }\n\n    public function test_index_endpoint_returns_list_of_projects_of_organization_which_are_public_or_where_user_is_member_for_user_with_restricted_permission(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:view',\n        ]);\n        $privateProjects = Project::factory()->forOrganization($data->organization)->isPrivate()->createMany(2);\n        $publicProjects = Project::factory()->forOrganization($data->organization)->isPublic()->createMany(2);\n        $privateProjectsWithMembership = Project::factory()->forOrganization($data->organization)->addMember($data->member)->isPrivate()->createMany(2);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.projects.index', [$data->organization->getKey()]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonCount(4, 'data');\n    }\n\n    public function test_index_endpoint_sets_billable_rate_to_null_if_member_is_employee_and_organization_does_not_allow_employees_to_see_billable_rates(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(Role::Employee);\n        $organization = $data->organization;\n        $organization->employees_can_see_billable_rates = false;\n        $organization->save();\n        $privateProjects = Project::factory()->forOrganization($data->organization)->isPrivate()->billable(111)->createMany(2);\n        $publicProjects = Project::factory()->forOrganization($data->organization)->isPublic()->billable(112)->createMany(2);\n        $privateProjectsWithMembership = Project::factory()->forOrganization($data->organization)->addMember($data->member)->billable(113)->isPrivate()->createMany(2);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.projects.index', [$organization->getKey()]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonCount(4, 'data');\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->has('links')\n            ->has('meta')\n            ->where('data.0.billable_rate', null)\n            ->where('data.1.billable_rate', null)\n            ->where('data.2.billable_rate', null)\n            ->where('data.3.billable_rate', null)\n        );\n    }\n\n    public function test_index_endpoint_does_not_set_billable_rate_to_null_if_member_is_employee_and_organization_allows_employees_to_see_billable_rates(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(Role::Employee);\n        $organization = $data->organization;\n        $organization->employees_can_see_billable_rates = true;\n        $organization->save();\n        $privateProjects = Project::factory()->forOrganization($data->organization)->isPrivate()->billable(111)->createdAt(now()->subMinutes(4))->createMany(2);\n        $publicProjects = Project::factory()->forOrganization($data->organization)->isPublic()->billable(112)->createdAt(now()->subMinutes(3))->createMany(2);\n        $privateProjectsWithMembership = Project::factory()->forOrganization($data->organization)->addMember($data->member)->billable(113)->isPrivate()->createdAt(now()->subMinutes(2))->createMany(2);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.projects.index', [$organization->getKey()]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonCount(4, 'data');\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->has('links')\n            ->has('meta')\n            ->where('data.0.billable_rate', 113)\n            ->where('data.1.billable_rate', 113)\n            ->where('data.2.billable_rate', 112)\n            ->where('data.3.billable_rate', 112)\n        );\n    }\n\n    public function test_show_endpoint_fails_if_user_is_not_part_of_project_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:view',\n        ]);\n        $otherOrganization = Organization::factory()->create();\n        $project = Project::factory()->forOrganization($otherOrganization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.projects.show', [$data->organization->getKey(), $project->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_show_endpoint_fails_if_user_has_no_permission_to_view_projects(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.projects.show', [$data->organization->getKey(), $project->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_show_endpoint_returns_project(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:view',\n            'projects:view:all',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.projects.show', [$data->organization->getKey(), $project->getKey()]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonPath('data.id', $project->getKey());\n    }\n\n    public function test_show_endpoint_fails_if_employee_tries_to_access_private_project_that_they_are_not_a_member_of(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(Role::Employee);\n        $privateProject = Project::factory()->forOrganization($data->organization)->isPrivate()->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.projects.show', [$data->organization->getKey(), $privateProject->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_store_endpoint_fails_if_user_has_no_permission_to_create_projects(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $projectFake = Project::factory()->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'client_id' => null,\n            'is_billable' => $projectFake->is_billable,\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_store_endpoint_highest_possible_billable_rate_can_be_stored_in_database(): void\n    {\n        // Arrange\n        $billableRate = 2147483647;\n        $data = $this->createUserWithPermission([\n            'projects:create',\n        ]);\n        $projectFake = Project::factory()->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'is_billable' => $projectFake->is_billable,\n            'client_id' => null,\n            'billable_rate' => $billableRate,\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        $this->assertDatabaseHas(Project::class, [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'organization_id' => $projectFake->organization_id,\n            'is_billable' => $projectFake->is_billable,\n            'client_id' => null,\n            'billable_rate' => $billableRate,\n        ]);\n    }\n\n    public function test_store_endpoint_fails_if_billable_rate_is_too_high(): void\n    {\n        // Arrange\n        $billableRate = 2147483647 + 1;\n        $data = $this->createUserWithPermission([\n            'projects:create',\n        ]);\n        $projectFake = Project::factory()->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'is_billable' => $projectFake->is_billable,\n            'client_id' => null,\n            'billable_rate' => $billableRate,\n        ]);\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertJsonValidationErrors([\n            'billable_rate' => 'The billable rate field must not be greater than 2147483647.',\n        ]);\n    }\n\n    public function test_store_endpoint_creates_new_project(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:create',\n        ]);\n        $projectFake = Project::factory()->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'client_id' => null,\n            'is_billable' => $projectFake->is_billable,\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        $this->assertDatabaseHas(Project::class, [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'organization_id' => $projectFake->organization_id,\n            'client_id' => null,\n            'is_billable' => $projectFake->is_billable,\n        ]);\n    }\n\n    public function test_store_endpoint_ignores_estimated_time_if_pro_features_are_disabled(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:create',\n        ]);\n        $projectFake = Project::factory()->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'is_billable' => $projectFake->is_billable,\n            'client_id' => null,\n            'estimated_time' => 10000,\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.name', $projectFake->name)\n            ->where('data.color', $projectFake->color)\n            ->where('data.estimated_time', null)\n        );\n        $this->assertDatabaseHas(Project::class, [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'organization_id' => $projectFake->organization_id,\n            'is_billable' => $projectFake->is_billable,\n            'client_id' => null,\n            'estimated_time' => null,\n        ]);\n    }\n\n    public function test_store_endpoint_can_store_project_with_estimated_time_with_pro_features_enabled(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:create',\n        ]);\n        $projectFake = Project::factory()->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n        $this->actAsOrganizationWithSubscription();\n\n        // Act\n        $response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'is_billable' => $projectFake->is_billable,\n            'client_id' => null,\n            'estimated_time' => 10000,\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.name', $projectFake->name)\n            ->where('data.color', $projectFake->color)\n            ->where('data.estimated_time', 10000)\n        );\n        $this->assertDatabaseHas(Project::class, [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'organization_id' => $projectFake->organization_id,\n            'is_billable' => $projectFake->is_billable,\n            'client_id' => null,\n            'estimated_time' => 10000,\n        ]);\n    }\n\n    public function test_store_endpoint_can_create_project_if_project_name_already_exists_in_organization_but_with_different_client(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:create',\n        ]);\n        $name = 'Project Name';\n        $clientA = Client::factory()->forOrganization($data->organization)->create();\n        $clientB = Client::factory()->forOrganization($data->organization)->create();\n        $projectA = Project::factory()->forOrganization($data->organization)->forClient($clientA)->create([\n            'name' => $name,\n        ]);\n        $projectFake = Project::factory()->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [\n            'name' => $name,\n            'color' => $projectFake->color,\n            'client_id' => $clientB->getKey(),\n            'is_billable' => $projectFake->is_billable,\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        $this->assertDatabaseHas(Project::class, [\n            'name' => $name,\n            'client_id' => $clientB->getKey(),\n        ]);\n        $this->assertDatabaseHas(Project::class, [\n            'name' => $name,\n            'client_id' => $clientA->getKey(),\n        ]);\n    }\n\n    public function test_store_endpoint_fails_without_client_if_name_is_already_used_for_project_without_client_in_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:create',\n        ]);\n        $name = 'Project Name';\n        $project = Project::factory()->forOrganization($data->organization)->create([\n            'name' => $name,\n        ]);\n        $projectFake = Project::factory()->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [\n            'name' => $name,\n            'color' => $projectFake->color,\n            'client_id' => null,\n            'is_billable' => $projectFake->is_billable,\n        ]);\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertJsonValidationErrors([\n            'name' => 'A project with the same name and client already exists in the organization.',\n        ]);\n    }\n\n    public function test_store_endpoint_fails_with_client_if_name_is_already_used_for_the_same_client(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:create',\n        ]);\n        $name = 'Project Name';\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create([\n            'name' => $name,\n        ]);\n        $projectFake = Project::factory()->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [\n            'name' => $name,\n            'color' => $projectFake->color,\n            'client_id' => $client->getKey(),\n            'is_billable' => $projectFake->is_billable,\n        ]);\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertJsonValidationErrors([\n            'name' => 'A project with the same name and client already exists in the organization.',\n        ]);\n    }\n\n    public function test_store_endpoint_creates_project_if_name_is_used_in_other_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:create',\n        ]);\n        $name = 'Project Name';\n        $otherOrganization = Organization::factory()->create();\n        $project = Project::factory()->forOrganization($otherOrganization)->create([\n            'name' => $name,\n        ]);\n        $projectFake = Project::factory()->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [\n            'name' => $name,\n            'color' => $projectFake->color,\n            'client_id' => null,\n            'is_billable' => $projectFake->is_billable,\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        $this->assertDatabaseHas(Project::class, [\n            'name' => $name,\n            'color' => $projectFake->color,\n            'organization_id' => $data->organization->getKey(),\n            'is_billable' => $projectFake->is_billable,\n        ]);\n    }\n\n    public function test_store_endpoint_creates_new_project_with_client(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:create',\n        ]);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $projectFake = Project::factory()->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'is_billable' => $projectFake->is_billable,\n            'client_id' => $client->getKey(),\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        $this->assertDatabaseHas(Project::class, [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'is_billable' => $projectFake->is_billable,\n            'organization_id' => $projectFake->organization_id,\n            'client_id' => $client->getKey(),\n        ]);\n    }\n\n    public function test_store_endpoint_creates_new_project_with_billable_rate(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:create',\n        ]);\n        $projectFake = Project::factory()->forOrganization($data->organization)->make();\n        $this->assertBillableRateServiceIsUnused();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'client_id' => null,\n            'is_billable' => true,\n            'billable_rate' => 10001,\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        $this->assertDatabaseHas(Project::class, [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'is_billable' => true,\n            'billable_rate' => 10001,\n            'organization_id' => $projectFake->organization_id,\n        ]);\n    }\n\n    public function test_update_endpoint_fails_if_user_is_not_part_of_project_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:update',\n        ]);\n        $otherOrganization = Organization::factory()->create();\n        $project = Project::factory()->forOrganization($otherOrganization)->create();\n        $projectFake = Project::factory()->make();\n        $this->assertBillableRateServiceIsUnused();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'client_id' => null,\n            'is_billable' => $projectFake->is_billable,\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_update_endpoint_fails_if_user_has_no_permission_to_update_projects(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $projectFake = Project::factory()->make();\n        $this->assertBillableRateServiceIsUnused();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'client_id' => null,\n            'is_billable' => $projectFake->is_billable,\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_update_endpoint_can_update_project_if_project_name_already_exists_in_organization_but_with_different_client(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:update',\n        ]);\n        $name = 'Project Name';\n        $clientA = Client::factory()->forOrganization($data->organization)->create();\n        $clientB = Client::factory()->forOrganization($data->organization)->create();\n        $projectWithTheName = Project::factory()->forOrganization($data->organization)->forClient($clientA)->create([\n            'name' => $name,\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $projectFake = Project::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [\n            'name' => $name,\n            'color' => $projectFake->color,\n            'client_id' => $clientB->getKey(),\n            'is_billable' => $projectFake->is_billable,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $this->assertDatabaseHas(Project::class, [\n            'name' => $name,\n            'client_id' => $clientA->getKey(),\n        ]);\n        $this->assertDatabaseHas(Project::class, [\n            'name' => $name,\n            'client_id' => $clientB->getKey(),\n        ]);\n    }\n\n    public function test_update_endpoint_fails_without_client_if_name_is_already_used_for_project_without_client_in_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:update',\n        ]);\n        $name = 'Project Name';\n        $projectWithTheName = Project::factory()->forOrganization($data->organization)->create([\n            'name' => $name,\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $projectFake = Project::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [\n            'name' => $name,\n            'color' => $projectFake->color,\n            'client_id' => null,\n            'is_billable' => $projectFake->is_billable,\n        ]);\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertJsonValidationErrors([\n            'name' => 'A project with the same name and client already exists in the organization.',\n        ]);\n    }\n\n    public function test_update_endpoint_fails_with_client_if_name_is_already_used_for_the_same_client(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:update',\n        ]);\n        $name = 'Project Name';\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $projectWithTheName = Project::factory()->forOrganization($data->organization)->forClient($client)->create([\n            'name' => $name,\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $projectFake = Project::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [\n            'name' => $name,\n            'color' => $projectFake->color,\n            'client_id' => $client->getKey(),\n            'is_billable' => $projectFake->is_billable,\n        ]);\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertJsonValidationErrors([\n            'name' => 'A project with the same name and client already exists in the organization.',\n        ]);\n    }\n\n    public function test_update_endpoint_updates_the_client_id_of_the_associated_time_entries_if_the_client_of_the_project_changed(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:update',\n        ]);\n        $clientOld = Client::factory()->forOrganization($data->organization)->create();\n        $clientNew = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($clientOld)->create();\n        $projectFake = Project::factory()->make();\n        $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'is_billable' => $projectFake->is_billable,\n            'client_id' => $clientNew->getKey(),\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $timeEntry->refresh();\n        $this->assertSame($clientNew->getKey(), $timeEntry->client_id);\n    }\n\n    public function test_update_endpoint_updates_the_client_id_of_the_associated_time_entries_if_the_client_of_the_project_is_removed(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:update',\n        ]);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $projectFake = Project::factory()->make();\n        $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'is_billable' => $projectFake->is_billable,\n            'client_id' => null,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $timeEntry->refresh();\n        $this->assertNull($timeEntry->client_id);\n    }\n\n    public function test_update_endpoint_updates_the_client_id_of_the_associated_time_entries_if_the_client_of_the_project_is_added(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:update',\n        ]);\n        $clientNew = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient(null)->create();\n        $projectFake = Project::factory()->make();\n        $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'is_billable' => $projectFake->is_billable,\n            'client_id' => $clientNew->getKey(),\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $timeEntry->refresh();\n        $this->assertSame($clientNew->getKey(), $timeEntry->client_id);\n    }\n\n    public function test_update_endpoint_updates_project_if_name_is_used_in_other_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:update',\n        ]);\n        $name = 'Project Name';\n        $otherOrganization = Organization::factory()->create();\n        $otherProject = Project::factory()->forOrganization($otherOrganization)->create([\n            'name' => $name,\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create([\n            'name' => $name,\n        ]);\n        $projectFake = Project::factory()->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [\n            'name' => $name,\n            'color' => $projectFake->color,\n            'client_id' => null,\n            'is_billable' => $projectFake->is_billable,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $this->assertDatabaseHas(Project::class, [\n            'name' => $name,\n            'color' => $projectFake->color,\n            'organization_id' => $data->organization->getKey(),\n            'is_billable' => $projectFake->is_billable,\n        ]);\n    }\n\n    public function test_update_endpoint_updates_project(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:update',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $projectFake = Project::factory()->make();\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $this->assertBillableRateServiceIsUnused();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'is_billable' => $projectFake->is_billable,\n            'client_id' => $client->getKey(),\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $project->refresh();\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.name', $projectFake->name)\n            ->where('data.color', $projectFake->color)\n            ->where('data.client_id', $client->getKey())\n        );\n        $this->assertSame($projectFake->name, $project->name);\n        $this->assertSame($projectFake->color, $project->color);\n        $this->assertSame($client->getKey(), $project->client_id);\n        $this->assertFalse($project->is_archived);\n    }\n\n    public function test_update_endpoint_ignores_estimated_time_if_pro_features_are_disabled(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:update',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $projectFake = Project::factory()->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'client_id' => null,\n            'is_billable' => $projectFake->is_billable,\n            'estimated_time' => 10000,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $project->refresh();\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.name', $projectFake->name)\n            ->where('data.color', $projectFake->color)\n            ->where('data.estimated_time', null)\n        );\n        $this->assertSame($projectFake->name, $project->name);\n        $this->assertSame($projectFake->color, $project->color);\n        $this->assertNull($project->estimated_time);\n    }\n\n    public function test_update_endpoint_can_store_project_with_estimated_time_with_pro_features_enabled(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:update',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $projectFake = Project::factory()->make();\n        Passport::actingAs($data->user);\n        $this->actAsOrganizationWithSubscription();\n\n        // Act\n        $response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'client_id' => null,\n            'is_billable' => $projectFake->is_billable,\n            'estimated_time' => 10000,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $project->refresh();\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.name', $projectFake->name)\n            ->where('data.color', $projectFake->color)\n            ->where('data.estimated_time', 10000)\n        );\n        $this->assertSame($projectFake->name, $project->name);\n        $this->assertSame($projectFake->color, $project->color);\n        $this->assertSame(10000, $project->estimated_time);\n    }\n\n    public function test_update_endpoint_does_not_update_billable_rates_of_time_entries_if_billable_rate_is_unchanged(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:update',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $projectFake = Project::factory()->make();\n        $this->assertBillableRateServiceIsUnused();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'client_id' => null,\n            'is_billable' => $projectFake->is_billable,\n            'billable_rate' => $project->billable_rate,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $this->assertDatabaseHas(Project::class, [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'billable_rate' => $project->billable_rate,\n        ]);\n    }\n\n    public function test_update_endpoint_can_update_projects_billable_rate_and_update_time_entries(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:update',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $projectFake = Project::factory()->make();\n        $this->mock(BillableRateService::class, function (MockInterface $mock) use ($project): void {\n            $mock->shouldReceive('updateTimeEntriesBillableRateForProject')\n                ->once()\n                ->withArgs(fn (Project $projectArg) => $projectArg->is($project) && $projectArg->billable_rate === 10003);\n        });\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'client_id' => null,\n            'is_billable' => $projectFake->is_billable,\n            'billable_rate' => 10003,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $this->assertDatabaseHas(Project::class, [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'billable_rate' => 10003,\n        ]);\n    }\n\n    public function test_update_endpoint_can_archive_a_project(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:update',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $projectFake = Project::factory()->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'client_id' => null,\n            'is_billable' => $projectFake->is_billable,\n            'is_archived' => true,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.is_archived', true)\n        );\n        $project->refresh();\n        $this->assertTrue($project->is_archived);\n    }\n\n    public function test_update_endpoint_can_unarchive_a_project(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:update',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->archived()->create();\n        $projectFake = Project::factory()->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [\n            'name' => $projectFake->name,\n            'color' => $projectFake->color,\n            'client_id' => null,\n            'is_billable' => $projectFake->is_billable,\n            'is_archived' => false,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.is_archived', false)\n        );\n        $project->refresh();\n        $this->assertFalse($project->is_archived);\n    }\n\n    public function test_destroy_endpoint_fails_if_user_is_not_part_of_project_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:delete',\n        ]);\n        $otherOrganization = Organization::factory()->create();\n        $project = Project::factory()->forOrganization($otherOrganization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.projects.destroy', [$data->organization->getKey(), $project->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_destroy_endpoint_fails_if_user_has_no_permission_to_delete_projects(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.projects.destroy', [$data->organization->getKey(), $project->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_destroy_endpoint_fails_if_project_is_still_in_use_by_a_task(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:delete',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $task = Task::factory()->forProject($project)->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.projects.destroy', [$data->organization->getKey(), $project->getKey()]));\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertJsonPath('message', 'The project is still used by a task and can not be deleted.');\n        $this->assertDatabaseHas(Project::class, [\n            'id' => $project->getKey(),\n        ]);\n    }\n\n    public function test_destroy_endpoint_fails_if_project_is_still_in_use_by_a_time_entry(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:delete',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $timeEntry = TimeEntry::factory()->forProject($project)->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.projects.destroy', [$data->organization->getKey(), $project->getKey()]));\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertJsonPath('message', 'The project is still used by a time entry and can not be deleted.');\n        $this->assertDatabaseHas(Project::class, [\n            'id' => $project->getKey(),\n        ]);\n    }\n\n    public function test_destroy_endpoint_deletes_project_with_project_members(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'projects:delete',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $projectMember = ProjectMember::factory()->forMember($data->member)->forProject($project)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.projects.destroy', [$data->organization->getKey(), $project->getKey()]));\n\n        // Assert\n        $response->assertStatus(204);\n        $response->assertNoContent();\n        $this->assertDatabaseMissing(Project::class, [\n            'id' => $project->getKey(),\n        ]);\n        $this->assertDatabaseMissing(ProjectMember::class, [\n            'id' => $projectMember->getKey(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Endpoint\\Api\\V1;\n\nuse App\\Http\\Controllers\\Api\\V1\\ProjectMemberController;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\ProjectMember;\nuse App\\Models\\User;\nuse App\\Service\\BillableRateService;\nuse Laravel\\Passport\\Passport;\nuse Mockery\\MockInterface;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\n\n#[UsesClass(ProjectMemberController::class)]\nclass ProjectMemberEndpointTest extends ApiEndpointTestAbstract\n{\n    public function test_index_endpoint_fails_if_user_has_no_permission_to_view_project_members(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        ProjectMember::factory()->forProject($project)->createMany(4);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.project-members.index', [\n            $data->organization->getKey(),\n            $project->getKey(),\n        ]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_index_endpoint_fails_if_the_project_does_not_belong_to_given_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'project-members:view',\n        ]);\n        $otherData = $this->createUserWithPermission([\n            'project-members:view',\n        ]);\n        $project = Project::factory()->forOrganization($otherData->organization)->create();\n        ProjectMember::factory()->forProject($project)->createMany(4);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.project-members.index', [\n            $data->organization->getKey(),\n            $project->getKey(),\n        ]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_index_endpoint_returns_list_of_all_project_members_of_a_project(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'project-members:view',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        ProjectMember::factory()->forProject($project)->createMany(4);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.project-members.index', [\n            $data->organization->getKey(),\n            $project->getKey(),\n        ]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonCount(4, 'data');\n    }\n\n    public function test_index_endpoint_returns_project_members_ordered_by_created_at_descending(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'project-members:view',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $pmOldest = ProjectMember::factory()->forProject($project)->create([\n            'created_at' => now()->subDays(3),\n        ]);\n        $pmNewest = ProjectMember::factory()->forProject($project)->create([\n            'created_at' => now()->subDay(),\n        ]);\n        $pmMiddle = ProjectMember::factory()->forProject($project)->create([\n            'created_at' => now()->subDays(2),\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.project-members.index', [\n            $data->organization->getKey(),\n            $project->getKey(),\n        ]));\n\n        // Assert\n        $response->assertStatus(200);\n        $ids = collect($response->json('data'))->pluck('id')->values()->toArray();\n        $this->assertSame([$pmNewest->getKey(), $pmMiddle->getKey(), $pmOldest->getKey()], $ids);\n    }\n\n    public function test_store_endpoint_fails_if_user_has_no_permission_to_add_members_to_project(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $projectMemberFake = ProjectMember::factory()->make();\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($data->organization)->forUser($user)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [\n            'billable_rate' => $projectMemberFake->billable_rate,\n            'member_id' => $member->getKey(),\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_store_endpoint_fails_if_given_project_does_not_belong_to_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'project-members:create',\n        ]);\n        $otherData = $this->createUserWithPermission([\n            'project-members:create',\n        ]);\n        $project = Project::factory()->forOrganization($otherData->organization)->create();\n        $projectMemberFake = ProjectMember::factory()->make();\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($data->organization)->forUser($user)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [\n            'billable_rate' => $projectMemberFake->billable_rate,\n            'member_id' => $member->getKey(),\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_store_endpoint_fails_if_given_user_does_not_belong_to_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'project-members:create',\n        ]);\n        $otherData = $this->createUserWithPermission([\n            'project-members:create',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $projectMemberFake = ProjectMember::factory()->make();\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($otherData->organization)->forUser($user)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [\n            'billable_rate' => $projectMemberFake->billable_rate,\n            'member_id' => $member->getKey(),\n        ]);\n\n        // Assert\n        $response->assertInvalid(['member_id']);\n    }\n\n    public function test_store_endpoint_fails_if_user_is_a_placeholder(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'project-members:create',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $projectMemberFake = ProjectMember::factory()->make();\n        $user = User::factory()->placeholder()->create();\n        $member = Member::factory()->forOrganization($data->organization)->forUser($user)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [\n            'billable_rate' => $projectMemberFake->billable_rate,\n            'member_id' => $member->getKey(),\n        ]);\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertExactJson([\n            'error' => true,\n            'key' => 'inactive_user_can_not_be_used',\n            'message' => 'Inactive user can not be used',\n        ]);\n        $this->assertDatabaseMissing(ProjectMember::class, [\n            'billable_rate' => $projectMemberFake->billable_rate,\n            'member_id' => $member->getKey(),\n            'project_id' => $project->getKey(),\n        ]);\n    }\n\n    public function test_store_endpoint_fails_if_user_is_already_member_of_project(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'project-members:create',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $projectMemberFake = ProjectMember::factory()->make();\n        $member = Member::factory()->forOrganization($data->organization)->create();\n        ProjectMember::factory()->forProject($project)->forMember($member)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [\n            'billable_rate' => $projectMemberFake->billable_rate,\n            'member_id' => $member->getKey(),\n        ]);\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertExactJson([\n            'error' => true,\n            'key' => 'user_is_already_member_of_project',\n            'message' => 'User is already a member of the project',\n        ]);\n        $this->assertDatabaseMissing(ProjectMember::class, [\n            'billable_rate' => $projectMemberFake->billable_rate,\n            'member_id' => $member->getKey(),\n            'project_id' => $project->getKey(),\n        ]);\n    }\n\n    public function test_store_endpoint_creates_new_project_member_and_updates_billable_rate(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'project-members:create',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $projectMemberFake = ProjectMember::factory()->make([\n            'billable_rate' => 1200,\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($data->organization)->forUser($user)->create();\n        $this->mock(BillableRateService::class, function (MockInterface $mock) use ($projectMemberFake): void {\n            $mock->shouldReceive('updateTimeEntriesBillableRateForProjectMember')\n                ->once()\n                ->withArgs(fn (ProjectMember $projectMemberArg) => $projectMemberArg->billable_rate === $projectMemberFake->billable_rate);\n        });\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [\n            'billable_rate' => $projectMemberFake->billable_rate,\n            'member_id' => $member->getKey(),\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        $this->assertDatabaseHas(ProjectMember::class, [\n            'billable_rate' => $projectMemberFake->billable_rate,\n            'member_id' => $member->getKey(),\n            'project_id' => $project->getKey(),\n        ]);\n    }\n\n    public function test_store_endpoint_creates_new_project_member_and_does_not_update_billable_rate_if_it_is_null(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'project-members:create',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $projectMemberFake = ProjectMember::factory()->make([\n            'billable_rate' => null,\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($data->organization)->forUser($user)->create();\n        $this->assertBillableRateServiceIsUnused();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [\n            'billable_rate' => $projectMemberFake->billable_rate,\n            'member_id' => $member->getKey(),\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        $this->assertDatabaseHas(ProjectMember::class, [\n            'billable_rate' => null,\n            'member_id' => $member->getKey(),\n            'project_id' => $project->getKey(),\n        ]);\n    }\n\n    public function test_update_endpoint_fails_if_project_member_is_not_part_of_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'project-members:update',\n        ]);\n        $otherData = $this->createUserWithPermission([\n            'project-members:update',\n        ]);\n        $project = Project::factory()->forOrganization($otherData->organization)->create();\n        $projectMember = ProjectMember::factory()->forProject($project)->create();\n        $projectMemberFake = ProjectMember::factory()->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.project-members.update', [$data->organization->getKey(), $projectMember->getKey()]), [\n            'billable_rate' => $projectMemberFake->billable_rate,\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_update_endpoint_fails_if_user_has_no_permission_to_update_projects(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $projectMember = ProjectMember::factory()->forProject($project)->create();\n        $projectMemberFake = ProjectMember::factory()->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.project-members.update', [$data->organization->getKey(), $projectMember->getKey()]), [\n            'billable_rate' => $projectMemberFake->billable_rate,\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_update_endpoint_updates_project_member_with_unchanged_billable_rate(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'project-members:update',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $projectMember = ProjectMember::factory()->forProject($project)->create();\n        $this->assertBillableRateServiceIsUnused();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.project-members.update', [$data->organization->getKey(), $projectMember->getKey()]), [\n            'billable_rate' => $projectMember->billable_rate,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $this->assertDatabaseHas(ProjectMember::class, [\n            'id' => $projectMember->getKey(),\n            'billable_rate' => $projectMember->billable_rate,\n            'member_id' => $projectMember->member_id,\n        ]);\n    }\n\n    public function test_update_endpoints_can_update_billable_rate_and_update_time_entries(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'project-members:update',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $billableRate = 1001;\n        $projectMember = ProjectMember::factory()->forProject($project)->create();\n        $this->mock(BillableRateService::class, function (MockInterface $mock) use ($projectMember, $billableRate): void {\n            $mock->shouldReceive('updateTimeEntriesBillableRateForProjectMember')\n                ->once()\n                ->withArgs(fn (ProjectMember $projectMemberArg) => $projectMemberArg->is($projectMember) && $projectMemberArg->billable_rate === $billableRate);\n        });\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.project-members.update', [$data->organization->getKey(), $projectMember->getKey()]), [\n            'billable_rate' => $billableRate,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $this->assertDatabaseHas(ProjectMember::class, [\n            'id' => $projectMember->getKey(),\n            'billable_rate' => $billableRate,\n            'member_id' => $projectMember->member_id,\n        ]);\n    }\n\n    public function test_destroy_endpoint_fails_if_user_is_not_part_of_project_members_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'project-members:delete',\n        ]);\n        $otherData = $this->createUserWithPermission([\n            'project-members:delete',\n        ]);\n        $project = Project::factory()->forOrganization($otherData->organization)->create();\n        $projectMember = ProjectMember::factory()->forProject($project)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.project-members.destroy', [$data->organization->getKey(), $projectMember->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n        $this->assertDatabaseHas(ProjectMember::class, [\n            'id' => $projectMember->getKey(),\n        ]);\n    }\n\n    public function test_destroy_endpoint_fails_if_user_has_no_permission_to_delete_project_members(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $projectMember = ProjectMember::factory()->forProject($project)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.project-members.destroy', [$data->organization->getKey(), $projectMember->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n        $this->assertDatabaseHas(ProjectMember::class, [\n            'id' => $projectMember->getKey(),\n        ]);\n    }\n\n    public function test_destroy_endpoint_deletes_project_member(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'project-members:delete',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $projectMember = ProjectMember::factory()->forProject($project)->forMember($data->member)->create([\n            'billable_rate' => null,\n        ]);\n        $this->assertBillableRateServiceIsUnused();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.project-members.destroy', [$data->organization->getKey(), $projectMember->getKey()]));\n\n        // Assert\n        $response->assertStatus(204);\n        $response->assertNoContent();\n        $this->assertDatabaseMissing(ProjectMember::class, [\n            'id' => $projectMember->getKey(),\n        ]);\n    }\n\n    public function test_destroy_endpoint_updates_billable_rate_of_time_entries_if_project_member_had_billable_rate(): void\n    {\n        $data = $this->createUserWithPermission([\n            'project-members:delete',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $projectMember = ProjectMember::factory()->forProject($project)->forMember($data->member)->create([\n            'billable_rate' => 1200,\n        ]);\n        $this->mock(BillableRateService::class, function (MockInterface $mock) use ($projectMember): void {\n            $mock->shouldReceive('updateTimeEntriesBillableRateForMember')\n                ->once()\n                ->withArgs(fn (Member $memberArg) => $memberArg->is($projectMember->member));\n            $mock->shouldReceive('updateTimeEntriesBillableRateForProject')\n                ->once()\n                ->withArgs(fn (Project $projectArg) => $projectArg->is($projectMember->project));\n            $mock->shouldReceive('updateTimeEntriesBillableRateForOrganization')\n                ->once()\n                ->withArgs(fn (Organization $organizationArg) => $organizationArg->is($projectMember->project->organization));\n        });\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.project-members.destroy', [$data->organization->getKey(), $projectMember->getKey()]));\n\n        // Assert\n        $response->assertStatus(204);\n        $response->assertNoContent();\n        $this->assertDatabaseMissing(ProjectMember::class, [\n            'id' => $projectMember->getKey(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Endpoint/Api/V1/Public/PublicReportEndpointTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Endpoint\\Api\\V1\\Public;\n\nuse App\\Enums\\TimeEntryAggregationType;\nuse App\\Enums\\TimeEntryAggregationTypeInterval;\nuse App\\Enums\\Weekday;\nuse App\\Models\\Client;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\Report;\nuse App\\Models\\Tag;\nuse App\\Models\\Task;\nuse App\\Models\\TimeEntry;\nuse App\\Service\\CurrencyService;\nuse App\\Service\\Dto\\ReportPropertiesDto;\nuse App\\Service\\TimeEntryFilter;\nuse Illuminate\\Support\\Str;\nuse Tests\\Unit\\Endpoint\\Api\\V1\\ApiEndpointTestAbstract;\n\nclass PublicReportEndpointTest extends ApiEndpointTestAbstract\n{\n    public function test_show_fails_with_not_found_if_secret_is_incorrect(): void\n    {\n        // Arrange\n        Report::factory()->public()->create();\n\n        // Act\n        $response = $this->getJson(route('api.v1.public.reports.show'), [\n            'X-Api-Key' => 'incorrect-secret',\n        ]);\n\n        // Assert\n        $response->assertNotFound();\n    }\n\n    public function test_show_fails_with_not_found_if_no_secret_is_provided(): void\n    {\n        // Arrange\n        Report::factory()->public()->create();\n\n        // Act\n        $response = $this->getJson(route('api.v1.public.reports.show'));\n\n        // Assert\n        $response->assertNotFound();\n    }\n\n    public function test_show_fails_with_not_found_if_report_is_not_public(): void\n    {\n        // Arrange\n        $report = Report::factory()->private()->create();\n\n        // Act\n        $response = $this->getJson(route('api.v1.public.reports.show'), [\n            'X-Api-Key' => $report->share_secret,\n        ]);\n\n        // Assert\n        $response->assertNotFound();\n    }\n\n    public function test_show_fails_with_not_found_if_report_is_expired(): void\n    {\n        // Arrange\n        $report = Report::factory()->public()->create([\n            'public_until' => now()->subDay(),\n        ]);\n\n        // Act\n        $response = $this->getJson(route('api.v1.public.reports.show'), [\n            'X-Api-Key' => $report->share_secret,\n        ]);\n\n        // Assert\n        $response->assertNotFound();\n    }\n\n    public function test_show_returns_detailed_information_about_the_report(): void\n    {\n        // Arrange\n        $timezone = 'Europe/Vienna';\n        $reportDto = new ReportPropertiesDto;\n        $organization = Organization::factory()->create();\n        $reportDto->start = now()->subDays(2);\n        $reportDto->end = now();\n        $reportDto->group = TimeEntryAggregationType::Project;\n        $reportDto->subGroup = TimeEntryAggregationType::Task;\n        $reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;\n        $reportDto->weekStart = Weekday::Monday;\n        $reportDto->timezone = $timezone;\n        $report = Report::factory()->forOrganization($organization)->public()->create([\n            'public_until' => null,\n            'properties' => $reportDto,\n        ]);\n        $project = Project::factory()->forOrganization($organization)->create();\n        $task1 = Task::factory()->forOrganization($organization)->forProject($project)->create([\n            'id' => '1b0f1b32-0def-4932-8829-b68f52161987',\n        ]);\n        $task2 = Task::factory()->forOrganization($organization)->forProject($project)->create([\n            'id' => '3c54796d-5ab4-41e1-8f30-aa61a0a919ae',\n        ]);\n        TimeEntry::factory()->forOrganization($organization)->forTask($task1)->startWithDuration(now()->subDay(), 100)->create();\n        TimeEntry::factory()->forOrganization($organization)->forTask($task2)->startWithDuration(now()->subDay(), 100)->create();\n        TimeEntry::factory()->forOrganization($organization)->startWithDuration(now()->subDay(), 100)->create();\n\n        $currencyService = app(CurrencyService::class);\n\n        // Act\n        $response = $this->getJson(route('api.v1.public.reports.show'), [\n            'X-Api-Key' => $report->share_secret,\n        ]);\n\n        // Assert\n        $response->assertOk();\n        $response->assertExactJson([\n            'name' => $report->name,\n            'description' => $report->description,\n            'public_until' => $report->public_until?->toIso8601ZuluString(),\n            'currency' => $organization->currency,\n            'number_format' => $organization->number_format,\n            'interval_format' => $organization->interval_format,\n            'currency_format' => $organization->currency_format,\n            'currency_symbol' => $currencyService->getCurrencySymbol($organization->currency),\n            'time_format' => $organization->time_format,\n            'date_format' => $organization->date_format,\n            'properties' => [\n                'group' => $reportDto->group->value,\n                'sub_group' => $reportDto->subGroup->value,\n                'history_group' => $reportDto->historyGroup->value,\n                'start' => $reportDto->start->toIso8601ZuluString(),\n                'end' => $reportDto->end->toIso8601ZuluString(),\n            ],\n            'data' => [\n                'seconds' => 300,\n                'cost' => 0,\n                'grouped_type' => TimeEntryAggregationType::Project->value,\n                'grouped_data' => [\n                    [\n                        'key' => $project->id,\n                        'seconds' => 200,\n                        'cost' => 0,\n                        'grouped_type' => TimeEntryAggregationType::Task->value,\n                        'grouped_data' => [\n                            [\n                                'key' => $task1->id,\n                                'seconds' => 100,\n                                'cost' => 0,\n                                'grouped_type' => null,\n                                'grouped_data' => null,\n                                'description' => $task1->name,\n                                'color' => null,\n                            ],\n                            [\n                                'key' => $task2->id,\n                                'seconds' => 100,\n                                'cost' => 0,\n                                'grouped_type' => null,\n                                'grouped_data' => null,\n                                'description' => $task2->name,\n                                'color' => null,\n                            ],\n                        ],\n                        'description' => $project->name,\n                        'color' => $project->color,\n                    ],\n                    [\n                        'key' => null,\n                        'seconds' => 100,\n                        'cost' => 0,\n                        'grouped_type' => TimeEntryAggregationType::Task->value,\n                        'grouped_data' => [\n                            [\n                                'key' => null,\n                                'seconds' => 100,\n                                'cost' => 0,\n                                'grouped_type' => null,\n                                'grouped_data' => null,\n                                'description' => null,\n                                'color' => null,\n                            ],\n                        ],\n                        'description' => null,\n                        'color' => null,\n                    ],\n                ],\n            ],\n            'history_data' => [\n                'seconds' => 300,\n                'cost' => 0,\n                'grouped_type' => TimeEntryAggregationTypeInterval::Day->value,\n                'grouped_data' => [\n                    [\n                        'key' => now()->timezone($timezone)->subDays(2)->toDateString(),\n                        'seconds' => 0,\n                        'cost' => 0,\n                        'grouped_type' => null,\n                        'grouped_data' => null,\n                        'description' => null,\n                        'color' => null,\n                    ],\n                    [\n                        'key' => now()->timezone($timezone)->subDays(1)->toDateString(),\n                        'seconds' => 300,\n                        'cost' => 0,\n                        'grouped_type' => null,\n                        'grouped_data' => null,\n                        'description' => null,\n                        'color' => null,\n                    ],\n                    [\n                        'key' => now()->timezone($timezone)->toDateString(),\n                        'seconds' => 0,\n                        'cost' => 0,\n                        'grouped_type' => null,\n                        'grouped_data' => null,\n                        'description' => null,\n                        'color' => null,\n                    ],\n                ],\n            ],\n        ]);\n    }\n\n    public function test_show_returns_detailed_information_about_the_report_with_not_expired_expiration_date(): void\n    {\n        // Arrange\n        $report = Report::factory()->public()->create([\n            'public_until' => now()->addDay(),\n        ]);\n\n        // Act\n        $response = $this->getJson(route('api.v1.public.reports.show'), [\n            'X-Api-Key' => $report->share_secret,\n        ]);\n\n        // Assert\n        $response->assertOk();\n        $response->assertJsonFragment([\n            'name' => $report->name,\n            'description' => $report->description,\n            'public_until' => $report->public_until?->toIso8601ZuluString(),\n        ]);\n    }\n\n    public function test_show_returns_detailed_information_about_the_report_with_all_available_filters(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $client = Client::factory()->forOrganization($organization)->create();\n        $otherClient = Client::factory()->forOrganization($organization)->create();\n        $project = Project::factory()->forClient($client)->forOrganization($organization)->create();\n        $otherProject = Project::factory()->forOrganization($organization)->create();\n        $otherProjectWithClient = Project::factory()->forClient($client)->forOrganization($organization)->create();\n        $task = Task::factory()->forOrganization($organization)->forProject($project)->create();\n        $tag = Tag::factory()->forOrganization($organization)->create();\n        $otherTag = Tag::factory()->forOrganization($organization)->create();\n\n        // Match for all filters\n        TimeEntry::factory()->forOrganization($organization)\n            ->forTask($task)\n            ->billable()\n            ->startWithDuration(now()->subDay(), 100)\n            ->create([\n                'tags' => [$tag->getKey()],\n            ]);\n        // No match for task filter\n        TimeEntry::factory()->forOrganization($organization)\n            ->forProject($otherProject)\n            ->startWithDuration(now()->subDay(), 100)\n            ->create();\n        // No match for client filter\n        TimeEntry::factory()->forOrganization($organization)\n            ->forProject($otherProjectWithClient)\n            ->startWithDuration(now()->subDay(), 100)\n            ->create();\n\n        $reportDto = new ReportPropertiesDto;\n        $reportDto->start = now()->subDays(2);\n        $reportDto->end = now();\n        $reportDto->group = TimeEntryAggregationType::Project;\n        $reportDto->subGroup = TimeEntryAggregationType::Task;\n        $reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;\n        $reportDto->weekStart = Weekday::Monday;\n        $reportDto->timezone = 'Europe/Vienna';\n        $reportDto->active = false;\n        $reportDto->billable = true;\n        $reportDto->setMemberIds(null);\n        $reportDto->setClientIds([$client->getKey()]);\n        $reportDto->setProjectIds([$project->getKey()]);\n        $reportDto->setTagIds([$tag->getKey()]);\n        $reportDto->setTaskIds([$task->getKey()]);\n        $report = Report::factory()->forOrganization($organization)->public()->create([\n            'public_until' => null,\n            'properties' => $reportDto,\n        ]);\n\n        // Act\n        $response = $this->getJson(route('api.v1.public.reports.show'), [\n            'X-Api-Key' => $report->share_secret,\n        ]);\n\n        // Assert\n        $response->assertOk();\n        $response->assertJson([\n            'name' => $report->name,\n            'description' => $report->description,\n            'public_until' => $report->public_until?->toIso8601ZuluString(),\n            'properties' => [\n                'group' => $reportDto->group->value,\n                'sub_group' => $reportDto->subGroup->value,\n                'history_group' => $reportDto->historyGroup->value,\n                'start' => $reportDto->start->toIso8601ZuluString(),\n                'end' => $reportDto->end->toIso8601ZuluString(),\n            ],\n            'data' => [\n                'seconds' => 100,\n                'cost' => 0,\n                'grouped_type' => TimeEntryAggregationType::Project->value,\n            ],\n            'history_data' => [\n                'seconds' => 100,\n                'cost' => 0,\n                'grouped_type' => TimeEntryAggregationTypeInterval::Day->value,\n            ],\n        ]);\n    }\n\n    public function test_if_the_resources_behind_the_filters_no_longer_exist_the_report_ignores_those_filters_but_this_does_not_increase_the_visible_data(): void\n    {\n        // Arrange\n        $timezone = 'Europe/Vienna';\n        $organization = Organization::factory()->create();\n        $client = Client::factory()->forOrganization($organization)->create();\n        $project = Project::factory()->forClient($client)->forOrganization($organization)->create();\n        $task = Task::factory()->forOrganization($organization)->forProject($project)->create();\n        $tag = Tag::factory()->forOrganization($organization)->create();\n\n        TimeEntry::factory()->forOrganization($organization)\n            ->forTask($task)\n            ->billable()\n            ->startWithDuration(now()->subDay(), 100)\n            ->create([\n                'tags' => [$tag->getKey()],\n            ]);\n\n        $reportDto = new ReportPropertiesDto;\n        $reportDto->start = now()->subDays(2);\n        $reportDto->end = now();\n        $reportDto->group = TimeEntryAggregationType::Project;\n        $reportDto->subGroup = TimeEntryAggregationType::Task;\n        $reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;\n        $reportDto->weekStart = Weekday::Monday;\n        $reportDto->timezone = $timezone;\n        $reportDto->setMemberIds([Str::uuid()->toString()]);\n        $reportDto->setClientIds([Str::uuid()->toString()]);\n        $reportDto->setProjectIds([Str::uuid()->toString()]);\n        $reportDto->setTagIds([Str::uuid()->toString()]);\n        $reportDto->setTaskIds([Str::uuid()->toString()]);\n        $report = Report::factory()->forOrganization($organization)->public()->create([\n            'public_until' => null,\n            'properties' => $reportDto,\n        ]);\n\n        // Act\n        $response = $this->getJson(route('api.v1.public.reports.show'), [\n            'X-Api-Key' => $report->share_secret,\n        ]);\n\n        // Assert\n        $response->assertOk();\n        $response->assertJson([\n            'name' => $report->name,\n            'description' => $report->description,\n            'public_until' => $report->public_until?->toIso8601ZuluString(),\n            'properties' => [\n                'group' => $reportDto->group->value,\n                'sub_group' => $reportDto->subGroup->value,\n                'history_group' => $reportDto->historyGroup->value,\n                'start' => $reportDto->start->toIso8601ZuluString(),\n                'end' => $reportDto->end->toIso8601ZuluString(),\n            ],\n            'data' => [\n                'seconds' => 0,\n                'cost' => 0,\n                'grouped_type' => TimeEntryAggregationType::Project->value,\n                'grouped_data' => [],\n            ],\n            'history_data' => [\n                'seconds' => 0,\n                'cost' => 0,\n                'grouped_type' => TimeEntryAggregationTypeInterval::Day->value,\n                'grouped_data' => [\n                    [\n                        'key' => now()->timezone($timezone)->subDays(2)->toDateString(),\n                        'seconds' => 0,\n                        'cost' => 0,\n                        'grouped_type' => null,\n                        'grouped_data' => null,\n                        'description' => null,\n                        'color' => null,\n                    ],\n                    [\n                        'key' => now()->timezone($timezone)->subDays(1)->toDateString(),\n                        'seconds' => 0,\n                        'cost' => 0,\n                        'grouped_type' => null,\n                        'grouped_data' => null,\n                        'description' => null,\n                        'color' => null,\n                    ],\n                    [\n                        'key' => now()->timezone($timezone)->toDateString(),\n                        'seconds' => 0,\n                        'cost' => 0,\n                        'grouped_type' => null,\n                        'grouped_data' => null,\n                        'description' => null,\n                        'color' => null,\n                    ],\n                ],\n            ],\n        ]);\n    }\n\n    public function test_show_returns_only_entries_without_project_when_none_project_filter_is_set(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $project = Project::factory()->forOrganization($organization)->create();\n\n        // Entry with project (should be excluded)\n        TimeEntry::factory()->forOrganization($organization)\n            ->forProject($project)\n            ->startWithDuration(now()->subDay(), 100)\n            ->create();\n        // Entry without project (should be included)\n        TimeEntry::factory()->forOrganization($organization)\n            ->startWithDuration(now()->subDay(), 200)\n            ->create();\n\n        $reportDto = new ReportPropertiesDto;\n        $reportDto->start = now()->subDays(2);\n        $reportDto->end = now();\n        $reportDto->group = TimeEntryAggregationType::Project;\n        $reportDto->subGroup = TimeEntryAggregationType::Task;\n        $reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;\n        $reportDto->weekStart = Weekday::Monday;\n        $reportDto->timezone = 'Europe/Vienna';\n        $reportDto->setProjectIds([TimeEntryFilter::NONE_VALUE]);\n        $report = Report::factory()->forOrganization($organization)->public()->create([\n            'public_until' => null,\n            'properties' => $reportDto,\n        ]);\n\n        // Act\n        $response = $this->getJson(route('api.v1.public.reports.show'), [\n            'X-Api-Key' => $report->share_secret,\n        ]);\n\n        // Assert\n        $response->assertOk();\n        $response->assertJson([\n            'data' => [\n                'seconds' => 200,\n                'cost' => 0,\n                'grouped_type' => TimeEntryAggregationType::Project->value,\n            ],\n        ]);\n    }\n\n    public function test_show_returns_entries_with_and_without_project_when_none_and_real_id_combined(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $projectA = Project::factory()->forOrganization($organization)->create();\n        $projectB = Project::factory()->forOrganization($organization)->create();\n\n        // Entry with project A (should be included)\n        TimeEntry::factory()->forOrganization($organization)\n            ->forProject($projectA)\n            ->startWithDuration(now()->subDay(), 100)\n            ->create();\n        // Entry with project B (should be excluded)\n        TimeEntry::factory()->forOrganization($organization)\n            ->forProject($projectB)\n            ->startWithDuration(now()->subDay(), 100)\n            ->create();\n        // Entry without project (should be included)\n        TimeEntry::factory()->forOrganization($organization)\n            ->startWithDuration(now()->subDay(), 200)\n            ->create();\n\n        $reportDto = new ReportPropertiesDto;\n        $reportDto->start = now()->subDays(2);\n        $reportDto->end = now();\n        $reportDto->group = TimeEntryAggregationType::Project;\n        $reportDto->subGroup = TimeEntryAggregationType::Task;\n        $reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;\n        $reportDto->weekStart = Weekday::Monday;\n        $reportDto->timezone = 'Europe/Vienna';\n        $reportDto->setProjectIds([$projectA->getKey(), TimeEntryFilter::NONE_VALUE]);\n        $report = Report::factory()->forOrganization($organization)->public()->create([\n            'public_until' => null,\n            'properties' => $reportDto,\n        ]);\n\n        // Act\n        $response = $this->getJson(route('api.v1.public.reports.show'), [\n            'X-Api-Key' => $report->share_secret,\n        ]);\n\n        // Assert\n        $response->assertOk();\n        $response->assertJson([\n            'data' => [\n                'seconds' => 300,\n                'cost' => 0,\n                'grouped_type' => TimeEntryAggregationType::Project->value,\n            ],\n        ]);\n    }\n\n    public function test_show_returns_only_entries_without_task_when_none_task_filter_is_set(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $project = Project::factory()->forOrganization($organization)->create();\n        $task = Task::factory()->forOrganization($organization)->forProject($project)->create();\n\n        // Entry with task (should be excluded)\n        TimeEntry::factory()->forOrganization($organization)\n            ->forTask($task)\n            ->startWithDuration(now()->subDay(), 100)\n            ->create();\n        // Entry without task (should be included)\n        TimeEntry::factory()->forOrganization($organization)\n            ->forProject($project)\n            ->startWithDuration(now()->subDay(), 200)\n            ->create();\n\n        $reportDto = new ReportPropertiesDto;\n        $reportDto->start = now()->subDays(2);\n        $reportDto->end = now();\n        $reportDto->group = TimeEntryAggregationType::Project;\n        $reportDto->subGroup = TimeEntryAggregationType::Task;\n        $reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;\n        $reportDto->weekStart = Weekday::Monday;\n        $reportDto->timezone = 'Europe/Vienna';\n        $reportDto->setTaskIds([TimeEntryFilter::NONE_VALUE]);\n        $report = Report::factory()->forOrganization($organization)->public()->create([\n            'public_until' => null,\n            'properties' => $reportDto,\n        ]);\n\n        // Act\n        $response = $this->getJson(route('api.v1.public.reports.show'), [\n            'X-Api-Key' => $report->share_secret,\n        ]);\n\n        // Assert\n        $response->assertOk();\n        $response->assertJson([\n            'data' => [\n                'seconds' => 200,\n                'cost' => 0,\n                'grouped_type' => TimeEntryAggregationType::Project->value,\n            ],\n        ]);\n    }\n\n    public function test_show_returns_only_entries_without_client_when_none_client_filter_is_set(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $client = Client::factory()->forOrganization($organization)->create();\n        $projectWithClient = Project::factory()->forClient($client)->forOrganization($organization)->create();\n\n        // Entry with client (should be excluded)\n        TimeEntry::factory()->forOrganization($organization)\n            ->forProject($projectWithClient)\n            ->startWithDuration(now()->subDay(), 100)\n            ->create();\n        // Entry without client (should be included)\n        TimeEntry::factory()->forOrganization($organization)\n            ->startWithDuration(now()->subDay(), 200)\n            ->create();\n\n        $reportDto = new ReportPropertiesDto;\n        $reportDto->start = now()->subDays(2);\n        $reportDto->end = now();\n        $reportDto->group = TimeEntryAggregationType::Project;\n        $reportDto->subGroup = TimeEntryAggregationType::Task;\n        $reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;\n        $reportDto->weekStart = Weekday::Monday;\n        $reportDto->timezone = 'Europe/Vienna';\n        $reportDto->setClientIds([TimeEntryFilter::NONE_VALUE]);\n        $report = Report::factory()->forOrganization($organization)->public()->create([\n            'public_until' => null,\n            'properties' => $reportDto,\n        ]);\n\n        // Act\n        $response = $this->getJson(route('api.v1.public.reports.show'), [\n            'X-Api-Key' => $report->share_secret,\n        ]);\n\n        // Assert\n        $response->assertOk();\n        $response->assertJson([\n            'data' => [\n                'seconds' => 200,\n                'cost' => 0,\n                'grouped_type' => TimeEntryAggregationType::Project->value,\n            ],\n        ]);\n    }\n\n    public function test_show_returns_only_entries_without_tags_when_none_tag_filter_is_set(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $tag = Tag::factory()->forOrganization($organization)->create();\n\n        // Entry with tag (should be excluded)\n        TimeEntry::factory()->forOrganization($organization)\n            ->startWithDuration(now()->subDay(), 100)\n            ->create([\n                'tags' => [$tag->getKey()],\n            ]);\n        // Entry without tags (should be included)\n        TimeEntry::factory()->forOrganization($organization)\n            ->startWithDuration(now()->subDay(), 200)\n            ->create();\n\n        $reportDto = new ReportPropertiesDto;\n        $reportDto->start = now()->subDays(2);\n        $reportDto->end = now();\n        $reportDto->group = TimeEntryAggregationType::Project;\n        $reportDto->subGroup = TimeEntryAggregationType::Task;\n        $reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;\n        $reportDto->weekStart = Weekday::Monday;\n        $reportDto->timezone = 'Europe/Vienna';\n        $reportDto->setTagIds([TimeEntryFilter::NONE_VALUE]);\n        $report = Report::factory()->forOrganization($organization)->public()->create([\n            'public_until' => null,\n            'properties' => $reportDto,\n        ]);\n\n        // Act\n        $response = $this->getJson(route('api.v1.public.reports.show'), [\n            'X-Api-Key' => $report->share_secret,\n        ]);\n\n        // Assert\n        $response->assertOk();\n        $response->assertJson([\n            'data' => [\n                'seconds' => 200,\n                'cost' => 0,\n                'grouped_type' => TimeEntryAggregationType::Project->value,\n            ],\n        ]);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Endpoint/Api/V1/ReportEndpointTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Endpoint\\Api\\V1;\n\nuse App\\Enums\\TimeEntryAggregationType;\nuse App\\Enums\\TimeEntryRoundingType;\nuse App\\Enums\\Weekday;\nuse App\\Http\\Controllers\\Api\\V1\\ReportController;\nuse App\\Models\\Client;\nuse App\\Models\\Project;\nuse App\\Models\\Report;\nuse App\\Models\\Tag;\nuse App\\Models\\Task;\nuse App\\Service\\TimeEntryFilter;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Testing\\Fluent\\AssertableJson;\nuse Laravel\\Passport\\Passport;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\n\n#[UsesClass(ReportController::class)]\nclass ReportEndpointTest extends ApiEndpointTestAbstract\n{\n    public function test_index_endpoint_fails_if_user_does_not_have_permission_to_view_reports(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        Report::factory()->forOrganization($data->organization)->createMany(4);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.reports.index', ['organization' => $data->organization->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_index_endpoint_returns_list_of_all_reports_of_organization_ordered_by_created_at_desc_per_default(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'reports:view',\n        ]);\n        Report::factory()->forOrganization($data->organization)->randomCreatedAt()->createMany(4);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.reports.index', [$data->organization->getKey()]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonCount(4, 'data');\n        $reports = Report::query()->orderBy('created_at', 'desc')->get();\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->has('links')\n            ->has('meta')\n            ->count('data', 4)\n            ->where('data.0.id', $reports->get(0)->getKey())\n            ->where('data.1.id', $reports->get(1)->getKey())\n            ->where('data.2.id', $reports->get(2)->getKey())\n            ->where('data.3.id', $reports->get(3)->getKey())\n        );\n    }\n\n    public function test_store_endpoint_fails_if_user_has_no_permission_to_create_report(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [\n            'name' => 'Test Report',\n            'is_public' => false,\n            'properties' => [\n                'group' => TimeEntryAggregationType::Project->value,\n                'sub_group' => TimeEntryAggregationType::Task->value,\n                'history_group' => TimeEntryAggregationType::Day->value,\n                'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(),\n                'end' => Carbon::now()->toIso8601ZuluString(),\n            ],\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_store_endpoint_creates_new_report_with_minimal_properties(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'reports:create',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [\n            'name' => 'Test Report',\n            'is_public' => false,\n            'properties' => [\n                'group' => TimeEntryAggregationType::Project->value,\n                'sub_group' => TimeEntryAggregationType::Task->value,\n                'history_group' => TimeEntryAggregationType::Day->value,\n                'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(),\n                'end' => Carbon::now()->toIso8601ZuluString(),\n            ],\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.name', 'Test Report')\n            ->where('data.description', null)\n            ->where('data.is_public', false)\n            ->where('data.shareable_link', null)\n            ->where('data.properties.group', TimeEntryAggregationType::Project->value)\n            ->where('data.properties.sub_group', TimeEntryAggregationType::Task->value)\n        );\n    }\n\n    public function test_store_endpoint_creates_new_report_with_all_properties(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'reports:create',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->withoutExceptionHandling()->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [\n            'name' => 'Test Report',\n            'description' => 'Test description',\n            'is_public' => true,\n            'public_until' => Carbon::now()->addDays(30)->toIso8601ZuluString(),\n            'properties' => [\n                'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(),\n                'end' => Carbon::now()->toIso8601ZuluString(),\n                'active' => true,\n                'member_ids' => [],\n                'billable' => true,\n                'client_ids' => [],\n                'project_ids' => [],\n                'tag_ids' => [],\n                'task_ids' => [],\n                'group' => TimeEntryAggregationType::Project->value,\n                'sub_group' => TimeEntryAggregationType::Task->value,\n                'history_group' => TimeEntryAggregationType::Day->value,\n                'week_start' => Weekday::Monday->value,\n                'timezone' => 'Europe/Berlin',\n            ],\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        /** @var Report $report */\n        $report = Report::query()->findOrFail($response->json('data.id'));\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.name', 'Test Report')\n            ->where('data.description', 'Test description')\n            ->where('data.is_public', true)\n            ->where('data.shareable_link', $report->getShareableLink())\n            ->where('data.properties.group', TimeEntryAggregationType::Project->value)\n            ->where('data.properties.sub_group', TimeEntryAggregationType::Task->value)\n        );\n    }\n\n    public function test_store_endpoint_creates_new_report_with_rounding_properties(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'reports:create',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->withoutExceptionHandling()->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [\n            'name' => 'Test Report with Rounding',\n            'description' => 'Test description',\n            'is_public' => true,\n            'public_until' => Carbon::now()->addDays(30)->toIso8601ZuluString(),\n            'properties' => [\n                'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(),\n                'end' => Carbon::now()->toIso8601ZuluString(),\n                'active' => true,\n                'member_ids' => [],\n                'billable' => true,\n                'client_ids' => [],\n                'project_ids' => [],\n                'tag_ids' => [],\n                'task_ids' => [],\n                'group' => TimeEntryAggregationType::Project->value,\n                'sub_group' => TimeEntryAggregationType::Task->value,\n                'history_group' => TimeEntryAggregationType::Day->value,\n                'week_start' => Weekday::Monday->value,\n                'timezone' => 'Europe/Berlin',\n                'rounding_type' => 'nearest',\n                'rounding_minutes' => 15,\n            ],\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        /** @var Report $report */\n        $report = Report::query()->findOrFail($response->json('data.id'));\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.name', 'Test Report with Rounding')\n            ->where('data.description', 'Test description')\n            ->where('data.is_public', true)\n            ->where('data.shareable_link', $report->getShareableLink())\n            ->where('data.properties.group', TimeEntryAggregationType::Project->value)\n            ->where('data.properties.sub_group', TimeEntryAggregationType::Task->value)\n            ->where('data.properties.rounding_type', 'nearest')\n            ->where('data.properties.rounding_minutes', 15)\n        );\n\n        // Also verify the properties are saved in the database\n        $this->assertSame(TimeEntryRoundingType::Nearest, $report->properties->roundingType);\n        $this->assertSame(15, $report->properties->roundingMinutes);\n    }\n\n    public function test_update_endpoint_fails_if_user_has_no_permission_to_update_report(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $report = Report::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [\n            'name' => 'Updated Report',\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_update_endpoint_fails_if_report_does_not_exist(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'reports:update',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), 1]), [\n            'name' => 'Updated Report',\n        ]);\n\n        // Assert\n        $response->assertNotFound();\n    }\n\n    public function test_update_endpoint_fails_if_report_does_not_belong_to_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'reports:update',\n        ]);\n        $report = Report::factory()->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [\n            'name' => 'Updated Report',\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_update_endpoint_can_update_only_the_name_of_the_report(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'reports:update',\n        ]);\n        $report = Report::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [\n            'name' => 'Updated Report',\n        ]);\n\n        // Assert\n        $report->refresh();\n        $this->assertSame('Updated Report', $report->name);\n        $response->assertStatus(200);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.name', 'Updated Report')\n        );\n    }\n\n    public function test_update_endpoint_can_update_only_the_description_of_the_report(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'reports:update',\n        ]);\n        $report = Report::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [\n            'description' => 'Updated description',\n        ]);\n\n        // Assert\n        $report->refresh();\n        $this->assertSame('Updated description', $report->description);\n        $response->assertStatus(200);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.description', 'Updated description')\n        );\n    }\n\n    public function test_update_endpoint_can_set_a_report_from_private_to_public_which_generates_a_new_secret(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'reports:update',\n        ]);\n        $report = Report::factory()->private()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [\n            'is_public' => true,\n        ]);\n\n        // Assert\n        $report->refresh();\n        $this->assertTrue($report->is_public);\n        $this->assertNotNull($report->share_secret);\n        $this->assertResponseCode($response, 200);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.is_public', true)\n            ->where('data.shareable_link', $report->getShareableLink())\n        );\n    }\n\n    public function test_update_endpoint_can_set_a_report_from_public_to_private_which_resets_the_secret(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'reports:update',\n        ]);\n        $report = Report::factory()->public()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [\n            'is_public' => false,\n        ]);\n\n        // Assert\n        $report->refresh();\n        $this->assertFalse($report->is_public);\n        $this->assertNull($report->share_secret);\n        $response->assertStatus(200);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.is_public', false)\n            ->where('data.shareable_link', null)\n        );\n    }\n\n    public function test_update_endpoint_does_not_change_the_secret_of_a_public_report_if_it_is_set_to_public_again(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'reports:update',\n        ]);\n        $report = Report::factory()->public()->forOrganization($data->organization)->create();\n        $secret = $report->share_secret;\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [\n            'is_public' => true,\n        ]);\n\n        // Assert\n        $report->refresh();\n        $this->assertTrue($report->is_public);\n        $this->assertSame($secret, $report->share_secret);\n        $response->assertStatus(200);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.is_public', true)\n            ->where('data.shareable_link', $report->getShareableLink())\n        );\n    }\n\n    public function test_update_endpoint_can_update_the_report_all_properties_set(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'reports:update',\n        ]);\n        $report = Report::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [\n            'name' => 'Updated Report',\n            'description' => 'Updated description',\n            'is_public' => true,\n            'public_until' => Carbon::now()->addDays(30)->toIso8601ZuluString(),\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.name', 'Updated Report')\n            ->where('data.description', 'Updated description')\n            ->where('data.is_public', true)\n            ->whereType('data.public_until', 'string')\n            ->where('data.properties.group', TimeEntryAggregationType::Project->value)\n            ->where('data.properties.sub_group', TimeEntryAggregationType::Task->value)\n        );\n    }\n\n    public function test_update_endpoint_can_update_public_until_on_already_public_report(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'reports:update',\n        ]);\n        $report = Report::factory()->public()->forOrganization($data->organization)->create([\n            'public_until' => null,\n        ]);\n        Passport::actingAs($data->user);\n        $newPublicUntil = Carbon::now()->addDays(30);\n\n        // Act\n        $response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [\n            'public_until' => $newPublicUntil->toIso8601ZuluString(),\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $report->refresh();\n        $this->assertTrue($report->is_public);\n        $this->assertNotNull($report->public_until);\n        $this->assertTrue($newPublicUntil->isSameDay($report->public_until));\n    }\n\n    public function test_update_endpoint_can_clear_public_until_on_already_public_report(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'reports:update',\n        ]);\n        $report = Report::factory()->public()->forOrganization($data->organization)->create([\n            'public_until' => Carbon::now()->addDays(30),\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [\n            'public_until' => null,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $report->refresh();\n        $this->assertTrue($report->is_public);\n        $this->assertNull($report->public_until);\n    }\n\n    public function test_show_endpoint_fails_if_user_has_no_permission_to_view_report(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $report = Report::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.reports.show', [$data->organization->getKey(), $report->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_show_endpoint_fails_if_report_does_not_exist(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'reports:view',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.reports.show', [$data->organization->getKey(), 1]));\n\n        // Assert\n        $response->assertNotFound();\n    }\n\n    public function test_show_endpoint_fails_if_report_does_not_belong_to_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'reports:view',\n        ]);\n        $report = Report::factory()->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.reports.show', [$data->organization->getKey(), $report->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_show_endpoint_returns_detailed_report(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'reports:view',\n        ]);\n        $report = Report::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.reports.show', [$data->organization->getKey(), $report->getKey()]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.id', $report->getKey())\n        );\n    }\n\n    public function test_store_endpoint_creates_report_with_none_filter_values(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'reports:create',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->withoutExceptionHandling()->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [\n            'name' => 'Test Report with None Filters',\n            'is_public' => false,\n            'properties' => [\n                'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(),\n                'end' => Carbon::now()->toIso8601ZuluString(),\n                'group' => TimeEntryAggregationType::Project->value,\n                'sub_group' => TimeEntryAggregationType::Task->value,\n                'history_group' => TimeEntryAggregationType::Day->value,\n                'project_ids' => [TimeEntryFilter::NONE_VALUE],\n                'client_ids' => [TimeEntryFilter::NONE_VALUE],\n                'tag_ids' => [TimeEntryFilter::NONE_VALUE],\n                'task_ids' => [TimeEntryFilter::NONE_VALUE],\n            ],\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        /** @var Report $report */\n        $report = Report::query()->findOrFail($response->json('data.id'));\n        $this->assertTrue($report->properties->projectIds->contains(TimeEntryFilter::NONE_VALUE));\n        $this->assertTrue($report->properties->clientIds->contains(TimeEntryFilter::NONE_VALUE));\n        $this->assertTrue($report->properties->tagIds->contains(TimeEntryFilter::NONE_VALUE));\n        $this->assertTrue($report->properties->taskIds->contains(TimeEntryFilter::NONE_VALUE));\n    }\n\n    public function test_store_endpoint_creates_report_with_none_combined_with_real_ids(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'reports:create',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();\n        $tag = Tag::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->withoutExceptionHandling()->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [\n            'name' => 'Test Report with Combined Filters',\n            'is_public' => false,\n            'properties' => [\n                'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(),\n                'end' => Carbon::now()->toIso8601ZuluString(),\n                'group' => TimeEntryAggregationType::Project->value,\n                'sub_group' => TimeEntryAggregationType::Task->value,\n                'history_group' => TimeEntryAggregationType::Day->value,\n                'project_ids' => [$project->getKey(), TimeEntryFilter::NONE_VALUE],\n                'client_ids' => [$client->getKey(), TimeEntryFilter::NONE_VALUE],\n                'tag_ids' => [$tag->getKey(), TimeEntryFilter::NONE_VALUE],\n                'task_ids' => [$task->getKey(), TimeEntryFilter::NONE_VALUE],\n            ],\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        /** @var Report $report */\n        $report = Report::query()->findOrFail($response->json('data.id'));\n        $this->assertTrue($report->properties->projectIds->contains($project->getKey()));\n        $this->assertTrue($report->properties->projectIds->contains(TimeEntryFilter::NONE_VALUE));\n        $this->assertTrue($report->properties->clientIds->contains($client->getKey()));\n        $this->assertTrue($report->properties->clientIds->contains(TimeEntryFilter::NONE_VALUE));\n        $this->assertTrue($report->properties->tagIds->contains($tag->getKey()));\n        $this->assertTrue($report->properties->tagIds->contains(TimeEntryFilter::NONE_VALUE));\n        $this->assertTrue($report->properties->taskIds->contains($task->getKey()));\n        $this->assertTrue($report->properties->taskIds->contains(TimeEntryFilter::NONE_VALUE));\n    }\n\n    public function test_destroy_endpoint_fails_if_user_has_no_permission_to_delete_report(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $report = Report::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.reports.destroy', [$data->organization->getKey(), $report->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_destroy_endpoint_fails_if_report_belongs_to_another_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'reports:delete',\n        ]);\n        $report = Report::factory()->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.reports.destroy', [$data->organization->getKey(), $report->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_destroy_endpoint_fails_if_report_does_not_exist(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'reports:delete',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.reports.destroy', [$data->organization->getKey(), 1]));\n\n        // Assert\n        $response->assertNotFound();\n    }\n\n    public function test_destroy_endpoint_deletes_a_report(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'reports:delete',\n        ]);\n        $report = Report::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.reports.destroy', [$data->organization->getKey(), $report->getKey()]));\n\n        // Assert\n        $response->assertNoContent();\n        $this->assertDatabaseMissing(Report::class, [\n            'id' => $report->getKey(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Endpoint/Api/V1/TagEndpointTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Endpoint\\Api\\V1;\n\nuse App\\Http\\Controllers\\Api\\V1\\TagController;\nuse App\\Models\\Organization;\nuse App\\Models\\Tag;\nuse App\\Models\\TimeEntry;\nuse Illuminate\\Testing\\Fluent\\AssertableJson;\nuse Laravel\\Passport\\Passport;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\n\n#[UsesClass(TagController::class)]\nclass TagEndpointTest extends ApiEndpointTestAbstract\n{\n    public function test_index_endpoint_fails_if_user_has_no_permission_to_view_tags(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $tags = Tag::factory()->forOrganization($data->organization)->createMany(4);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.tags.index', [$data->organization->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_index_endpoint_returns_list_of_all_tags_of_organization_ordered_by_created_at_desc_per_default(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tags:view',\n        ]);\n        $tags = Tag::factory()->forOrganization($data->organization)->randomCreatedAt()->createMany(4);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.tags.index', [$data->organization->getKey()]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonCount(4, 'data');\n        $tags = Tag::query()->orderBy('created_at', 'desc')->get();\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->has('links')\n            ->has('meta')\n            ->count('data', 4)\n            ->where('data.0.id', $tags->get(0)->getKey())\n            ->where('data.1.id', $tags->get(1)->getKey())\n            ->where('data.2.id', $tags->get(2)->getKey())\n            ->where('data.3.id', $tags->get(3)->getKey())\n        );\n    }\n\n    public function test_store_endpoint_fails_if_user_has_no_permission_to_create_tags(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.tags.store', [$data->organization->getKey()]), [\n            'name' => 'Test Tag',\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_store_endpoint_fails_if_name_is_already_taken(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tags:create',\n        ]);\n        $name = 'Test Tag';\n        $tagWithName = Tag::factory()->forOrganization($data->organization)->create([\n            'name' => $name,\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.tags.store', [$data->organization->getKey()]), [\n            'name' => $tagWithName->name,\n        ]);\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertJsonValidationErrors([\n            'name' => 'A tag with the same name already exists in the organization.',\n        ]);\n    }\n\n    public function test_store_endpoint_creates_tag_if_tag_name_is_only_used_in_other_organizations(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tags:create',\n        ]);\n        $name = 'Test Tag';\n        $tagWithName = Tag::factory()->create([\n            'name' => $name,\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.tags.store', [$data->organization->getKey()]), [\n            'name' => $tagWithName->name,\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.name', $tagWithName->name)\n        );\n    }\n\n    public function test_store_endpoint_creates_new_tag(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tags:create',\n        ]);\n        $tagFake = Tag::factory()->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.tags.store', [$data->organization->getKey()]), [\n            'name' => $tagFake->name,\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.name', $tagFake->name)\n        );\n    }\n\n    public function test_update_endpoint_fails_if_user_has_no_permission_to_update_tags(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $tag = Tag::factory()->forOrganization($data->organization)->create();\n        $tagFake = Tag::factory()->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.tags.update', [$data->organization->getKey(), $tag->getKey()]), [\n            'name' => $tagFake->name,\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_update_endpoint_fails_if_user_is_not_part_of_tag_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tags:update',\n        ]);\n        $otherOrganization = Organization::factory()->create();\n        $tag = Tag::factory()->forOrganization($otherOrganization)->create();\n        $tagFake = Tag::factory()->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.tags.update', [$data->organization->getKey(), $tag->getKey()]), [\n            'name' => $tagFake->name,\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n        $this->assertDatabaseHas(Tag::class, [\n            'id' => $tag->getKey(),\n            'name' => $tag->name,\n            'organization_id' => $otherOrganization->getKey(),\n        ]);\n    }\n\n    public function test_update_endpoint_fails_if_name_is_already_taken(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tags:update',\n        ]);\n        $tag = Tag::factory()->forOrganization($data->organization)->create();\n        $tagWithName = Tag::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.tags.update', [$data->organization->getKey(), $tag->getKey()]), [\n            'name' => $tagWithName->name,\n        ]);\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertJsonValidationErrors([\n            'name' => 'A tag with the same name already exists in the organization.',\n        ]);\n    }\n\n    public function test_update_endpoint_updates_tag_if_tag_name_is_only_used_in_other_organizations(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tags:update',\n        ]);\n        $tag = Tag::factory()->forOrganization($data->organization)->create();\n        $tagWithName = Tag::factory()->create([\n            'name' => 'Test Tag',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.tags.update', [$data->organization->getKey(), $tag->getKey()]), [\n            'name' => $tagWithName->name,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.name', $tagWithName->name)\n        );\n        $this->assertDatabaseHas(Tag::class, [\n            'name' => $tagWithName->name,\n            'organization_id' => $data->organization->getKey(),\n        ]);\n    }\n\n    public function test_update_endpoint_updates_tag(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tags:update',\n        ]);\n        $tag = Tag::factory()->forOrganization($data->organization)->create();\n        $tagFake = Tag::factory()->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.tags.update', [$data->organization->getKey(), $tag->getKey()]), [\n            'name' => $tagFake->name,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.name', $tagFake->name)\n        );\n        $this->assertDatabaseHas(Tag::class, [\n            'name' => $tagFake->name,\n            'organization_id' => $data->organization->getKey(),\n        ]);\n    }\n\n    public function test_destroy_endpoint_fails_if_user_has_no_permission_to_delete_tags(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $tag = Tag::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.tags.destroy', [$data->organization->getKey(), $tag->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_destroy_endpoint_fails_if_user_is_not_part_of_tag_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tags:delete',\n        ]);\n        $otherOrganization = Organization::factory()->create();\n        $tag = Tag::factory()->forOrganization($otherOrganization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.tags.destroy', [$data->organization->getKey(), $tag->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n        $this->assertDatabaseHas(Tag::class, [\n            'id' => $tag->getKey(),\n            'name' => $tag->name,\n            'organization_id' => $otherOrganization->getKey(),\n        ]);\n    }\n\n    public function test_destroy_endpoint_fails_if_tag_is_still_in_use_by_a_time_entry(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tags:delete',\n        ]);\n        $tag = Tag::factory()->forOrganization($data->organization)->create();\n        TimeEntry::factory()->forMember($data->member)->forOrganization($data->organization)->create([\n            'tags' => [$tag->getKey()],\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.tags.destroy', [$data->organization->getKey(), $tag->getKey()]));\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertJsonPath('message', 'The tag is still used by a time entry and can not be deleted.');\n        $this->assertDatabaseHas(Tag::class, [\n            'id' => $tag->getKey(),\n        ]);\n    }\n\n    public function test_destroy_endpoint_deletes_tag(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tags:delete',\n        ]);\n        $tag = Tag::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.tags.destroy', [$data->organization->getKey(), $tag->getKey()]));\n\n        // Assert\n        $response->assertStatus(204);\n        $response->assertNoContent();\n        $this->assertDatabaseMissing(Tag::class, [\n            'id' => $tag->getKey(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Endpoint/Api/V1/TaskEndpointTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Endpoint\\Api\\V1;\n\nuse App\\Http\\Controllers\\Api\\V1\\TaskController;\nuse App\\Models\\Project;\nuse App\\Models\\ProjectMember;\nuse App\\Models\\Task;\nuse App\\Models\\TimeEntry;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Testing\\Fluent\\AssertableJson;\nuse Laravel\\Passport\\Passport;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\n\n#[UsesClass(TaskController::class)]\nclass TaskEndpointTest extends ApiEndpointTestAbstract\n{\n    public function test_non_valid_uuid_for_organization_id_fails(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tasks:view',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.tasks.index', ['invalid-uuid']));\n\n        // Assert\n        $response->assertStatus(404);\n    }\n\n    public function test_index_endpoint_fails_if_user_has_no_permission_to_view_tasks(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        Task::factory()->forOrganization($data->organization)->createMany(4);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.tasks.index', [$data->organization->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_index_endpoint_validation_fails_if_project_id_is_not_pat(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        Task::factory()->forOrganization($data->organization)->createMany(4);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.tasks.index', [$data->organization->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_index_endpoint_returns_list_of_all_tasks_of_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tasks:view',\n            'tasks:view:all',\n        ]);\n        $tasks = Task::factory()->forOrganization($data->organization)->createMany(4);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.tasks.index', [$data->organization->getKey()]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonCount(4, 'data');\n    }\n\n    public function test_index_endpoint_returns_tasks_ordered_by_created_at_descending(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tasks:view',\n            'tasks:view:all',\n        ]);\n        $taskOldest = Task::factory()->forOrganization($data->organization)->create([\n            'created_at' => now()->subDays(3),\n        ]);\n        $taskNewest = Task::factory()->forOrganization($data->organization)->create([\n            'created_at' => now()->subDay(),\n        ]);\n        $taskMiddle = Task::factory()->forOrganization($data->organization)->create([\n            'created_at' => now()->subDays(2),\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.tasks.index', [$data->organization->getKey(), 'done' => 'all']));\n\n        // Assert\n        $response->assertStatus(200);\n        $ids = collect($response->json('data'))->pluck('id')->values()->toArray();\n        $this->assertSame([$taskNewest->getKey(), $taskMiddle->getKey(), $taskOldest->getKey()], $ids);\n    }\n\n    public function test_index_endpoint_without_filter_done_returns_list_of_all_tasks_of_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tasks:view',\n            'tasks:view:all',\n        ]);\n        $notDoneTasks = Task::factory()->forOrganization($data->organization)->createMany(2);\n        $doneTasks = Task::factory()->forOrganization($data->organization)->isDone()->createMany(2);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.tasks.index', [$data->organization->getKey()]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonCount(2, 'data');\n        $this->assertEqualsCanonicalizing($notDoneTasks->pluck('id')->toArray(), $response->json('data.*.id'));\n    }\n\n    public function test_index_endpoint_with_filter_done_true_returns_list_of_all_done_tasks_of_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tasks:view',\n            'tasks:view:all',\n        ]);\n        $notDoneTasks = Task::factory()->forOrganization($data->organization)->createMany(2);\n        $doneTasks = Task::factory()->forOrganization($data->organization)->isDone()->createMany(2);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.tasks.index', [$data->organization->getKey(), 'done' => 'true']));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonCount(2, 'data');\n        $this->assertEqualsCanonicalizing($doneTasks->pluck('id')->toArray(), $response->json('data.*.id'));\n    }\n\n    public function test_index_endpoint_with_filter_done_false_returns_list_of_all_not_done_tasks_of_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tasks:view',\n            'tasks:view:all',\n        ]);\n        $notDoneTasks = Task::factory()->forOrganization($data->organization)->createMany(2);\n        $doneTasks = Task::factory()->forOrganization($data->organization)->isDone()->createMany(2);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.tasks.index', [$data->organization->getKey(), 'done' => 'false']));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonCount(2, 'data');\n        $this->assertEqualsCanonicalizing($notDoneTasks->pluck('id')->toArray(), $response->json('data.*.id'));\n    }\n\n    public function test_index_endpoint_with_filter_done_all_returns_list_of_all_tasks_of_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tasks:view',\n            'tasks:view:all',\n        ]);\n        $notDoneTasks = Task::factory()->forOrganization($data->organization)->createMany(2);\n        $doneTasks = Task::factory()->forOrganization($data->organization)->isDone()->createMany(2);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.tasks.index', [$data->organization->getKey(), 'done' => 'all']));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonCount(4, 'data');\n    }\n\n    public function test_index_endpoint_returns_list_of_all_tasks_with_access_of_organization_if_user_has_no_all_permission(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tasks:view',\n        ]);\n        $otherProject = Project::factory()->create();\n        Task::factory()->forOrganization($data->organization)->forProject($otherProject)->createMany(4);\n        $projectPublic = Project::factory()->isPublic()->create();\n        Task::factory()->forOrganization($data->organization)->forProject($projectPublic)->createMany(2);\n        $projectAsMember = Project::factory()->isPrivate()->create();\n        ProjectMember::factory()->forProject($projectAsMember)->forMember($data->member)->create();\n        Task::factory()->forOrganization($data->organization)->forProject($projectAsMember)->createMany(2);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.tasks.index', [$data->organization->getKey()]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonCount(4, 'data');\n    }\n\n    public function test_index_endpoint_returns_list_of_all_tasks_of_organization_filtered_by_project(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tasks:view',\n            'tasks:view:all',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        Task::factory()->forOrganization($data->organization)->createMany(4);\n        Task::factory()->forOrganization($data->organization)->forProject($project)->createMany(2);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.tasks.index', [\n            $data->organization->getKey(),\n            'project_id' => $project->getKey(),\n        ]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonCount(2, 'data');\n    }\n\n    public function test_index_endpoint_validation_fails_if_project_id_does_not_belong_to_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tasks:view',\n        ]);\n        $otherData = $this->createUserWithPermission([\n            'tasks:view',\n        ]);\n        $project = Project::factory()->forOrganization($otherData->organization)->create();\n        Task::factory()->forOrganization($data->organization)->createMany(4);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.tasks.index', [\n            $data->organization->getKey(),\n            'project_id' => $project->getKey(),\n        ]));\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertInvalid([\n            'project_id',\n        ]);\n    }\n\n    public function test_index_endpoint_validation_fails_if_project_is_not_visible_by_user_and_user_does_not_have_tasks_all_permission(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tasks:view',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        Task::factory()->forOrganization($data->organization)->createMany(4);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.tasks.index', [\n            $data->organization->getKey(),\n            'project_id' => $project->getKey(),\n        ]));\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertInvalid([\n            'project_id',\n        ]);\n    }\n\n    public function test_index_endpoint_returns_list_of_all_tasks_of_organization_filtered_by_project_if_user_has_access_to_project(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tasks:view',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        ProjectMember::factory()->forProject($project)->forMember($data->member)->create();\n        Task::factory()->forOrganization($data->organization)->createMany(4);\n        Task::factory()->forOrganization($data->organization)->forProject($project)->createMany(2);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.tasks.index', [\n            $data->organization->getKey(),\n            'project_id' => $project->getKey(),\n        ]));\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJsonCount(2, 'data');\n    }\n\n    public function test_store_endpoint_fails_if_user_has_no_permission_to_create_tasks(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [\n            'name' => 'Task 1',\n            'project_id' => $project->getKey(),\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n        $this->assertDatabaseMissing(Task::class, [\n            'name' => 'Task 1',\n        ]);\n    }\n\n    public function test_store_endpoint_fails_if_task_with_same_name_already_exists_in_same_project(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tasks:create:all',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $task = Task::factory()->forOrganization($data->organization)->forProject($project)->create([\n            'name' => 'Task 1',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [\n            'name' => $task->name,\n            'project_id' => $project->getKey(),\n        ]);\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertJsonValidationErrors([\n            'name' => 'A task with the same name already exists in the project.',\n        ]);\n    }\n\n    public function test_store_endpoint_creates_new_task_even_if_task_with_same_name_already_exists_in_other_project(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tasks:create:all',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $otherProject = Project::factory()->forOrganization($data->organization)->create();\n        $task = Task::factory()->forOrganization($data->organization)->forProject($otherProject)->create([\n            'name' => 'Task 1',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [\n            'name' => $task->name,\n            'project_id' => $project->getKey(),\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        $this->assertDatabaseHas(Task::class, [\n            'name' => $task->name,\n            'project_id' => $project->getKey(),\n            'organization_id' => $data->organization->getKey(),\n        ]);\n    }\n\n    public function test_store_endpoint_creates_new_task_if_user_has_permission_to_create_tasks(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tasks:create:all',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [\n            'name' => 'Task 1',\n            'project_id' => $project->getKey(),\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        $this->assertDatabaseHas(Task::class, [\n            'name' => 'Task 1',\n            'project_id' => $project->getKey(),\n            'organization_id' => $data->organization->getKey(),\n        ]);\n    }\n\n    public function test_store_endpoint_ignores_estimated_time_if_pro_features_are_disabled(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tasks:create:all',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [\n            'name' => 'Task 1',\n            'project_id' => $project->getKey(),\n            'estimated_time' => 3600,\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.name', 'Task 1')\n            ->where('data.project_id', $project->getKey())\n            ->where('data.estimated_time', null)\n        );\n        $this->assertDatabaseHas(Task::class, [\n            'name' => 'Task 1',\n            'project_id' => $project->getKey(),\n            'organization_id' => $data->organization->getKey(),\n            'estimated_time' => null,\n        ]);\n    }\n\n    public function test_store_endpoint_can_store_with_estimated_time_with_pro_features_enabled(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tasks:create:all',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n        $this->actAsOrganizationWithSubscription();\n\n        // Act\n        $response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [\n            'name' => 'Task 1',\n            'project_id' => $project->getKey(),\n            'estimated_time' => 3600,\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.name', 'Task 1')\n            ->where('data.project_id', $project->getKey())\n            ->where('data.estimated_time', 3600)\n        );\n        $this->assertDatabaseHas(Task::class, [\n            'name' => 'Task 1',\n            'project_id' => $project->getKey(),\n            'organization_id' => $data->organization->getKey(),\n            'estimated_time' => 3600,\n        ]);\n    }\n\n    public function test_update_endpoint_fails_if_user_has_no_permission(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $task = Task::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [\n            'name' => 'Updated Task',\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n        $this->assertDatabaseHas(Task::class, [\n            'id' => $task->getKey(),\n            'name' => $task->name,\n        ]);\n        $this->assertDatabaseMissing(Task::class, [\n            'id' => $task->getKey(),\n            'name' => 'Updated Task',\n        ]);\n    }\n\n    public function test_update_endpoint_fails_if_task_with_same_name_already_exists_in_same_project(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tasks:update:all',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $name = 'Task 1';\n        $task = Task::factory()->forProject($project)->forOrganization($data->organization)->create([\n            'name' => $name,\n        ]);\n        $otherTask = Task::factory()->forProject($project)->forOrganization($data->organization)->create([\n            'name' => $name,\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [\n            'name' => $name,\n        ]);\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertJsonValidationErrors([\n            'name' => 'A task with the same name already exists in the project.',\n        ]);\n    }\n\n    public function test_update_endpoint_updates_task_if_task_with_same_name_already_exists_in_other_project(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tasks:update:all',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $otherProject = Project::factory()->forOrganization($data->organization)->create();\n        $name = 'Task 1';\n        $task = Task::factory()->forProject($project)->forOrganization($data->organization)->create([\n            'name' => $name,\n        ]);\n        $otherTask = Task::factory()->forProject($otherProject)->forOrganization($data->organization)->create([\n            'name' => $name,\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [\n            'name' => $name,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $this->assertDatabaseHas(Task::class, [\n            'id' => $task->getKey(),\n            'name' => $name,\n        ]);\n    }\n\n    public function test_update_endpoint_updates_task_if_user_has_permission(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tasks:update:all',\n        ]);\n        $task = Task::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [\n            'name' => 'Updated Task',\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $this->assertDatabaseHas(Task::class, [\n            'id' => $task->getKey(),\n            'name' => 'Updated Task',\n        ]);\n    }\n\n    public function test_update_endpoint_can_set_task_to_done(): void\n    {\n        // Arrange\n        $now = Carbon::now();\n        $this->travelTo($now);\n        $data = $this->createUserWithPermission([\n            'tasks:update:all',\n        ]);\n        $task = Task::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [\n            'name' => $task->name,\n            'is_done' => true,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $this->assertDatabaseHas(Task::class, [\n            'id' => $task->getKey(),\n            'done_at' => $now->toDateTimeString(),\n        ]);\n    }\n\n    public function test_update_endpoint_can_set_task_to_not_done(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tasks:update:all',\n        ]);\n        $task = Task::factory()->forOrganization($data->organization)->isDone()->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [\n            'name' => $task->name,\n            'is_done' => false,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $this->assertDatabaseHas(Task::class, [\n            'id' => $task->getKey(),\n            'done_at' => null,\n        ]);\n    }\n\n    public function test_update_endpoint_ignores_estimated_time_if_pro_features_are_disabled(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tasks:update:all',\n        ]);\n        $task = Task::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [\n            'name' => $task->name,\n            'estimated_time' => 3600,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.name', $task->name)\n            ->where('data.estimated_time', null)\n        );\n        $this->assertDatabaseHas(Task::class, [\n            'id' => $task->getKey(),\n            'estimated_time' => null,\n        ]);\n    }\n\n    public function test_update_endpoint_can_update_estimated_time_with_pro_features_enabled(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tasks:update:all',\n        ]);\n        $task = Task::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n        $this->actAsOrganizationWithSubscription();\n\n        // Act\n        $response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [\n            'name' => $task->name,\n            'estimated_time' => 3600,\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->where('data.name', $task->name)\n            ->where('data.estimated_time', 3600)\n        );\n        $this->assertDatabaseHas(Task::class, [\n            'id' => $task->getKey(),\n            'estimated_time' => 3600,\n        ]);\n    }\n\n    public function test_delete_endpoint_deletes_tasks_if_user_has_permission(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tasks:delete:all',\n        ]);\n        $task = Task::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.tasks.destroy', [$data->organization->getKey(), $task->getKey()]));\n\n        // Assert\n        $response->assertStatus(204);\n        $this->assertDatabaseMissing(Task::class, [\n            'id' => $task->getKey(),\n        ]);\n    }\n\n    public function test_destroy_endpoint_fails_if_task_is_still_in_use_by_a_time_entry(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tasks:delete:all',\n        ]);\n        $task = Task::factory()->forOrganization($data->organization)->create();\n        TimeEntry::factory()->forMember($data->member)->forTask($task)->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.tasks.destroy', [$data->organization->getKey(), $task->getKey()]));\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertJsonPath('message', 'The task is still used by a time entry and can not be deleted.');\n        $this->assertDatabaseHas(Task::class, [\n            'id' => $task->getKey(),\n        ]);\n    }\n\n    public function test_delete_endpoint_fails_if_user_has_no_permission_to_delete_tasks(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $task = Task::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.tasks.destroy', [$data->organization->getKey(), $task->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n        $this->assertDatabaseHas(Task::class, [\n            'id' => $task->getKey(),\n        ]);\n    }\n\n    public function test_delete_endpoint_fails_if_task_does_not_belong_to_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'tasks:delete:all',\n        ]);\n        $otherData = $this->createUserWithPermission([\n            'tasks:delete:all',\n        ]);\n        $task = Task::factory()->forOrganization($otherData->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.tasks.destroy', [$data->organization->getKey(), $task->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n        $this->assertDatabaseHas(Task::class, [\n            'id' => $task->getKey(),\n        ]);\n    }\n\n    public function test_store_endpoint_allows_employee_to_create_task_in_public_project_when_employees_can_manage_tasks_is_enabled(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(\\App\\Enums\\Role::Employee);\n        $data->organization->employees_can_manage_tasks = true;\n        $data->organization->save();\n        $project = Project::factory()->forOrganization($data->organization)->isPublic()->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [\n            'name' => 'Employee Task',\n            'project_id' => $project->getKey(),\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        $this->assertDatabaseHas(Task::class, [\n            'name' => 'Employee Task',\n            'project_id' => $project->getKey(),\n            'organization_id' => $data->organization->getKey(),\n        ]);\n    }\n\n    public function test_store_endpoint_allows_employee_to_create_task_in_accessible_private_project_when_employees_can_manage_tasks_is_enabled(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(\\App\\Enums\\Role::Employee);\n        $data->organization->employees_can_manage_tasks = true;\n        $data->organization->save();\n        $project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();\n        ProjectMember::factory()->forProject($project)->forMember($data->member)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [\n            'name' => 'Employee Task',\n            'project_id' => $project->getKey(),\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        $this->assertDatabaseHas(Task::class, [\n            'name' => 'Employee Task',\n            'project_id' => $project->getKey(),\n            'organization_id' => $data->organization->getKey(),\n        ]);\n    }\n\n    public function test_store_endpoint_fails_for_employee_creating_task_in_inaccessible_private_project_when_employees_can_manage_tasks_is_enabled(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(\\App\\Enums\\Role::Employee);\n        $data->organization->employees_can_manage_tasks = true;\n        $data->organization->save();\n        $project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [\n            'name' => 'Employee Task',\n            'project_id' => $project->getKey(),\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n        $this->assertDatabaseMissing(Task::class, [\n            'name' => 'Employee Task',\n            'project_id' => $project->getKey(),\n        ]);\n    }\n\n    public function test_store_endpoint_fails_for_employee_when_employees_can_manage_tasks_is_disabled(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(\\App\\Enums\\Role::Employee);\n        $data->organization->employees_can_manage_tasks = false;\n        $data->organization->save();\n        $project = Project::factory()->forOrganization($data->organization)->isPublic()->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [\n            'name' => 'Employee Task',\n            'project_id' => $project->getKey(),\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n        $this->assertDatabaseMissing(Task::class, [\n            'name' => 'Employee Task',\n        ]);\n    }\n\n    public function test_update_endpoint_allows_employee_to_update_task_in_public_project_when_employees_can_manage_tasks_is_enabled(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(\\App\\Enums\\Role::Employee);\n        $data->organization->employees_can_manage_tasks = true;\n        $data->organization->save();\n        $project = Project::factory()->forOrganization($data->organization)->isPublic()->create();\n        $task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [\n            'name' => 'Updated by Employee',\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $this->assertDatabaseHas(Task::class, [\n            'id' => $task->getKey(),\n            'name' => 'Updated by Employee',\n        ]);\n    }\n\n    public function test_update_endpoint_allows_employee_to_update_task_in_accessible_private_project_when_employees_can_manage_tasks_is_enabled(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(\\App\\Enums\\Role::Employee);\n        $data->organization->employees_can_manage_tasks = true;\n        $data->organization->save();\n        $project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();\n        ProjectMember::factory()->forProject($project)->forMember($data->member)->create();\n        $task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [\n            'name' => 'Updated by Employee',\n        ]);\n\n        // Assert\n        $response->assertStatus(200);\n        $this->assertDatabaseHas(Task::class, [\n            'id' => $task->getKey(),\n            'name' => 'Updated by Employee',\n        ]);\n    }\n\n    public function test_update_endpoint_fails_for_employee_updating_task_in_inaccessible_private_project_when_employees_can_manage_tasks_is_enabled(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(\\App\\Enums\\Role::Employee);\n        $data->organization->employees_can_manage_tasks = true;\n        $data->organization->save();\n        $project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();\n        $task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();\n        $originalName = $task->name;\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [\n            'name' => 'Updated by Employee',\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n        $this->assertDatabaseHas(Task::class, [\n            'id' => $task->getKey(),\n            'name' => $originalName,\n        ]);\n    }\n\n    public function test_update_endpoint_fails_for_employee_when_employees_can_manage_tasks_is_disabled(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(\\App\\Enums\\Role::Employee);\n        $data->organization->employees_can_manage_tasks = false;\n        $data->organization->save();\n        $project = Project::factory()->forOrganization($data->organization)->isPublic()->create();\n        $task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();\n        $originalName = $task->name;\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [\n            'name' => 'Updated by Employee',\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n        $this->assertDatabaseHas(Task::class, [\n            'id' => $task->getKey(),\n            'name' => $originalName,\n        ]);\n    }\n\n    public function test_delete_endpoint_allows_employee_to_delete_task_in_public_project_when_employees_can_manage_tasks_is_enabled(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(\\App\\Enums\\Role::Employee);\n        $data->organization->employees_can_manage_tasks = true;\n        $data->organization->save();\n        $project = Project::factory()->forOrganization($data->organization)->isPublic()->create();\n        $task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.tasks.destroy', [$data->organization->getKey(), $task->getKey()]));\n\n        // Assert\n        $response->assertStatus(204);\n        $this->assertDatabaseMissing(Task::class, [\n            'id' => $task->getKey(),\n        ]);\n    }\n\n    public function test_delete_endpoint_allows_employee_to_delete_task_in_accessible_private_project_when_employees_can_manage_tasks_is_enabled(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(\\App\\Enums\\Role::Employee);\n        $data->organization->employees_can_manage_tasks = true;\n        $data->organization->save();\n        $project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();\n        ProjectMember::factory()->forProject($project)->forMember($data->member)->create();\n        $task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.tasks.destroy', [$data->organization->getKey(), $task->getKey()]));\n\n        // Assert\n        $response->assertStatus(204);\n        $this->assertDatabaseMissing(Task::class, [\n            'id' => $task->getKey(),\n        ]);\n    }\n\n    public function test_delete_endpoint_fails_for_employee_deleting_task_in_inaccessible_private_project_when_employees_can_manage_tasks_is_enabled(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(\\App\\Enums\\Role::Employee);\n        $data->organization->employees_can_manage_tasks = true;\n        $data->organization->save();\n        $project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();\n        $task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.tasks.destroy', [$data->organization->getKey(), $task->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n        $this->assertDatabaseHas(Task::class, [\n            'id' => $task->getKey(),\n        ]);\n    }\n\n    public function test_delete_endpoint_fails_for_employee_when_employees_can_manage_tasks_is_disabled(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(\\App\\Enums\\Role::Employee);\n        $data->organization->employees_can_manage_tasks = false;\n        $data->organization->save();\n        $project = Project::factory()->forOrganization($data->organization)->isPublic()->create();\n        $task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.tasks.destroy', [$data->organization->getKey(), $task->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n        $this->assertDatabaseHas(Task::class, [\n            'id' => $task->getKey(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Endpoint\\Api\\V1;\n\nuse App\\Enums\\ExportFormat;\nuse App\\Enums\\Role;\nuse App\\Enums\\TimeEntryAggregationType;\nuse App\\Enums\\TimeEntryAggregationTypeInterval;\nuse App\\Enums\\TimeEntryRoundingType;\nuse App\\Exceptions\\Api\\TimeEntryCanNotBeRestartedApiException;\nuse App\\Http\\Controllers\\Api\\V1\\TimeEntryController;\nuse App\\Jobs\\RecalculateSpentTimeForProject;\nuse App\\Jobs\\RecalculateSpentTimeForTask;\nuse App\\Models\\Client;\nuse App\\Models\\Member;\nuse App\\Models\\Project;\nuse App\\Models\\Tag;\nuse App\\Models\\Task;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse App\\Service\\TimeEntryFilter;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Facades\\Config;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Facades\\Queue;\nuse Illuminate\\Support\\Facades\\Storage;\nuse Illuminate\\Support\\Str;\nuse Illuminate\\Testing\\Fluent\\AssertableJson;\nuse Laravel\\Passport\\Passport;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\nuse Ramsey\\Uuid\\Type\\Time;\nuse TiMacDonald\\Log\\LogEntry;\n\n#[UsesClass(TimeEntryController::class)]\nclass TimeEntryEndpointTest extends ApiEndpointTestAbstract\n{\n    protected function setUp(): void\n    {\n        parent::setUp();\n        Storage::fake('local');\n    }\n\n    public function test_index_endpoint_fails_if_user_has_no_permission_to_view_time_entries(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index', [$data->organization->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_index_endpoint_fails_if_user_has_no_permission_to_view_time_entries_for_others_but_wants_all_entries(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:own',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index', [$data->organization->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_index_endpoint_returns_time_entries_for_current_user(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:own',\n        ]);\n        $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index', [\n            $data->organization->getKey(),\n            'member_id' => $data->member->getKey(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $response->assertJsonPath('data.0.id', $timeEntry->getKey());\n    }\n\n    public function test_index_endpoint_fails_if_user_filter_is_from_different_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        $otherData = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index', [\n            $data->organization->getKey(),\n            'member_id' => $otherData->member->getKey(),\n        ]));\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertJsonValidationErrorFor('member_id');\n    }\n\n    public function test_index_endpoint_returns_time_entries_for_other_user_in_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($data->organization)->forUser($user)->role(Role::Employee)->create();\n        $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($member)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index', [$data->organization->getKey(), 'user_id' => $user->getKey()]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $response->assertJsonPath('data.0.id', $timeEntry->getKey());\n    }\n\n    public function test_index_endpoint_returns_time_entries_for_all_users_in_organization_default_sort_by_start_date_desc(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($data->organization)->forUser($user)->role(Role::Employee)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create([\n            'start' => Carbon::now()->subDay(),\n        ]);\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forMember($member)->create([\n            'start' => Carbon::now()->subDays(2),\n        ]);\n        $timeEntry3 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create([\n            'start' => Carbon::now()->subDays(3),\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index', [$data->organization->getKey()]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $response->assertJsonPath('data.0.id', $timeEntry1->getKey());\n        $response->assertJsonPath('data.1.id', $timeEntry2->getKey());\n        $response->assertJsonPath('data.2.id', $timeEntry3->getKey());\n    }\n\n    public function test_index_endpoint_returns_only_active_time_entries(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:own',\n        ]);\n        $activeTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->active()->create();\n        $nonActiveTimeEntries = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->createMany(3);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index', [\n            $data->organization->getKey(),\n            'active' => 'true',\n            'member_id' => $data->member->getKey(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $response->assertJsonCount(1, 'data');\n        $response->assertJsonPath('meta.total', 1);\n        $response->assertJsonPath('data.0.id', $activeTimeEntry->getKey());\n    }\n\n    public function test_index_endpoint_returns_only_non_active_time_entries(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:own',\n        ]);\n        $activeTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->active()->createMany(3);\n        $nonActiveTimeEntries = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index', [\n            $data->organization->getKey(),\n            'active' => 'false',\n            'member_id' => $data->member->getKey(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $response->assertJsonCount(1, 'data');\n        $response->assertJsonPath('meta.total', 1);\n        $response->assertJsonPath('data.0.id', $nonActiveTimeEntries->getKey());\n    }\n\n    public function test_index_endpoint_filter_only_full_dates_returns_time_entries_for_the_whole_day_case_less_time_entries_than_limit(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:own',\n        ]);\n        $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->createMany(3);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index', [\n            $data->organization->getKey(),\n            'only_full_dates' => 'true',\n            'limit' => 5,\n            'member_id' => $data->member->getKey(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $response->assertJsonCount(3, 'data');\n        $response->assertJsonPath('meta.total', 3);\n    }\n\n    public function test_index_endpoint_filter_only_full_dates_returns_time_entries_for_the_whole_day_case_more_time_entries_than_limit(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:own',\n        ]);\n        $timeEntriesDay1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)\n            ->startBetween(Carbon::now($data->user->timezone)->subDay()->startOfDay(), Carbon::now($data->user->timezone)->subDay()->endOfDay())\n            ->createMany(3);\n        $timeEntriesDay2 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)\n            ->startBetween(Carbon::now($data->user->timezone)->subDays(2)->startOfDay(), Carbon::now($data->user->timezone)->subDays(2)->endOfDay())\n            ->createMany(3);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index', [\n            $data->organization->getKey(),\n            'only_full_dates' => 'true',\n            'limit' => 5,\n            'member_id' => $data->member->getKey(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $response->assertJsonCount(3, 'data');\n        $response->assertJsonPath('meta.total', 6);\n    }\n\n    public function test_index_endpoint_filter_only_full_dates_returns_time_entries_for_the_whole_day_case_more_time_entries_than_limit_with_a_timezone_edge_case(): void\n    {\n        // Arrange\n        $now = Carbon::create(2024, 1, 1, 12, 0, 0, 'Europe/Vienna');\n        $this->travelTo($now);\n        $data = $this->createUserWithPermission([\n            'time-entries:view:own',\n        ]);\n        $data->user->timezone = 'America/New_York';\n        $data->user->save();\n        /**\n         * We create in the eyes of the users timezone 2 time entries yesterday, 2 time entries two days ago, and 3 time entries three days ago\n         * The time entries are created in a way that they jump to the next day if the endpoint ignores the users timezone and just uses UTC\n         */\n\n        // Note: This entry is yesterday in user timezone and yesterday in UTC\n        $timeEntriesDay1InUserTimeZone = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)\n            ->state([\n                'start' => Carbon::now()->timezone($data->user->timezone)->subDay()->startOfDay()->utc(),\n            ])\n            ->createMany(2);\n        // Note: This entry is yesterday in UTC timezone, but two days ago in user timezone\n        $timeEntriesDay1InUTC = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)\n            ->state([\n                'start' => Carbon::now()->utc()->subDay()->startOfDay()->utc(),\n            ])\n            ->createMany(2);\n        // Note: This entry is two days ago in user timezone\n        $timeEntriesDay2InUserTimeZone = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)\n            ->state([\n                'start' => Carbon::now()->timezone($data->user->timezone)->subDays(2)->startOfDay()->utc(),\n            ])\n            ->createMany(3);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index', [\n            $data->organization->getKey(),\n            'only_full_dates' => 'true',\n            'limit' => 5,\n            'member_id' => $data->member->getKey(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $response->assertJsonCount(2, 'data');\n        $response->assertJsonPath('meta.total', 7);\n    }\n\n    public function test_index_endpoint_filter_only_full_dates_returns_time_entries_for_the_whole_day_case_more_time_entries_in_latest_day_than_limit(): void\n    {\n        // Arrange\n        $now = Carbon::create(2024, 1, 1, 12, 0, 0, 'Europe/Vienna');\n        $this->travelTo($now);\n        $data = $this->createUserWithPermission([\n            'time-entries:view:own',\n        ]);\n        $timeEntriesDay1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)\n            ->state([\n                'start' => Carbon::now()->timezone($data->user->timezone)->subDay()->startOfDay()->utc(),\n            ])\n            ->createMany(7);\n        $timeEntriesDay2 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)\n            ->state([\n                'start' => Carbon::now()->timezone($data->user->timezone)->subDays(2)->endOfDay()->utc(),\n            ])\n            ->createMany(3);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index', [\n            $data->organization->getKey(),\n            'only_full_dates' => 'true',\n            'limit' => 5,\n            'member_id' => $data->member->getKey(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $response->assertJsonCount(7, 'data');\n        $response->assertJsonPath('meta.total', 10);\n        Log::assertLogged(fn (LogEntry $log) => $log->level === 'warning'\n            && $log->message === 'User has has more than 5 time entries on one date'\n        );\n    }\n\n    public function test_index_endpoint_before_filter_returns_time_entries_before_date(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:own',\n        ]);\n        $timeEntriesAfter = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)\n            ->startBetween(\n                Carbon::now()->timezone($data->user->timezone)->subDay()->startOfDay()->utc(),\n                Carbon::now()->timezone($data->user->timezone)->utc()\n            )\n            ->createMany(3);\n        $timeEntriesBefore = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)\n            ->startBetween(\n                Carbon::now()->timezone($data->user->timezone)->subDays(2)->startOfDay()->utc(),\n                Carbon::now()->timezone($data->user->timezone)->subDays(2)->endOfDay()->utc()\n            )\n            ->createMany(3);\n        $timeEntriesBeforeSorted = $timeEntriesBefore->sortByDesc('start')->values();\n        $timeEntriesDirectlyBeforeLimit = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)\n            ->create([\n                'start' => Carbon::now()->timezone($data->user->timezone)->subDays(2)->endOfDay()->utc(),\n            ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index', [\n            $data->organization->getKey(),\n            'end' => Carbon::now()->timezone($data->user->timezone)->subDay()->startOfDay()->toIso8601ZuluString(),\n            'member_id' => $data->member->getKey(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->has('meta')\n            ->where('meta.total', 4)\n            ->count('data', 4)\n            ->where('data.0.id', $timeEntriesDirectlyBeforeLimit->getKey())\n            ->where('data.1.id', $timeEntriesBeforeSorted->get(0)->getKey())\n            ->where('data.2.id', $timeEntriesBeforeSorted->get(1)->getKey())\n            ->where('data.3.id', $timeEntriesBeforeSorted->get(2)->getKey())\n        );\n    }\n\n    public function test_index_endpoint_can_round_up(): void\n    {\n        // Arrange\n        $this->travelTo(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:15:04'));\n        $data = $this->createUserWithPermission([\n            'time-entries:view:own',\n        ]);\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)\n            ->forMember($data->member)\n            ->create([\n                'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:08'),\n                'end' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'),\n            ]);\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)\n            ->forMember($data->member)\n            ->create([\n                'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'),\n                'end' => null,\n            ]);\n        $this->actAsOrganizationWithSubscription();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index', [\n            $data->organization->getKey(),\n            'member_id' => $data->member->getKey(),\n            'rounding_type' => TimeEntryRoundingType::Up,\n            'rounding_minutes' => 6,\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->has('meta')\n            ->where('meta.total', 2)\n            ->count('data', 2)\n            ->where('data.0.id', $timeEntry1->getKey())\n            ->where('data.0.start', '2020-01-01T00:00:00Z')\n            ->where('data.0.end', '2020-01-01T00:06:00Z')\n            ->where('data.1.id', $timeEntry2->getKey())\n            ->where('data.1.start', '2020-01-01T00:00:00Z')\n            ->where('data.1.end', '2020-01-01T00:18:00Z')\n        );\n    }\n\n    public function test_index_endpoint_can_round_up_but_does_not_round_up_if_already_on_border(): void\n    {\n        // Arrange\n        $this->travelTo(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:15:04'));\n        $data = $this->createUserWithPermission([\n            'time-entries:view:own',\n        ]);\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)\n            ->forMember($data->member)\n            ->create([\n                'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:08'),\n                'end' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:06:00'),\n            ]);\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)\n            ->forMember($data->member)\n            ->create([\n                'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'),\n                'end' => null,\n            ]);\n        $this->actAsOrganizationWithSubscription();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index', [\n            $data->organization->getKey(),\n            'member_id' => $data->member->getKey(),\n            'rounding_type' => TimeEntryRoundingType::Up,\n            'rounding_minutes' => 6,\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->has('meta')\n            ->where('meta.total', 2)\n            ->count('data', 2)\n            ->where('data.0.id', $timeEntry1->getKey())\n            ->where('data.0.start', '2020-01-01T00:00:00Z')\n            ->where('data.0.end', '2020-01-01T00:06:00Z')\n            ->where('data.1.id', $timeEntry2->getKey())\n            ->where('data.1.start', '2020-01-01T00:00:00Z')\n            ->where('data.1.end', '2020-01-01T00:18:00Z')\n        );\n    }\n\n    public function test_index_endpoint_ignores_rounding_if_organization_has_no_premium_features(): void\n    {\n        // Arrange\n        $this->travelTo(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:15:04'));\n        $data = $this->createUserWithPermission([\n            'time-entries:view:own',\n        ]);\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)\n            ->forMember($data->member)\n            ->create([\n                'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:08'),\n                'end' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'),\n            ]);\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)\n            ->forMember($data->member)\n            ->create([\n                'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'),\n                'end' => null,\n            ]);\n        $this->actAsOrganizationWithoutSubscriptionAndWithoutTrial();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index', [\n            $data->organization->getKey(),\n            'member_id' => $data->member->getKey(),\n            'rounding_type' => TimeEntryRoundingType::Up,\n            'rounding_minutes' => 6,\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->has('meta')\n            ->where('meta.total', 2)\n            ->count('data', 2)\n            ->where('data.0.id', $timeEntry1->getKey())\n            ->where('data.0.start', '2020-01-01T00:00:08Z')\n            ->where('data.0.end', '2020-01-01T00:00:01Z')\n            ->where('data.1.id', $timeEntry2->getKey())\n            ->where('data.1.start', '2020-01-01T00:00:07Z')\n            ->where('data.1.end', null)\n        );\n    }\n\n    public function test_index_endpoint_can_round_down(): void\n    {\n        // Arrange\n        $this->travelTo(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:15:04'));\n        $data = $this->createUserWithPermission([\n            'time-entries:view:own',\n        ]);\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)\n            ->forMember($data->member)\n            ->create([\n                'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:08'),\n                'end' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'),\n            ]);\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)\n            ->forMember($data->member)\n            ->create([\n                'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'),\n                'end' => null,\n            ]);\n        $this->actAsOrganizationWithSubscription();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index', [\n            $data->organization->getKey(),\n            'member_id' => $data->member->getKey(),\n            'rounding_type' => TimeEntryRoundingType::Down,\n            'rounding_minutes' => 6,\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->has('meta')\n            ->where('meta.total', 2)\n            ->count('data', 2)\n            ->where('data.0.id', $timeEntry1->getKey())\n            ->where('data.0.start', '2020-01-01T00:00:00Z')\n            ->where('data.0.end', '2020-01-01T00:00:00Z')\n            ->where('data.1.id', $timeEntry2->getKey())\n            ->where('data.1.start', '2020-01-01T00:00:00Z')\n            ->where('data.1.end', '2020-01-01T00:12:00Z')\n        );\n    }\n\n    public function test_index_endpoint_can_round_nearest(): void\n    {\n        // Arrange\n        $this->travelTo(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:15:00'));\n        $data = $this->createUserWithPermission([\n            'time-entries:view:own',\n        ]);\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)\n            ->forMember($data->member)\n            ->create([\n                'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:08'),\n                'end' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:02:59'),\n            ]);\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)\n            ->forMember($data->member)\n            ->create([\n                'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'),\n                'end' => null,\n            ]);\n        $this->actAsOrganizationWithSubscription();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index', [\n            $data->organization->getKey(),\n            'member_id' => $data->member->getKey(),\n            'rounding_type' => TimeEntryRoundingType::Nearest,\n            'rounding_minutes' => 6,\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->has('meta')\n            ->where('meta.total', 2)\n            ->count('data', 2)\n            ->where('data.0.id', $timeEntry1->getKey())\n            ->where('data.0.start', '2020-01-01T00:00:00Z')\n            ->where('data.0.end', '2020-01-01T00:00:00Z')\n            ->where('data.1.id', $timeEntry2->getKey())\n            ->where('data.1.start', '2020-01-01T00:00:00Z')\n            ->where('data.1.end', '2020-01-01T00:18:00Z')\n        );\n    }\n\n    public function test_index_endpoint_after_filter_returns_time_entries_after_date(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:own',\n        ]);\n        $timeEntriesAfter = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)\n            ->startBetween(Carbon::now($data->user->timezone)->startOfDay()->utc(), Carbon::now($data->user->timezone)->utc())\n            ->createMany(3);\n        $timeEntriesAfterSorted = $timeEntriesAfter->sortByDesc('start')->values();\n        $timeEntriesBefore = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)\n            ->startBetween(Carbon::now($data->user->timezone)->subDay()->startOfDay()->utc(), Carbon::now($data->user->timezone)->subDay()->endOfDay()->utc())\n            ->createMany(3);\n        $timeEntriesDirectlyAfterLimit = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)\n            ->create([\n                'start' => Carbon::now($data->user->timezone)->startOfDay()->utc(),\n            ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index', [\n            $data->organization->getKey(),\n            'start' => Carbon::now($data->user->timezone)->subDay()->endOfDay()->toIso8601ZuluString(), // yesterday\n            'member_id' => $data->member->getKey(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->has('meta')\n            ->where('meta.total', 4)\n            ->count('data', 4)\n            ->where('data.0.id', $timeEntriesAfterSorted->get(0)->getKey())\n            ->where('data.1.id', $timeEntriesAfterSorted->get(1)->getKey())\n            ->where('data.2.id', $timeEntriesAfterSorted->get(2)->getKey())\n            ->where('data.3.id', $timeEntriesDirectlyAfterLimit->getKey())\n        );\n    }\n\n    public function test_index_endpoint_with_all_available_filters(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n            'time-entries:view:own',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();\n        $tag = Tag::factory()->forOrganization($data->organization)->create();\n        $timeEntry1 = TimeEntry::factory()\n            ->forOrganization($data->organization)\n            ->forProject($project)\n            ->forTask($task)\n            ->forMember($data->member)\n            ->billable()\n            ->active()\n            ->create([\n                'start' => Carbon::now()->subHour(),\n                'tags' => [$tag->getKey()],\n            ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index', [\n            $data->organization->getKey(),\n            'member_id' => $data->member->getKey(),\n            'member_ids' => [$data->member->getKey()],\n            'project_ids' => [$project->getKey()],\n            'task_ids' => [$task->getKey()],\n            'tag_ids' => [$tag->getKey()],\n            'start' => Carbon::now()->subDay()->toIso8601ZuluString(),\n            'end' => Carbon::now()->toIso8601ZuluString(),\n            'active' => 'true',\n            'only_full_dates' => 'true',\n            'limit' => 1,\n        ]));\n\n        // Assert\n        $response->assertValid();\n        $this->assertResponseCode($response, 200);\n        $response->assertJson(fn (AssertableJson $json) => $json\n            ->has('data')\n            ->has('meta')\n            ->where('meta.total', 1)\n            ->count('data', 1)\n            ->where('data.0.id', $timeEntry1->getKey())\n        );\n    }\n\n    public function test_index_endpoint_with_limit_offset_and_only_full_dates_deactivated(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:own',\n        ]);\n        $project1 = Project::factory()->forOrganization($data->organization)->create();\n        $project2 = Project::factory()->forOrganization($data->organization)->create();\n        TimeEntry::factory()->forMember($data->member)->forProject($project1)->forOrganization($data->organization)->create([\n            'start' => Carbon::now()->subDays(2),\n        ]);\n        $timeEntry = TimeEntry::factory()->forMember($data->member)->forProject($project1)->forOrganization($data->organization)->create([\n            'start' => Carbon::now()->subDays(3),\n        ]);\n        TimeEntry::factory()->forMember($data->member)->forProject($project1)->forOrganization($data->organization)->create([\n            'start' => Carbon::now()->subDays(4),\n        ]);\n        TimeEntry::factory()->forMember($data->member)->forProject($project2)->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index', [\n            $data->organization->getKey(),\n            'member_id' => $data->member->getKey(),\n            'project_ids' => [$project1->getKey()],\n            'limit' => 1,\n            'offset' => 1,\n            'only_full_dates' => 'false',\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $response->assertJsonCount(1, 'data');\n        $response->assertJsonPath('meta.total', 3);\n        $response->assertJsonPath('data.*.id', [$timeEntry->getKey()]);\n    }\n\n    public function test_index_export_endpoint_fails_if_user_has_no_permission_to_view_time_entries(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::CSV,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n        ]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_index_export_endpoint_fails_if_pdf_renderer_is_not_configured_but_a_user_want_a_pdf_report(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        Passport::actingAs($data->user);\n        Config::set('services.gotenberg.url', null);\n        $this->actAsOrganizationWithSubscription();\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::PDF,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n        ]));\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertExactJson([\n            'error' => true,\n            'key' => 'pdf_renderer_is_not_configured',\n            'message' => 'PDF renderer is not configured',\n        ]);\n    }\n\n    public function test_index_export_endpoint_fails_if_user_wants_a_pdf_export_but_has_no_subscription(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        Passport::actingAs($data->user);\n        $this->actAsOrganizationWithoutSubscriptionAndWithoutTrial();\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::PDF,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n        ]));\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertExactJson([\n            'error' => true,\n            'key' => 'feature_is_not_available_in_free_plan',\n            'message' => 'Feature is not available in free plan',\n        ]);\n    }\n\n    public function test_index_export_endpoint_fails_if_user_has_only_access_to_own_time_entries_but_does_not_filter_for_this(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:own',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::CSV,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n        ]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_csv(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::CSV,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n    }\n\n    public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_ods(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::ODS,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n    }\n\n    public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_xlxs(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::XLSX,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n    }\n\n    public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_pdf(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        Passport::actingAs($data->user);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $this->actAsOrganizationWithSubscription();\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::PDF,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n    }\n\n    public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_csv_as_employee_role_with_show_billable_rate(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(Role::Employee, true);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::CSV,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n            'member_id' => $data->member->id,\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n    }\n\n    public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_ods_as_employee_role_with_show_billable_rate(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(Role::Employee, true);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::ODS,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n            'member_id' => $data->member->id,\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n    }\n\n    public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_xlxs_as_employee_role_with_show_billable_rate(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(Role::Employee, true);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::XLSX,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n            'member_id' => $data->member->id,\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n    }\n\n    public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_pdf_as_employee_role_with_show_billable_rate(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(Role::Employee, true);\n        Passport::actingAs($data->user);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $this->actAsOrganizationWithSubscription();\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::PDF,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n            'member_id' => $data->member->id,\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n    }\n\n    public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_csv_as_employee_role_without_show_billable_rate(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(Role::Employee, false);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::CSV,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n            'member_id' => $data->member->id,\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n    }\n\n    public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_ods_as_employee_role_without_show_billable_rate(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(Role::Employee, false);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::ODS,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n            'member_id' => $data->member->id,\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n    }\n\n    public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_xlxs_as_employee_role_without_show_billable_rate(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(Role::Employee, false);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::XLSX,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n            'member_id' => $data->member->id,\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n    }\n\n    public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_pdf_as_employee_role_without_show_billable_rate(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(Role::Employee, false);\n        Passport::actingAs($data->user);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $this->actAsOrganizationWithSubscription();\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::PDF,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n            'member_id' => $data->member->id,\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n    }\n\n    public function test_aggregate_export_endpoints_fails_if_user_no_permission_to_view_time_entries(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::CSV,\n            'group' => TimeEntryAggregationType::Client,\n            'sub_group' => TimeEntryAggregationType::Project,\n            'history_group' => TimeEntryAggregationTypeInterval::Month,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n        ]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_aggregate_endpoint_fails_if_user_has_no_permission_to_view_time_entries(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.aggregate', [\n            $data->organization->getKey(),\n        ]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_aggregate_export_endpoint_fails_if_user_wants_a_pdf_export_but_has_no_subscription(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        Passport::actingAs($data->user);\n        $this->actAsOrganizationWithoutSubscriptionAndWithoutTrial();\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::PDF,\n            'group' => TimeEntryAggregationType::Client,\n            'sub_group' => TimeEntryAggregationType::Project,\n            'history_group' => TimeEntryAggregationTypeInterval::Month,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n        ]));\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertExactJson([\n            'error' => true,\n            'key' => 'feature_is_not_available_in_free_plan',\n            'message' => 'Feature is not available in free plan',\n        ]);\n    }\n\n    public function test_aggregate_export_endpoint_fails_if_user_has_only_access_to_own_time_entries_but_does_not_filter_for_this(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:own',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::CSV,\n            'group' => TimeEntryAggregationType::Client,\n            'sub_group' => TimeEntryAggregationType::Project,\n            'history_group' => TimeEntryAggregationTypeInterval::Month,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n        ]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_aggregate_export_endpoints_can_create_a_csv_report(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::CSV,\n            'group' => TimeEntryAggregationType::Client,\n            'sub_group' => TimeEntryAggregationType::Project,\n            'history_group' => TimeEntryAggregationTypeInterval::Month,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n    }\n\n    public function test_aggregate_export_endpoints_can_create_a_csv_report_as_employee_role_with_show_billable_rate(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(Role::Employee, true);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::CSV,\n            'group' => TimeEntryAggregationType::Client,\n            'sub_group' => TimeEntryAggregationType::Project,\n            'history_group' => TimeEntryAggregationTypeInterval::Month,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n            'member_id' => $data->member->getKey(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n    }\n\n    public function test_aggregate_export_endpoints_can_create_a_csv_report_as_employee_role_without_show_billable_rate(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(Role::Employee, false);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::CSV,\n            'group' => TimeEntryAggregationType::Client,\n            'sub_group' => TimeEntryAggregationType::Project,\n            'history_group' => TimeEntryAggregationTypeInterval::Month,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n            'member_id' => $data->member->getKey(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n    }\n\n    public function test_aggregate_export_endpoints_can_create_a_xlsx_report(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::XLSX,\n            'group' => TimeEntryAggregationType::Client,\n            'sub_group' => TimeEntryAggregationType::Project,\n            'history_group' => TimeEntryAggregationTypeInterval::Month,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n    }\n\n    public function test_aggregate_export_endpoints_can_create_a_xlsx_report_as_employee_role_with_show_billable_rate(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(Role::Employee, true);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::XLSX,\n            'group' => TimeEntryAggregationType::Client,\n            'sub_group' => TimeEntryAggregationType::Project,\n            'history_group' => TimeEntryAggregationTypeInterval::Month,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n            'member_id' => $data->member->getKey(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n    }\n\n    public function test_aggregate_export_endpoints_can_create_a_xlsx_report_as_employee_role_without_show_billable_rate(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(Role::Employee, false);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::XLSX,\n            'group' => TimeEntryAggregationType::Client,\n            'sub_group' => TimeEntryAggregationType::Project,\n            'history_group' => TimeEntryAggregationTypeInterval::Month,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n            'member_id' => $data->member->getKey(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n    }\n\n    public function test_aggregate_export_endpoints_can_create_a_ods_report(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::ODS,\n            'group' => TimeEntryAggregationType::User,\n            'sub_group' => TimeEntryAggregationType::Project,\n            'history_group' => TimeEntryAggregationTypeInterval::Month,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n    }\n\n    public function test_aggregate_export_endpoints_can_create_a_ods_report_as_employee_role_with_show_billable_rate(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(Role::Employee, true);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::ODS,\n            'group' => TimeEntryAggregationType::User,\n            'sub_group' => TimeEntryAggregationType::Project,\n            'history_group' => TimeEntryAggregationTypeInterval::Month,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n            'member_id' => $data->member->getKey(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n    }\n\n    public function test_aggregate_export_endpoints_can_create_a_ods_report_as_employee_role_without_show_billable_rate(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(Role::Employee, false);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::ODS,\n            'group' => TimeEntryAggregationType::User,\n            'sub_group' => TimeEntryAggregationType::Project,\n            'history_group' => TimeEntryAggregationTypeInterval::Month,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n            'member_id' => $data->member->getKey(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n    }\n\n    public function test_aggregate_export_endpoint_fails_if_pdf_renderer_is_not_configured_but_a_user_want_a_pdf_report(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        Passport::actingAs($data->user);\n        $this->actAsOrganizationWithSubscription();\n        Config::set('services.gotenberg.url', null);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::PDF,\n            'group' => TimeEntryAggregationType::User,\n            'sub_group' => TimeEntryAggregationType::Project,\n            'history_group' => TimeEntryAggregationTypeInterval::Month,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n        ]));\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertExactJson([\n            'error' => true,\n            'key' => 'pdf_renderer_is_not_configured',\n            'message' => 'PDF renderer is not configured',\n        ]);\n    }\n\n    public function test_aggregate_export_endpoints_can_create_a_pdf_report(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        Passport::actingAs($data->user);\n        $this->actAsOrganizationWithSubscription();\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::PDF,\n            'group' => TimeEntryAggregationType::User,\n            'sub_group' => TimeEntryAggregationType::Project,\n            'history_group' => TimeEntryAggregationTypeInterval::Month,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n    }\n\n    public function test_aggregate_export_endpoints_can_create_a_pdf_report_as_employee_role_with_show_billable_rate(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(Role::Employee, true);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        Passport::actingAs($data->user);\n        $this->actAsOrganizationWithSubscription();\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::PDF,\n            'group' => TimeEntryAggregationType::User,\n            'sub_group' => TimeEntryAggregationType::Project,\n            'history_group' => TimeEntryAggregationTypeInterval::Month,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n            'member_id' => $data->member->getKey(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n    }\n\n    public function test_aggregate_export_endpoints_can_create_a_pdf_report_as_employee_role_without_show_billable_rate(): void\n    {\n        // Arrange\n        $data = $this->createUserWithRole(Role::Employee, false);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        Passport::actingAs($data->user);\n        $this->actAsOrganizationWithSubscription();\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.aggregate-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::PDF,\n            'group' => TimeEntryAggregationType::User,\n            'sub_group' => TimeEntryAggregationType::Project,\n            'history_group' => TimeEntryAggregationTypeInterval::Month,\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n            'member_id' => $data->member->getKey(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n    }\n\n    public function test_index_export_endpoint_with_client_ids_filter_returns_filtered_entries(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        $clientA = Client::factory()->forOrganization($data->organization)->create();\n        $clientB = Client::factory()->forOrganization($data->organization)->create();\n        $projectA = Project::factory()->forOrganization($data->organization)->forClient($clientA)->create();\n        $projectB = Project::factory()->forOrganization($data->organization)->forClient($clientB)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forProject($projectA)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($projectB)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::CSV,\n            'client_ids' => [$clientA->getKey()],\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n    }\n\n    public function test_index_export_endpoint_with_none_client_ids_filter_succeeds(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::CSV,\n            'client_ids' => [TimeEntryFilter::NONE_VALUE],\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n    }\n\n    public function test_index_export_endpoint_with_client_ids_of_other_organization_fails_validation(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        $otherData = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        $otherClient = Client::factory()->forOrganization($otherData->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index-export', [\n            $data->organization->getKey(),\n            'format' => ExportFormat::CSV,\n            'client_ids' => [$otherClient->getKey()],\n            'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),\n            'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),\n        ]));\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertJsonValidationErrorFor('client_ids.0');\n    }\n\n    public function test_aggregate_endpoint_fails_if_user_has_only_access_to_own_time_entries_but_does_not_filter_for_this(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:own',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.aggregate', [\n            $data->organization->getKey(),\n            'group' => 'day',\n            'sub_group' => 'project',\n        ]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_aggregate_endpoint_fails_if_request_has_sub_group_but_no_group(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.aggregate', [\n            $data->organization->getKey(),\n            'sub_group' => TimeEntryAggregationType::Task->value,\n        ]));\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertJsonValidationErrorFor('group');\n    }\n\n    public function test_aggregate_endpoint_works_for_user_with_only_access_to_own_time_entries(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:own',\n        ]);\n        $otherUser = User::factory()->create();\n        $otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->create();\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $start = Carbon::now()->timezone($data->user->timezone)->subDays(2);\n        $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->forProject($project)->startWithDuration($start, 100)->create();\n        $timeEntryOtherMember = TimeEntry::factory()->forOrganization($data->organization)->forMember($otherMember)->forProject($project)->startWithDuration($start, 100)->create();\n\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.aggregate', [\n            $data->organization->getKey(),\n            'member_id' => $data->member->getKey(),\n            'group' => 'project',\n        ]));\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertExactJson([\n            'data' => [\n                'seconds' => 100,\n                'cost' => 0,\n                'grouped_data' => [\n                    0 => [\n                        'key' => $project->getKey(),\n                        'seconds' => 100,\n                        'cost' => 0,\n                        'grouped_type' => null,\n                        'grouped_data' => null,\n                    ],\n                ],\n                'grouped_type' => 'project',\n            ],\n        ]);\n    }\n\n    public function test_aggregate_endpoint_groups_by_two_groups(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $day1 = Carbon::now()->timezone($data->user->timezone)->subDays(1);\n        $day2 = Carbon::now()->timezone($data->user->timezone)->subDays(3);\n        $timeEntry1NoProject = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($day1, 10)->create();\n        $timeEntry2NoProject = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($day2, 10)->create();\n        $timeEntry1WithProject = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->forProject($project)->startWithDuration($day1, 10)->create();\n        $timeEntry2WithProject = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->forProject($project)->startWithDuration($day2, 10)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.aggregate', [\n            $data->organization->getKey(),\n            'group' => 'day',\n            'sub_group' => 'project',\n        ]));\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertExactJson([\n            'data' => [\n                'seconds' => 40,\n                'cost' => 0,\n                'grouped_data' => [\n                    0 => [\n                        'key' => $day2->format('Y-m-d'),\n                        'seconds' => 20,\n                        'cost' => 0,\n                        'grouped_type' => 'project',\n                        'grouped_data' => [\n                            0 => [\n                                'key' => $project->getKey(),\n                                'seconds' => 10,\n                                'cost' => 0,\n                                'grouped_type' => null,\n                                'grouped_data' => null,\n                            ],\n                            1 => [\n                                'key' => null,\n                                'seconds' => 10,\n                                'cost' => 0,\n                                'grouped_type' => null,\n                                'grouped_data' => null,\n                            ],\n                        ],\n                    ],\n                    1 => [\n                        'key' => $day1->format('Y-m-d'),\n                        'seconds' => 20,\n                        'cost' => 0,\n                        'grouped_type' => 'project',\n                        'grouped_data' => [\n                            0 => [\n                                'key' => $project->getKey(),\n                                'seconds' => 10,\n                                'cost' => 0,\n                                'grouped_type' => null,\n                                'grouped_data' => null,\n                            ],\n                            1 => [\n                                'key' => null,\n                                'seconds' => 10,\n                                'cost' => 0,\n                                'grouped_type' => null,\n                                'grouped_data' => null,\n                            ],\n                        ],\n                    ],\n                ],\n                'grouped_type' => 'day',\n            ],\n        ]);\n    }\n\n    public function test_aggregate_endpoint_groups_by_two_groups_with_fill_gaps_argument(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $day1 = Carbon::now()->timezone($data->user->timezone)->subDays(1);\n        $day2 = Carbon::now()->timezone($data->user->timezone)->subDays(3);\n        $timeEntry1NoProject = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($day1, 10)->create();\n        $timeEntry2NoProject = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($day2, 10)->create();\n        $timeEntry1WithProject = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->forProject($project)->startWithDuration($day1, 10)->create();\n        $timeEntry2WithProject = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->forProject($project)->startWithDuration($day2, 10)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.aggregate', [\n            $data->organization->getKey(),\n            'group' => 'project',\n            'sub_group' => 'day',\n            'fill_gaps_in_time_groups' => 'true',\n            'start' => $day2->copy()->subSecond()->toIso8601ZuluString(),\n            'end' => $day1->copy()->addSecond()->toIso8601ZuluString(),\n        ]));\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertExactJson(['data' => [\n            'seconds' => 40,\n            'cost' => 0,\n            'grouped_type' => 'project',\n            'grouped_data' => [\n                0 => [\n                    'key' => $project->getKey(),\n                    'seconds' => 20,\n                    'cost' => 0,\n                    'grouped_type' => 'day',\n                    'grouped_data' => [\n                        0 => [\n                            'key' => $day2->format('Y-m-d'),\n                            'seconds' => 10,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                        1 => [\n                            'key' => $day2->copy()->addDay()->format('Y-m-d'),\n                            'seconds' => 0,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                        2 => [\n                            'key' => $day1->format('Y-m-d'),\n                            'seconds' => 10,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                    ],\n                ],\n                1 => [\n                    'key' => null,\n                    'seconds' => 20,\n                    'cost' => 0,\n                    'grouped_type' => 'day',\n                    'grouped_data' => [\n                        0 => [\n                            'key' => $day2->format('Y-m-d'),\n                            'seconds' => 10,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                        1 => [\n                            'key' => $day2->copy()->addDay()->format('Y-m-d'),\n                            'seconds' => 0,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                        2 => [\n                            'key' => $day1->format('Y-m-d'),\n                            'seconds' => 10,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                    ],\n                ],\n            ],\n        ],\n        ]);\n    }\n\n    public function test_aggregate_endpoint_groups_by_one_group(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        $week1 = Carbon::now()->timezone($data->user->timezone)->startOfWeek($data->user->week_start->carbonWeekDay());\n        $week2 = Carbon::now()->timezone($data->user->timezone)->subWeeks(2)->startOfWeek($data->user->week_start->carbonWeekDay());\n        $timeEntry1Week1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($week1->copy()->addDays(1), 10)->create();\n        $timeEntry2Week1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($week1->copy()->addDays(2), 10)->create();\n        $timeEntry1Week2 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($week2->copy()->addDays(3), 10)->create();\n        $timeEntry2Week2 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($week2->copy()->addDays(4), 10)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.aggregate', [\n            $data->organization->getKey(),\n            'group' => 'week',\n        ]));\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertExactJson([\n            'data' => [\n                'seconds' => 40,\n                'cost' => 0,\n                'grouped_type' => 'week',\n                'grouped_data' => [\n                    0 => [\n                        'key' => $week2->format('Y-m-d'),\n                        'seconds' => 20,\n                        'cost' => 0,\n                        'grouped_type' => null,\n                        'grouped_data' => null,\n                    ],\n                    1 => [\n                        'key' => $week1->format('Y-m-d'),\n                        'seconds' => 20,\n                        'cost' => 0,\n                        'grouped_type' => null,\n                        'grouped_data' => null,\n                    ],\n                ],\n            ],\n        ]);\n    }\n\n    public function test_aggregate_endpoint_groups_by_one_group_with_fill_gaps_argument(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        $laterWeekEnd = Carbon::now()->timezone($data->user->timezone)->endOfWeek($data->user->week_start->toEndOfWeek()->carbonWeekDay());\n        $earlierWeekStart = Carbon::now()->timezone($data->user->timezone)->subWeeks(2)->startOfWeek($data->user->week_start->carbonWeekDay());\n\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($laterWeekEnd->copy()->subDays(1), 10)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($laterWeekEnd->copy()->subDays(2), 10)->create();\n        $timeEntry3 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($earlierWeekStart->copy()->addDays(1), 10)->create();\n        $timeEntry4 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($earlierWeekStart->copy()->addDays(2), 10)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.aggregate', [\n            $data->organization->getKey(),\n            'group' => 'week',\n            'fill_gaps_in_time_groups' => 'true',\n            'start' => $earlierWeekStart->toIso8601ZuluString(),\n            'end' => $laterWeekEnd->toIso8601ZuluString(),\n        ]));\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertExactJson([\n            'data' => [\n                'seconds' => 40,\n                'cost' => 0,\n                'grouped_type' => 'week',\n                'grouped_data' => [\n                    0 => [\n                        'key' => $earlierWeekStart->startOfWeek($data->user->week_start->carbonWeekDay())->format('Y-m-d'),\n                        'seconds' => 20,\n                        'cost' => 0,\n                        'grouped_type' => null,\n                        'grouped_data' => null,\n                    ],\n                    1 => [\n                        'key' => $laterWeekEnd->copy()->subWeek()->startOfWeek($data->user->week_start->carbonWeekDay())->format('Y-m-d'),\n                        'seconds' => 0,\n                        'cost' => 0,\n                        'grouped_type' => null,\n                        'grouped_data' => null,\n                    ],\n                    2 => [\n                        'key' => $laterWeekEnd->startOfWeek($data->user->week_start->carbonWeekDay())->format('Y-m-d'),\n                        'seconds' => 20,\n                        'cost' => 0,\n                        'grouped_type' => null,\n                        'grouped_data' => null,\n                    ],\n                ],\n            ],\n        ]\n        );\n    }\n\n    public function test_aggregate_endpoint_with_no_group(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->createMany(3);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->forProject($project)->createMany(3);\n        $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->state([\n            'start' => $timeEntries->get(0)->start,\n        ])->createMany(3);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.aggregate', [\n            $data->organization->getKey(),\n        ]));\n\n        // Assert\n        $response->assertSuccessful();\n    }\n\n    public function test_store_endpoint_fails_if_user_has_no_permission_to_create_time_entries(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTags($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [\n            'description' => $timeEntryFake->description,\n            'billable' => $timeEntryFake->billable,\n            'start' => $timeEntryFake->start->toIso8601ZuluString(),\n            'end' => $timeEntryFake->end->toIso8601ZuluString(),\n            'tags' => $timeEntryFake->tags,\n            'member_id' => $data->member->getKey(),\n            'task_id' => $timeEntryFake->task_id,\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_store_endpoint_fails_if_user_already_has_active_time_entry_and_tries_to_start_new_one(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:create:own',\n            'projects:view:all',\n        ]);\n        $activeTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->active()->create();\n        $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->withTags($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [\n            'description' => $timeEntryFake->description,\n            'billable' => $timeEntryFake->billable,\n            'start' => $timeEntryFake->start->toIso8601ZuluString(),\n            'end' => null,\n            'tags' => $timeEntryFake->tags,\n            'member_id' => $data->member->getKey(),\n            'project_id' => $timeEntryFake->project_id,\n            'task_id' => $timeEntryFake->task_id,\n        ]);\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertJsonPath('error', true);\n    }\n\n    public function test_store_endpoint_validation_fails_if_task_id_does_not_belong_to_project_id(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:create:own',\n            'projects:view:all',\n        ]);\n        $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();\n        $timeEntryFake2 = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [\n            'description' => $timeEntryFake->description,\n            'billable' => $timeEntryFake->billable,\n            'start' => $timeEntryFake->start->toIso8601ZuluString(),\n            'end' => $timeEntryFake->end->toIso8601ZuluString(),\n            'tags' => $timeEntryFake->tags,\n            'member_id' => $data->member->getKey(),\n            'project_id' => $timeEntryFake->project_id,\n            'task_id' => $timeEntryFake2->task_id,\n        ]);\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertJsonValidationErrors([\n            'task_id' => 'The task is not part of the given project.',\n        ]);\n    }\n\n    public function test_store_endpoint_validation_fails_if_project_id_is_missing_but_request_has_task_id(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:create:own',\n            'projects:view:all',\n        ]);\n        $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();\n        $timeEntryFake2 = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [\n            'description' => $timeEntryFake->description,\n            'billable' => $timeEntryFake->billable,\n            'start' => $timeEntryFake->start->toIso8601ZuluString(),\n            'end' => $timeEntryFake->end->toIso8601ZuluString(),\n            'tags' => $timeEntryFake->tags,\n            'member_id' => $data->member->getKey(),\n            'task_id' => $timeEntryFake2->task_id,\n        ]);\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertJsonValidationErrors([\n            'project_id' => 'The project field is required when task is present.',\n            'task_id' => 'The task is not part of the given project.',\n        ]);\n    }\n\n    public function test_store_endpoint_creates_new_time_entry_for_current_user(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:create:own',\n            'projects:view:all',\n        ]);\n        $timeEntryFake = TimeEntry::factory()->withTask($data->organization)->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [\n            'description' => $timeEntryFake->description,\n            'billable' => $timeEntryFake->billable,\n            'start' => $timeEntryFake->start->toIso8601ZuluString(),\n            'end' => $timeEntryFake->end->toIso8601ZuluString(),\n            'tags' => $timeEntryFake->tags,\n            'member_id' => $data->member->getKey(),\n            'project_id' => $timeEntryFake->project_id,\n            'task_id' => $timeEntryFake->task_id,\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $response->json('data.id'),\n            'member_id' => $data->member->getKey(),\n            'task_id' => $timeEntryFake->task_id,\n        ]);\n    }\n\n    public function test_store_endpoint_fails_gracefully_if_non_uuid_text_is_in_uuid_validated_field_in_body(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:create:own',\n            'projects:view:all',\n        ]);\n        $timeEntryFake = TimeEntry::factory()->withTask($data->organization)->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [\n            'member_id' => 'non-uuid-text',\n            'tags' => ['non-uuid-text', 1],\n            'project_id' => 'non-uuid-text',\n            'task_id' => 'non-uuid-text',\n        ]);\n\n        // Assert\n        $response->assertStatus(422);\n    }\n\n    public function test_store_endpoint_fails_if_employee_tries_to_create_time_entry_for_private_project_without_access(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:create:own',\n        ]);\n\n        // Create a private project that the employee is not a member of\n        $privateProject = Project::factory()->forOrganization($data->organization)->create([\n            'is_public' => false,\n        ]);\n\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [\n            'description' => 'Test time entry',\n            'billable' => false,\n            'start' => now()->toIso8601ZuluString(),\n            'end' => now()->addHour()->toIso8601ZuluString(),\n            'member_id' => $data->member->getKey(),\n            'project_id' => $privateProject->getKey(),\n        ]);\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertJsonValidationErrors(['project_id']);\n\n        // Verify the time entry was NOT created in the database\n        $this->assertDatabaseMissing(TimeEntry::class, [\n            'project_id' => $privateProject->getKey(),\n            'member_id' => $data->member->getKey(),\n        ]);\n    }\n\n    public function test_store_endpoints_sets_billable_rate(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:create:own',\n            'projects:view:all',\n        ]);\n        $timeEntryFake = TimeEntry::factory()->withTask($data->organization)->forOrganization($data->organization)->make();\n        $project = Project::factory()->forOrganization($data->organization)->billable()->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [\n            'billable' => true,\n            'start' => $timeEntryFake->start->toIso8601ZuluString(),\n            'end' => $timeEntryFake->end->toIso8601ZuluString(),\n            'member_id' => $data->member->getKey(),\n            'project_id' => $project->getKey(),\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $response->json('data.id'),\n            'member_id' => $data->member->getKey(),\n            'task_id' => null,\n            'project_id' => $project->getKey(),\n            'billable_rate' => $project->billable_rate,\n        ]);\n    }\n\n    public function test_store_endpoint_creates_new_time_entry_with_minimal_fields(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:create:own',\n            'projects:view:all',\n        ]);\n        $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [\n            'billable' => $timeEntryFake->billable,\n            'start' => $timeEntryFake->start->toIso8601ZuluString(),\n            'member_id' => $data->member->getKey(),\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $response->json('data.id'),\n            'member_id' => $data->member->getKey(),\n            'task_id' => null,\n        ]);\n    }\n\n    public function test_store_endpoint_can_create_new_time_entry_with_project_and_automatically_set_client(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:create:own',\n            'projects:view:all',\n        ]);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [\n            'billable' => $timeEntryFake->billable,\n            'start' => $timeEntryFake->start->toIso8601ZuluString(),\n            'member_id' => $data->member->getKey(),\n            'project_id' => $project->getKey(),\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $response->json('data.id'),\n            'member_id' => $data->member->getKey(),\n            'task_id' => null,\n            'project_id' => $project->getKey(),\n            'client_id' => $client->getKey(),\n        ]);\n    }\n\n    public function test_store_endpoint_fails_if_user_has_no_permission_to_create_time_entries_for_others(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:create:own',\n            'projects:view:all',\n        ]);\n        $otherUser = User::factory()->create();\n        $otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();\n        $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [\n            'description' => $timeEntryFake->description,\n            'billable' => $timeEntryFake->billable,\n            'start' => $timeEntryFake->start->toIso8601ZuluString(),\n            'end' => $timeEntryFake->end->toIso8601ZuluString(),\n            'tags' => $timeEntryFake->tags,\n            'member_id' => $otherMember->getKey(),\n            'project_id' => $timeEntryFake->project_id,\n            'task_id' => $timeEntryFake->task_id,\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_store_endpoint_creates_new_time_entry_for_other_user_in_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:create:all',\n        ]);\n        $otherUser = User::factory()->create();\n        $otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();\n        $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [\n            'description' => $timeEntryFake->description,\n            'billable' => $timeEntryFake->billable,\n            'start' => $timeEntryFake->start->toIso8601ZuluString(),\n            'end' => $timeEntryFake->end->toIso8601ZuluString(),\n            'tags' => $timeEntryFake->tags,\n            'member_id' => $otherMember->getKey(),\n            'task_id' => $timeEntryFake->task_id,\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $response->json('data.id'),\n            'user_id' => $otherUser->getKey(),\n            'member_id' => $otherMember->getKey(),\n            'task_id' => $timeEntryFake->task_id,\n        ]);\n    }\n\n    public function test_create_endpoint_recalculates_project_and_task_spent_time_if_time_entry_has_project_and_task(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:create:own',\n            'projects:view:all',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();\n        TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->forProject($project)->forTask($task)->create();\n        $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n        Queue::fake([\n            RecalculateSpentTimeForProject::class,\n            RecalculateSpentTimeForTask::class,\n        ]);\n\n        // Act\n        $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [\n            'description' => $timeEntryFake->description,\n            'billable' => $timeEntryFake->billable,\n            'start' => Carbon::now()->toIso8601ZuluString(),\n            'end' => Carbon::now()->addHour()->toIso8601ZuluString(),\n            'member_id' => $data->member->getKey(),\n            'project_id' => $project->getKey(),\n            'task_id' => $task->getKey(),\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n        Queue::assertPushed(RecalculateSpentTimeForProject::class, 1);\n        Queue::assertPushed(RecalculateSpentTimeForTask::class, 1);\n        Queue::assertPushed(RecalculateSpentTimeForProject::class, function (RecalculateSpentTimeForProject $job) use ($project): bool {\n            return $job->project->is($project);\n        });\n        Queue::assertPushed(RecalculateSpentTimeForTask::class, function (RecalculateSpentTimeForTask $job) use ($task): bool {\n            return $job->task->is($task);\n        });\n    }\n\n    public function test_update_endpoint_fails_if_employee_tries_to_update_time_entry_to_private_project_without_access(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:own',\n        ]);\n\n        // Create a time entry for the employee\n        $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();\n\n        // Create a private project that the employee is not a member of\n        $privateProject = Project::factory()->forOrganization($data->organization)->create([\n            'is_public' => false,\n        ]);\n\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [\n            'project_id' => $privateProject->getKey(),\n        ]);\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertJsonValidationErrors(['project_id']);\n\n        // Verify the time entry was NOT updated in the database\n        $this->assertDatabaseMissing(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'project_id' => $privateProject->getKey(),\n        ]);\n    }\n\n    public function test_update_endpoint_fails_if_user_has_no_permission_to_update_own_time_entries(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();\n        $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [\n            'description' => $timeEntryFake->description,\n            'billable' => $timeEntryFake->billable,\n            'start' => $timeEntryFake->start->toIso8601ZuluString(),\n            'end' => $timeEntryFake->end->toIso8601ZuluString(),\n            'tags' => $timeEntryFake->tags,\n            'member_id' => $data->member->getKey(),\n            'task_id' => $timeEntryFake->task_id,\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_update_endpoint_fails_if_user_is_not_part_of_time_entry_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:own',\n            'projects:view:all',\n        ]);\n        $otherUser = $this->createUserWithPermission([\n            'time-entries:update:own',\n            'projects:view:all',\n        ]);\n        $timeEntry = TimeEntry::factory()->forOrganization($otherUser->organization)->forMember($otherUser->member)->create();\n        $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [\n            'description' => $timeEntryFake->description,\n            'start' => $timeEntryFake->start->toIso8601ZuluString(),\n            'end' => $timeEntryFake->end->toIso8601ZuluString(),\n            'tags' => $timeEntryFake->tags,\n            'task_id' => $timeEntryFake->task_id,\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_update_endpoint_fails_if_user_has_no_permission_to_update_time_entries_for_other_users_in_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:own',\n            'projects:view:all',\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($data->organization)->forUser($user)->role(Role::Employee)->create();\n        $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($member)->create();\n        $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [\n            'description' => $timeEntryFake->description,\n            'start' => $timeEntryFake->start->toIso8601ZuluString(),\n            'end' => $timeEntryFake->end->toIso8601ZuluString(),\n            'tags' => $timeEntryFake->tags,\n            'task_id' => $timeEntryFake->task_id,\n        ]);\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_update_endpoint_validation_fails_if_task_id_does_not_belong_to_project_id(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:own',\n            'projects:view:all',\n        ]);\n        $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();\n        $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();\n        $timeEntryFake2 = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [\n            'description' => $timeEntryFake->description,\n            'billable' => $timeEntryFake->billable,\n            'start' => $timeEntryFake->start->toIso8601ZuluString(),\n            'end' => $timeEntryFake->end->toIso8601ZuluString(),\n            'tags' => $timeEntryFake->tags,\n            'project_id' => $timeEntryFake->project_id,\n            'task_id' => $timeEntryFake2->task_id,\n        ]);\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertJsonValidationErrors([\n            'task_id' => 'The task is not part of the given project.',\n        ]);\n    }\n\n    public function test_update_endpoint_validation_fails_if_project_id_is_missing_but_request_has_task_id(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:own',\n            'projects:view:all',\n        ]);\n        $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();\n        $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();\n        $timeEntryFake2 = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [\n            'description' => $timeEntryFake->description,\n            'billable' => $timeEntryFake->billable,\n            'start' => $timeEntryFake->start->toIso8601ZuluString(),\n            'end' => $timeEntryFake->end->toIso8601ZuluString(),\n            'tags' => $timeEntryFake->tags,\n            'task_id' => $timeEntryFake2->task_id,\n        ]);\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertJsonValidationErrors([\n            'project_id' => 'The project field is required when task is present.',\n            'task_id' => 'The task is not part of the given project.',\n        ]);\n    }\n\n    public function test_update_endpoint_updates_time_entry_for_current_user(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:own',\n            'projects:view:all',\n        ]);\n        $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();\n        $timeEntryFake = TimeEntry::factory()->withTags($data->organization)->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [\n            'description' => $timeEntryFake->description,\n            'start' => $timeEntryFake->start->toIso8601ZuluString(),\n            'end' => $timeEntryFake->end->toIso8601ZuluString(),\n            'tags' => $timeEntryFake->tags,\n            'member_id' => $data->member->getKey(),\n        ]);\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'member_id' => $data->member->getKey(),\n            'task_id' => $timeEntryFake->task_id,\n        ]);\n    }\n\n    public function test_update_endpoints_sets_billable_rate(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:own',\n            'projects:view:all',\n        ]);\n        $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();\n        $timeEntryFake = TimeEntry::factory()->withTags($data->organization)->forOrganization($data->organization)->make();\n        $project = Project::factory()->forOrganization($data->organization)->billable()->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [\n            'billable' => true,\n            'start' => $timeEntryFake->start->toIso8601ZuluString(),\n            'end' => $timeEntryFake->end->toIso8601ZuluString(),\n            'member_id' => $data->member->getKey(),\n            'project_id' => $project->getKey(),\n        ]);\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'member_id' => $data->member->getKey(),\n            'task_id' => null,\n            'project_id' => $project->getKey(),\n            'billable_rate' => $project->billable_rate,\n        ]);\n    }\n\n    public function test_update_endpoint_updates_time_entry_for_current_user_but_does_not_send_member_id(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:own',\n            'projects:view:all',\n        ]);\n        $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();\n        $timeEntryFake = TimeEntry::factory()->withTags($data->organization)->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [\n            'description' => $timeEntryFake->description,\n            'start' => $timeEntryFake->start->toIso8601ZuluString(),\n            'end' => $timeEntryFake->end->toIso8601ZuluString(),\n            'tags' => $timeEntryFake->tags,\n        ]);\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'member_id' => $data->member->getKey(),\n            'task_id' => $timeEntryFake->task_id,\n        ]);\n    }\n\n    public function test_update_endpoint_fails_if_user_tries_to_reactivate_a_time_entry(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:own',\n            'projects:view:all',\n        ]);\n        $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();\n        $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [\n            'description' => $timeEntryFake->description,\n            'start' => $timeEntryFake->start->toIso8601ZuluString(),\n            'end' => null,\n            'tags' => $timeEntryFake->tags,\n            'member_id' => $data->member->getKey(),\n            'task_id' => $timeEntryFake->task_id,\n        ]);\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertJsonPath('error', true);\n        $response->assertJsonPath('message', __('exceptions.api.'.TimeEntryCanNotBeRestartedApiException::KEY));\n    }\n\n    public function test_update_endpoint_updates_time_entry_of_other_user_in_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:all',\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($data->organization)->forUser($user)->role(Role::Employee)->create();\n        $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($member)->create();\n        $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [\n            'description' => $timeEntryFake->description,\n            'start' => $timeEntryFake->start->toIso8601ZuluString(),\n            'end' => $timeEntryFake->end->toIso8601ZuluString(),\n            'tags' => $timeEntryFake->tags,\n            'member_id' => $member->getKey(),\n            'task_id' => $timeEntryFake->task_id,\n        ]);\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'member_id' => $member->getKey(),\n            'task_id' => $timeEntryFake->task_id,\n        ]);\n    }\n\n    public function test_update_endpoint_can_update_project_and_automatically_set_client(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:all',\n        ]);\n        $user = User::factory()->create();\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $member = Member::factory()->forOrganization($data->organization)->forUser($user)->role(Role::Employee)->create();\n        $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($member)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [\n            'project_id' => $project->getKey(),\n        ]);\n\n        // Assert\n        $response->assertValid();\n        $this->assertResponseCode($response, 200);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'member_id' => $member->getKey(),\n            'task_id' => $timeEntry->task_id,\n            'project_id' => $project->getKey(),\n            'client_id' => $client->getKey(),\n        ]);\n    }\n\n    public function test_update_endpoint_can_removed_project_from_time_entry_and_automatically_remove_client(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:all',\n        ]);\n        $user = User::factory()->create();\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();\n        $member = Member::factory()->forOrganization($data->organization)->forUser($user)->role(Role::Employee)->create();\n        $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($member)->forProject($project)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [\n            'project_id' => null,\n        ]);\n\n        // Assert\n        $response->assertValid();\n        $this->assertResponseCode($response, 200);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'member_id' => $member->getKey(),\n            'task_id' => null,\n            'project_id' => null,\n            'client_id' => null,\n        ]);\n    }\n\n    public function test_update_endpoint_recalculates_project_and_task_spend_time_after_updating_time_entry_settings_a_project_and_a_task(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:own',\n            'projects:view:all',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();\n        $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forProject(null)->forTask(null)->forMember($data->member)->create();\n        TimeEntry::factory()->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n        Queue::fake([\n            RecalculateSpentTimeForProject::class,\n            RecalculateSpentTimeForTask::class,\n        ]);\n\n        // Act\n        $response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [\n            'project_id' => $project->getKey(),\n            'task_id' => $task->getKey(),\n        ]);\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        Queue::assertPushed(RecalculateSpentTimeForProject::class, 1);\n        Queue::assertPushed(RecalculateSpentTimeForTask::class, 1);\n        Queue::assertPushed(function (RecalculateSpentTimeForProject $job) use ($project): bool {\n            return $job->project->is($project);\n        }, 1);\n        Queue::assertPushed(function (RecalculateSpentTimeForTask $job) use ($task): bool {\n            return $job->task->is($task);\n        }, 1);\n    }\n\n    public function test_update_endpoint_recalculates_project_and_task_spend_time_after_updating_time_entry_settings_a_new_project_and_a_new_task(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:own',\n            'projects:view:all',\n        ]);\n        $oldProject = Project::factory()->forOrganization($data->organization)->create();\n        $oldTask = Task::factory()->forOrganization($data->organization)->forProject($oldProject)->create();\n        $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forProject($oldProject)->forTask($oldTask)->forMember($data->member)->create();\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();\n        TimeEntry::factory()->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n        Queue::fake([\n            RecalculateSpentTimeForProject::class,\n            RecalculateSpentTimeForTask::class,\n        ]);\n\n        // Act\n        $response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [\n            'project_id' => $project->getKey(),\n            'task_id' => $task->getKey(),\n        ]);\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        Queue::assertPushed(RecalculateSpentTimeForProject::class, 2);\n        Queue::assertPushed(RecalculateSpentTimeForTask::class, 2);\n        Queue::assertPushed(function (RecalculateSpentTimeForProject $job) use ($project): bool {\n            return $job->project->is($project);\n        }, 1);\n        Queue::assertPushed(function (RecalculateSpentTimeForProject $job) use ($oldProject): bool {\n            return $job->project->is($oldProject);\n        }, 1);\n        Queue::assertPushed(function (RecalculateSpentTimeForTask $job) use ($task): bool {\n            return $job->task->is($task);\n        }, 1);\n        Queue::assertPushed(function (RecalculateSpentTimeForTask $job) use ($oldTask): bool {\n            return $job->task->is($oldTask);\n        }, 1);\n    }\n\n    public function test_destroy_endpoint_fails_if_user_tries_to_delete_time_entry_in_organization_that_they_does_belong_to(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:delete:all',\n        ]);\n        $otherUser = $this->createUserWithPermission([\n            'time-entries:delete:all',\n        ]);\n        $timeEntry = TimeEntry::factory()->forOrganization($otherUser->organization)->forMember($otherUser->member)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.time-entries.destroy', [$data->organization->getKey(), $timeEntry->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_destroy_endpoint_fails_if_user_tries_to_delete_non_existing_time_entry(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:delete:own',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.time-entries.destroy', [$data->organization->getKey(), Str::uuid()]));\n\n        // Assert\n        $response->assertStatus(404);\n    }\n\n    public function test_destroy_endpoint_fails_if_user_has_no_permission_to_delete_own_time_entries(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.time-entries.destroy', [$data->organization->getKey(), $timeEntry->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_destroy_endpoint_fails_if_user_has_no_permission_to_delete_time_entries_for_other_users_in_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:delete:own',\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($data->organization)->forUser($user)->role(Role::Employee)->create();\n        $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($member)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.time-entries.destroy', [$data->organization->getKey(), $timeEntry->getKey()]));\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_destroy_endpoint_deletes_own_time_entry(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:delete:own',\n        ]);\n        $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.time-entries.destroy', [$data->organization->getKey(), $timeEntry->getKey()]));\n\n        // Assert\n        $response->assertStatus(204);\n        $response->assertNoContent();\n        $this->assertDatabaseMissing(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n        ]);\n    }\n\n    public function test_destroy_endpoint_deletes_time_entry_of_other_user_in_organization(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:delete:all',\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($data->organization)->forUser($user)->role(Role::Employee)->create();\n        $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($member)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.time-entries.destroy', [$data->organization->getKey(), $timeEntry->getKey()]));\n\n        // Assert\n        $response->assertStatus(204);\n        $response->assertNoContent();\n        $this->assertDatabaseMissing(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n        ]);\n    }\n\n    public function test_destroy_multiple_endpoint_fails_if_user_has_no_permission_to_delete_own_time_entries_or_all_time_entries(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->createMany(3);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.time-entries.destroy-multiple', [$data->organization->getKey()]), [\n            'ids' => $timeEntries->pluck('id')->toArray(),\n        ]);\n\n        // Assert\n        $response->assertValid();\n        $response->assertForbidden();\n    }\n\n    public function test_destroy_multiple_endpoint_fails_if_ids_contains_non_uuid_id(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:delete:own',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.time-entries.destroy-multiple', [$data->organization->getKey()]), [\n            'ids' => [\n                Str::uuid(),\n                'non-uuid',\n            ],\n        ]);\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertJsonValidationErrors([\n            'ids.1' => ['The ids.1 field must be a valid UUID.'],\n        ]);\n    }\n\n    public function test_destroy_multiple_endpoint_own_time_entries_and_fails_for_time_entries_of_other_users_and_and_other_organizations_with_own_time_entries_permission(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:delete:own',\n        ]);\n        $otherData = $this->createUserWithPermission();\n        $otherUser = User::factory()->create();\n        $otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();\n\n        $ownTimeEntry = TimeEntry::factory()->forMember($data->member)->create();\n        $otherTimeEntry = TimeEntry::factory()->forMember($otherMember)->create();\n        $otherOrganizationTimeEntry = TimeEntry::factory()->forMember($otherData->member)->create();\n        $wrongId = Str::uuid();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.time-entries.destroy-multiple', [$data->organization->getKey()]), [\n            'ids' => [\n                $ownTimeEntry->getKey(),\n                $otherTimeEntry->getKey(),\n                $otherOrganizationTimeEntry->getKey(),\n                $wrongId,\n            ],\n        ]);\n\n        // Assert\n        $response->assertValid();\n        $this->assertResponseCode($response, 200);\n        $response->assertExactJson([\n            'success' => [\n                $ownTimeEntry->getKey(),\n            ],\n            'error' => [\n                $otherTimeEntry->getKey(),\n                $otherOrganizationTimeEntry->getKey(),\n                $wrongId,\n            ],\n        ]);\n        $this->assertDatabaseMissing(TimeEntry::class, [\n            'id' => $ownTimeEntry->getKey(),\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $otherTimeEntry->getKey(),\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $otherOrganizationTimeEntry->getKey(),\n        ]);\n    }\n\n    public function test_destroy_multiple_deletes_all_time_entries_and_fails_for_time_entries_of_other_users_and_and_other_organizations_with_all_time_entries_permission(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:delete:all',\n        ]);\n        $otherData = $this->createUserWithPermission();\n        $otherUser = User::factory()->create();\n        $otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();\n\n        $ownTimeEntry = TimeEntry::factory()->forMember($data->member)->create();\n        $otherTimeEntry = TimeEntry::factory()->forMember($otherMember)->create();\n        $otherOrganizationTimeEntry = TimeEntry::factory()->forMember($otherData->member)->create();\n        $wrongId = Str::uuid();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.time-entries.destroy-multiple', [$data->organization->getKey()]), [\n            'ids' => [\n                $ownTimeEntry->getKey(),\n                $otherTimeEntry->getKey(),\n                $otherOrganizationTimeEntry->getKey(),\n                $wrongId,\n            ],\n        ]);\n\n        // Assert\n        $response->assertValid();\n        $this->assertResponseCode($response, 200);\n        $response->assertExactJson([\n            'success' => [\n                $ownTimeEntry->getKey(),\n                $otherTimeEntry->getKey(),\n            ],\n            'error' => [\n                $otherOrganizationTimeEntry->getKey(),\n                $wrongId,\n            ],\n        ]);\n        $this->assertDatabaseMissing(TimeEntry::class, [\n            'id' => $ownTimeEntry->getKey(),\n        ]);\n        $this->assertDatabaseMissing(TimeEntry::class, [\n            'id' => $otherTimeEntry->getKey(),\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $otherOrganizationTimeEntry->getKey(),\n        ]);\n    }\n\n    public function test_destroy_multiple_recalculates_project_and_task_spend_time_after_deleting_time_entries(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:delete:all',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $task = Task::factory()->forOrganization($data->organization)->create();\n        $timeEntryWithProject = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->create();\n        $timeEntryWithTask = TimeEntry::factory()->forOrganization($data->organization)->forTask($task)->forMember($data->member)->create();\n        $timeEntryWithProjectAndTask = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forTask($task)->forMember($data->member)->create();\n        $timeEntryWithoutProjectAndTask = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();\n        Passport::actingAs($data->user);\n        Queue::fake([\n            RecalculateSpentTimeForProject::class,\n            RecalculateSpentTimeForTask::class,\n        ]);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.time-entries.destroy-multiple', [$data->organization->getKey()]), [\n            'ids' => [\n                $timeEntryWithProject->getKey(),\n                $timeEntryWithTask->getKey(),\n                $timeEntryWithProjectAndTask->getKey(),\n                $timeEntryWithoutProjectAndTask->getKey(),\n            ],\n        ]);\n\n        // Assert\n        $response->assertValid();\n        $this->assertResponseCode($response, 200);\n        $response->assertExactJson([\n            'success' => [\n                $timeEntryWithProject->getKey(),\n                $timeEntryWithTask->getKey(),\n                $timeEntryWithProjectAndTask->getKey(),\n                $timeEntryWithoutProjectAndTask->getKey(),\n            ],\n            'error' => [\n            ],\n        ]);\n        Queue::assertPushed(RecalculateSpentTimeForProject::class, 3);\n        Queue::assertPushed(RecalculateSpentTimeForTask::class, 2);\n        Queue::assertPushed(RecalculateSpentTimeForProject::class, function (RecalculateSpentTimeForProject $job) use ($project) {\n            return $job->project->is($project);\n        });\n        Queue::assertPushed(RecalculateSpentTimeForTask::class, function (RecalculateSpentTimeForTask $job) use ($task) {\n            return $job->task->is($task);\n        });\n    }\n\n    public function test_destroy_endpoint_recalculates_project_and_task_spend_time_after_deleting_time_entry(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:delete:own',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $task = Task::factory()->forOrganization($data->organization)->create();\n        $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forTask($task)->forMember($data->member)->create();\n        $project = $timeEntry->project;\n        $task = $timeEntry->task;\n        Passport::actingAs($data->user);\n        Queue::fake([\n            RecalculateSpentTimeForProject::class,\n            RecalculateSpentTimeForTask::class,\n        ]);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.time-entries.destroy', [$data->organization->getKey(), $timeEntry->getKey()]));\n\n        // Assert\n        $response->assertStatus(204);\n        $response->assertNoContent();\n        $this->assertDatabaseMissing(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n        ]);\n        Queue::assertPushed(RecalculateSpentTimeForProject::class, 1);\n        Queue::assertPushed(RecalculateSpentTimeForTask::class, 1);\n        Queue::assertPushed(RecalculateSpentTimeForProject::class, function (RecalculateSpentTimeForProject $job) use ($project) {\n            return $job->project->is($project);\n        });\n        Queue::assertPushed(RecalculateSpentTimeForTask::class, function (RecalculateSpentTimeForTask $job) use ($task) {\n            return $job->task->is($task);\n        });\n    }\n\n    public function test_destroy_endpoint_does_not_recalculate_project_and_task_spend_time_after_deleting_time_entry_if_time_entry_had_no_project_and_task(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:delete:own',\n        ]);\n        $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forProject(null)->forTask(null)->forMember($data->member)->create();\n        Passport::actingAs($data->user);\n        Queue::fake([\n            RecalculateSpentTimeForProject::class,\n            RecalculateSpentTimeForTask::class,\n        ]);\n\n        // Act\n        $response = $this->deleteJson(route('api.v1.time-entries.destroy', [$data->organization->getKey(), $timeEntry->getKey()]));\n\n        // Assert\n        $response->assertStatus(204);\n        $response->assertNoContent();\n        $this->assertDatabaseMissing(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n        ]);\n        Queue::assertNotPushed(RecalculateSpentTimeForProject::class);\n        Queue::assertNotPushed(RecalculateSpentTimeForTask::class);\n    }\n\n    public function test_update_multiple_endpoint_fails_if_user_has_no_permission_to_update_own_time_entries_or_all_time_entries(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->createMany(3);\n        $timeEntriesFake = TimeEntry::factory()->forOrganization($data->organization)->make();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [\n            'ids' => $timeEntries->pluck('id')->toArray(),\n            'changes' => [\n                'description' => $timeEntriesFake->description,\n            ],\n        ]);\n\n        // Assert\n        $response->assertValid();\n        $response->assertForbidden();\n    }\n\n    public function test_update_multiple_endpoint_fails_if_employee_tries_to_update_time_entries_to_private_project_without_access(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:own',\n        ]);\n\n        // Create time entries for the employee\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();\n\n        // Create a private project that the employee is not a member of\n        $privateProject = Project::factory()->forOrganization($data->organization)->create([\n            'is_public' => false,\n        ]);\n\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [\n            'ids' => [$timeEntry1->getKey(), $timeEntry2->getKey()],\n            'changes' => [\n                'project_id' => $privateProject->getKey(),\n            ],\n        ]);\n\n        // Assert\n        $response->assertStatus(422);\n        $response->assertJsonValidationErrors(['changes.project_id']);\n\n        // Verify the time entries were NOT updated in the database\n        $this->assertDatabaseMissing(TimeEntry::class, [\n            'id' => $timeEntry1->getKey(),\n            'project_id' => $privateProject->getKey(),\n        ]);\n        $this->assertDatabaseMissing(TimeEntry::class, [\n            'id' => $timeEntry2->getKey(),\n            'project_id' => $privateProject->getKey(),\n        ]);\n    }\n\n    public function test_update_multiple_remove_task_from_time_entries_only_if_project_is_set_to_a_new_value_without_setting_a_new_task(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:own',\n            'projects:view:all',\n        ]);\n        $project1 = Project::factory()->forOrganization($data->organization)->create();\n        $project2 = Project::factory()->forOrganization($data->organization)->create();\n        $task1 = Task::factory()->forProject($project1)->forOrganization($data->organization)->create();\n        $task2 = Task::factory()->forProject($project2)->forOrganization($data->organization)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project1)->forTask($task1)->forMember($data->member)->create();\n        $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project2)->forTask($task2)->forMember($data->member)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [\n            'ids' => [\n                $timeEntry1->getKey(),\n                $timeEntry2->getKey(),\n            ],\n            'changes' => [\n                'project_id' => $project2->getKey(),\n            ],\n        ]);\n\n        // Assert\n        $response->assertValid();\n        $this->assertResponseCode($response, 200);\n        $response->assertExactJson([\n            'success' => [\n                $timeEntry1->getKey(),\n                $timeEntry2->getKey(),\n            ],\n            'error' => [],\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry1->getKey(),\n            'project_id' => $project2->getKey(),\n            'task_id' => null,\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry2->getKey(),\n            'project_id' => $project2->getKey(),\n            'task_id' => $task2->getKey(),\n        ]);\n    }\n\n    public function test_update_multiple_updates_own_time_entries_and_fails_for_time_entries_of_other_users_and_and_other_organizations_with_own_time_entries_permission(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:own',\n            'projects:view:all',\n        ]);\n        $otherData = $this->createUserWithPermission();\n        $otherUser = User::factory()->create();\n        $otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();\n\n        $ownTimeEntry = TimeEntry::factory()->forMember($data->member)->create();\n        $otherTimeEntry = TimeEntry::factory()->forMember($otherMember)->create();\n        $otherOrganizationTimeEntry = TimeEntry::factory()->forMember($otherData->member)->create();\n        $timeEntriesFake = TimeEntry::factory()->forOrganization($data->organization)->make();\n        $wrongId = Str::uuid();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [\n            'ids' => [\n                $ownTimeEntry->getKey(),\n                $otherTimeEntry->getKey(),\n                $otherOrganizationTimeEntry->getKey(),\n                $wrongId,\n            ],\n            'changes' => [\n                'description' => $timeEntriesFake->description,\n            ],\n        ]);\n\n        // Assert\n        $response->assertValid();\n        $this->assertResponseCode($response, 200);\n        $response->assertExactJson([\n            'success' => [\n                $ownTimeEntry->getKey(),\n            ],\n            'error' => [\n                $otherTimeEntry->getKey(),\n                $otherOrganizationTimeEntry->getKey(),\n                $wrongId,\n            ],\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $ownTimeEntry->getKey(),\n            'description' => $timeEntriesFake->description,\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $otherOrganizationTimeEntry->getKey(),\n            'description' => $otherOrganizationTimeEntry->description,\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $otherTimeEntry->getKey(),\n            'description' => $otherTimeEntry->description,\n        ]);\n    }\n\n    public function test_update_multiple_updates_own_time_entries_and_fails_for_time_entries_of_other_users_and_and_other_organizations_with_own_time_entries_permission_and_full_changeset(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:own',\n            'projects:view:all',\n        ]);\n        $otherData = $this->createUserWithPermission();\n        $otherUser = User::factory()->create();\n        $otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();\n\n        $ownTimeEntry = TimeEntry::factory()->forMember($data->member)->create();\n        $otherTimeEntry = TimeEntry::factory()->forMember($otherMember)->create();\n        $otherOrganizationTimeEntry = TimeEntry::factory()->forMember($otherData->member)->create();\n        $timeEntriesFake = TimeEntry::factory()->forOrganization($data->organization)->withTags($data->organization)->make();\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $task = Task::factory()->forProject($project)->forOrganization($data->organization)->create();\n        $wrongId = Str::uuid();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [\n            'ids' => [\n                $ownTimeEntry->getKey(),\n                $otherTimeEntry->getKey(),\n                $otherOrganizationTimeEntry->getKey(),\n                $wrongId,\n            ],\n            'changes' => [\n                'member_id' => $data->member->getKey(),\n                'project_id' => $project->getKey(),\n                'task_id' => $task->getKey(),\n                'billable' => $timeEntriesFake->billable,\n                'description' => $timeEntriesFake->description,\n                'tags' => $timeEntriesFake->tags,\n            ],\n        ]);\n\n        // Assert\n        $response->assertValid();\n        $this->assertResponseCode($response, 200);\n        $response->assertExactJson([\n            'success' => [\n                $ownTimeEntry->getKey(),\n            ],\n            'error' => [\n                $otherTimeEntry->getKey(),\n                $otherOrganizationTimeEntry->getKey(),\n                $wrongId,\n            ],\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $ownTimeEntry->getKey(),\n            'member_id' => $data->member->getKey(),\n            'project_id' => $project->getKey(),\n            'task_id' => $task->getKey(),\n            'billable' => $timeEntriesFake->billable,\n            'description' => $timeEntriesFake->description,\n            'tags' => json_encode($timeEntriesFake->tags),\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $otherOrganizationTimeEntry->getKey(),\n            'member_id' => $otherOrganizationTimeEntry->member_id,\n            'project_id' => $otherOrganizationTimeEntry->project_id,\n            'task_id' => $otherOrganizationTimeEntry->task_id,\n            'billable' => $otherOrganizationTimeEntry->billable,\n            'description' => $otherOrganizationTimeEntry->description,\n            'tags' => json_encode($otherOrganizationTimeEntry->tags),\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $otherTimeEntry->getKey(),\n            'member_id' => $otherTimeEntry->member_id,\n            'project_id' => $otherTimeEntry->project_id,\n            'task_id' => $otherTimeEntry->task_id,\n            'billable' => $otherTimeEntry->billable,\n            'description' => $otherTimeEntry->description,\n            'tags' => json_encode($otherTimeEntry->tags),\n        ]);\n    }\n\n    public function test_update_multiple_updates_sets_description_to_empty_if_the_client_sends_null(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:own',\n            'projects:view:all',\n        ]);\n        $timeEntry1 = TimeEntry::factory()->forMember($data->member)->create([\n            'description' => '',\n        ]);\n        $timeEntry2 = TimeEntry::factory()->forMember($data->member)->create([\n            'description' => 'test',\n        ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [\n            'ids' => [\n                $timeEntry1->getKey(),\n                $timeEntry2->getKey(),\n            ],\n            'changes' => [\n                'description' => null,\n            ],\n        ]);\n\n        // Assert\n        $response->assertValid();\n        $response->assertStatus(200);\n        $response->assertExactJson([\n            'success' => [\n                $timeEntry1->getKey(),\n                $timeEntry2->getKey(),\n            ],\n            'error' => [],\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry1->getKey(),\n            'description' => '',\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry2->getKey(),\n            'description' => '',\n        ]);\n    }\n\n    public function test_update_multiple_updates_all_time_entries_and_fails_for_time_entries_of_other_users_and_and_other_organizations_with_all_time_entries_permission(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:all',\n        ]);\n        $otherData = $this->createUserWithPermission();\n        $otherUser = User::factory()->create();\n        $otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();\n\n        $ownTimeEntry = TimeEntry::factory()->forMember($data->member)->create();\n        $otherTimeEntry = TimeEntry::factory()->forMember($otherMember)->create();\n        $otherOrganizationTimeEntry = TimeEntry::factory()->forMember($otherData->member)->create();\n        $timeEntriesFake = TimeEntry::factory()->forOrganization($data->organization)->make();\n        $wrongId = Str::uuid();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [\n            'ids' => [\n                $ownTimeEntry->getKey(),\n                $otherTimeEntry->getKey(),\n                $otherOrganizationTimeEntry->getKey(),\n                $wrongId,\n            ],\n            'changes' => [\n                'description' => $timeEntriesFake->description,\n            ],\n        ]);\n\n        // Assert\n        $response->assertValid();\n        $this->assertResponseCode($response, 200);\n        $response->assertExactJson([\n            'success' => [\n                $ownTimeEntry->getKey(),\n                $otherTimeEntry->getKey(),\n            ],\n            'error' => [\n                $otherOrganizationTimeEntry->getKey(),\n                $wrongId,\n            ],\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $ownTimeEntry->getKey(),\n            'description' => $timeEntriesFake->description,\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $otherOrganizationTimeEntry->getKey(),\n            'description' => $otherOrganizationTimeEntry->description,\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $otherTimeEntry->getKey(),\n            'description' => $timeEntriesFake->description,\n        ]);\n    }\n\n    public function test_update_multiple_updates_all_time_entries_and_fails_for_time_entries_of_other_users_and_and_other_organizations_with_all_time_entries_permission_and_full_changeset(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:all',\n        ]);\n        $otherData = $this->createUserWithPermission();\n        $otherUser = User::factory()->create();\n        $otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();\n\n        $ownTimeEntry = TimeEntry::factory()->forMember($data->member)->create();\n        $otherTimeEntry = TimeEntry::factory()->forMember($otherMember)->create();\n        $otherOrganizationTimeEntry = TimeEntry::factory()->forMember($otherData->member)->create();\n        $timeEntriesFake = TimeEntry::factory()->forOrganization($data->organization)->withTags($data->organization)->make();\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $task = Task::factory()->forProject($project)->forOrganization($data->organization)->create();\n        $wrongId = Str::uuid();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->withoutExceptionHandling()->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [\n            'ids' => [\n                $ownTimeEntry->getKey(),\n                $otherTimeEntry->getKey(),\n                $otherOrganizationTimeEntry->getKey(),\n                $wrongId,\n            ],\n            'changes' => [\n                'member_id' => $otherMember->getKey(),\n                'project_id' => $project->getKey(),\n                'task_id' => $task->getKey(),\n                'billable' => $timeEntriesFake->billable,\n                'description' => $timeEntriesFake->description,\n                'tags' => $timeEntriesFake->tags,\n            ],\n        ]);\n\n        // Assert\n        $response->assertValid();\n        $this->assertResponseCode($response, 200);\n        $response->assertExactJson([\n            'success' => [\n                $ownTimeEntry->getKey(),\n                $otherTimeEntry->getKey(),\n            ],\n            'error' => [\n                $otherOrganizationTimeEntry->getKey(),\n                $wrongId,\n            ],\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $ownTimeEntry->getKey(),\n            'member_id' => $otherMember->getKey(),\n            'project_id' => $project->getKey(),\n            'task_id' => $task->getKey(),\n            'billable' => $timeEntriesFake->billable,\n            'description' => $timeEntriesFake->description,\n            'tags' => json_encode($timeEntriesFake->tags),\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $otherOrganizationTimeEntry->getKey(),\n            'member_id' => $otherOrganizationTimeEntry->member_id,\n            'project_id' => $otherOrganizationTimeEntry->project_id,\n            'task_id' => $otherOrganizationTimeEntry->task_id,\n            'billable' => $otherOrganizationTimeEntry->billable,\n            'description' => $otherOrganizationTimeEntry->description,\n            'tags' => json_encode($otherOrganizationTimeEntry->tags),\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $otherTimeEntry->getKey(),\n            'member_id' => $otherMember->getKey(),\n            'project_id' => $project->getKey(),\n            'task_id' => $task->getKey(),\n            'billable' => $timeEntriesFake->billable,\n            'description' => $timeEntriesFake->description,\n            'tags' => json_encode($timeEntriesFake->tags),\n        ]);\n    }\n\n    public function test_store_endpoint_blocks_overlapping_entries_when_start_overlaps(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:create:own',\n            'projects:view:all',\n        ]);\n        $data->organization->prevent_overlapping_time_entries = true;\n        $data->organization->save();\n        $baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');\n        $baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC');\n        TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)\n            ->create([\n                'start' => $baseStart,\n                'end' => $baseEnd,\n            ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [\n            'member_id' => $data->member->getKey(),\n            'billable' => true,\n            'start' => $baseStart->copy()->addMinutes(30)->toIso8601ZuluString(),\n            'end' => $baseEnd->copy()->addMinutes(30)->toIso8601ZuluString(),\n        ]);\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertExactJson([\n            'error' => true,\n            'key' => 'overlapping_time_entry',\n            'message' => 'Overlapping time entries are not allowed.',\n        ]);\n    }\n\n    public function test_store_endpoint_blocks_overlapping_entries_when_end_overlaps(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:create:own',\n            'projects:view:all',\n        ]);\n        $data->organization->prevent_overlapping_time_entries = true;\n        $data->organization->save();\n        $baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');\n        $baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC');\n        TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)\n            ->create([\n                'start' => $baseStart,\n                'end' => $baseEnd,\n            ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [\n            'member_id' => $data->member->getKey(),\n            'billable' => true,\n            'start' => $baseStart->copy()->subMinutes(30)->toIso8601ZuluString(),\n            'end' => $baseStart->copy()->addMinutes(30)->toIso8601ZuluString(),\n        ]);\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertExactJson([\n            'error' => true,\n            'key' => 'overlapping_time_entry',\n            'message' => 'Overlapping time entries are not allowed.',\n        ]);\n    }\n\n    public function test_store_endpoint_blocks_overlapping_entries_when_new_entry_is_within_existing(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:create:own',\n            'projects:view:all',\n        ]);\n        $data->organization->prevent_overlapping_time_entries = true;\n        $data->organization->save();\n        $baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');\n        $baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC');\n        TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)\n            ->create([\n                'start' => $baseStart,\n                'end' => $baseEnd,\n            ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [\n            'member_id' => $data->member->getKey(),\n            'billable' => true,\n            'start' => $baseStart->copy()->addMinutes(15)->toIso8601ZuluString(),\n            'end' => $baseStart->copy()->addMinutes(45)->toIso8601ZuluString(),\n        ]);\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertExactJson([\n            'error' => true,\n            'key' => 'overlapping_time_entry',\n            'message' => 'Overlapping time entries are not allowed.',\n        ]);\n    }\n\n    public function test_store_endpoint_blocks_overlapping_entries_when_new_entry_surrounds_existing(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:create:own',\n            'projects:view:all',\n        ]);\n        $data->organization->prevent_overlapping_time_entries = true;\n        $data->organization->save();\n        $baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');\n        $baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC');\n        TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)\n            ->create([\n                'start' => $baseStart,\n                'end' => $baseEnd,\n            ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [\n            'member_id' => $data->member->getKey(),\n            'billable' => true,\n            'start' => $baseStart->copy()->subMinutes(30)->toIso8601ZuluString(),\n            'end' => $baseEnd->copy()->addMinutes(30)->toIso8601ZuluString(),\n        ]);\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertExactJson([\n            'error' => true,\n            'key' => 'overlapping_time_entry',\n            'message' => 'Overlapping time entries are not allowed.',\n        ]);\n    }\n\n    public function test_store_endpoint_blocks_starting_active_entry_when_it_overlaps_with_existing(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:create:own',\n            'projects:view:all',\n        ]);\n        $data->organization->prevent_overlapping_time_entries = true;\n        $data->organization->save();\n        $baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');\n        $baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC');\n        TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)\n            ->create([\n                'start' => $baseStart,\n                'end' => $baseEnd,\n            ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [\n            'member_id' => $data->member->getKey(),\n            'billable' => true,\n            'start' => $baseStart->copy()->addMinutes(30)->toIso8601ZuluString(),\n            'end' => null,\n        ]);\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertExactJson([\n            'error' => true,\n            'key' => 'overlapping_time_entry',\n            'message' => 'Overlapping time entries are not allowed.',\n        ]);\n    }\n\n    public function test_store_endpoint_allows_future_time_entries_even_with_running_now(): void\n    {\n        // Arrange\n        $now = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');\n        $this->travelTo($now);\n        $data = $this->createUserWithPermission([\n            'time-entries:create:own',\n            'projects:view:all',\n        ]);\n        $data->organization->prevent_overlapping_time_entries = true;\n        $data->organization->save();\n        TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)\n            ->create([\n                'start' => $now->copy()->subHour(),\n                'end' => null,\n            ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [\n            'member_id' => $data->member->getKey(),\n            'billable' => true,\n            'start' => $now->copy()->addDay()->toIso8601ZuluString(),\n            'end' => $now->copy()->addDay()->addHour()->toIso8601ZuluString(),\n        ]);\n\n        // Assert\n        $response->assertStatus(201);\n    }\n\n    public function test_update_endpoint_blocks_overlap_and_excludes_current_entry(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:own',\n            'projects:view:all',\n        ]);\n        $data->organization->prevent_overlapping_time_entries = true;\n        $data->organization->save();\n        $baseStart = Carbon::create(2025, 1, 1, 14, 0, 0, 'UTC');\n        $baseEnd = Carbon::create(2025, 1, 1, 15, 0, 0, 'UTC');\n        $base = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)\n            ->create([\n                'start' => $baseStart,\n                'end' => $baseEnd,\n            ]);\n        $toUpdate = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)\n            ->create([\n                'start' => $baseEnd->copy()->addMinutes(30),\n                'end' => $baseEnd->copy()->addHour(),\n            ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $toUpdate->getKey()]), [\n            'start' => $baseStart->copy()->addMinutes(30)->toIso8601ZuluString(),\n        ]);\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertExactJson([\n            'error' => true,\n            'key' => 'overlapping_time_entry',\n            'message' => 'Overlapping time entries are not allowed.',\n        ]);\n    }\n\n    public function test_update_multiple_refreshes_billable_rate_on_updates_time_entries(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:all',\n        ]);\n\n        $oldProject = Project::factory()->forOrganization($data->organization)->billable()->create();\n        $timeEntry1 = TimeEntry::factory()->forMember($data->member)->forProject($oldProject)->billable()->create();\n        $timeEntry2 = TimeEntry::factory()->forMember($data->member)->forProject($oldProject)->notBillable()->create();\n        $project = Project::factory()->billable()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [\n            'ids' => [\n                $timeEntry1->getKey(),\n                $timeEntry2->getKey(),\n            ],\n            'changes' => [\n                'project_id' => $project->getKey(),\n            ],\n        ]);\n\n        // Assert\n        $response->assertValid();\n        $this->assertResponseCode($response, 200);\n        $response->assertExactJson([\n            'success' => [\n                $timeEntry1->getKey(),\n                $timeEntry2->getKey(),\n            ],\n            'error' => [\n            ],\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry1->getKey(),\n            'project_id' => $project->getKey(),\n            'billable' => true,\n            'billable_rate' => $project->billable_rate,\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry2->getKey(),\n            'project_id' => $project->getKey(),\n            'billable' => false,\n            'billable_rate' => null,\n        ]);\n    }\n\n    public function test_update_multiple_ignores_other_fields_in_changes(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:all',\n        ]);\n        $timeEntry1 = TimeEntry::factory()->forMember($data->member)->create();\n        $timeEntry2 = TimeEntry::factory()->forMember($data->member)->create();\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [\n            'ids' => [\n                $timeEntry1->getKey(),\n                $timeEntry2->getKey(),\n            ],\n            'changes' => [\n                'project_id' => $project->getKey(),\n                'other_field' => 'test123',\n            ],\n        ]);\n\n        // Assert\n        $response->assertValid();\n        $this->assertResponseCode($response, 200);\n        $response->assertExactJson([\n            'success' => [\n                $timeEntry1->getKey(),\n                $timeEntry2->getKey(),\n            ],\n            'error' => [\n            ],\n        ]);\n    }\n\n    public function test_update_multiple_can_update_project_and_sets_client_automatically(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:all',\n        ]);\n\n        $oldClient = Client::factory()->forOrganization($data->organization)->create();\n        $oldProject = Project::factory()->forOrganization($data->organization)->forClient($oldClient)->create();\n        $timeEntry1 = TimeEntry::factory()->forMember($data->member)->forProject($oldProject)->create();\n        $timeEntry2 = TimeEntry::factory()->forMember($data->member)->create();\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forClient($client)->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [\n            'ids' => [\n                $timeEntry1->getKey(),\n                $timeEntry2->getKey(),\n            ],\n            'changes' => [\n                'project_id' => $project->getKey(),\n            ],\n        ]);\n\n        // Assert\n        $response->assertValid();\n        $this->assertResponseCode($response, 200);\n        $response->assertExactJson([\n            'success' => [\n                $timeEntry1->getKey(),\n                $timeEntry2->getKey(),\n            ],\n            'error' => [\n            ],\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry1->getKey(),\n            'client_id' => $client->getKey(),\n            'project_id' => $project->getKey(),\n            'task_id' => null,\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry2->getKey(),\n            'client_id' => $client->getKey(),\n            'project_id' => $project->getKey(),\n            'task_id' => null,\n        ]);\n    }\n\n    public function test_update_multiple_can_remove_project_from_time_entries_and_sets_client_automatically(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:all',\n        ]);\n\n        $oldClient = Client::factory()->forOrganization($data->organization)->create();\n        $oldProject = Project::factory()->forOrganization($data->organization)->forClient($oldClient)->create();\n        $timeEntry1 = TimeEntry::factory()->forMember($data->member)->forProject($oldProject)->create();\n        $timeEntry2 = TimeEntry::factory()->forMember($data->member)->create();\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $project = Project::factory()->forClient($client)->forOrganization($data->organization)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [\n            'ids' => [\n                $timeEntry1->getKey(),\n                $timeEntry2->getKey(),\n            ],\n            'changes' => [\n                'project_id' => null,\n            ],\n        ]);\n\n        // Assert\n        $response->assertValid();\n        $this->assertResponseCode($response, 200);\n        $response->assertExactJson([\n            'success' => [\n                $timeEntry1->getKey(),\n                $timeEntry2->getKey(),\n            ],\n            'error' => [\n            ],\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry1->getKey(),\n            'client_id' => null,\n            'project_id' => null,\n            'task_id' => null,\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry2->getKey(),\n            'client_id' => null,\n            'project_id' => null,\n            'task_id' => null,\n        ]);\n    }\n\n    public function test_update_multiple_updates_own_time_entries_fails_if_member_id_is_not_your_own_and_you_dont_have_update_all_permission(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:update:own',\n            'projects:view:all',\n        ]);\n        $otherUser = User::factory()->create();\n        $otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();\n\n        $ownTimeEntry = TimeEntry::factory()->forMember($data->member)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [\n            'ids' => [\n                $ownTimeEntry->getKey(),\n            ],\n            'changes' => [\n                'member_id' => $otherMember->getKey(),\n            ],\n        ]);\n\n        // Assert\n        $response->assertValid();\n        $response->assertStatus(403);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $ownTimeEntry->getKey(),\n            'member_id' => $ownTimeEntry->member_id,\n        ]);\n    }\n\n    public function test_index_endpoint_with_none_project_filter_returns_entries_without_project(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $timeEntryWithProject = TimeEntry::factory()\n            ->forOrganization($data->organization)\n            ->forProject($project)\n            ->forMember($data->member)\n            ->create([\n                'start' => Carbon::now()->subHour(),\n            ]);\n        $timeEntryWithoutProject = TimeEntry::factory()\n            ->forOrganization($data->organization)\n            ->forMember($data->member)\n            ->create([\n                'project_id' => null,\n                'start' => Carbon::now()->subHour(),\n            ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index', [\n            $data->organization->getKey(),\n            'project_ids' => [TimeEntryFilter::NONE_VALUE],\n            'start' => Carbon::now()->subDay()->toIso8601ZuluString(),\n            'end' => Carbon::now()->addDay()->toIso8601ZuluString(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $response->assertJsonCount(1, 'data');\n        $response->assertJsonPath('data.0.id', $timeEntryWithoutProject->getKey());\n    }\n\n    public function test_index_endpoint_with_none_and_id_project_filter_returns_both(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $otherProject = Project::factory()->forOrganization($data->organization)->create();\n        $timeEntryWithProject = TimeEntry::factory()\n            ->forOrganization($data->organization)\n            ->forProject($project)\n            ->forMember($data->member)\n            ->create([\n                'start' => Carbon::now()->subHour(),\n            ]);\n        $timeEntryWithoutProject = TimeEntry::factory()\n            ->forOrganization($data->organization)\n            ->forMember($data->member)\n            ->create([\n                'project_id' => null,\n                'start' => Carbon::now()->subHour(),\n            ]);\n        $timeEntryWithOtherProject = TimeEntry::factory()\n            ->forOrganization($data->organization)\n            ->forProject($otherProject)\n            ->forMember($data->member)\n            ->create([\n                'start' => Carbon::now()->subHour(),\n            ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index', [\n            $data->organization->getKey(),\n            'project_ids' => [TimeEntryFilter::NONE_VALUE, $project->getKey()],\n            'start' => Carbon::now()->subDay()->toIso8601ZuluString(),\n            'end' => Carbon::now()->addDay()->toIso8601ZuluString(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $response->assertJsonCount(2, 'data');\n        $ids = collect($response->json('data'))->pluck('id')->toArray();\n        $this->assertContains($timeEntryWithProject->getKey(), $ids);\n        $this->assertContains($timeEntryWithoutProject->getKey(), $ids);\n        $this->assertNotContains($timeEntryWithOtherProject->getKey(), $ids);\n    }\n\n    public function test_index_endpoint_with_none_task_filter_returns_entries_without_task(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        $project = Project::factory()->forOrganization($data->organization)->create();\n        $task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();\n        $timeEntryWithTask = TimeEntry::factory()\n            ->forOrganization($data->organization)\n            ->forProject($project)\n            ->forTask($task)\n            ->forMember($data->member)\n            ->create([\n                'start' => Carbon::now()->subHour(),\n            ]);\n        $timeEntryWithoutTask = TimeEntry::factory()\n            ->forOrganization($data->organization)\n            ->forMember($data->member)\n            ->create([\n                'task_id' => null,\n                'start' => Carbon::now()->subHour(),\n            ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index', [\n            $data->organization->getKey(),\n            'task_ids' => [TimeEntryFilter::NONE_VALUE],\n            'start' => Carbon::now()->subDay()->toIso8601ZuluString(),\n            'end' => Carbon::now()->addDay()->toIso8601ZuluString(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $response->assertJsonCount(1, 'data');\n        $response->assertJsonPath('data.0.id', $timeEntryWithoutTask->getKey());\n    }\n\n    public function test_index_endpoint_with_none_client_filter_returns_entries_without_client(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        $client = Client::factory()->forOrganization($data->organization)->create();\n        $timeEntryWithClient = TimeEntry::factory()\n            ->forOrganization($data->organization)\n            ->forMember($data->member)\n            ->create([\n                'client_id' => $client->getKey(),\n                'start' => Carbon::now()->subHour(),\n            ]);\n        $timeEntryWithoutClient = TimeEntry::factory()\n            ->forOrganization($data->organization)\n            ->forMember($data->member)\n            ->create([\n                'client_id' => null,\n                'start' => Carbon::now()->subHour(),\n            ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index', [\n            $data->organization->getKey(),\n            'client_ids' => [TimeEntryFilter::NONE_VALUE],\n            'start' => Carbon::now()->subDay()->toIso8601ZuluString(),\n            'end' => Carbon::now()->addDay()->toIso8601ZuluString(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $response->assertJsonCount(1, 'data');\n        $response->assertJsonPath('data.0.id', $timeEntryWithoutClient->getKey());\n    }\n\n    public function test_index_endpoint_with_none_tag_filter_returns_entries_without_tags(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission([\n            'time-entries:view:all',\n        ]);\n        $tag = Tag::factory()->forOrganization($data->organization)->create();\n        $timeEntryWithTag = TimeEntry::factory()\n            ->forOrganization($data->organization)\n            ->forMember($data->member)\n            ->create([\n                'start' => Carbon::now()->subHour(),\n                'tags' => [$tag->getKey()],\n            ]);\n        $timeEntryWithoutTag = TimeEntry::factory()\n            ->forOrganization($data->organization)\n            ->forMember($data->member)\n            ->create([\n                'start' => Carbon::now()->subHour(),\n                'tags' => [],\n            ]);\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.time-entries.index', [\n            $data->organization->getKey(),\n            'tag_ids' => [TimeEntryFilter::NONE_VALUE],\n            'start' => Carbon::now()->subDay()->toIso8601ZuluString(),\n            'end' => Carbon::now()->addDay()->toIso8601ZuluString(),\n        ]));\n\n        // Assert\n        $this->assertResponseCode($response, 200);\n        $response->assertJsonCount(1, 'data');\n        $response->assertJsonPath('data.0.id', $timeEntryWithoutTag->getKey());\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Endpoint/Api/V1/UserEndpointTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Endpoint\\Api\\V1;\n\nuse Laravel\\Passport\\Passport;\n\nclass UserEndpointTest extends ApiEndpointTestAbstract\n{\n    public function test_me_fails_when_not_authenticated(): void\n    {\n        // Act\n        $response = $this->getJson(route('api.v1.users.me'));\n\n        // Assert\n        $response->assertUnauthorized();\n        $response->assertJson(['message' => 'Unauthenticated.']);\n    }\n\n    public function test_me_returns_information_about_the_current_user(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.users.me'));\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertJson([\n            'data' => [\n                'id' => $data->user->getKey(),\n                'name' => $data->user->name,\n                'email' => $data->user->email,\n                'profile_photo_url' => $data->user->profile_photo_url,\n                'timezone' => $data->user->timezone,\n                'week_start' => $data->user->week_start->value,\n            ],\n        ]);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Endpoint/Api/V1/UserMembershipEndpointTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Endpoint\\Api\\V1;\n\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse Laravel\\Passport\\Passport;\n\nclass UserMembershipEndpointTest extends ApiEndpointTestAbstract\n{\n    public function test_my_members_fails_when_not_authenticated(): void\n    {\n        // Act\n        $response = $this->getJson(route('api.v1.users.memberships.my-memberships'));\n\n        // Assert\n        $response->assertUnauthorized();\n        $response->assertJson(['message' => 'Unauthenticated.']);\n    }\n\n    public function test_my_members_returns_information_about_the_organization_membership_of_the_current_user(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $otherOrganization = Organization::factory()->create();\n        $otherMember = Member::factory()->forOrganization($otherOrganization)->forUser($data->user)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.users.memberships.my-memberships'));\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertJsonCount(2, 'data');\n        $otherMemberResponse = collect($response->json('data'))->where('id', '=', $otherMember->getKey())->first();\n        $this->assertNotNull($otherMemberResponse);\n        $this->assertSame($otherMember->organization->getKey(), $otherMemberResponse['organization']['id']);\n        $memberResponse = collect($response->json('data'))->where('id', '=', $data->member->getKey())->first();\n        $this->assertNotNull($memberResponse);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Endpoint/Api/V1/UserTimeEntryEndpointTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Endpoint\\Api\\V1;\n\nuse App\\Http\\Controllers\\Api\\V1\\UserTimeEntryController;\nuse App\\Models\\TimeEntry;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Facades\\Log;\nuse Laravel\\Passport\\Passport;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\nuse TiMacDonald\\Log\\LogEntry;\n\n#[UsesClass(UserTimeEntryController::class)]\nclass UserTimeEntryEndpointTest extends ApiEndpointTestAbstract\n{\n    public function test_my_active_endpoint_returns_unauthorized_if_user_is_not_logged_in(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n\n        // Act\n        $response = $this->getJson(route('api.v1.users.time-entries.my-active'));\n\n        // Assert\n        $response->assertUnauthorized();\n    }\n\n    public function test_my_active_endpoint_returns_current_time_entry_of_logged_in_user(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $activeTimeEntry = TimeEntry::factory()->forMember($data->member)->active()->create();\n        $inactiveTimeEntry = TimeEntry::factory()->forMember($data->member)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.users.time-entries.my-active'));\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertJsonPath('data.id', $activeTimeEntry->getKey());\n    }\n\n    public function test_my_active_endpoint_logs_a_warning_if_user_has_multiple_active_time_entries_and_return_the_latest_one(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $activeTimeEntry1 = TimeEntry::factory()->forMember($data->member)->active()->start(Carbon::now()->subDay())->create();\n        $activeTimeEntry2 = TimeEntry::factory()->forMember($data->member)->active()->start(Carbon::now())->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.users.time-entries.my-active'));\n\n        // Assert\n        Log::assertLogged(fn (LogEntry $log) => $log->level === 'warning'\n            && $log->message === 'User has more than one active time entry.'\n            && $log->context === ['user' => $data->user->getKey()]\n        );\n        $response->assertSuccessful();\n        $response->assertJsonPath('data.id', $activeTimeEntry2->getKey());\n    }\n\n    public function test_my_active_endpoint_returns_not_found_if_user_has_no_active_time_entry(): void\n    {\n        // Arrange\n        $data = $this->createUserWithPermission();\n        $inactiveTimeEntry = TimeEntry::factory()->forMember($data->member)->create();\n        Passport::actingAs($data->user);\n\n        // Act\n        $response = $this->getJson(route('api.v1.users.time-entries.my-active'));\n\n        // Assert\n        $response->assertNotFound();\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Endpoint/Web/DashboardEndpointTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Endpoint\\Web;\n\nuse App\\Enums\\Role;\nuse App\\Http\\Controllers\\Web\\DashboardController;\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse Inertia\\Testing\\AssertableInertia as Assert;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(DashboardController::class)]\nclass DashboardEndpointTest extends EndpointTestAbstract\n{\n    public function test_showing_dashboard_succeeds_for_empty_user(): void\n    {\n        // Arrange\n        $user = User::factory()->withPersonalOrganization()->create();\n        $this->actingAs($user);\n\n        // Act\n        $response = $this->get('/dashboard');\n\n        // Assert\n        $response->assertSuccessful();\n    }\n\n    public function test_showing_dashboard_succeeds_for_user_with_employee_role(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $user = User::factory()->forCurrentOrganization($organization)->create();\n        $organization->users()->attach($user, ['role' => Role::Employee->value]);\n        $this->actingAs($user);\n\n        // Act\n        $response = $this->get('/dashboard');\n\n        // Assert\n        $response->assertSuccessful();\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Endpoint/Web/EndpointTestAbstract.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Endpoint\\Web;\n\nuse Tests\\TestCaseWithDatabase;\n\nabstract class EndpointTestAbstract extends TestCaseWithDatabase {}\n"
  },
  {
    "path": "tests/Unit/Endpoint/Web/HealthCheckEndpointTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Endpoint\\Web;\n\nuse App\\Http\\Controllers\\Web\\HealthCheckController;\nuse Illuminate\\Support\\Facades\\DB;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(HealthCheckController::class)]\nclass HealthCheckEndpointTest extends EndpointTestAbstract\n{\n    public function test_up_endpoint_returns_ok(): void\n    {\n        // Arrange\n        DB::enableQueryLog();\n        DB::flushQueryLog();\n\n        // Act\n        $response = $this->get('health-check/up');\n        $queryLog = DB::getQueryLog();\n\n        // Assert\n        $this->assertCount(0, $queryLog);\n        $response->assertSuccessful();\n        $response->assertExactJson([\n            'success' => true,\n        ]);\n    }\n\n    public function test_debug_endpoint_returns_ok(): void\n    {\n        // Arrange\n        config(['app.debug' => false]);\n\n        // Act\n        $response = $this->get('health-check/debug');\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertExactJsonStructure([\n            'date_time_app',\n            'date_time_utc',\n            'hostname',\n            'ip_address',\n            'is_trusted_proxy',\n            'path',\n            'secure',\n            'timestamp',\n            'timezone',\n            'url',\n        ]);\n        config(['app.debug' => true]);\n    }\n\n    public function test_debug_endpoint_returns_more_information_if_debug_mode_is_enabled(): void\n    {\n        // Arrange\n        config(['app.debug' => true]);\n\n        // Act\n        $response = $this->get('health-check/debug');\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertExactJsonStructure([\n            'app_debug',\n            'app_env',\n            'app_force_https',\n            'app_timezone',\n            'app_url',\n            'date_time_app',\n            'date_time_utc',\n            'headers',\n            'hostname',\n            'ip_address',\n            'is_trusted_proxy',\n            'path',\n            'secure',\n            'timestamp',\n            'timezone',\n            'session_secure',\n            'trusted_proxies',\n            'url',\n        ]);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Endpoint/Web/HomeEndpointTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Endpoint\\Web;\n\nuse App\\Http\\Controllers\\Web\\HomeController;\nuse App\\Http\\Middleware\\Authenticate;\nuse App\\Http\\Middleware\\RedirectIfAuthenticated;\nuse App\\Models\\User;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(HomeController::class)]\n#[CoversClass(Authenticate::class)]\n#[CoversClass(RedirectIfAuthenticated::class)]\nclass HomeEndpointTest extends EndpointTestAbstract\n{\n    public function test_index_redirects_to_dashboard_if_user_is_logged_in(): void\n    {\n        // Arrange\n        $user = User::factory()->withPersonalOrganization()->create();\n        $this->actingAs($user);\n\n        // Act\n        $response = $this->get('/');\n\n        // Assert\n        $response->assertRedirect('/dashboard');\n    }\n\n    public function test_index_redirects_to_login_if_user_is_not_logged_in(): void\n    {\n        // Arrange\n\n        // Act\n        $response = $this->get('/');\n\n        // Assert\n        $response->assertRedirect('/login');\n    }\n\n    public function test_login_redirects_to_dashboard_if_user_is_logged_in(): void\n    {\n        // Arrange\n        $user = User::factory()->withPersonalOrganization()->create();\n        $this->actingAs($user);\n\n        // Act\n        $response = $this->get('/login');\n\n        // Assert\n        $response->assertRedirect('/dashboard');\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Filament/FilamentTestCase.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Filament;\n\nuse Filament\\Facades\\Filament;\nuse Tests\\TestCaseWithDatabase;\n\nabstract class FilamentTestCase extends TestCaseWithDatabase\n{\n    protected function setUp(): void\n    {\n        parent::setUp();\n        Filament::setServingStatus();\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Filament/Resources/AuditResourceTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Filament\\Resources;\n\nuse App\\Filament\\Resources\\AuditResource;\nuse App\\Models\\Audit;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse Illuminate\\Support\\Facades\\Config;\nuse Illuminate\\Support\\Facades\\DB;\nuse Livewire\\Livewire;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\nuse Tests\\Unit\\Filament\\FilamentTestCase;\n\n#[UsesClass(AuditResource::class)]\nclass AuditResourceTest extends FilamentTestCase\n{\n    protected function setUp(): void\n    {\n        parent::setUp();\n        Config::set('auth.super_admins', ['admin@example.com']);\n        $user = User::factory()->withPersonalOrganization()->create([\n            'email' => 'admin@example.com',\n        ]);\n\n        $this->actingAs($user);\n    }\n\n    public function test_can_list_audits(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $timeEntry = TimeEntry::factory()->forMember($user->member)->create();\n        DB::table((new Audit)->getTable())->delete();\n        $audits = Audit::factory()->auditFor($timeEntry)->auditUser($user->user)->createMany(5);\n\n        // Act\n        $response = Livewire::test(AuditResource\\Pages\\ListAudits::class);\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertCanSeeTableRecords($audits);\n    }\n\n    public function test_can_see_view_page_of_audit(): void\n    {\n        // Arrange\n        DB::table((new Audit)->getTable())->delete();\n        $audit = Audit::factory()->create();\n\n        // Act\n        $response = Livewire::test(AuditResource\\Pages\\ViewAudit::class, ['record' => $audit->getKey()]);\n\n        // Assert\n        $response->assertSuccessful();\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Filament/Resources/ClientResourceTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Filament\\Resources;\n\nuse App\\Filament\\Resources\\ClientResource;\nuse App\\Models\\Client;\nuse App\\Models\\User;\nuse Illuminate\\Support\\Facades\\Config;\nuse Livewire\\Livewire;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\nuse Tests\\Unit\\Filament\\FilamentTestCase;\n\n#[UsesClass(ClientResource::class)]\nclass ClientResourceTest extends FilamentTestCase\n{\n    protected function setUp(): void\n    {\n        parent::setUp();\n        Config::set('auth.super_admins', ['admin@example.com']);\n        $user = User::factory()->withPersonalOrganization()->create([\n            'email' => 'admin@example.com',\n        ]);\n\n        $this->actingAs($user);\n    }\n\n    public function test_can_list_clients(): void\n    {\n        // Arrange\n        $clients = Client::factory()->createMany(5);\n\n        // Act\n        $response = Livewire::test(ClientResource\\Pages\\ListClients::class);\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertCanSeeTableRecords($clients);\n    }\n\n    public function test_can_see_edit_page_of_client(): void\n    {\n        // Arrange\n        $client = Client::factory()->create();\n\n        // Act\n        $response = Livewire::test(ClientResource\\Pages\\EditClient::class, ['record' => $client->getKey()]);\n\n        // Assert\n        $response->assertSuccessful();\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Filament/Resources/FailedJobResourceTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Filament\\Resources;\n\nuse App\\Filament\\Resources\\FailedJobResource;\nuse App\\Filament\\Resources\\FailedJobResource\\Pages\\ViewFailedJobs;\nuse App\\Models\\FailedJob;\nuse App\\Models\\User;\nuse Illuminate\\Support\\Facades\\Config;\nuse Livewire\\Livewire;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\nuse Tests\\Unit\\Filament\\FilamentTestCase;\n\n#[UsesClass(FailedJobResource::class)]\nclass FailedJobResourceTest extends FilamentTestCase\n{\n    protected function setUp(): void\n    {\n        parent::setUp();\n        Config::set('auth.super_admins', ['admin@example.com']);\n        $user = User::factory()->withPersonalOrganization()->create([\n            'email' => 'admin@example.com',\n        ]);\n\n        $this->actingAs($user);\n    }\n\n    public function test_can_list_failed_jobs(): void\n    {\n        // Arrange\n        $failedJobs = FailedJob::factory()->createMany(5);\n\n        // Act\n        $response = Livewire::test(FailedJobResource\\Pages\\ListFailedJobs::class);\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertCanSeeTableRecords($failedJobs);\n    }\n\n    public function test_can_see_view_page_of_failed_job(): void\n    {\n        // Arrange\n        $failedJob = FailedJob::factory()->create();\n\n        // Act\n        $response = Livewire::test(ViewFailedJobs::class, ['record' => $failedJob->getKey()]);\n\n        // Assert\n        $response->assertSuccessful();\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Filament/Resources/OrganizationInvitationResourceTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Filament\\Resources;\n\nuse App\\Filament\\Resources\\OrganizationInvitationResource;\nuse App\\Models\\Organization;\nuse App\\Models\\OrganizationInvitation;\nuse App\\Models\\User;\nuse Filament\\Actions\\DeleteAction;\nuse Illuminate\\Support\\Facades\\Config;\nuse Livewire\\Livewire;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\nuse Tests\\Unit\\Filament\\FilamentTestCase;\n\n#[UsesClass(OrganizationInvitationResource::class)]\nclass OrganizationInvitationResourceTest extends FilamentTestCase\n{\n    protected function setUp(): void\n    {\n        parent::setUp();\n        Config::set('auth.super_admins', ['admin@example.com']);\n        $user = User::factory()->withPersonalOrganization()->create([\n            'email' => 'admin@example.com',\n        ]);\n\n        $this->actingAs($user);\n    }\n\n    public function test_can_list_organization_invitations(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $organization = Organization::factory()->withOwner($user)->create();\n        $organizationInvitations = OrganizationInvitation::factory()->forOrganization($organization)->createMany(5);\n\n        // Act\n        $response = Livewire::test(OrganizationInvitationResource\\Pages\\ListOrganizationInvitations::class);\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertCanSeeTableRecords($organizationInvitations);\n    }\n\n    public function test_can_see_edit_page_of_organization_invitation(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $organizationInvitation = OrganizationInvitation::factory()->forOrganization($organization)->create();\n\n        // Act\n        $response = Livewire::test(OrganizationInvitationResource\\Pages\\EditOrganizationInvitation::class, [\n            'record' => $organizationInvitation->getKey(),\n        ]);\n\n        // Assert\n        $response->assertSuccessful();\n    }\n\n    public function test_can_delete_a_organization_invitation(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $organizationInvitation = OrganizationInvitation::factory()->forOrganization($organization)->create();\n\n        // Act\n        $response = Livewire::test(OrganizationInvitationResource\\Pages\\EditOrganizationInvitation::class, [\n            'record' => $organizationInvitation->getKey(),\n        ])->callAction(DeleteAction::class);\n\n        // Assert\n        $response->assertSuccessful();\n        $this->assertDatabaseMissing(OrganizationInvitation::class, [\n            'id' => $organizationInvitation->getKey(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Filament/Resources/OrganizationResourceTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Filament\\Resources;\n\nuse App\\Filament\\Resources\\OrganizationResource;\nuse App\\Models\\Organization;\nuse App\\Models\\OrganizationInvitation;\nuse App\\Models\\User;\nuse App\\Service\\DeletionService;\nuse Illuminate\\Support\\Facades\\Config;\nuse Livewire\\Livewire;\nuse Mockery\\MockInterface;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\nuse Tests\\Unit\\Filament\\FilamentTestCase;\n\n#[UsesClass(OrganizationResource::class)]\nclass OrganizationResourceTest extends FilamentTestCase\n{\n    protected function setUp(): void\n    {\n        parent::setUp();\n        Config::set('auth.super_admins', ['admin@example.com']);\n        $user = User::factory()->withPersonalOrganization()->create([\n            'email' => 'admin@example.com',\n        ]);\n\n        $this->actingAs($user);\n    }\n\n    public function test_can_list_organizations(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $organizations = Organization::factory()->state([\n            'user_id' => $user->getKey(),\n        ])->createMany(5);\n\n        // Act\n        $response = Livewire::test(OrganizationResource\\Pages\\ListOrganizations::class);\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertCanSeeTableRecords($organizations);\n    }\n\n    public function test_can_see_edit_page_of_organization(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n\n        // Act\n        $response = Livewire::test(OrganizationResource\\Pages\\EditOrganization::class, ['record' => $organization->getKey()]);\n\n        // Assert\n        $response->assertSuccessful();\n    }\n\n    public function test_can_delete_a_organization(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $this->mock(DeletionService::class, function (MockInterface $mock) use ($user): void {\n            $mock->shouldReceive('deleteOrganization')\n                ->withArgs(fn (Organization $organizationArg) => $organizationArg->is($user->organization))\n                ->once();\n        });\n\n        // Act\n        $response = Livewire::test(OrganizationResource\\Pages\\EditOrganization::class, ['record' => $user->organization->getKey()])\n            ->callAction('delete')\n            ->assertHasNoActionErrors();\n\n        // Assert\n        $response->assertSuccessful();\n    }\n\n    public function test_can_list_related_users(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $user1 = User::factory()->create();\n        $user2 = User::factory()->create();\n        $organization->users()->attach($user1);\n        $organization->users()->attach($user2);\n\n        // Act\n        $response = Livewire::test(OrganizationResource\\RelationManagers\\UsersRelationManager::class, [\n            'ownerRecord' => $organization,\n            'pageClass' => OrganizationResource\\Pages\\EditOrganization::class,\n        ]);\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertCanSeeTableRecords($organization->users()->get());\n    }\n\n    public function test_can_list_related_invitations(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $organizationInvitations = OrganizationInvitation::factory()->forOrganization($organization)->createMany(5);\n\n        // Act\n        $response = Livewire::test(OrganizationResource\\RelationManagers\\InvitationsRelationManager::class, [\n            'ownerRecord' => $organization,\n            'pageClass' => OrganizationResource\\Pages\\EditOrganization::class,\n        ]);\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertCanSeeTableRecords($organizationInvitations);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Filament/Resources/ProjectResourceTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Filament\\Resources;\n\nuse App\\Filament\\Resources\\ProjectResource;\nuse App\\Models\\Project;\nuse App\\Models\\User;\nuse Illuminate\\Support\\Facades\\Config;\nuse Livewire\\Livewire;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\nuse Tests\\Unit\\Filament\\FilamentTestCase;\n\n#[UsesClass(ProjectResource::class)]\nclass ProjectResourceTest extends FilamentTestCase\n{\n    protected function setUp(): void\n    {\n        parent::setUp();\n        Config::set('auth.super_admins', ['admin@example.com']);\n        $user = User::factory()->withPersonalOrganization()->create([\n            'email' => 'admin@example.com',\n        ]);\n\n        $this->actingAs($user);\n    }\n\n    public function test_can_list_projects(): void\n    {\n        // Arrange\n        $projects = Project::factory()->createMany(5);\n\n        // Act\n        $response = Livewire::test(ProjectResource\\Pages\\ListProjects::class);\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertCanSeeTableRecords($projects);\n    }\n\n    public function test_can_see_edit_page_of_project(): void\n    {\n        // Arrange\n        $project = Project::factory()->create();\n\n        // Act\n        $response = Livewire::test(ProjectResource\\Pages\\EditProject::class, ['record' => $project->getKey()]);\n\n        // Assert\n        $response->assertSuccessful();\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Filament/Resources/ReportResourceTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Filament\\Resources;\n\nuse App\\Filament\\Resources\\ReportResource;\nuse App\\Models\\Report;\nuse App\\Models\\User;\nuse Illuminate\\Support\\Facades\\Config;\nuse Livewire\\Livewire;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\nuse Tests\\Unit\\Filament\\FilamentTestCase;\n\n#[UsesClass(ReportResource::class)]\nclass ReportResourceTest extends FilamentTestCase\n{\n    protected function setUp(): void\n    {\n        parent::setUp();\n        Config::set('auth.super_admins', ['admin@example.com']);\n        $user = User::factory()->withPersonalOrganization()->create([\n            'email' => 'admin@example.com',\n        ]);\n\n        $this->actingAs($user);\n    }\n\n    public function test_can_list_reports(): void\n    {\n        // Arrange\n        $reports = Report::factory()->createMany(5);\n\n        // Act\n        $response = Livewire::test(ReportResource\\Pages\\ListReports::class);\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertCanSeeTableRecords($reports);\n    }\n\n    public function test_can_see_edit_page_of_report(): void\n    {\n        // Arrange\n        $report = Report::factory()->create();\n\n        // Act\n        $response = Livewire::test(ReportResource\\Pages\\EditReport::class, [\n            'record' => $report->getKey(),\n        ]);\n\n        // Assert\n        $response->assertSuccessful();\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Filament/Resources/TagResourceTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Filament\\Resources;\n\nuse App\\Filament\\Resources\\TagResource;\nuse App\\Models\\Tag;\nuse App\\Models\\User;\nuse Illuminate\\Support\\Facades\\Config;\nuse Livewire\\Livewire;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\nuse Tests\\Unit\\Filament\\FilamentTestCase;\n\n#[UsesClass(TagResource::class)]\nclass TagResourceTest extends FilamentTestCase\n{\n    protected function setUp(): void\n    {\n        parent::setUp();\n        Config::set('auth.super_admins', ['admin@example.com']);\n        $user = User::factory()->withPersonalOrganization()->create([\n            'email' => 'admin@example.com',\n        ]);\n\n        $this->actingAs($user);\n    }\n\n    public function test_can_list_tags(): void\n    {\n        // Arrange\n        $tags = Tag::factory()->createMany(5);\n\n        // Act\n        $response = Livewire::test(TagResource\\Pages\\ListTags::class);\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertCanSeeTableRecords($tags);\n    }\n\n    public function test_can_see_edit_page_of_tag(): void\n    {\n        // Arrange\n        $tag = Tag::factory()->create();\n\n        // Act\n        $response = Livewire::test(TagResource\\Pages\\EditTag::class, ['record' => $tag->getKey()]);\n\n        // Assert\n        $response->assertSuccessful();\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Filament/Resources/TaskResourceTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Filament\\Resources;\n\nuse App\\Filament\\Resources\\TaskResource;\nuse App\\Models\\Task;\nuse App\\Models\\User;\nuse Illuminate\\Support\\Facades\\Config;\nuse Livewire\\Livewire;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\nuse Tests\\Unit\\Filament\\FilamentTestCase;\n\n#[UsesClass(TaskResource::class)]\nclass TaskResourceTest extends FilamentTestCase\n{\n    protected function setUp(): void\n    {\n        parent::setUp();\n        Config::set('auth.super_admins', ['admin@example.com']);\n        $user = User::factory()->withPersonalOrganization()->create([\n            'email' => 'admin@example.com',\n        ]);\n\n        $this->actingAs($user);\n    }\n\n    public function test_can_list_tasks(): void\n    {\n        // Arrange\n        $tasks = Task::factory()->createMany(5);\n\n        // Act\n        $response = Livewire::test(TaskResource\\Pages\\ListTasks::class);\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertCanSeeTableRecords($tasks);\n    }\n\n    public function test_can_see_edit_page_of_task(): void\n    {\n        // Arrange\n        $task = Task::factory()->create();\n\n        // Act\n        $response = Livewire::test(TaskResource\\Pages\\EditTask::class, ['record' => $task->getKey()]);\n\n        // Assert\n        $response->assertSuccessful();\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Filament/Resources/TimeEntryResourceTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Filament\\Resources;\n\nuse App\\Filament\\Resources\\TimeEntryResource;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse Illuminate\\Support\\Facades\\Config;\nuse Livewire\\Livewire;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\nuse Tests\\Unit\\Filament\\FilamentTestCase;\n\n#[UsesClass(TimeEntryResource::class)]\nclass TimeEntryResourceTest extends FilamentTestCase\n{\n    protected function setUp(): void\n    {\n        parent::setUp();\n        Config::set('auth.super_admins', ['admin@example.com']);\n        $user = User::factory()->withPersonalOrganization()->create([\n            'email' => 'admin@example.com',\n        ]);\n\n        $this->actingAs($user);\n    }\n\n    public function test_can_list_time_entry(): void\n    {\n        // Arrange\n        $timeEntry = TimeEntry::factory()->createMany(5);\n\n        // Act\n        $response = Livewire::test(TimeEntryResource\\Pages\\ListTimeEntries::class);\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertCanSeeTableRecords($timeEntry);\n    }\n\n    public function test_can_see_edit_page_of_time_entry(): void\n    {\n        // Arrange\n        $timeEntry = TimeEntry::factory()->create();\n\n        // Act\n        $response = Livewire::test(TimeEntryResource\\Pages\\EditTimeEntry::class, ['record' => $timeEntry->getKey()]);\n\n        // Assert\n        $response->assertSuccessful();\n    }\n\n    public function test_can_see_create_page_of_time_entry(): void\n    {\n        // Act\n        $response = Livewire::test(TimeEntryResource\\Pages\\CreateTimeEntry::class);\n\n        // Assert\n        $response->assertSuccessful();\n    }\n\n    public function test_can_create_time_entry(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $user = User::factory()->create();\n        $member = Member::factory()\n            ->forOrganization($organization)\n            ->forUser($user)\n            ->create();\n\n        // Act\n        $response = Livewire::test(TimeEntryResource\\Pages\\CreateTimeEntry::class)\n            ->fillForm([\n                'description' => 'Test time entry',\n                'billable' => true,\n                'start' => '2024-01-01 08:00:00',\n                'end' => '2024-01-01 10:00:00',\n                'member_id' => $member->getKey(),\n            ])\n            ->call('create')\n            ->assertHasNoFormErrors();\n\n        // Assert\n        $response->assertSuccessful();\n        $timeEntry = TimeEntry::where('description', 'Test time entry')->first();\n        $this->assertNotNull($timeEntry);\n        $this->assertSame($member->getKey(), $timeEntry->member_id);\n        $this->assertSame($user->getKey(), $timeEntry->user_id);\n        $this->assertSame($organization->getKey(), $timeEntry->organization_id);\n        $this->assertTrue($timeEntry->billable);\n    }\n\n    public function test_can_create_time_entry_and_derives_user_and_organization_from_member(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $user = User::factory()->create();\n        $member = Member::factory()\n            ->forOrganization($organization)\n            ->forUser($user)\n            ->create();\n        $otherUser = User::factory()->create();\n        $otherOrganization = Organization::factory()->create();\n\n        // Act\n        $response = Livewire::test(TimeEntryResource\\Pages\\CreateTimeEntry::class)\n            ->fillForm([\n                'description' => 'Derived fields test',\n                'billable' => false,\n                'start' => '2024-03-01 09:00:00',\n                'end' => '2024-03-01 11:00:00',\n                'member_id' => $member->getKey(),\n                'user_id' => $otherUser->getKey(),\n                'organization_id' => $otherOrganization->getKey(),\n            ])\n            ->call('create')\n            ->assertHasNoFormErrors();\n\n        // Assert\n        $response->assertSuccessful();\n        $timeEntry = TimeEntry::where('description', 'Derived fields test')->first();\n        $this->assertNotNull($timeEntry);\n        $this->assertSame($user->getKey(), $timeEntry->user_id);\n        $this->assertSame($organization->getKey(), $timeEntry->organization_id);\n    }\n\n    public function test_can_update_time_entry(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $user = User::factory()->create();\n        $member = Member::factory()\n            ->forOrganization($organization)\n            ->forUser($user)\n            ->create();\n        $timeEntry = TimeEntry::factory()->forMember($member)->create();\n\n        // Act\n        $response = Livewire::test(TimeEntryResource\\Pages\\EditTimeEntry::class, ['record' => $timeEntry->getKey()])\n            ->fillForm([\n                'description' => 'Updated description',\n                'billable' => true,\n                'start' => '2024-02-01 08:00:00',\n                'end' => '2024-02-01 12:00:00',\n                'member_id' => $member->getKey(),\n            ])\n            ->call('save')\n            ->assertHasNoFormErrors();\n\n        // Assert\n        $response->assertSuccessful();\n        $timeEntry->refresh();\n        $this->assertSame('Updated description', $timeEntry->description);\n        $this->assertTrue($timeEntry->billable);\n        $this->assertSame($user->getKey(), $timeEntry->user_id);\n        $this->assertSame($organization->getKey(), $timeEntry->organization_id);\n    }\n\n    public function test_update_time_entry_derives_user_and_organization_from_new_member(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $user = User::factory()->create();\n        $member = Member::factory()\n            ->forOrganization($organization)\n            ->forUser($user)\n            ->create();\n        $timeEntry = TimeEntry::factory()->create();\n\n        $newOrganization = Organization::factory()->create();\n        $newUser = User::factory()->create();\n        $newMember = Member::factory()\n            ->forOrganization($newOrganization)\n            ->forUser($newUser)\n            ->create();\n\n        // Act\n        $response = Livewire::test(TimeEntryResource\\Pages\\EditTimeEntry::class, ['record' => $timeEntry->getKey()])\n            ->fillForm([\n                'description' => 'Reassigned entry',\n                'billable' => false,\n                'start' => '2024-02-01 08:00:00',\n                'end' => '2024-02-01 12:00:00',\n                'member_id' => $newMember->getKey(),\n            ])\n            ->call('save')\n            ->assertHasNoFormErrors();\n\n        // Assert\n        $response->assertSuccessful();\n        $timeEntry->refresh();\n        $this->assertSame($newMember->getKey(), $timeEntry->member_id);\n        $this->assertSame($newUser->getKey(), $timeEntry->user_id);\n        $this->assertSame($newOrganization->getKey(), $timeEntry->organization_id);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Filament/Resources/TokenResourceTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Filament\\Resources;\n\nuse App\\Filament\\Resources\\TokenResource;\nuse App\\Models\\Passport\\Client;\nuse App\\Models\\Passport\\Token;\nuse App\\Models\\User;\nuse Illuminate\\Support\\Facades\\Config;\nuse Livewire\\Livewire;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\nuse Tests\\Unit\\Filament\\FilamentTestCase;\n\n#[UsesClass(TokenResource::class)]\nclass TokenResourceTest extends FilamentTestCase\n{\n    protected function setUp(): void\n    {\n        parent::setUp();\n        Config::set('auth.super_admins', ['admin@example.com']);\n        $user = User::factory()->withPersonalOrganization()->create([\n            'email' => 'admin@example.com',\n        ]);\n\n        $this->actingAs($user);\n    }\n\n    public function test_can_list_tokens(): void\n    {\n        // Arrange\n        $client = Client::factory()->create();\n        $tokens = Token::factory()->forClient($client)->createMany(5);\n\n        // Act\n        $response = Livewire::test(TokenResource\\Pages\\ListTokens::class);\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertCanSeeTableRecords($tokens);\n    }\n\n    public function test_list_tokens_with_filter_is_personal_access_client_true(): void\n    {\n        // Arrange\n        $client = Client::factory()->create();\n        $personalAccessClient = Client::factory()->personalAccessClient()->create();\n        $tokens = Token::factory()->forClient($client)->createMany(5);\n        $personalAccessTokens = Token::factory()->forClient($personalAccessClient)->createMany(5);\n\n        // Act\n        $response = Livewire::test(TokenResource\\Pages\\ListTokens::class)\n            ->filterTable('is_personal_access_client', true);\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertCountTableRecords(5);\n        $response->assertCanSeeTableRecords($personalAccessTokens);\n        $response->assertCanNotSeeTableRecords($tokens);\n    }\n\n    public function test_list_tokens_with_filter_is_personal_access_client_false(): void\n    {\n        // Arrange\n        $client = Client::factory()->create();\n        $personalAccessClient = Client::factory()->personalAccessClient()->create();\n        $tokens = Token::factory()->forClient($client)->createMany(5);\n        $personalAccessTokens = Token::factory()->forClient($personalAccessClient)->createMany(5);\n\n        // Act\n        $response = Livewire::test(TokenResource\\Pages\\ListTokens::class)\n            ->filterTable('is_personal_access_client', false);\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertCountTableRecords(5);\n        $response->assertCanSeeTableRecords($tokens);\n        $response->assertCanNotSeeTableRecords($personalAccessTokens);\n    }\n\n    public function test_can_see_view_page_of_token(): void\n    {\n        // Arrange\n        $client = Client::factory()->create();\n        $token = Token::factory()->forClient($client)->create();\n\n        // Act\n        $response = Livewire::test(TokenResource\\Pages\\ViewToken::class, ['record' => $token->getKey()]);\n\n        // Assert\n        $response->assertSuccessful();\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Filament/Resources/UserResourceTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Filament\\Resources;\n\nuse App\\Exceptions\\Api\\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;\nuse App\\Filament\\Resources\\TimeEntryResource;\nuse App\\Filament\\Resources\\UserResource;\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse App\\Service\\DeletionService;\nuse Illuminate\\Support\\Facades\\Config;\nuse Illuminate\\Support\\Facades\\Hash;\nuse Livewire\\Livewire;\nuse Mockery\\MockInterface;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\nuse Tests\\Unit\\Filament\\FilamentTestCase;\n\n#[UsesClass(TimeEntryResource::class)]\nclass UserResourceTest extends FilamentTestCase\n{\n    protected function setUp(): void\n    {\n        parent::setUp();\n        Config::set('auth.super_admins', ['admin@example.com']);\n        $user = User::factory()->withPersonalOrganization()->create([\n            'email' => 'admin@example.com',\n        ]);\n\n        $this->actingAs($user);\n    }\n\n    public function test_can_list_users(): void\n    {\n        // Arrange\n        $users = User::factory()->createMany(5);\n\n        // Act\n        $response = Livewire::test(UserResource\\Pages\\ListUsers::class);\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertCanSeeTableRecords($users);\n    }\n\n    public function test_can_see_edit_page_of_user(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n\n        // Act\n        $response = Livewire::test(UserResource\\Pages\\EditUser::class, ['record' => $user->getKey()]);\n\n        // Assert\n        $response->assertSuccessful();\n    }\n\n    public function test_can_see_view_page_of_user(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n\n        // Act\n        $response = Livewire::test(UserResource\\Pages\\ViewUser::class, ['record' => $user->getKey()]);\n\n        // Assert\n        $response->assertSuccessful();\n    }\n\n    public function test_can_see_create_page_of_user(): void\n    {\n        // Act\n        $response = Livewire::test(UserResource\\Pages\\CreateUser::class);\n\n        // Assert\n        $response->assertSuccessful();\n    }\n\n    public function test_can_create_user(): void\n    {\n        // Arrange\n        $userFake = User::factory()->make();\n\n        // Act\n        $response = Livewire::test(UserResource\\Pages\\CreateUser::class)\n            ->fillForm([\n                'name' => $userFake->name,\n                'email' => $userFake->email,\n                'password_create' => 'password',\n                'timezone' => $userFake->timezone,\n                'week_start' => $userFake->week_start->value,\n                'currency' => 'EUR',\n            ])\n            ->call('create')\n            ->assertHasNoFormErrors();\n\n        // Assert\n        $response->assertSuccessful();\n        $user = User::where('email', $userFake->email)->first();\n        $this->assertNotNull($user);\n        $this->assertSame($userFake->name, $user->name);\n        $this->assertSame($userFake->email, $user->email);\n        $this->assertSame($userFake->timezone, $user->timezone);\n        $this->assertSame($userFake->week_start->value, $user->week_start->value);\n        $organization = $user->ownedTeams()->first();\n        $this->assertNotNull($organization);\n        $this->assertSame('EUR', $organization->currency);\n        $this->assertTrue(Hash::check('password', $user->password));\n    }\n\n    public function test_can_delete_a_user(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $this->mock(DeletionService::class, function (MockInterface $mock) use ($user): void {\n            $mock->shouldReceive('deleteUser')\n                ->withArgs(fn (User $userArg) => $userArg->is($user->user))\n                ->once();\n        });\n\n        // Act\n        $response = Livewire::test(UserResource\\Pages\\EditUser::class, ['record' => $user->user->getKey()])\n            ->callAction('delete');\n\n        // Assert\n        $response->assertHasNoActionErrors();\n        $response->assertSuccessful();\n    }\n\n    public function test_delete_user_shows_error_notification_on_failure(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $this->mock(DeletionService::class, function (MockInterface $mock) use ($user): void {\n            $mock->shouldReceive('deleteUser')\n                ->withArgs(fn (User $userArg) => $userArg->is($user->user))\n                ->andThrow(new CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers);\n        });\n\n        // Act\n        $response = Livewire::test(UserResource\\Pages\\EditUser::class, ['record' => $user->user->getKey()])\n            ->callAction('delete');\n\n        // Assert\n        $response->assertNotified(__('exceptions.api.can_not_delete_user_who_is_owner_of_organization_with_multiple_members'));\n        $response->assertSuccessful();\n    }\n\n    public function test_can_list_related_organizations(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $ownedOrganization = Organization::factory()->withOwner($user)->create();\n        $organization = Organization::factory()->create();\n\n        // Act\n        $response = Livewire::test(UserResource\\RelationManagers\\OrganizationsRelationManager::class, [\n            'ownerRecord' => $user,\n            'pageClass' => UserResource\\Pages\\EditUser::class,\n        ]);\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertCanSeeTableRecords($user->organizations()->get());\n        $response->assertCanNotSeeTableRecords($user->ownedTeams()->get());\n    }\n\n    public function test_can_list_related_owned_organizations(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $ownedOrganization = Organization::factory()->withOwner($user)->create();\n        $organization = Organization::factory()->create();\n\n        // Act\n        $response = Livewire::test(UserResource\\RelationManagers\\OwnedOrganizationsRelationManager::class, [\n            'ownerRecord' => $user,\n            'pageClass' => UserResource\\Pages\\EditUser::class,\n        ]);\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertCanSeeTableRecords($user->ownedTeams()->get());\n        $response->assertCanNotSeeTableRecords($user->organizations()->get());\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Filament/Widgets/ServerOverviewWidgetTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Filament\\Widgets;\n\nuse App\\Filament\\Widgets\\ServerOverview;\nuse App\\Models\\User;\nuse Cache;\nuse Illuminate\\Support\\Facades\\Config;\nuse Livewire\\Livewire;\nuse PHPUnit\\Framework\\Attributes\\UsesClass;\nuse Tests\\Unit\\Filament\\FilamentTestCase;\n\n#[UsesClass(ServerOverview::class)]\nclass ServerOverviewWidgetTest extends FilamentTestCase\n{\n    protected function setUp(): void\n    {\n        parent::setUp();\n        Config::set('auth.super_admins', ['admin@example.com']);\n        $user = User::factory()->withPersonalOrganization()->create([\n            'email' => 'admin@example.com',\n        ]);\n\n        $this->actingAs($user);\n    }\n\n    public function test_shows_version_and_build_it_no_information_about_the_current_version_exists(): void\n    {\n        // Arrange\n        Config::set('app.version', '1.0.0');\n        Config::set('app.build', 'ABC123');\n        Cache::forget('latest_version');\n\n        // Act\n        $response = Livewire::test(ServerOverview::class);\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertSee('1.0.0');\n        $response->assertSee('ABC123');\n        $response->assertDontSee('Update available');\n        $response->assertDontSee('Current version');\n    }\n\n    public function test_show_version_is_current_when_the_latest_version_is_the_same_as_the_current_version(): void\n    {\n        // Arrange\n        Config::set('app.version', '1.0.0');\n        Config::set('app.build', 'ABC123');\n        Cache::put('latest_version', '1.0.0');\n\n        // Act\n        $response = Livewire::test(ServerOverview::class);\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertSee('1.0.0');\n        $response->assertSee('ABC123');\n        $response->assertDontSee('Update available');\n        $response->assertSee('Current version');\n    }\n\n    public function test_shows_update_available(): void\n    {\n        // Arrange\n        Config::set('app.version', '1.0.0');\n        Config::set('app.build', 'ABC123');\n        Cache::put('latest_version', '1.0.1');\n\n        // Act\n        $response = Livewire::test(ServerOverview::class);\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertSee('1.0.0');\n        $response->assertSee('ABC123');\n        $response->assertSee('Update available');\n        $response->assertDontSee('Current version');\n        $response->assertSee('1.0.1');\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Jobs/RecalculateSpentTimeForProjectTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Jobs;\n\nuse App\\Jobs\\RecalculateSpentTimeForProject;\nuse App\\Models\\Project;\nuse App\\Models\\TimeEntry;\nuse Illuminate\\Support\\Facades\\DB;\nuse Tests\\TestCaseWithDatabase;\n\nclass RecalculateSpentTimeForProjectTest extends TestCaseWithDatabase\n{\n    public function test_recalculates_spent_time_for_project(): void\n    {\n        // Arrange\n        $project = Project::factory()->create([\n            'spent_time' => 0,\n        ]);\n        TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project)->create();\n        TimeEntry::factory()->startWithDuration(now(), 11)->forProject($project)->create();\n\n        $project->refresh();\n        $recalculateSpentTimeForProject = new RecalculateSpentTimeForProject($project);\n        DB::enableQueryLog();\n\n        // Act\n        $recalculateSpentTimeForProject->handle();\n\n        // Assert\n        self::assertCount(2, DB::getQueryLog());\n        $project->refresh();\n        self::assertEquals(21, $project->spent_time);\n    }\n\n    public function test_does_not_save_project_if_value_is_already_correct(): void\n    {\n        // Arrange\n        $project = Project::factory()->create([\n            'spent_time' => 21,\n        ]);\n        TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project)->create();\n        TimeEntry::factory()->startWithDuration(now(), 11)->forProject($project)->create();\n\n        $project->refresh();\n        $recalculateSpentTimeForProject = new RecalculateSpentTimeForProject($project);\n        DB::enableQueryLog();\n\n        // Act\n        $recalculateSpentTimeForProject->handle();\n\n        // Assert\n        self::assertCount(1, DB::getQueryLog());\n        $project->refresh();\n        self::assertEquals(21, $project->spent_time);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Jobs/RecalculateSpentTimeForTaskTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Jobs;\n\nuse App\\Jobs\\RecalculateSpentTimeForTask;\nuse App\\Models\\Task;\nuse App\\Models\\TimeEntry;\nuse Illuminate\\Support\\Facades\\DB;\nuse Tests\\TestCaseWithDatabase;\n\nclass RecalculateSpentTimeForTaskTest extends TestCaseWithDatabase\n{\n    public function test_recalculates_spent_time_for_task(): void\n    {\n        // Arrange\n        $task = Task::factory()->create([\n            'spent_time' => 0,\n        ]);\n        TimeEntry::factory()->startWithDuration(now(), 10)->forTask($task)->create();\n        TimeEntry::factory()->startWithDuration(now(), 11)->forTask($task)->create();\n\n        $task->refresh();\n        $recalculateSpentTimeForTask = new RecalculateSpentTimeForTask($task);\n        DB::enableQueryLog();\n\n        // Act\n        $recalculateSpentTimeForTask->handle();\n\n        // Assert\n        self::assertCount(2, DB::getQueryLog());\n        $task->refresh();\n        self::assertEquals(21, $task->spent_time);\n    }\n\n    public function test_does_not_save_task_if_value_is_already_correct(): void\n    {\n        // Arrange\n        $task = Task::factory()->create([\n            'spent_time' => 21,\n        ]);\n        TimeEntry::factory()->startWithDuration(now(), 10)->forTask($task)->create();\n        TimeEntry::factory()->startWithDuration(now(), 11)->forTask($task)->create();\n\n        $task->refresh();\n        $recalculateSpentTimeForTask = new RecalculateSpentTimeForTask($task);\n        DB::enableQueryLog();\n\n        // Act\n        $recalculateSpentTimeForTask->handle();\n\n        // Assert\n        self::assertCount(1, DB::getQueryLog());\n        $task->refresh();\n        self::assertEquals(21, $task->spent_time);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Jobs/Test/TestJobTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Jobs\\Test;\n\nuse App\\Jobs\\Test\\TestJob;\nuse App\\Models\\User;\nuse Illuminate\\Support\\Facades\\Log;\nuse Tests\\TestCaseWithDatabase;\nuse TiMacDonald\\Log\\LogEntry;\n\nclass TestJobTest extends TestCaseWithDatabase\n{\n    public function test_logs_debug_message(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $message = 'Test message';\n        $job = new TestJob($user, $message);\n\n        // Act\n        $job->handle();\n\n        // Assert\n        Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug'\n            && $log->message === 'TestJob: '.$message\n            && $log->context['user'] === $user->getKey(),\n            1\n        );\n    }\n\n    public function test_can_fail_if_parameter_fail_is_true(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $message = 'Test message';\n        $job = new TestJob($user, $message, true);\n\n        // Act\n        try {\n            $job->handle();\n        } catch (\\Exception $e) {\n            // Assert\n            $this->assertEquals('TestJob failed.', $e->getMessage());\n\n            return;\n        }\n        $this->fail('Expected exception not thrown');\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Mail/AuthApiTokenExpirationReminderMailTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Mail;\n\nuse App\\Mail\\AuthApiTokenExpirationReminderMail;\nuse App\\Models\\Passport\\Client;\nuse App\\Models\\Passport\\Token;\nuse App\\Models\\User;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCaseWithDatabase;\n\n#[CoversClass(AuthApiTokenExpirationReminderMail::class)]\nclass AuthApiTokenExpirationReminderMailTest extends TestCaseWithDatabase\n{\n    public function test_mail_renders_content_correctly(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $client = Client::factory()->apiClient()->create();\n        $token = Token::factory()->forClient($client)->forUser($user)->create([\n            'name' => 'TEST',\n        ]);\n        $mail = new AuthApiTokenExpirationReminderMail($token, $user);\n\n        // Act\n        $rendered = $mail->render();\n\n        // Assert\n        $this->assertStringContainsString('The API token \"TEST\" expired.', $rendered);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Mail/AuthApiTokenExpiredMailTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Mail;\n\nuse App\\Mail\\AuthApiTokenExpiredMail;\nuse App\\Models\\Passport\\Client;\nuse App\\Models\\Passport\\Token;\nuse App\\Models\\User;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCaseWithDatabase;\n\n#[CoversClass(AuthApiTokenExpiredMail::class)]\nclass AuthApiTokenExpiredMailTest extends TestCaseWithDatabase\n{\n    public function test_mail_renders_content_correctly(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $client = Client::factory()->apiClient()->create();\n        $token = Token::factory()->forClient($client)->forUser($user)->create([\n            'name' => 'TEST',\n        ]);\n        $mail = new AuthApiTokenExpiredMail($token, $user);\n\n        // Act\n        $rendered = $mail->render();\n\n        // Assert\n        $this->assertStringContainsString('The API token \"TEST\" will expire in 7 days!', $rendered);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Mail/OrganizationInvitationMailTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Mail;\n\nuse App\\Mail\\OrganizationInvitationMail;\nuse App\\Models\\Organization;\nuse App\\Models\\OrganizationInvitation;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCaseWithDatabase;\n\n#[CoversClass(OrganizationInvitationMail::class)]\nclass OrganizationInvitationMailTest extends TestCaseWithDatabase\n{\n    public function test_mail_renders_content_correctly(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $invitation = OrganizationInvitation::factory()->forOrganization($organization)->create();\n        $mail = new OrganizationInvitationMail($invitation);\n\n        // Act\n        $rendered = $mail->render();\n\n        // Assert\n        $this->assertStringContainsString('You have been invited to join the '.$invitation->organization->name.' organization', $rendered);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Mail/TimeEntryStillRunningMailTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Mail;\n\nuse App\\Mail\\TimeEntryStillRunningMail;\nuse App\\Models\\TimeEntry;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCaseWithDatabase;\n\n#[CoversClass(TimeEntryStillRunningMail::class)]\nclass TimeEntryStillRunningMailTest extends TestCaseWithDatabase\n{\n    public function test_mail_renders_content_correctly(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $timeEntry = TimeEntry::factory()->create([\n            'description' => 'TEST 123',\n        ]);\n        $mail = new TimeEntryStillRunningMail($timeEntry, $user->user);\n\n        // Act\n        $rendered = $mail->render();\n\n        // Assert\n        $this->assertStringContainsString('Your currently running time entry \"TEST 123\"', $rendered);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Middleware/CheckOrganizationBlockedMiddlewareTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Middleware;\n\nuse App\\Http\\Middleware\\CheckOrganizationBlocked;\nuse App\\Models\\Organization;\nuse App\\Service\\BillingContract;\nuse Illuminate\\Routing\\Middleware\\SubstituteBindings;\nuse Illuminate\\Session\\Middleware\\StartSession;\nuse Illuminate\\Support\\Facades\\Route;\nuse Illuminate\\Support\\Str;\nuse Laravel\\Passport\\Passport;\nuse Mockery\\MockInterface;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(CheckOrganizationBlocked::class)]\nclass CheckOrganizationBlockedMiddlewareTest extends MiddlewareTestAbstract\n{\n    private function createTestRoute(): void\n    {\n        Route::get('/test-route/{organization}', function (Organization $organization) {\n            return response()->json(['message' => 'Test route', 'id' => $organization->getKey()]);\n        })->middleware([StartSession::class, SubstituteBindings::class, CheckOrganizationBlocked::class]);\n\n    }\n\n    private function createTestRouteNoModelBinding(): string\n    {\n        $route = Route::get('/test-route', function () {\n            return response()->json(['message' => 'Test route']);\n        })->middleware([StartSession::class, SubstituteBindings::class, CheckOrganizationBlocked::class]);\n\n        return $route->uri;\n    }\n\n    public function test_request_fails_if_organization_is_blocked_by_the_billing_system(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $this->createTestRoute();\n        $this->mock(BillingContract::class, function (MockInterface $mock): void {\n            $mock->shouldReceive('isBlocked')->andReturn(true)->once();\n        });\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->get('/test-route/'.$user->organization->getKey());\n\n        // Assert\n        $response->assertStatus(400);\n        $response->assertJson(['message' => 'Organization has no subscription but multiple members']);\n    }\n\n    public function test_request_fails_if_organization_is_not_found(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $this->createTestRoute();\n        $this->mock(BillingContract::class, function (MockInterface $mock): void {\n            $mock->shouldReceive('isBlocked')->never();\n        });\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->get('/test-route/'.Str::uuid());\n\n        // Assert\n        $response->assertStatus(404);\n    }\n\n    public function test_request_fails_on_route_without_organization_model_binding(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $route = $this->createTestRouteNoModelBinding();\n        $this->mock(BillingContract::class, function (MockInterface $mock): void {\n            $mock->shouldReceive('isBlocked')->never();\n        });\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->get($route);\n\n        // Assert\n        $response->assertStatus(500);\n    }\n\n    public function test_request_succeeds_if_organization_is_not_blocked_by_the_billing_system(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $this->createTestRoute();\n        $this->mock(BillingContract::class, function (MockInterface $mock): void {\n            $mock->shouldReceive('isBlocked')->andReturn(false)->once();\n        });\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->get('/test-route/'.$user->organization->getKey());\n\n        // Assert\n        $response->assertStatus(200);\n        $response->assertJson(['message' => 'Test route', 'id' => $user->organization->getKey()]);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Middleware/EnsureEmailIsVerifiedMiddlewareTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Middleware;\n\nuse App\\Http\\Middleware\\EnsureEmailIsVerified;\nuse App\\Models\\User;\nuse Illuminate\\Support\\Facades\\Route;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(EnsureEmailIsVerified::class)]\nclass EnsureEmailIsVerifiedMiddlewareTest extends MiddlewareTestAbstract\n{\n    private function createTestRoute(): string\n    {\n        return Route::get('/test-route', function () {\n            return 'test-response';\n        })->middleware(EnsureEmailIsVerified::class)->uri;\n    }\n\n    public function test_guests_are_redirected_to_verification_notice_route(): void\n    {\n        // Arrange\n        $route = $this->createTestRoute();\n\n        // Act\n        $response = $this->get($route);\n\n        // Assert\n        $response->assertRedirect(route('verification.notice'));\n    }\n\n    public function test_users_with_unverified_email_are_redirected_to_verification_notice_route(): void\n    {\n        // Arrange\n        $user = User::factory()->unverified()->create();\n        $route = $this->createTestRoute();\n        $this->actingAs($user);\n\n        // Act\n        $response = $this->get($route);\n\n        // Assert\n        $response->assertRedirect(route('verification.notice'));\n    }\n\n    public function test_users_with_unverified_email_get_error_if_the_request_is_json(): void\n    {\n        // Arrange\n        $user = User::factory()->unverified()->create();\n        $route = $this->createTestRoute();\n        $this->actingAs($user);\n\n        // Act\n        $response = $this->getJson($route);\n\n        // Assert\n        $response->assertForbidden();\n    }\n\n    public function test_users_with_verified_email_can_access_route(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $route = $this->createTestRoute();\n        $this->actingAs($user);\n\n        // Act\n        $response = $this->get($route);\n\n        // Assert\n        $response->assertOk();\n    }\n\n    public function test_users_with_unverified_email_can_access_route_in_local_environment(): void\n    {\n        // Arrange\n        $user = User::factory()->unverified()->create();\n        $route = $this->createTestRoute();\n        $this->actingAs($user);\n        $this->app->detectEnvironment(fn () => 'local');\n\n        // Act\n        $response = $this->get($route);\n\n        // Assert\n        $response->assertOk();\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Middleware/ForceHttpsMiddlewareTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Middleware;\n\nuse App\\Http\\Middleware\\ForceHttps;\nuse Illuminate\\Support\\Facades\\Config;\nuse Illuminate\\Support\\Facades\\Route;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(ForceHttps::class)]\nclass ForceHttpsMiddlewareTest extends MiddlewareTestAbstract\n{\n    private function createTestRoute(): string\n    {\n        $uri = Route::get('/test-route', function () {\n            return [\n                'is_secure' => request()->secure(),\n            ];\n        })->middleware(ForceHttps::class)->uri;\n\n        return url($uri, [], false);\n    }\n\n    public function test_if_config_app_force_https_is_true_then_the_request_will_be_modified_to_make_the_app_think_it_was_a_https_request(): void\n    {\n        // Arrange\n        Config::set('app.force_https', true);\n        $route = $this->createTestRoute();\n\n        // Act\n        $response = $this->get($route);\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertJson(['is_secure' => true]);\n    }\n\n    public function test_if_config_app_force_https_is_true_then_the_request_will_be_modified_to_make_the_app_think_it_was_a_https_request_even_if_a_load_balancer_says_it_was_a_http_request(): void\n    {\n        // Arrange\n        Config::set('app.force_https', true);\n        $route = $this->createTestRoute();\n\n        // Act\n        $response = $this->get($route, ['X-Forwarded-Proto' => 'http']);\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertJson(['is_secure' => true]);\n    }\n\n    public function test_if_config_app_force_https_is_false_then_the_request_will_not_be_modified_to_make_the_app_think_it_was_a_https_request(): void\n    {\n        // Arrange\n        Config::set('app.force_https', false);\n        $route = $this->createTestRoute();\n\n        // Act\n        $response = $this->get($route);\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertJson(['is_secure' => false]);\n    }\n\n    public function test_if_config_app_force_https_is_false_then_the_request_will_not_be_modified_but_the_request_can_still_be_https(): void\n    {\n        // Arrange\n        Config::set('app.force_https', false);\n        $route = $this->createTestRoute();\n\n        // Act\n        $response = $this->get($route, ['X-Forwarded-Proto' => 'https']);\n\n        // Assert\n        $response->assertSuccessful();\n        $response->assertJson(['is_secure' => true]);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Middleware/HandleInertiaRequestsMiddlewareTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Middleware;\n\nuse App\\Http\\Middleware\\HandleInertiaRequests;\nuse App\\Service\\BillingContract;\nuse Illuminate\\Session\\Middleware\\StartSession;\nuse Illuminate\\Support\\Carbon;\nuse Illuminate\\Support\\Facades\\Route;\nuse Inertia\\Inertia;\nuse Inertia\\Testing\\AssertableInertia as Assert;\nuse Laravel\\Passport\\Passport;\nuse Mockery\\MockInterface;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(HandleInertiaRequests::class)]\nclass HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract\n{\n    private function createTestRoute(): string\n    {\n        return Route::get('/test-route', function () {\n            return Inertia::render('Welcome');\n        })->middleware([StartSession::class, HandleInertiaRequests::class])->uri;\n    }\n\n    public function test_adds_billing_information_to_shared_data_of_inertia_requests(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $route = $this->createTestRoute();\n        $this->mock(BillingContract::class, function (MockInterface $mock): void {\n            $mock->shouldReceive('hasSubscription')->andReturn(false);\n            $mock->shouldReceive('hasTrial')->andReturn(false);\n            $mock->shouldReceive('getTrialUntil')->andReturn(null);\n            $mock->shouldReceive('isBlocked')->andReturn(false);\n        });\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->get($route);\n\n        // Assert\n        $response->assertInertia(fn (Assert $page) => $page\n            ->where('billing.has_subscription', false)\n            ->where('billing.has_trial', false)\n            ->where('billing.trial_until', null)\n            ->where('billing.is_blocked', false)\n        );\n    }\n\n    public function test_adds_billing_information_to_shared_data_of_inertia_requests_with_active_trial(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $route = $this->createTestRoute();\n        $trialUntil = Carbon::now()->addDays(10);\n        $this->mock(BillingContract::class, function (MockInterface $mock) use ($trialUntil): void {\n            $mock->shouldReceive('hasSubscription')->andReturn(false);\n            $mock->shouldReceive('hasTrial')->andReturn(true);\n            $mock->shouldReceive('getTrialUntil')->andReturn($trialUntil);\n            $mock->shouldReceive('isBlocked')->andReturn(false);\n        });\n        Passport::actingAs($user->user);\n\n        // Act\n        $response = $this->get($route);\n\n        // Assert\n        $response->assertInertia(fn (Assert $page) => $page\n            ->where('billing.has_subscription', false)\n            ->where('billing.has_trial', true)\n            ->where('billing.trial_until', $trialUntil->toIso8601ZuluString())\n            ->where('billing.is_blocked', false)\n        );\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Middleware/MiddlewareTestAbstract.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Middleware;\n\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Tests\\TestCaseWithDatabase;\n\nabstract class MiddlewareTestAbstract extends TestCaseWithDatabase\n{\n    use RefreshDatabase;\n}\n"
  },
  {
    "path": "tests/Unit/Model/ClientModelTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Model;\n\nuse App\\Models\\Client;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(Client::class)]\nclass ClientModelTest extends ModelTestAbstract\n{\n    public function test_it_belongs_to_a_organization(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $client = Client::factory()->forOrganization($organization)->create();\n\n        // Act\n        $client->refresh();\n        $organizationRel = $client->organization;\n\n        // Assert\n        $this->assertNotNull($organizationRel);\n        $this->assertTrue($organizationRel->is($organization));\n    }\n\n    public function test_it_has_many_projects(): void\n    {\n        // Arrange\n        $client = Client::factory()->create();\n        $otherClient = Client::factory()->create();\n        $projects = Project::factory()->forClient($client)->createMany(4);\n        $projectsOtherClient = Project::factory()->forClient($otherClient)->createMany(4);\n\n        // Act\n        $client->refresh();\n        $projectsRel = $client->projects;\n\n        // Assert\n        $this->assertNotNull($projectsRel);\n        $this->assertCount(4, $projectsRel);\n        $this->assertTrue($projectsRel->first()->is($projects->first()));\n    }\n\n    public function test_accessor_is_archived_is_true_if_archived_at_is_not_null(): void\n    {\n        // Arrange\n        $client = Client::factory()->archived()->create();\n\n        // Act\n        $client->refresh();\n        $isArchived = $client->is_archived;\n\n        // Assert\n        $this->assertTrue($isArchived);\n    }\n\n    public function test_accessor_is_archived_is_false_if_archived_at_is_null(): void\n    {\n        // Arrange\n        $client = Client::factory()->create();\n\n        // Act\n        $client->refresh();\n        $isArchived = $client->is_archived;\n\n        // Assert\n        $this->assertFalse($isArchived);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Model/MemberModelTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Model;\n\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\ProjectMember;\nuse App\\Models\\User;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(Member::class)]\nclass MemberModelTest extends ModelTestAbstract\n{\n    public function test_it_belongs_to_a_user(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $member = Member::factory()->forUser($user)->create();\n\n        // Act\n        $member->refresh();\n        $userRel = $member->user;\n\n        // Assert\n        $this->assertNotNull($userRel);\n        $this->assertTrue($userRel->is($user));\n    }\n\n    public function test_it_belongs_to_a_organization(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $member = Member::factory()->forOrganization($organization)->create();\n\n        // Act\n        $member->refresh();\n        $organizationRel = $member->organization;\n\n        // Assert\n        $this->assertNotNull($organizationRel);\n        $this->assertTrue($organizationRel->is($organization));\n    }\n\n    public function test_it_has_many_project_members(): void\n    {\n        // Arrange\n        $member = Member::factory()->create();\n        $project1 = Project::factory()->create();\n        $project2 = Project::factory()->create();\n        $projectMember1 = ProjectMember::factory()->forMember($member)->forProject($project1)->create();\n        $projectMember2 = ProjectMember::factory()->forMember($member)->forProject($project2)->createMany();\n\n        // Act\n        $member->refresh();\n        $projectMembersRel = $member->projectMembers;\n\n        // Assert\n        $this->assertNotNull($projectMembersRel);\n        $this->assertCount(2, $projectMembersRel);\n        $this->assertEqualsCanonicalizing([\n            $projectMember1->getKey(),\n            $projectMember2->first()->getKey(),\n        ], $projectMembersRel->pluck('id')->all());\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Model/ModelTestAbstract.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Model;\n\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Tests\\TestCase;\n\nabstract class ModelTestAbstract extends TestCase\n{\n    use RefreshDatabase;\n}\n"
  },
  {
    "path": "tests/Unit/Model/OrganizationModelTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Model;\n\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(Organization::class)]\nclass OrganizationModelTest extends ModelTestAbstract\n{\n    public function test_it_has_many_members(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $members = Member::factory()->forOrganization($organization)->createMany(3);\n\n        // Act\n        $organization->refresh();\n        $membersRel = $organization->members;\n\n        // Assert\n        $this->assertNotNull($membersRel);\n        $this->assertCount(3, $membersRel);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Model/Passport/TokenModelTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Model\\Passport;\n\nuse App\\Models\\Passport\\Client;\nuse App\\Models\\Passport\\Token;\nuse App\\Models\\User;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\Unit\\Model\\ModelTestAbstract;\n\n#[CoversClass(Token::class)]\nclass TokenModelTest extends ModelTestAbstract\n{\n    public function test_it_belongs_to_a_client(): void\n    {\n        // Arrange\n        $client = Client::factory()->create();\n        $token = Token::factory()->forClient($client)->create();\n\n        // Act\n        $token->refresh();\n        $clientRel = $token->client;\n\n        // Assert\n        $this->assertNotNull($clientRel);\n        $this->assertTrue($clientRel->is($client));\n    }\n\n    public function test_it_belongs_to_a_user(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $client = Client::factory()->create();\n        $token = Token::factory()->forUser($user)->forClient($client)->create();\n\n        // Act\n        $token->refresh();\n        $userRel = $token->user;\n\n        // Assert\n        $this->assertNotNull($userRel);\n        $this->assertTrue($userRel->is($user));\n    }\n\n    public function test_scope_is_api_tokens_only_returns_api_tokens_with_no_parameters(): void\n    {\n        // Arrange\n        $clientApi = Client::factory()->apiClient()->create();\n        $clientDesktop = Client::factory()->desktopClient()->create();\n        $token1 = Token::factory()->forClient($clientApi)->create();\n        $token2 = Token::factory()->forClient($clientDesktop)->create();\n\n        // Act\n        $apiTokens = Token::query()\n            ->isApiToken()\n            ->get();\n\n        // Assert\n        $this->assertCount(1, $apiTokens);\n        $this->assertTrue($apiTokens->first()->is($token1));\n    }\n\n    public function test_scope_is_api_tokens_only_returns_api_tokens_with_true(): void\n    {\n        // Arrange\n        $clientApi = Client::factory()->apiClient()->create();\n        $clientDesktop = Client::factory()->desktopClient()->create();\n        $token1 = Token::factory()->forClient($clientApi)->create();\n        $token2 = Token::factory()->forClient($clientDesktop)->create();\n\n        // Act\n        $apiTokens = Token::query()\n            ->isApiToken(true)\n            ->get();\n\n        // Assert\n        $this->assertCount(1, $apiTokens);\n        $this->assertTrue($apiTokens->first()->is($token1));\n    }\n\n    public function test_scope_is_api_tokens_only_returns_api_tokens_with_false(): void\n    {\n        // Arrange\n        $clientApi = Client::factory()->apiClient()->create();\n        $clientDesktop = Client::factory()->desktopClient()->create();\n        $token1 = Token::factory()->forClient($clientApi)->create();\n        $token2 = Token::factory()->forClient($clientDesktop)->create();\n\n        // Act\n        $apiTokens = Token::query()\n            ->isApiToken(false)\n            ->get();\n\n        // Assert\n        $this->assertCount(1, $apiTokens);\n        $this->assertTrue($apiTokens->first()->is($token2));\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Model/ProjectMemberModelTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Model;\n\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\ProjectMember;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(ProjectMember::class)]\nclass ProjectMemberModelTest extends ModelTestAbstract\n{\n    public function test_it_belongs_to_a_project(): void\n    {\n        // Arrange\n        $project = Project::factory()->create();\n        $member = Member::factory()->create();\n        $projectMember = ProjectMember::factory()->forProject($project)->forMember($member)->create();\n\n        // Act\n        $projectMember->refresh();\n        $projectRel = $projectMember->project;\n\n        // Assert\n        $this->assertNotNull($projectRel);\n        $this->assertTrue($projectRel->is($project));\n    }\n\n    public function test_it_belongs_to_a_member(): void\n    {\n        // Arrange\n        $member = Member::factory()->create();\n        $projectMember = ProjectMember::factory()->forMember($member)->create();\n\n        // Act\n        $projectMember->refresh();\n        $memberRel = $projectMember->member;\n\n        // Assert\n        $this->assertNotNull($memberRel);\n        $this->assertTrue($memberRel->is($member));\n    }\n\n    public function test_scope_where_belongs_to_organization_filters_project_members_to_only_retrieve_project_members_that_belong_to_a_project_of_the_organization(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $otherOrganization = Organization::factory()->create();\n        $project = Project::factory()->forOrganization($organization)->create();\n        $projectNotBelongingToOrganization = Project::factory()->forOrganization($otherOrganization)->create();\n        $projectMember = ProjectMember::factory()->forProject($project)->create();\n        $projectMemberNotBelongingToOrganization = ProjectMember::factory()->for($projectNotBelongingToOrganization)->create();\n\n        // Act\n        $projectMembers = ProjectMember::whereBelongsToOrganization($organization)->get();\n\n        // Assert\n        $this->assertCount(1, $projectMembers);\n        $this->assertTrue($projectMembers->first()->is($projectMember));\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Model/ProjectModelTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Model;\n\nuse App\\Models\\Client;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\ProjectMember;\nuse App\\Models\\Task;\nuse App\\Models\\TimeEntry;\nuse Illuminate\\Support\\Facades\\DB;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(Project::class)]\nclass ProjectModelTest extends ModelTestAbstract\n{\n    public function test_it_belongs_to_a_organization(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $project = Project::factory()->forOrganization($organization)->create();\n\n        // Act\n        $project->refresh();\n        $organizationRel = $project->organization;\n\n        // Assert\n        $this->assertNotNull($organizationRel);\n        $this->assertTrue($organizationRel->is($organization));\n    }\n\n    public function test_it_can_belong_to_a_client(): void\n    {\n        // Arrange\n        $client = Client::factory()->create();\n        $project = Project::factory()->forClient($client)->create();\n\n        // Act\n        $project->refresh();\n        $clientRel = $project->client;\n\n        // Assert\n        $this->assertNotNull($clientRel);\n        $this->assertTrue($clientRel->is($client));\n    }\n\n    public function test_it_can_belong_to_no_client(): void\n    {\n        // Arrange\n        $project = Project::factory()->forClient(null)->create();\n\n        // Act\n        $project->refresh();\n        $clientRel = $project->client;\n\n        // Assert\n        $this->assertNull($clientRel);\n    }\n\n    public function test_it_has_many_tasks(): void\n    {\n        // Arrange\n        $project = Project::factory()->create();\n        $tasks = Task::factory()->forProject($project)->createMany(3);\n\n        // Act\n        $project->refresh();\n        $tasksRel = $project->tasks;\n\n        // Assert\n        $this->assertNotNull($tasksRel);\n        $this->assertCount(3, $tasksRel);\n        $this->assertTrue($tasksRel->first()->is($tasks->first()));\n    }\n\n    public function test_it_has_many_members(): void\n    {\n        // Arrange\n        $project = Project::factory()->create();\n        $members = ProjectMember::factory()->forProject($project)->createMany(3);\n\n        // Act\n        $project->refresh();\n        $membersRel = $project->members;\n\n        // Assert\n        $this->assertNotNull($membersRel);\n        $this->assertCount(3, $membersRel);\n        $this->assertTrue($membersRel->first()->is($members->first()));\n    }\n\n    public function test_scope_visible_by_user_filters_so_that_only_public_projects_or_projects_where_the_user_is_member_are_shown(): void\n    {\n        // Arrange\n        $member = Member::factory()->create();\n        $projectPrivate = Project::factory()->isPrivate()->create();\n        $projectPublic = Project::factory()->isPublic()->create();\n        $projectPrivateButMember = Project::factory()->isPrivate()->create();\n        ProjectMember::factory()->forProject($projectPrivateButMember)->forMember($member)->create();\n\n        // Act\n        $projectsVisible = Project::query()->visibleByEmployee($member->user)->get();\n        $allProjects = Project::query()->get();\n\n        // Assert\n        $this->assertEqualsIdsOfEloquentCollection([\n            $projectPublic->getKey(),\n            $projectPrivateButMember->getKey(),\n        ], $projectsVisible);\n        $this->assertEqualsIdsOfEloquentCollection([\n            $projectPrivate->getKey(),\n            $projectPublic->getKey(),\n            $projectPrivateButMember->getKey(),\n        ], $allProjects);\n    }\n\n    public function test_computed_spent_time_returns_the_sum_of_all_time_entries_excl_running_timers(): void\n    {\n        // Arrange\n        $project = Project::factory()->create();\n        $otherProject = Project::factory()->create();\n        TimeEntry::factory()->forProject($project)->startWithDuration(now(), 10)->create();\n        TimeEntry::factory()->forProject($project)->startWithDuration(now(), 10)->create();\n        TimeEntry::factory()->forProject($project)->startWithDuration(now(), 10)->create();\n        TimeEntry::factory()->forProject($otherProject)->startWithDuration(now(), 10)->create();\n        TimeEntry::factory()->forProject($otherProject)->start(now())->active()->create();\n\n        // Act\n        $project->refresh();\n        $spentTime = $project->getSpentTimeComputed();\n\n        // Assert\n        $this->assertEquals(30, $spentTime);\n    }\n\n    public function test_computed_spent_time_returns_already_computed_value_if_present(): void\n    {\n        // Arrange\n        $project = Project::factory()->create();\n        $otherProject = Project::factory()->create();\n        TimeEntry::factory()->forProject($project)->startWithDuration(now(), 10)->create();\n        TimeEntry::factory()->forProject($project)->startWithDuration(now(), 10)->create();\n        TimeEntry::factory()->forProject($project)->startWithDuration(now(), 10)->create();\n        TimeEntry::factory()->forProject($otherProject)->startWithDuration(now(), 10)->create();\n        TimeEntry::factory()->forProject($otherProject)->start(now())->active()->create();\n        $timeEntries = Project::query()\n            ->withAggregate('timeEntries as spent_time_computed', DB::raw('extract(epoch from (\"end\" - start))'), 'sum')\n            ->get();\n\n        // Act\n        $project->refresh();\n        $spentTime = $timeEntries->first()->getSpentTimeComputed();\n\n        // Assert\n        $this->assertEquals(30, $spentTime);\n    }\n\n    public function test_accessor_is_archived_is_true_if_archived_at_is_not_null(): void\n    {\n        // Arrange\n        $project = Project::factory()->archived()->create();\n\n        // Act\n        $project->refresh();\n        $isArchived = $project->is_archived;\n\n        // Assert\n        $this->assertTrue($isArchived);\n    }\n\n    public function test_accessor_is_archived_is_false_if_archived_at_is_null(): void\n    {\n        // Arrange\n        $project = Project::factory()->create();\n\n        // Act\n        $project->refresh();\n        $isArchived = $project->is_archived;\n\n        // Assert\n        $this->assertFalse($isArchived);\n    }\n\n    public function test_project_can_store_big_amounts_of_spent_time(): void\n    {\n        // Arrange\n        $project = Project::factory()->create();\n        $spentTime = 100 * 365 * 24 * 60 * 60; // 100 years in seconds\n\n        // Act\n        $project->spent_time = $spentTime;\n        $project->save();\n        $project->refresh();\n\n        // Assert\n        $this->assertSame($spentTime, $project->spent_time);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Model/ReportModelTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Model;\n\nuse App\\Models\\Organization;\nuse App\\Models\\Report;\nuse App\\Service\\ReportService;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(Report::class)]\nclass ReportModelTest extends ModelTestAbstract\n{\n    public function test_it_belongs_to_a_organization(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $report = Report::factory()->forOrganization($organization)->create();\n\n        // Act\n        $report->refresh();\n        $organizationRel = $report->organization;\n\n        // Assert\n        $this->assertNotNull($organizationRel);\n        $this->assertTrue($organizationRel->is($organization));\n    }\n\n    public function test_shareable_link_is_null_when_report_is_private_but_share_secret_exists(): void\n    {\n        // Arrange\n        $report = Report::factory()->private()->create([\n            'share_secret' => app(ReportService::class)->generateSecret(),\n        ]);\n\n        // Act\n        $report->refresh();\n\n        // Assert\n        $this->assertNull($report->getShareableLink());\n    }\n\n    public function test_shareable_link_is_null_when_report_is_public_but_share_secret_is_null(): void\n    {\n        // Arrange\n        $report = Report::factory()->public()->create([\n            'share_secret' => null,\n        ]);\n\n        // Act\n        $report->refresh();\n\n        // Assert\n        $this->assertNull($report->getShareableLink());\n    }\n\n    public function test_shareable_link_is_null_when_report_is_public(): void\n    {\n        // Arrange\n        $report = Report::factory()->public()->create();\n\n        // Act\n        $report->refresh();\n\n        // Assert\n        $this->assertNotNull($report->getShareableLink());\n    }\n\n    public function test_shareable_link_is_url_to_web_endpoint_when_report_is_public(): void\n    {\n        // Arrange\n        $report = Report::factory()->public()->create();\n\n        // Act\n        $report->refresh();\n\n        // Assert\n        $this->assertSame(url('/shared-report#'.$report->share_secret), $report->getShareableLink());\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Model/TagModelTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Model;\n\nuse App\\Models\\Organization;\nuse App\\Models\\Tag;\nuse App\\Models\\TimeEntry;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(Tag::class)]\nclass TagModelTest extends ModelTestAbstract\n{\n    public function test_it_belongs_to_a_organization(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $tag = Tag::factory()->forOrganization($organization)->create();\n\n        // Act\n        $tag->refresh();\n        $organizationRel = $tag->organization;\n\n        // Assert\n        $this->assertNotNull($organizationRel);\n        $this->assertTrue($organizationRel->is($organization));\n    }\n\n    public function test_it_has_many_time_entries_via_json_field(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $tag1 = Tag::factory()->forOrganization($organization)->create();\n        $tag2 = Tag::factory()->forOrganization($organization)->create();\n        $timeEntry1 = TimeEntry::factory()->forOrganization($organization)->create([\n            'tags' => [$tag1->id, $tag2->id],\n        ]);\n        $timeEntry2 = TimeEntry::factory()->forOrganization($organization)->create([\n            'tags' => [$tag1->id],\n        ]);\n        $timeEntry3 = TimeEntry::factory()->forOrganization($organization)->create([\n            'tags' => [$tag2->id],\n        ]);\n\n        // Act\n        $tag1->refresh();\n        $timeEntries = $tag1->timeEntries;\n\n        // Assert\n        $this->assertCount(2, $timeEntries);\n        $this->assertEqualsCanonicalizing([$timeEntry1->getKey(), $timeEntry2->getKey()], $timeEntries->pluck('id')->toArray());\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Model/TaskModelTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Model;\n\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\ProjectMember;\nuse App\\Models\\Task;\nuse App\\Models\\TimeEntry;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(Task::class)]\nclass TaskModelTest extends ModelTestAbstract\n{\n    public function test_it_belongs_to_a_organization(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $task = Task::factory()->forOrganization($organization)->create();\n\n        // Act\n        $task->refresh();\n        $organizationRel = $task->organization;\n\n        // Assert\n        $this->assertNotNull($organizationRel);\n        $this->assertTrue($organizationRel->is($organization));\n    }\n\n    public function test_it_belongs_to_a_project(): void\n    {\n        // Arrange\n        $project = Project::factory()->create();\n        $task = Task::factory()->forProject($project)->create();\n\n        // Act\n        $task->refresh();\n        $projectRel = $task->project;\n\n        // Assert\n        $this->assertNotNull($projectRel);\n        $this->assertTrue($projectRel->is($project));\n    }\n\n    public function test_it_has_many_time_entries(): void\n    {\n        // Arrange\n        $otherTask = Task::factory()->create();\n        $task = Task::factory()->create();\n        $timeEntries = TimeEntry::factory()->forTask($task)->count(3)->create();\n        $otherTimeEntries = TimeEntry::factory()->forTask($otherTask)->count(2)->create();\n\n        // Act\n        $task->refresh();\n        $timeEntries = $task->timeEntries;\n\n        // Assert\n        $this->assertCount(3, $timeEntries);\n    }\n\n    public function test_scope_visible_by_user_filters_so_that_only_tasks_of_public_projects_or_projects_where_the_user_is_member_are_shown(): void\n    {\n        // Arrange\n        $member = Member::factory()->create();\n        $projectPrivate = Project::factory()->isPrivate()->create();\n        $projectPublic = Project::factory()->isPublic()->create();\n        $projectPrivateButMember = Project::factory()->isPrivate()->create();\n        ProjectMember::factory()->forProject($projectPrivateButMember)->forMember($member)->create();\n        $taskPrivate = Task::factory()->forProject($projectPrivate)->create();\n        $taskPublic = Task::factory()->forProject($projectPublic)->create();\n        $taskPrivateButMember = Task::factory()->forProject($projectPrivateButMember)->create();\n\n        // Act\n        $tasksVisible = Task::query()->visibleByEmployee($member->user)->get();\n        $allTasks = Task::query()->get();\n\n        // Assert\n        $this->assertEqualsIdsOfEloquentCollection([\n            $taskPublic->getKey(),\n            $taskPrivateButMember->getKey(),\n        ], $tasksVisible);\n        $this->assertEqualsIdsOfEloquentCollection([\n            $taskPrivate->getKey(),\n            $taskPublic->getKey(),\n            $taskPrivateButMember->getKey(),\n        ], $allTasks);\n    }\n\n    public function test_accessor_is_done_is_true_if_done_at_is_not_null(): void\n    {\n        // Arrange\n        $task = Task::factory()->isDone()->create();\n\n        // Act\n        $task->refresh();\n\n        // Assert\n        $this->assertTrue($task->is_done);\n    }\n\n    public function test_accessor_is_done_is_false_if_done_at_is_null(): void\n    {\n        // Arrange\n        $task = Task::factory()->create();\n\n        // Act\n        $task->refresh();\n\n        // Assert\n        $this->assertFalse($task->is_done);\n    }\n\n    public function test_task_can_store_big_amounts_of_spent_time(): void\n    {\n        // Arrange\n        $task = Task::factory()->create();\n        $spentTime = 100 * 365 * 24 * 60 * 60; // 100 years in seconds\n\n        // Act\n        $task->spent_time = $spentTime;\n        $task->save();\n        $task->refresh();\n\n        // Assert\n        $this->assertSame($spentTime, $task->spent_time);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Model/TimeEntryModelTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Model;\n\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\Tag;\nuse App\\Models\\Task;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse Illuminate\\Support\\Carbon;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(TimeEntry::class)]\nclass TimeEntryModelTest extends ModelTestAbstract\n{\n    public function test_it_belongs_to_a_user(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $timeEntry = TimeEntry::factory()->forUser($user)->create();\n\n        // Act\n        $timeEntry->refresh();\n        $userRel = $timeEntry->user;\n\n        // Assert\n        $this->assertNotNull($userRel);\n        $this->assertTrue($userRel->is($user));\n    }\n\n    public function test_it_belongs_to_a_organization(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $timeEntry = TimeEntry::factory()->forOrganization($organization)->create();\n\n        // Act\n        $timeEntry->refresh();\n        $organizationRel = $timeEntry->organization;\n\n        // Assert\n        $this->assertNotNull($organizationRel);\n        $this->assertTrue($organizationRel->is($organization));\n    }\n\n    public function test_it_can_belong_to_a_project(): void\n    {\n        // Arrange\n        $project = Project::factory()->create();\n        $timeEntry = TimeEntry::factory()->forProject($project)->create();\n\n        // Act\n        $timeEntry->refresh();\n        $projectRel = $timeEntry->project;\n\n        // Assert\n        $this->assertNotNull($projectRel);\n        $this->assertTrue($projectRel->is($project));\n    }\n\n    public function test_it_can_belong_to_no_project(): void\n    {\n        // Arrange\n        $timeEntry = TimeEntry::factory()->forProject(null)->create();\n\n        // Act\n        $timeEntry->refresh();\n        $project = $timeEntry->project;\n\n        // Assert\n        $this->assertNull($project);\n    }\n\n    public function test_it_can_belong_to_a_task(): void\n    {\n        // Arrange\n        $task = Task::factory()->create();\n        $timeEntry = TimeEntry::factory()->forTask($task)->create();\n\n        // Act\n        $timeEntry->refresh();\n        $taskRel = $timeEntry->task;\n\n        // Assert\n        $this->assertNotNull($taskRel);\n        $this->assertTrue($taskRel->is($task));\n    }\n\n    public function test_it_can_belong_to_no_task(): void\n    {\n        // Arrange\n        $timeEntry = TimeEntry::factory()->forTask(null)->create();\n\n        // Act\n        $timeEntry->refresh();\n        $taskRel = $timeEntry->task;\n\n        // Assert\n        $this->assertNull($taskRel);\n    }\n\n    public function test_eloquent_datetime_columns_remove_timezone_information_during_save(): void\n    {\n        // Arrange\n        $timeEntry = TimeEntry::factory()->forTask(null)->create();\n\n        // Act\n        $timeEntry->start = Carbon::create(2021, 1, 1, 12, 0, 0, 'UTC')->timezone('+1');\n        $timeEntry->save();\n\n        // Assert\n        $timeEntry->refresh();\n        $this->assertSame('UTC', $timeEntry->start->getTimezone()->toRegionName());\n        $this->assertSame('2021-01-01 13:00:00', $timeEntry->start->toDateTimeString());\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'start' => '2021-01-01 13:00:00',\n        ]);\n    }\n\n    public function test_scope_has_tag_filter_by_tag(): void\n    {\n        // Arrange\n        $tag1 = Tag::factory()->create();\n        $tag2 = Tag::factory()->create();\n        $timeEntry1 = TimeEntry::factory()->create([\n            'tags' => [$tag1->getKey()],\n        ]);\n        $timeEntry2 = TimeEntry::factory()->create([\n            'tags' => [$tag2->getKey()],\n        ]);\n        $timeEntry3 = TimeEntry::factory()->create([\n            'tags' => ['something-else'],\n        ]);\n        $timeEntry4 = TimeEntry::factory()->create([\n            'tags' => null,\n        ]);\n\n        // Act\n        $result = TimeEntry::hasTag($tag1)->get();\n\n        // Assert\n        $this->assertCount(1, $result);\n        $this->assertTrue($result->first()->is($timeEntry1));\n    }\n\n    public function test_computed_client_id_returns_null_when_no_project_is_assigned(): void\n    {\n        // Arrange\n        $timeEntry = TimeEntry::factory()->forProject(null)->create();\n        $timeEntry->client_id = null;\n        $timeEntry->save();\n\n        // Act\n        $timeEntry->setComputedAttributeValue('client_id');\n        $clientId = $timeEntry->client_id;\n\n        // Assert\n        $this->assertNull($clientId);\n    }\n\n    public function test_computed_client_id_returns_project_client_id(): void\n    {\n        // Arrange\n        $project = Project::factory()->create();\n        $timeEntry = TimeEntry::factory()->forProject($project)->create();\n        $timeEntry->client_id = null;\n        $timeEntry->save();\n\n        // Act\n        $timeEntry->setComputedAttributeValue('client_id');\n        $clientId = $timeEntry->client_id;\n\n        // Assert\n        $this->assertSame($project->client_id, $clientId);\n    }\n\n    public function test_has_many_tags_via_json_relation(): void\n    {\n        // Arrange\n        $tag1 = Tag::factory()->create();\n        $tag2 = Tag::factory()->create();\n        $timeEntry = TimeEntry::factory()->create([\n            'tags' => [$tag1->getKey(), $tag2->getKey()],\n        ]);\n\n        // Act\n        $timeEntry->refresh();\n        $tags = $timeEntry->tagsRelation;\n\n        // Assert\n        $this->assertCount(2, $tags);\n        $this->assertTrue($tags->contains($tag1));\n        $this->assertTrue($tags->contains($tag2));\n    }\n\n    public function test_has_many_tags_via_json_relation_eager_loaded(): void\n    {\n        // Arrange\n        $tag1 = Tag::factory()->create();\n        $tag2 = Tag::factory()->create();\n        $timeEntry1 = TimeEntry::factory()->create([\n            'tags' => [$tag1->getKey(), $tag2->getKey()],\n            'created_at' => Carbon::now()->subDay(),\n        ]);\n        $timeEntry2 = TimeEntry::factory()->create([\n            'tags' => [$tag1->getKey()],\n            'created_at' => Carbon::now()->subDays(2),\n        ]);\n\n        // Act\n        $timeEntries = TimeEntry::with('tagsRelation')->orderBy('created_at', 'desc')->get();\n        $tags1 = $timeEntries->get(0)->tagsRelation;\n        $tags2 = $timeEntries->get(1)->tagsRelation;\n\n        // Assert\n        $this->assertCount(2, $tags1);\n        $this->assertTrue($tags1->contains($tag1));\n        $this->assertTrue($tags1->contains($tag2));\n        $this->assertCount(1, $tags2);\n        $this->assertTrue($tags2->contains($tag1));\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Model/UserModelTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Model;\n\nuse App\\Enums\\Role;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\Passport\\AuthCode;\nuse App\\Models\\Passport\\Client;\nuse App\\Models\\Passport\\Token;\nuse App\\Models\\ProjectMember;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse App\\Providers\\Filament\\AdminPanelProvider;\nuse Filament\\Panel;\nuse Illuminate\\Support\\Facades\\Config;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(User::class)]\nclass UserModelTest extends ModelTestAbstract\n{\n    public function test_normal_user_can_not_access_admin_panel(): void\n    {\n        // Arrange\n        Config::set('auth.super_admins', ['some@email.test', 'other@email.test']);\n        $user = User::factory()->create();\n        $panelProvider = new AdminPanelProvider(app());\n        $mainPanel = $panelProvider->panel(Panel::make());\n\n        // Act\n        $canAccess = $user->canAccessPanel($mainPanel);\n\n        // Assert\n        $this->assertFalse($canAccess);\n    }\n\n    public function test_user_in_super_admin_config_can_access_admin_panel(): void\n    {\n        // Arrange\n        Config::set('auth.super_admins', ['some@email.test', 'other@email.test']);\n        $user = User::factory()->create([\n            'email' => 'some@email.test',\n        ]);\n        $panelProvider = new AdminPanelProvider(app());\n        $mainPanel = $panelProvider->panel(Panel::make());\n\n        // Act\n        $canAccess = $user->canAccessPanel($mainPanel);\n\n        // Assert\n        $this->assertTrue($canAccess);\n    }\n\n    public function test_scope_belongs_to_organization_returns_only_users_of_organization_including_owners(): void\n    {\n        // Arrange\n        $owner = User::factory()->create();\n        $organization = Organization::factory()->withOwner($owner)->create();\n        $user = User::factory()->create();\n        $user->organizations()->attach($organization, [\n            'role' => Role::Employee->value,\n        ]);\n        $otherOrganization = Organization::factory()->create();\n        $otherUser = User::factory()->create();\n        $otherUser->organizations()->attach($otherOrganization, [\n            'role' => Role::Employee->value,\n        ]);\n\n        // Act\n        $users = User::query()\n            ->belongsToOrganization($organization)\n            ->get();\n\n        // Assert\n        $this->assertCount(2, $users);\n        $userIds = $users->pluck('id')->toArray();\n        $this->assertContains($user->getKey(), $userIds);\n        $this->assertContains($owner->getKey(), $userIds);\n    }\n\n    public function test_it_has_many_time_entries(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $timeEntries = TimeEntry::factory()->forUser($user)->createMany(3);\n\n        // Act\n        $user->refresh();\n        $timeEntriesRel = $user->timeEntries;\n\n        // Assert\n        $this->assertNotNull($timeEntriesRel);\n        $this->assertCount(3, $timeEntriesRel);\n        $this->assertEqualsCanonicalizing(\n            $timeEntries->pluck('id')->toArray(),\n            $timeEntriesRel->pluck('id')->toArray()\n        );\n    }\n\n    public function test_it_has_many_project_members(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $otherUser = User::factory()->create();\n        $member = Member::factory()->forUser($user)->create();\n        $otherMember = Member::factory()->forUser($otherUser)->create();\n        $projectMembers = ProjectMember::factory()->forMember($member)->createMany(3);\n        $otherProjectMembers = ProjectMember::factory()->forMember($otherMember)->createMany(3);\n\n        // Act\n        $user->refresh();\n        $projectMembersRel = $user->projectMembers;\n\n        // Assert\n        $this->assertNotNull($projectMembersRel);\n        $this->assertCount(3, $projectMembersRel);\n        $this->assertEqualsCanonicalizing(\n            $projectMembers->pluck('id')->toArray(),\n            $projectMembersRel->pluck('id')->toArray()\n        );\n    }\n\n    public function test_scope_active_returns_only_non_placeholder_users(): void\n    {\n        // Arrange\n        $placeholder = User::factory()->create([\n            'is_placeholder' => true,\n        ]);\n        $user = User::factory()->create([\n            'is_placeholder' => false,\n        ]);\n\n        // Act\n        $activeUsers = User::query()->active()->get();\n\n        // Assert\n        $this->assertCount(1, $activeUsers);\n        $this->assertTrue($activeUsers->first()->is($user));\n    }\n\n    public function test_it_has_many_access_tokens(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $client = new Client;\n        $client->name = 'desktop';\n        $client->redirect_uris = ['solidtime://oauth/callback'];\n        $client->grant_types = [];\n        $client->revoked = false;\n        $client->save();\n        $token = new Token;\n        $token->id = 'some-id';\n        $token->user_id = $user->getKey();\n        $token->client_id = $client->getKey();\n        $token->revoked = false;\n        $token->save();\n\n        // Act\n        $user->refresh();\n        $tokensRel = $user->accessTokens;\n\n        // Assert\n        $this->assertNotNull($tokensRel);\n        $this->assertCount(1, $tokensRel);\n        $this->assertEqualsCanonicalizing(\n            [$token->getKey()],\n            $tokensRel->pluck('id')->toArray()\n        );\n    }\n\n    public function test_it_has_many_auth_codes(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $client = new Client;\n        $client->name = 'desktop';\n        $client->redirect_uris = 'solidtime://oauth/callback';\n        $client->grant_types = [];\n        $client->revoked = false;\n        $client->save();\n        $authCode = new AuthCode;\n        $authCode->id = 'some-id';\n        $authCode->user_id = $user->getKey();\n        $authCode->client_id = $client->getKey();\n        $authCode->revoked = false;\n        $authCode->save();\n\n        // Act\n        $user->refresh();\n        $authCodesRel = $user->authCodes;\n\n        // Assert\n        $this->assertNotNull($authCodesRel);\n        $this->assertCount(1, $authCodesRel);\n        $this->assertEqualsCanonicalizing(\n            [$authCode->getKey()],\n            $authCodesRel->pluck('id')->toArray()\n        );\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Rules/ColorRuleTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Rules;\n\nuse App\\Rules\\ColorRule;\nuse Illuminate\\Support\\Facades\\Validator;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCase;\n\n#[CoversClass(ColorRule::class)]\nclass ColorRuleTest extends TestCase\n{\n    public function test_validation_passes_if_value_is_valid_color(): void\n    {\n        // Arrange\n        $validator = Validator::make([\n            'color' => '#ef5350',\n        ], [\n            'color' => [new ColorRule],\n        ]);\n\n        // Act\n        $isValid = $validator->passes();\n        $messages = $validator->messages()->toArray();\n\n        // Assert\n        $this->assertTrue($isValid);\n        $this->assertArrayNotHasKey('color', $messages);\n    }\n\n    public function test_validation_fails_if_value_is_not_a_string(): void\n    {\n        // Arrange\n        $validator = Validator::make([\n            'color' => true,\n        ], [\n            'color' => [new ColorRule],\n        ]);\n\n        // Act\n        $isValid = $validator->passes();\n        $messages = $validator->messages()->toArray();\n\n        // Assert\n        $this->assertFalse($isValid);\n        $this->assertEquals('The color field must be a string.', $messages['color'][0]);\n    }\n\n    public function test_validation_fails_if_value_is_not_a_valid_color(): void\n    {\n        // Arrange\n        $validator = Validator::make([\n            'color' => 'rgb(0,0,0)',\n        ], [\n            'color' => [new ColorRule],\n        ]);\n\n        // Act\n        $isValid = $validator->passes();\n        $messages = $validator->messages()->toArray();\n\n        // Assert\n        $this->assertFalse($isValid);\n        $this->assertEquals('The color field must be a valid color.', $messages['color'][0]);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Rules/CurrencyRuleTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Rules;\n\nuse App\\Rules\\CurrencyRule;\nuse Illuminate\\Support\\Facades\\Validator;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCase;\n\n#[CoversClass(CurrencyRule::class)]\nclass CurrencyRuleTest extends TestCase\n{\n    public function test_validation_passes_if_value_is_valid_currency_code(): void\n    {\n        // Arrange\n        $validator = Validator::make([\n            'currency' => 'EUR',\n        ], [\n            'currency' => [new CurrencyRule],\n        ]);\n\n        // Act\n        $isValid = $validator->passes();\n        $messages = $validator->messages()->toArray();\n\n        // Assert\n        $this->assertTrue($isValid);\n        $this->assertArrayNotHasKey('currency', $messages);\n    }\n\n    public function test_validation_fails_if_value_is_not_a_string(): void\n    {\n        // Arrange\n        $validator = Validator::make([\n            'currency' => true,\n        ], [\n            'currency' => [new CurrencyRule],\n        ]);\n\n        // Act\n        $isValid = $validator->passes();\n        $messages = $validator->messages()->toArray();\n\n        // Assert\n        $this->assertFalse($isValid);\n        $this->assertEquals('The currency field must be a string.', $messages['currency'][0]);\n    }\n\n    public function test_validation_fails_if_value_is_not_a_valid_currency(): void\n    {\n        // Arrange\n        $validator = Validator::make([\n            'currency' => 'XXX',\n        ], [\n            'currency' => [new CurrencyRule],\n        ]);\n\n        // Act\n        $isValid = $validator->passes();\n        $messages = $validator->messages()->toArray();\n\n        // Assert\n        $this->assertFalse($isValid);\n        $this->assertEquals('The currency field must be a valid currency code (ISO 4217).', $messages['currency'][0]);\n    }\n\n    public function test_validation_fails_if_value_is_lower_case(): void\n    {\n        // Arrange\n        $validator = Validator::make([\n            'currency' => 'eur',\n        ], [\n            'currency' => [new CurrencyRule],\n        ]);\n\n        // Act\n        $isValid = $validator->passes();\n        $messages = $validator->messages()->toArray();\n\n        // Assert\n        $this->assertFalse($isValid);\n        $this->assertEquals('The currency field must be a valid currency code (ISO 4217).', $messages['currency'][0]);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/BillableRateServiceTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service;\n\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\ProjectMember;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse App\\Service\\BillableRateService;\nuse DB;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCaseWithDatabase;\n\n#[CoversClass(BillableRateService::class)]\nclass BillableRateServiceTest extends TestCaseWithDatabase\n{\n    use RefreshDatabase;\n\n    private BillableRateService $billableRateService;\n\n    protected function setUp(): void\n    {\n        parent::setUp();\n        $this->billableRateService = app(BillableRateService::class);\n    }\n\n    /*\n     * Function: updateTimeEntriesBillableRateForProjectMember\n     */\n\n    public function test_update_time_entries_billable_rate_for_project_member_updates_time_entries_of_project_member(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $project = Project::factory()->forOrganization($user->organization)->create();\n        $projectMember = ProjectMember::factory()->forMember($user->member)->forProject($project)->create([\n            'billable_rate' => 123,\n        ]);\n        $timeEntry = TimeEntry::factory()->forMember($user->member)->forProject($project)->billableRate(1)->create();\n        $this->enableQueryLog();\n\n        // Act\n        $this->billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);\n\n        // Assert\n        $this->assertQueryCount(1);\n        $this->assertDatabaseCount(TimeEntry::class, 1);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'billable_rate' => 123,\n        ]);\n    }\n\n    public function test_update_time_entries_billable_rate_for_project_member_updates_time_entries_of_project_member_even_if_all_other_billable_rates_are_set(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $organization = $user->organization;\n        $member = $user->member;\n        $organization->billable_rate = 111;\n        $organization->save();\n        $member->billable_rate = 222;\n        $member->save();\n        $project = Project::factory()->forOrganization($user->organization)->create([\n            'billable_rate' => 321,\n        ]);\n        $projectMember = ProjectMember::factory()->forMember($user->member)->forProject($project)->create([\n            'billable_rate' => 123,\n        ]);\n        $timeEntry = TimeEntry::factory()->forMember($user->member)->forProject($project)->billableRate(1)->create();\n        $this->enableQueryLog();\n\n        // Act\n        $this->billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);\n\n        // Assert\n        $this->assertQueryCount(1);\n        $this->assertDatabaseCount(TimeEntry::class, 1);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'billable_rate' => 123,\n        ]);\n    }\n\n    public function test_update_time_entries_billable_rate_for_project_member_ignores_time_entries_of_other_member(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $otherUser = User::factory()->create();\n        $otherMember = Member::factory()->forUser($otherUser)->forOrganization($user->organization)->create();\n        $project = Project::factory()->forOrganization($user->organization)->create();\n        $projectMember = ProjectMember::factory()->forMember($user->member)->forProject($project)->create([\n            'billable_rate' => 123,\n        ]);\n        $otherProjectMember = ProjectMember::factory()->forMember($otherMember)->forProject($project)->create([\n            'billable_rate' => 321,\n        ]);\n        $timeEntry = TimeEntry::factory()->forMember($otherMember)->forProject($project)->billableRate(1)->create();\n        $this->enableQueryLog();\n\n        // Act\n        $this->billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);\n\n        // Assert\n        $this->assertQueryCount(1);\n        $this->assertDatabaseCount(TimeEntry::class, 1);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'billable_rate' => 1,\n        ]);\n    }\n\n    /*\n     * Function: updateTimeEntriesBillableRateForProject\n     */\n\n    public function test_update_time_entries_billable_rate_for_project_updates_time_entries_of_project(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $project = Project::factory()->forOrganization($user->organization)->create([\n            'billable_rate' => 321,\n        ]);\n        $timeEntry = TimeEntry::factory()->forMember($user->member)->forProject($project)->billableRate(1)->create();\n        $this->enableQueryLog();\n\n        // Act\n        $this->billableRateService->updateTimeEntriesBillableRateForProject($project);\n\n        // Assert\n        $this->assertQueryCount(1);\n        $this->assertDatabaseCount(TimeEntry::class, 1);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'billable_rate' => 321,\n        ]);\n    }\n\n    public function test_update_time_entries_billable_rate_for_project_updates_time_entries_of_project_all_other_billable_rates_null(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $project = Project::factory()->forOrganization($user->organization)->create([\n            'billable_rate' => 321,\n        ]);\n        $projectMember = ProjectMember::factory()->forMember($user->member)->forProject($project)->create([\n            'billable_rate' => null,\n        ]);\n        $timeEntry = TimeEntry::factory()->forMember($user->member)->forProject($project)->billableRate(1)->create();\n        $this->enableQueryLog();\n\n        // Act\n        $this->billableRateService->updateTimeEntriesBillableRateForProject($project);\n\n        // Assert\n        $this->assertQueryCount(1);\n        $this->assertDatabaseCount(TimeEntry::class, 1);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'billable_rate' => 321,\n        ]);\n    }\n\n    public function test_update_time_entries_billable_rate_for_project_ignores_time_entries_that_are_not_billable(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $project = Project::factory()->forOrganization($user->organization)->create([\n            'billable_rate' => 321,\n        ]);\n        $timeEntry = TimeEntry::factory()->forMember($user->member)->forProject($project)->notBillable()->create();\n        $this->enableQueryLog();\n\n        // Act\n        $this->billableRateService->updateTimeEntriesBillableRateForProject($project);\n\n        // Assert\n        $this->assertQueryCount(1);\n        $this->assertDatabaseCount(TimeEntry::class, 1);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'billable_rate' => null,\n        ]);\n    }\n\n    public function test_update_time_entries_billable_rate_for_project_ignores_time_entries_that_have_project_member_with_billable_rate(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $project = Project::factory()->forOrganization($user->organization)->create([\n            'billable_rate' => 321,\n        ]);\n        $projectMember = ProjectMember::factory()->forMember($user->member)->forProject($project)->create([\n            'billable_rate' => 123,\n        ]);\n        $timeEntry = TimeEntry::factory()->forMember($user->member)->forProject($project)->billableRate(1)->create();\n        $this->enableQueryLog();\n\n        // Act\n        $this->billableRateService->updateTimeEntriesBillableRateForProject($project);\n\n        // Assert\n        $this->assertQueryCount(1);\n        $this->assertDatabaseCount(TimeEntry::class, 1);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'billable_rate' => 1,\n        ]);\n    }\n\n    public function test_update_time_entries_billable_rate_for_project_ignores_time_entries_of_that_project_but_are_incorrectly_attached_to_other_organization(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $userInOtherOrga = $this->createUserWithPermission();\n        $project = Project::factory()->forOrganization($user->organization)->create([\n            'billable_rate' => 321,\n        ]);\n        $timeEntry = TimeEntry::factory()->forMember($user->member)->forProject($project)->billableRate(1)->create();\n        $brokenTimeEntryInOtherOrganizationButSameProject = TimeEntry::factory()->forMember($userInOtherOrga->member)->forProject($project)->billableRate(1)->create();\n        $this->enableQueryLog();\n\n        // Act\n        $this->billableRateService->updateTimeEntriesBillableRateForProject($project);\n\n        // Assert\n        $this->assertQueryCount(1);\n        $this->assertDatabaseCount(TimeEntry::class, 2);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'billable_rate' => 321,\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $brokenTimeEntryInOtherOrganizationButSameProject->getKey(),\n            'billable_rate' => 1,\n        ]);\n    }\n\n    /*\n     * Function: updateTimeEntriesBillableRateForMember\n     */\n\n    public function test_update_time_entries_billable_rate_for_member_updates_time_entries_of_member(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $member = $user->member;\n        $member->billable_rate = 567;\n        $member->save();\n        $timeEntry = TimeEntry::factory()->forMember($member)->billableRate(1)->create();\n        $this->enableQueryLog();\n\n        // Act\n        $this->billableRateService->updateTimeEntriesBillableRateForMember($member);\n\n        // Assert\n        $this->assertQueryCount(1);\n        $this->assertDatabaseCount(TimeEntry::class, 1);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'billable_rate' => 567,\n        ]);\n    }\n\n    public function test_update_time_entries_billable_rate_for_member_updates_time_entries_of_member_all_other_billable_rates_null(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $member = $user->member;\n        $member->billable_rate = 110;\n        $member->save();\n        $project = Project::factory()->forOrganization($user->organization)->create([\n            'billable_rate' => null,\n        ]);\n        $projectMember = ProjectMember::factory()->forMember($member)->forProject($project)->create([\n            'billable_rate' => null,\n        ]);\n        $timeEntry = TimeEntry::factory()->forMember($member)->forProject($project)->billableRate(1)->create();\n        $this->enableQueryLog();\n\n        // Act\n        $this->billableRateService->updateTimeEntriesBillableRateForMember($member);\n\n        // Assert\n        $queryLog = DB::getQueryLog();\n        $this->assertCount(1, $queryLog);\n        $this->assertDatabaseCount(TimeEntry::class, 1);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'billable_rate' => 110,\n        ]);\n    }\n\n    public function test_update_time_entries_billable_rate_for_member_ignores_time_entries_that_have_project_member_with_billable_rate(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $member = $user->member;\n        $member->billable_rate = 110;\n        $member->save();\n        $project = Project::factory()->forOrganization($user->organization)->create([\n            'billable_rate' => null,\n        ]);\n        $projectMember = ProjectMember::factory()->forMember($member)->forProject($project)->create([\n            'billable_rate' => 123,\n        ]);\n        $timeEntry = TimeEntry::factory()->forMember($member)->forProject($project)->billableRate(1)->create();\n        $this->enableQueryLog();\n\n        // Act\n        $this->billableRateService->updateTimeEntriesBillableRateForMember($member);\n\n        // Assert\n        $queryLog = DB::getQueryLog();\n        $this->assertCount(1, $queryLog);\n        $this->assertDatabaseCount(TimeEntry::class, 1);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'billable_rate' => 1,\n        ]);\n    }\n\n    public function test_update_time_entries_billable_rate_for_member_ignores_time_entries_that_have_project_with_billable_rate(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $member = $user->member;\n        $member->billable_rate = 110;\n        $member->save();\n        $project = Project::factory()->forOrganization($user->organization)->create([\n            'billable_rate' => 123,\n        ]);\n        $timeEntry = TimeEntry::factory()->forMember($member)->forProject($project)->billableRate(1)->create();\n        $this->enableQueryLog();\n\n        // Act\n        $this->billableRateService->updateTimeEntriesBillableRateForMember($member);\n\n        // Assert\n        $queryLog = DB::getQueryLog();\n        $this->assertCount(1, $queryLog);\n        $this->assertDatabaseCount(TimeEntry::class, 1);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'billable_rate' => 1,\n        ]);\n    }\n\n    /*\n     * Function: updateTimeEntriesBillableRateForOrganization\n     */\n\n    public function test_update_time_entries_billable_rate_for_organization_updates_time_entries_of_organization(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n\n        $organization = $user->organization;\n        $organization->billable_rate = 110;\n        $organization->save();\n        $timeEntry = TimeEntry::factory()->forMember($user->member)->billableRate(1)->create();\n        $this->enableQueryLog();\n\n        // Act\n        $this->billableRateService->updateTimeEntriesBillableRateForOrganization($user->organization);\n\n        // Assert\n        $this->assertQueryCount(1);\n        $this->assertDatabaseCount(TimeEntry::class, 1);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'billable_rate' => 110,\n        ]);\n    }\n\n    public function test_update_time_entries_billable_rate_for_organization_updates_time_entries_of_organization_all_other_billable_rates_null(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $member = $user->member;\n        $organization = $user->organization;\n        $organization->billable_rate = 110;\n        $organization->save();\n        $project = Project::factory()->forOrganization($organization)->create([\n            'billable_rate' => null,\n        ]);\n        $projectMember = ProjectMember::factory()->forProject($project)->forMember($member)->create([\n            'billable_rate' => null,\n        ]);\n\n        $timeEntry = TimeEntry::factory()->forMember($user->member)->forProject($project)->billableRate(1)->create();\n        $this->enableQueryLog();\n\n        // Act\n        $this->billableRateService->updateTimeEntriesBillableRateForOrganization($user->organization);\n\n        // Assert\n        $this->assertQueryCount(1);\n        $this->assertDatabaseCount(TimeEntry::class, 1);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'billable_rate' => 110,\n        ]);\n    }\n\n    public function test_update_time_entries_billable_rate_for_organization_ignores_time_entries_that_are_not_billable(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $organization = $user->organization;\n        $organization->billable_rate = 110;\n        $organization->save();\n        $timeEntry = TimeEntry::factory()->forMember($user->member)->notBillable()->create();\n        $this->enableQueryLog();\n\n        // Act\n        $this->billableRateService->updateTimeEntriesBillableRateForOrganization($organization);\n\n        // Assert\n        $this->assertQueryCount(1);\n        $this->assertDatabaseCount(TimeEntry::class, 1);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'billable_rate' => null,\n        ]);\n    }\n\n    public function test_update_time_entries_billable_rate_for_organization_ignores_time_entries_of_organization(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $organization = $user->organization;\n        $organization->billable_rate = 110;\n        $organization->save();\n        $otherUser = $this->createUserWithPermission();\n        $timeEntry = TimeEntry::factory()->forMember($otherUser->member)->billableRate(1)->create();\n        $this->enableQueryLog();\n\n        // Act\n        $this->billableRateService->updateTimeEntriesBillableRateForOrganization($organization);\n\n        // Assert\n        $this->assertQueryCount(1);\n        $this->assertDatabaseCount(TimeEntry::class, 1);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'billable_rate' => 1,\n        ]);\n    }\n\n    public function test_update_time_entries_billable_rate_for_organization_ignores_time_entries_with_member_with_billable_rate(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $member = $user->member;\n        $member->billable_rate = 120;\n        $member->save();\n        $organization = $user->organization;\n        $organization->billable_rate = 110;\n        $organization->save();\n\n        $timeEntry = TimeEntry::factory()->forMember($member)->billableRate(1)->create();\n        $this->enableQueryLog();\n\n        // Act\n        $this->billableRateService->updateTimeEntriesBillableRateForOrganization($organization);\n\n        // Assert\n        $this->assertQueryCount(1);\n        $this->assertDatabaseCount(TimeEntry::class, 1);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'billable_rate' => 1,\n        ]);\n    }\n\n    public function test_update_time_entries_billable_rate_for_organization_ignores_time_entries_with_project_with_billable_rate(): void\n    {\n        // Arrange\n        $user = $this->createUserWithPermission();\n        $member = $user->member;\n        $organization = $user->organization;\n        $organization->billable_rate = 110;\n        $organization->save();\n        $project = Project::factory()->forOrganization($organization)->create([\n            'billable_rate' => 120,\n        ]);\n\n        $timeEntry = TimeEntry::factory()->forMember($member)->forProject($project)->billableRate(1)->create();\n        $this->enableQueryLog();\n\n        // Act\n        $this->billableRateService->updateTimeEntriesBillableRateForOrganization($organization);\n\n        // Assert\n        $this->assertQueryCount(1);\n        $this->assertDatabaseCount(TimeEntry::class, 1);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'billable_rate' => 1,\n        ]);\n    }\n\n    public function test_update_time_entries_billable_rate_for_organization_ignores_time_entries_with_project_member_with_billable_rate(): void\n    {\n        $user = $this->createUserWithPermission();\n        $member = $user->member;\n        $organization = $user->organization;\n        $organization->billable_rate = 110;\n        $organization->save();\n        $project = Project::factory()->forOrganization($organization)->create([\n            'billable_rate' => null,\n        ]);\n        $projectMember = ProjectMember::factory()->forProject($project)->forMember($member)->create([\n            'billable_rate' => 120,\n        ]);\n\n        $timeEntry = TimeEntry::factory()->forMember($member)->forProject($project)->billableRate(1)->create();\n        $this->enableQueryLog();\n\n        // Act\n        $this->billableRateService->updateTimeEntriesBillableRateForOrganization($organization);\n\n        // Assert\n        $this->assertQueryCount(1);\n        $this->assertDatabaseCount(TimeEntry::class, 1);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $timeEntry->getKey(),\n            'billable_rate' => 1,\n        ]);\n    }\n\n    /*\n     * Function: getBillableRateForTimeEntryWithGivenRelations\n     */\n\n    public function test_billable_rate_is_null_if_time_entry_is_not_billable(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create([\n            'billable_rate' => 1001,\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($organization)->forUser($user)->create([\n            'billable_rate' => 2002,\n        ]);\n        $project = Project::factory()->forOrganization($organization)->create([\n            'billable_rate' => 3003,\n        ]);\n        $projectMember = ProjectMember::factory()->forMember($member)->forProject($project)->create([\n            'billable_rate' => 4004,\n        ]);\n        $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([\n            'billable' => false,\n        ]);\n\n        // Act\n        $billableRate = $this->billableRateService->getBillableRateForTimeEntry($timeEntry);\n\n        // Assert\n        $this->assertSame(null, $billableRate);\n    }\n\n    public function test_billable_rate_uses_project_member_rate_as_first_priority(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create([\n            'billable_rate' => 1001,\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($organization)->forUser($user)->create([\n            'billable_rate' => 2002,\n        ]);\n        $project = Project::factory()->forOrganization($organization)->create([\n            'billable_rate' => 3003,\n        ]);\n        $projectMember = ProjectMember::factory()->forMember($member)->forProject($project)->create([\n            'billable_rate' => 4004,\n        ]);\n        $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([\n            'billable' => true,\n        ]);\n\n        // Act\n        $billableRate = $this->billableRateService->getBillableRateForTimeEntry($timeEntry);\n\n        // Assert\n        $this->assertSame(4004, $billableRate);\n    }\n\n    public function test_billable_rate_uses_project_rate_as_second_priority_using_null_values_before(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create([\n            'billable_rate' => 1001,\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($organization)->forUser($user)->create([\n            'billable_rate' => 2002,\n        ]);\n        $project = Project::factory()->forOrganization($organization)->create([\n            'billable_rate' => 3003,\n        ]);\n        $projectMember = ProjectMember::factory()->forMember($member)->forProject($project)->create([\n            'billable_rate' => null,\n        ]);\n        $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([\n            'billable' => true,\n        ]);\n\n        // Act\n        $billableRate = $this->billableRateService->getBillableRateForTimeEntry($timeEntry);\n\n        // Assert\n        $this->assertSame(3003, $billableRate);\n    }\n\n    public function test_billable_rate_uses_project_rate_as_second_priority_using_non_existing_entities_before(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create([\n            'billable_rate' => 1001,\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($organization)->forUser($user)->create([\n            'billable_rate' => 2002,\n        ]);\n        $project = Project::factory()->forOrganization($organization)->create([\n            'billable_rate' => 3003,\n        ]);\n        $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([\n            'billable' => true,\n        ]);\n\n        // Act\n        $billableRate = $this->billableRateService->getBillableRateForTimeEntry($timeEntry);\n\n        // Assert\n        $this->assertSame(3003, $billableRate);\n    }\n\n    public function test_billable_rate_uses_organization_member_rate_as_third_priority_using_null_values_before(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create([\n            'billable_rate' => 1001,\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($organization)->forUser($user)->create([\n            'billable_rate' => 2002,\n        ]);\n        $project = Project::factory()->forOrganization($organization)->create([\n            'billable_rate' => null,\n        ]);\n        $projectMember = ProjectMember::factory()->forMember($member)->forProject($project)->create([\n            'billable_rate' => null,\n        ]);\n        $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([\n            'billable' => true,\n        ]);\n\n        // Act\n        $billableRate = $this->billableRateService->getBillableRateForTimeEntry($timeEntry);\n\n        // Assert\n        $this->assertSame(2002, $billableRate);\n    }\n\n    public function test_billable_rate_uses_organization_member_rate_as_third_priority_using_non_existing_entities_before(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create([\n            'billable_rate' => 1001,\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($organization)->forUser($user)->create([\n            'billable_rate' => 2002,\n        ]);\n        $timeEntry = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([\n            'billable' => true,\n        ]);\n\n        // Act\n        $billableRate = $this->billableRateService->getBillableRateForTimeEntry($timeEntry);\n\n        // Assert\n        $this->assertSame(2002, $billableRate);\n    }\n\n    public function test_billable_rate_uses_organization_rate_as_fourth_priority_using_null_values_before(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create([\n            'billable_rate' => 1001,\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($organization)->forUser($user)->create([\n            'billable_rate' => null,\n        ]);\n        $project = Project::factory()->forOrganization($organization)->create([\n            'billable_rate' => null,\n        ]);\n        $projectMember = ProjectMember::factory()->forMember($member)->forProject($project)->create([\n            'billable_rate' => null,\n        ]);\n        $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([\n            'billable' => true,\n        ]);\n\n        // Act\n        $billableRate = $this->billableRateService->getBillableRateForTimeEntry($timeEntry);\n\n        // Assert\n        $this->assertSame(1001, $billableRate);\n    }\n\n    public function test_billable_rate_uses_organization_rate_as_fourth_priority_using_non_existing_entities_before(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create([\n            'billable_rate' => 1001,\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($organization)->forUser($user)->create([\n            'billable_rate' => null,\n        ]);\n        $timeEntry = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([\n            'billable' => true,\n        ]);\n\n        // Act\n        $billableRate = $this->billableRateService->getBillableRateForTimeEntry($timeEntry);\n\n        // Assert\n        $this->assertSame(1001, $billableRate);\n    }\n\n    public function test_billable_rate_is_null_if_billable_rate_on_all_levels_are_null(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create([\n            'billable_rate' => null,\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($organization)->forUser($user)->create([\n            'billable_rate' => null,\n        ]);\n        $project = Project::factory()->forOrganization($organization)->create([\n            'billable_rate' => null,\n        ]);\n        $projectMember = ProjectMember::factory()->forMember($member)->forProject($project)->create([\n            'billable_rate' => null,\n        ]);\n        $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([\n            'billable' => true,\n        ]);\n\n        // Act\n        $billableRate = $this->billableRateService->getBillableRateForTimeEntry($timeEntry);\n\n        // Assert\n        $this->assertSame(null, $billableRate);\n    }\n\n    public function test_billable_rate_with_given_relations_returns_null_if_not_billable(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create([\n            'billable_rate' => 1001,\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($organization)->forUser($user)->create([\n            'billable_rate' => 2002,\n        ]);\n        $project = Project::factory()->forOrganization($organization)->create([\n            'billable_rate' => 3003,\n        ]);\n        $projectMember = ProjectMember::factory()->forMember($member)->forProject($project)->create([\n            'billable_rate' => 4004,\n        ]);\n        $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([\n            'billable' => false,\n        ]);\n        $this->enableQueryLog();\n\n        // Act\n        $billableRate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(\n            $timeEntry,\n            $projectMember,\n            $project,\n            $member,\n            $organization\n        );\n\n        // Assert\n        $this->assertQueryCount(0);\n        $this->assertSame(null, $billableRate);\n    }\n\n    public function test_billable_rate_with_given_relations_uses_project_member_rate_as_first_priority(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create([\n            'billable_rate' => 1001,\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($organization)->forUser($user)->create([\n            'billable_rate' => 2002,\n        ]);\n        $project = Project::factory()->forOrganization($organization)->create([\n            'billable_rate' => 3003,\n        ]);\n        $projectMember = ProjectMember::factory()->forMember($member)->forProject($project)->create([\n            'billable_rate' => 4004,\n        ]);\n        $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([\n            'billable' => true,\n        ]);\n        $this->enableQueryLog();\n\n        // Act\n        $billableRate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(\n            $timeEntry,\n            $projectMember,\n            $project,\n            $member,\n            $organization\n        );\n\n        // Assert\n        $this->assertQueryCount(0);\n        $this->assertSame(4004, $billableRate);\n    }\n\n    public function test_billable_rate_with_given_relations_uses_project_rate_as_second_priority_using_null_values_before(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create([\n            'billable_rate' => 1001,\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($organization)->forUser($user)->create([\n            'billable_rate' => 2002,\n        ]);\n        $project = Project::factory()->forOrganization($organization)->create([\n            'billable_rate' => 3003,\n        ]);\n        $projectMember = ProjectMember::factory()->forMember($member)->forProject($project)->create([\n            'billable_rate' => null,\n        ]);\n        $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([\n            'billable' => true,\n        ]);\n        $this->enableQueryLog();\n\n        // Act\n        $billableRate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(\n            $timeEntry,\n            $projectMember,\n            $project,\n            $member,\n            $organization\n        );\n\n        // Assert\n        $this->assertQueryCount(0);\n        $this->assertSame(3003, $billableRate);\n    }\n\n    public function test_billable_rate_with_given_relations_uses_project_rate_as_second_priority_using_non_existing_entities_before(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create([\n            'billable_rate' => 1001,\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($organization)->forUser($user)->create([\n            'billable_rate' => 2002,\n        ]);\n        $project = Project::factory()->forOrganization($organization)->create([\n            'billable_rate' => 3003,\n        ]);\n        $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([\n            'billable' => true,\n        ]);\n        $this->enableQueryLog();\n\n        // Act\n        $billableRate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(\n            $timeEntry,\n            null,\n            $project,\n            $member,\n            $organization\n        );\n\n        // Assert\n        $this->assertQueryCount(0);\n        $this->assertSame(3003, $billableRate);\n    }\n\n    public function test_billable_rate_with_given_relations_uses_organization_member_rate_as_third_priority_using_null_values_before(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create([\n            'billable_rate' => 1001,\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($organization)->forUser($user)->create([\n            'billable_rate' => 2002,\n        ]);\n        $project = Project::factory()->forOrganization($organization)->create([\n            'billable_rate' => null,\n        ]);\n        $projectMember = ProjectMember::factory()->forMember($member)->forProject($project)->create([\n            'billable_rate' => null,\n        ]);\n        $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([\n            'billable' => true,\n        ]);\n        $this->enableQueryLog();\n\n        // Act\n        $billableRate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(\n            $timeEntry,\n            $projectMember,\n            $project,\n            $member,\n            $organization\n        );\n\n        // Assert\n        $this->assertQueryCount(0);\n        $this->assertSame(2002, $billableRate);\n    }\n\n    public function test_billable_rate_with_given_relations_uses_organization_member_rate_as_third_priority_using_non_existing_entities_before(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create([\n            'billable_rate' => 1001,\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($organization)->forUser($user)->create([\n            'billable_rate' => 2002,\n        ]);\n        $timeEntry = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([\n            'billable' => true,\n        ]);\n        $this->enableQueryLog();\n\n        // Act\n        $billableRate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(\n            $timeEntry,\n            null,\n            null,\n            $member,\n            $organization\n        );\n\n        // Assert\n        $this->assertQueryCount(0);\n        $this->assertSame(2002, $billableRate);\n    }\n\n    public function test_billable_rate_with_given_relations_uses_organization_rate_as_fourth_priority_using_null_values_before(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create([\n            'billable_rate' => 1001,\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($organization)->forUser($user)->create([\n            'billable_rate' => null,\n        ]);\n        $project = Project::factory()->forOrganization($organization)->create([\n            'billable_rate' => null,\n        ]);\n        $projectMember = ProjectMember::factory()->forMember($member)->forProject($project)->create([\n            'billable_rate' => null,\n        ]);\n        $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([\n            'billable' => true,\n        ]);\n        $this->enableQueryLog();\n\n        // Act\n        $billableRate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(\n            $timeEntry,\n            $projectMember,\n            $project,\n            $member,\n            $organization\n        );\n\n        // Assert\n        $this->assertQueryCount(0);\n        $this->assertSame(1001, $billableRate);\n    }\n\n    public function test_billable_rate_with_given_relations_uses_organization_rate_as_fourth_priority_using_non_existing_entities_before(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create([\n            'billable_rate' => 1001,\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($organization)->forUser($user)->create([\n            'billable_rate' => null,\n        ]);\n        $timeEntry = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([\n            'billable' => true,\n        ]);\n        $this->enableQueryLog();\n\n        // Act\n        $billableRate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(\n            $timeEntry,\n            null,\n            null,\n            $member,\n            $organization\n        );\n\n        // Assert\n        $this->assertQueryCount(0);\n        $this->assertSame(1001, $billableRate);\n    }\n\n    public function test_billable_rate_with_given_relations_is_null_if_billable_rate_on_all_levels_are_null(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create([\n            'billable_rate' => null,\n        ]);\n        $user = User::factory()->create();\n        $member = Member::factory()->forOrganization($organization)->forUser($user)->create([\n            'billable_rate' => null,\n        ]);\n        $project = Project::factory()->forOrganization($organization)->create([\n            'billable_rate' => null,\n        ]);\n        $projectMember = ProjectMember::factory()->forMember($member)->forProject($project)->create([\n            'billable_rate' => null,\n        ]);\n        $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([\n            'billable' => true,\n        ]);\n        $this->enableQueryLog();\n\n        // Act\n        $billableRate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(\n            $timeEntry,\n            $projectMember,\n            $project,\n            $member,\n            $organization\n        );\n\n        // Assert\n        $this->assertQueryCount(0);\n        $this->assertSame(null, $billableRate);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/CurrencyServiceTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service;\n\nuse App\\Service\\CurrencyService;\nuse Brick\\Money\\Currency;\nuse Brick\\Money\\Money;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCaseWithDatabase;\n\n#[CoversClass(CurrencyService::class)]\nclass CurrencyServiceTest extends TestCaseWithDatabase\n{\n    private CurrencyService $currencyService;\n\n    protected function setUp(): void\n    {\n        parent::setUp();\n        $this->currencyService = new CurrencyService;\n    }\n\n    public function test_get_currency_symbol_for_currency_eur(): void\n    {\n        // Arrange\n        $money = Money::of(1, Currency::of('EUR'));\n\n        // Act\n        $symbol = $this->currencyService->getCurrencySymbolForMoney($money);\n\n        // Assert\n        $this->assertSame('€', $symbol);\n    }\n\n    public function test_get_currency_symbol_for_currency_usd(): void\n    {\n        // Arrange\n        $money = Money::of(1, Currency::of('USD'));\n\n        // Act\n        $symbol = $this->currencyService->getCurrencySymbolForMoney($money);\n\n        // Assert\n        $this->assertSame('$', $symbol);\n    }\n\n    public function test_get_currency_symbol_for_currency_gbp(): void\n    {\n        // Arrange\n        $money = Money::of(1, Currency::of('GBP'));\n\n        // Act\n        $symbol = $this->currencyService->getCurrencySymbolForMoney($money);\n\n        // Assert\n        $this->assertSame('£', $symbol);\n    }\n\n    public function test_get_currency_symbol_for_currency_cad(): void\n    {\n        // Arrange\n        $money = Money::of(1, Currency::of('CAD'));\n\n        // Act\n        $symbol = $this->currencyService->getCurrencySymbolForMoney($money);\n\n        // Assert\n        $this->assertSame('$', $symbol);\n    }\n\n    public function test_get_currency_symbol_for_currency_cop(): void\n    {\n        // Arrange\n        $money = Money::of(1, Currency::of('COP'));\n\n        // Act\n        $symbol = $this->currencyService->getCurrencySymbolForMoney($money);\n\n        // Assert\n        $this->assertSame('$', $symbol);\n    }\n\n    public function test_get_currency_symbol_for_currency_without_known_symbol(): void\n    {\n        // Arrange\n        $currency = 'XXX';\n\n        // Act\n        $symbol = $this->currencyService->getCurrencySymbol($currency);\n\n        // Assert\n        $this->assertSame('XXX', $symbol);\n    }\n\n    public function test_get_random_currency_code(): void\n    {\n        // Act\n        $currencyCode = $this->currencyService->getRandomCurrencyCode();\n\n        // Assert\n        $this->assertNotEmpty($currencyCode);\n        $this->assertIsString($currencyCode);\n        $this->assertNotNull(Currency::of($currencyCode));\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/DashboardServiceTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service;\n\nuse App\\Enums\\Role;\nuse App\\Enums\\Weekday;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\Task;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse App\\Service\\DashboardService;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Illuminate\\Support\\Carbon;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCase;\n\n#[CoversClass(DashboardService::class)]\nclass DashboardServiceTest extends TestCase\n{\n    use RefreshDatabase;\n\n    protected DashboardService $dashboardService;\n\n    protected function setUp(): void\n    {\n        parent::setUp();\n        $this->dashboardService = app(DashboardService::class);\n    }\n\n    public function test_daily_tracked_hours_returns_correct_values(): void\n    {\n        // Arrange\n        $this->travelTo(Carbon::create(2024, 1, 1, 12, 0, 0, 'Europe/Vienna'));\n        $organization = Organization::factory()->create();\n        $user = User::factory()->create([\n            'timezone' => 'Europe/Vienna',\n        ]);\n        $member = Member::factory()->forUser($user)->forOrganization($organization)->create();\n        $timeEntry1 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([\n            // Note: The start time shifts in timezone Europe/Vienna to the next day\n            'start' => Carbon::create(2023, 12, 30, 23, 0, 0, 'UTC'),\n            'end' => Carbon::create(2023, 12, 30, 23, 0, 40, 'UTC'),\n        ]);\n        $timeEntry2 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([\n            // Note: The start time NOT shifts in timezone Europe/Vienna to the next day\n            'start' => Carbon::create(2023, 12, 30, 22, 59, 59, 'UTC'),\n            'end' => Carbon::create(2023, 12, 30, 23, 0, 39, 'UTC'),\n        ]);\n\n        // Act\n        $result = $this->dashboardService->getDailyTrackedHours($user, $organization, 5);\n\n        // Assert\n        $this->assertSame([\n            [\n                'date' => '2023-12-28',\n                'duration' => 0,\n            ],\n            [\n                'date' => '2023-12-29',\n                'duration' => 0,\n            ],\n            [\n                'date' => '2023-12-30',\n                'duration' => 40,\n            ],\n            [\n                'date' => '2023-12-31',\n                'duration' => 40,\n            ],\n            [\n                'date' => '2024-01-01',\n                'duration' => 0,\n            ],\n        ], $result);\n    }\n\n    public function test_weekly_history_returns_correct_values(): void\n    {\n        // Arrange\n        // Note: Is a Monday\n        $this->travelTo(Carbon::create(2024, 1, 1, 12, 0, 0, 'Europe/Vienna'));\n        $organization = Organization::factory()->create();\n        $user = User::factory()->create([\n            'timezone' => 'Europe/Vienna',\n            'week_start' => Weekday::Sunday,\n        ]);\n        $member = Member::factory()->forUser($user)->forOrganization($organization)->create();\n        // Note: This is a Sunday\n        $timeEntry1 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([\n            // Note: The start time shifts in timezone Europe/Vienna to the next day\n            'start' => Carbon::create(2023, 12, 30, 23, 0, 0, 'UTC'),\n            'end' => Carbon::create(2023, 12, 30, 23, 0, 40, 'UTC'),\n        ]);\n        // Note: This is a Saturday\n        $timeEntry2 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([\n            // Note: The start time NOT shifts in timezone Europe/Vienna to the next day\n            'start' => Carbon::create(2023, 12, 30, 22, 59, 59, 'UTC'),\n            'end' => Carbon::create(2023, 12, 30, 23, 0, 39, 'UTC'),\n        ]);\n\n        // Act\n        $result = $this->dashboardService->getWeeklyHistory($user, $organization);\n\n        // Assert\n        $this->assertSame([\n            [\n                'date' => '2023-12-31',\n                'duration' => 40,\n            ],\n            [\n                'date' => '2024-01-01',\n                'duration' => 0,\n            ],\n            [\n                'date' => '2024-01-02',\n                'duration' => 0,\n            ],\n            [\n                'date' => '2024-01-03',\n                'duration' => 0,\n            ],\n            [\n                'date' => '2024-01-04',\n                'duration' => 0,\n            ],\n            [\n                'date' => '2024-01-05',\n                'duration' => 0,\n            ],\n            [\n                'date' => '2024-01-06',\n                'duration' => 0,\n            ],\n        ], $result);\n    }\n\n    public function test_total_weekly_time_returns_correct_value(): void\n    {\n        // Arrange\n        // Note: Is a Monday\n        $this->travelTo(Carbon::create(2024, 1, 1, 12, 0, 0, 'Europe/Vienna'));\n        $organization = Organization::factory()->create();\n        $user = User::factory()->create([\n            'timezone' => 'Europe/Vienna',\n            'week_start' => Weekday::Sunday,\n        ]);\n        $member = Member::factory()->forUser($user)->forOrganization($organization)->create();\n        // Note: This is a Sunday\n        $timeEntry1 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([\n            // Note: The start time shifts in timezone Europe/Vienna to the next day\n            'start' => Carbon::create(2023, 12, 30, 23, 0, 0, 'UTC'),\n            'end' => Carbon::create(2023, 12, 30, 23, 0, 40, 'UTC'),\n        ]);\n        // Note: This is a Saturday\n        $timeEntry2 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([\n            // Note: The start time NOT shifts in timezone Europe/Vienna to the next day\n            'start' => Carbon::create(2023, 12, 30, 22, 59, 59, 'UTC'),\n            'end' => Carbon::create(2023, 12, 30, 23, 0, 39, 'UTC'),\n        ]);\n\n        // Act\n        $result = $this->dashboardService->totalWeeklyTime($user, $organization);\n\n        // Assert\n        $this->assertSame(40, $result);\n    }\n\n    public function test_total_weekly_billable_time_returns_correct_value(): void\n    {\n        // Arrange\n        // Note: Is a Monday\n        $this->travelTo(Carbon::create(2024, 1, 1, 12, 0, 0, 'Europe/Vienna'));\n        $organization = Organization::factory()->create();\n        $user = User::factory()->create([\n            'timezone' => 'Europe/Vienna',\n            'week_start' => Weekday::Sunday,\n        ]);\n        $member = Member::factory()->forUser($user)->forOrganization($organization)->create();\n        // Note: This is a Sunday\n        $timeEntry1 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([\n            // Note: The start time shifts in timezone Europe/Vienna to the next day\n            'billable' => true,\n            'start' => Carbon::create(2023, 12, 30, 23, 0, 0, 'UTC'),\n            'end' => Carbon::create(2023, 12, 30, 23, 0, 40, 'UTC'),\n        ]);\n        // Note: This is a Sunday (non-billable)\n        $timeEntry1 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([\n            // Note: The start time shifts in timezone Europe/Vienna to the next day\n            'billable' => false,\n            'start' => Carbon::create(2023, 12, 30, 23, 0, 40, 'UTC'),\n            'end' => Carbon::create(2023, 12, 30, 23, 0, 59, 'UTC'),\n        ]);\n        // Note: This is a Saturday\n        $timeEntry2 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([\n            // Note: The start time NOT shifts in timezone Europe/Vienna to the next day\n            'billable' => true,\n            'start' => Carbon::create(2023, 12, 30, 22, 59, 59, 'UTC'),\n            'end' => Carbon::create(2023, 12, 30, 23, 0, 39, 'UTC'),\n        ]);\n\n        // Act\n        $result = $this->dashboardService->totalWeeklyBillableTime($user, $organization);\n\n        // Assert\n        $this->assertSame(40, $result);\n    }\n\n    public function test_total_weekly_billable_amount_returns_correct_value(): void\n    {\n        // Arrange\n        // Note: Is a Monday\n        $this->travelTo(Carbon::create(2024, 1, 1, 12, 0, 0, 'Europe/Vienna'));\n        $currency = 'USD';\n        $organization = Organization::factory()->create([\n            'currency' => $currency,\n        ]);\n        $user = User::factory()->create([\n            'timezone' => 'Europe/Vienna',\n            'week_start' => Weekday::Sunday,\n        ]);\n        $member = Member::factory()->forUser($user)->forOrganization($organization)->create();\n        // Note: This is a Sunday\n        $timeEntry1 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([\n            // Note: The start time shifts in timezone Europe/Vienna to the next day\n            'billable' => true,\n            'billable_rate' => 50 * 100,\n            'start' => Carbon::create(2023, 12, 30, 23, 0, 0, 'UTC'),\n            'end' => Carbon::create(2023, 12, 31, 0, 0, 0, 'UTC'),\n        ]);\n        // Note: This is a Sunday (non-billable)\n        $timeEntry2 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([\n            // Note: The start time shifts in timezone Europe/Vienna to the next day\n            'billable' => false,\n            'start' => Carbon::create(2023, 12, 30, 23, 0, 40, 'UTC'),\n            'end' => Carbon::create(2023, 12, 30, 23, 0, 59, 'UTC'),\n        ]);\n        // Note: This is a Saturday\n        $timeEntry3 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([\n            // Note: The start time NOT shifts in timezone Europe/Vienna to the next day\n            'billable' => true,\n            'billable_rate' => 100 * 100,\n            'start' => Carbon::create(2023, 12, 30, 22, 59, 59, 'UTC'),\n            'end' => Carbon::create(2023, 12, 30, 23, 0, 39, 'UTC'),\n        ]);\n\n        // Act\n        $result = $this->dashboardService->totalWeeklyBillableAmount($user, $organization);\n\n        // Assert\n        $this->assertSame([\n            'value' => 5000,\n            'currency' => $currency,\n        ], $result);\n    }\n\n    public function test_weekly_project_overview_returns_correct_value_if_time_entries_for_projects_exist_in_current_week(): void\n    {\n        // Arrange\n        // Note: Is a Monday\n        $now = Carbon::create(2024, 1, 1, 12, 0, 0, 'Europe/Vienna')->toImmutable();\n        $this->travelTo($now);\n        $user = User::factory()->create([\n            'timezone' => 'Europe/Vienna',\n            'week_start' => Weekday::Sunday,\n        ]);\n        $organization = Organization::factory()->withOwner($user)->create();\n        $member = Member::factory()->forUser($user)->forOrganization($organization)->role(Role::Owner)->create();\n        $project1 = Project::factory()->forOrganization($organization)->create();\n        $project2 = Project::factory()->forOrganization($organization)->create();\n        $timeEntry1Project1 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->forProject($project1)->create([\n            // Note: At the start of the week\n            'start' => $now->startOfWeek($user->week_start->carbonWeekDay())->utc(),\n            'end' => $now->startOfWeek($user->week_start->carbonWeekDay())->addSeconds(40)->utc(),\n        ]);\n        $timeEntry2Project1 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->forProject($project1)->create([\n            // Note: At the end of the week\n            'start' => $now->endOfWeek($user->week_start->toEndOfWeek()->carbonWeekDay())->utc(),\n            'end' => $now->endOfWeek($user->week_start->toEndOfWeek()->carbonWeekDay())->addSeconds(40)->utc(),\n        ]);\n        $timeEntry1Project2 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->forProject($project2)->create([\n            // Note: At the start of the week\n            'start' => $now->startOfWeek($user->week_start->carbonWeekDay())->utc(),\n            'end' => $now->startOfWeek($user->week_start->carbonWeekDay())->addSeconds(40)->utc(),\n        ]);\n        $timeEntry2Project2 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->forProject($project2)->create([\n            // Note: At the end of the week\n            'start' => $now->endOfWeek($user->week_start->toEndOfWeek()->carbonWeekDay())->utc(),\n            'end' => $now->endOfWeek($user->week_start->toEndOfWeek()->carbonWeekDay())->addSeconds(40)->utc(),\n        ]);\n        $timeEntry1WithoutProject = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([\n            // Note: At the start of the week\n            'start' => $now->startOfWeek($user->week_start->carbonWeekDay())->utc(),\n            'end' => $now->startOfWeek($user->week_start->carbonWeekDay())->addSeconds(40)->utc(),\n        ]);\n        $timeEntry2WithoutProject = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([\n            // Note: At the end of the week\n            'start' => $now->endOfWeek($user->week_start->toEndOfWeek()->carbonWeekDay())->utc(),\n            'end' => $now->endOfWeek($user->week_start->toEndOfWeek()->carbonWeekDay())->addSeconds(40)->utc(),\n        ]);\n        $timeEntry1WithoutProjectOutsideOfWeek = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([\n            // Note: Outside of week\n            'start' => $now->startOfWeek($user->week_start->carbonWeekDay())->subSecond()->utc(),\n            'end' => $now->startOfWeek($user->week_start->carbonWeekDay())->addSeconds(39)->utc(),\n        ]);\n\n        // Act\n        $result = $this->dashboardService->weeklyProjectOverview($user, $organization);\n\n        // Assert\n        $this->assertEqualsCanonicalizing([\n            [\n                'value' => 80,\n                'id' => $project1->getKey(),\n                'name' => $project1->name,\n                'color' => $project1->color,\n            ],\n            [\n                'value' => 80,\n                'id' => $project2->getKey(),\n                'name' => $project2->name,\n                'color' => $project2->color,\n            ],\n            [\n                'value' => 80,\n                'id' => null,\n                'name' => 'No project',\n                'color' => '#cccccc',\n            ],\n        ], $result);\n    }\n\n    public function test_weekly_project_overview_returns_correct_value_if_only_entries_without_project_exist_in_the_week(): void\n    {\n        // Arrange\n        // Note: Is a Monday\n        $now = Carbon::create(2024, 1, 1, 12, 0, 0, 'Europe/Vienna')->toImmutable();\n        $this->travelTo($now);\n        $organization = Organization::factory()->create();\n        $user = User::factory()->create([\n            'timezone' => 'Europe/Vienna',\n            'week_start' => Weekday::Sunday,\n        ]);\n        $member = Member::factory()->forUser($user)->forOrganization($organization)->create();\n        $timeEntry1WithoutProject = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([\n            // Note: At the start of the week\n            'start' => $now->startOfWeek($user->week_start->carbonWeekDay())->utc(),\n            'end' => $now->startOfWeek($user->week_start->carbonWeekDay())->addSeconds(40)->utc(),\n        ]);\n        $timeEntry2WithoutProject = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([\n            // Note: At the end of the week\n            'start' => $now->endOfWeek($user->week_start->toEndOfWeek()->carbonWeekDay())->utc(),\n            'end' => $now->endOfWeek($user->week_start->toEndOfWeek()->carbonWeekDay())->addSeconds(40)->utc(),\n        ]);\n        $timeEntry1WithoutProjectOutsideOfWeek = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([\n            // Note: Outside of week\n            'start' => $now->startOfWeek($user->week_start->carbonWeekDay())->subSecond()->utc(),\n            'end' => $now->startOfWeek($user->week_start->carbonWeekDay())->addSeconds(39)->utc(),\n        ]);\n\n        // Act\n        $result = $this->dashboardService->weeklyProjectOverview($user, $organization);\n\n        // Assert\n        $this->assertSame([\n            [\n                'value' => 80,\n                'id' => null,\n                'name' => 'No project',\n                'color' => '#cccccc',\n            ],\n        ], $result);\n    }\n\n    public function test_weekly_project_overview_returns_correct_value_if_no_entries_are_in_the_week(): void\n    {\n        // Arrange\n        // Note: Is a Monday\n        $this->travelTo(Carbon::create(2024, 1, 1, 12, 0, 0, 'Europe/Vienna'));\n        $organization = Organization::factory()->create();\n        $user = User::factory()->create([\n            'timezone' => 'Europe/Vienna',\n            'week_start' => Weekday::Sunday,\n        ]);\n        $member = Member::factory()->forUser($user)->forOrganization($organization)->create();\n\n        // Act\n        $result = $this->dashboardService->weeklyProjectOverview($user, $organization);\n\n        // Assert\n        $this->assertSame([\n            [\n                'value' => 0,\n                'id' => null,\n                'name' => 'No project',\n                'color' => '#cccccc',\n            ],\n        ], $result);\n    }\n\n    public function test_latest_team_activity_returns_the_most_current_working_users_and_what_they_are_working_on(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $member1 = Member::factory()->forOrganization($organization)->create();\n        $member2 = Member::factory()->forOrganization($organization)->create();\n        $member3 = Member::factory()->forOrganization($organization)->create();\n        $member4 = Member::factory()->forOrganization($organization)->create();\n        $member5 = Member::factory()->forOrganization($organization)->create();\n        $task1 = Task::factory()->forOrganization($organization)->create();\n        $timeEntry1 = TimeEntry::factory()->forMember($member1)->forOrganization($organization)->active()->create([\n            'start' => now()->subMinutes(10),\n        ]);\n        $timeEntry2 = TimeEntry::factory()->forMember($member2)->forOrganization($organization)->create([\n            'start' => now()->subMinutes(20),\n        ]);\n        $timeEntry3 = TimeEntry::factory()->forMember($member3)->forOrganization($organization)->forTask($task1)->create([\n            'description' => '',\n            'start' => now()->subMinutes(30),\n        ]);\n        $timeEntry4 = TimeEntry::factory()->forMember($member4)->forOrganization($organization)->forTask($task1)->create([\n            'description' => 'TEST 123',\n            'start' => now()->subMinutes(40),\n        ]);\n        $timeEntry5 = TimeEntry::factory()->forMember($member4)->forOrganization($organization)->forTask($task1)->create([\n            'description' => 'TEST 321',\n            'start' => now()->subMinutes(50),\n        ]);\n        $timeEntry6 = TimeEntry::factory()->forMember($member5)->forOrganization($organization)->forTask($task1)->create([\n            'description' => 'TEST 321',\n            'start' => now()->subMinutes(60),\n        ]);\n\n        // Act\n        $result = $this->dashboardService->latestTeamActivity($organization);\n\n        // Assert\n        $this->assertSame([\n            [\n                'member_id' => $member1->getKey(),\n                'name' => $member1->user->name,\n                'description' => $timeEntry1->description,\n                'time_entry_id' => $timeEntry1->getKey(),\n                'task_id' => null,\n                'status' => true,\n            ],\n            [\n                'member_id' => $member2->getKey(),\n                'name' => $member2->user->name,\n                'description' => $timeEntry2->description,\n                'time_entry_id' => $timeEntry2->getKey(),\n                'task_id' => null,\n                'status' => false,\n            ],\n            [\n                'member_id' => $member3->getKey(),\n                'name' => $member3->user->name,\n                'description' => $timeEntry3->description,\n                'time_entry_id' => $timeEntry3->getKey(),\n                'task_id' => $task1->getKey(),\n                'status' => false,\n            ],\n            [\n                'member_id' => $member4->getKey(),\n                'name' => $member4->user->name,\n                'description' => $timeEntry4->description,\n                'time_entry_id' => $timeEntry4->getKey(),\n                'task_id' => $task1->getKey(),\n                'status' => false,\n            ],\n        ], $result);\n    }\n\n    public function test_latest_tasks_returns_the_4_tasks_with_the_latest_time_entries(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $user = User::factory()->create();\n        $member = Member::factory()->forUser($user)->forOrganization($organization)->create();\n        $task1 = Task::factory()->forOrganization($organization)->create();\n        $task2 = Task::factory()->forOrganization($organization)->create();\n        $task3 = Task::factory()->forOrganization($organization)->create();\n        $task4 = Task::factory()->forOrganization($organization)->create();\n        $task5 = Task::factory()->forOrganization($organization)->create();\n\n        $timeEntry1Task1 = TimeEntry::factory()->forTask($task1)->forMember($member)->forOrganization($organization)->create([\n            'start' => now()->subMinutes(20),\n        ]);\n        $timeEntry1Task2 = TimeEntry::factory()->forTask($task2)->forMember($member)->forOrganization($organization)->create([\n            'start' => now()->subMinutes(30),\n        ]);\n        $timeEntry1Task3 = TimeEntry::factory()->forTask($task3)->forMember($member)->forOrganization($organization)->create([\n            'start' => now()->subMinutes(40),\n        ]);\n        $timeEntry1Task4 = TimeEntry::factory()->forTask($task4)->forMember($member)->forOrganization($organization)->create([\n            'start' => now()->subMinutes(50),\n        ]);\n        $timeEntry1Task5 = TimeEntry::factory()->forTask($task5)->forMember($member)->forOrganization($organization)->create([\n            'start' => now()->subMinutes(60),\n        ]);\n\n        // Act\n        $result = $this->dashboardService->latestTasks($user, $organization);\n\n        // Assert\n        $this->assertSame([\n            [\n                'id' => $timeEntry1Task1->task->getKey(),\n                'name' => $timeEntry1Task1->task->name,\n                'project_name' => $timeEntry1Task1->task->project->name,\n                'project_id' => $timeEntry1Task1->task->project->getKey(),\n            ],\n            [\n                'id' => $timeEntry1Task2->task->getKey(),\n                'name' => $timeEntry1Task2->task->name,\n                'project_name' => $timeEntry1Task2->task->project->name,\n                'project_id' => $timeEntry1Task2->task->project->getKey(),\n            ],\n            [\n                'id' => $timeEntry1Task3->task->getKey(),\n                'name' => $timeEntry1Task3->task->name,\n                'project_name' => $timeEntry1Task3->task->project->name,\n                'project_id' => $timeEntry1Task3->task->project->getKey(),\n            ],\n            [\n                'id' => $timeEntry1Task4->task->getKey(),\n                'name' => $timeEntry1Task4->task->name,\n                'project_name' => $timeEntry1Task4->task->project->name,\n                'project_id' => $timeEntry1Task4->task->project->getKey(),\n            ],\n        ], $result);\n    }\n\n    public function test_last_seven_days_returns_spend_time_in_the_last_seven_days_aggregated_in_three_hour_blocks(): void\n    {\n        // Arrange\n        $now = Carbon::create(2024, 4, 17, 12, 0, 0, 'Europe/Vienna')->utc();\n        $this->travelTo($now);\n        $organization = Organization::factory()->create();\n        $user = User::factory()->create([\n            'timezone' => 'Europe/Vienna',\n        ]);\n        $member = Member::factory()->forUser($user)->forOrganization($organization)->create();\n        $timeEntryOverWholePeriod = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([\n            'start' => now('Europe/Vienna')->subDays(7)->startOfDay()->utc(),\n            'end' => now('Europe/Vienna')->endOfDay()->addSecond()->utc(), // TODO: fix problem with last second\n        ]);\n        $timeEntryOverWholePeriodWithoutEnd = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([\n            'start' => now('Europe/Vienna')->subDays(7)->startOfDay()->utc(),\n            'end' => null,\n        ]);\n        $timeEntry1Task1 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([\n            'start' => now('Europe/Vienna')->subMinutes(30)->utc(),\n            'end' => now('Europe/Vienna')->subMinutes(20)->utc(),\n        ]);\n\n        // Act\n        $result = $this->dashboardService->lastSevenDays($user, $organization);\n\n        // Assert\n        $this->assertSame([\n            0 => [\n                'date' => '2024-04-17',\n                'duration' => 130200,\n                'history' => [\n                    0 => 21600,\n                    1 => 21600,\n                    2 => 21600,\n                    3 => 22200,\n                    4 => 10800,\n                    5 => 10800,\n                    6 => 10800,\n                    7 => 10800,\n                ],\n            ],\n            1 => [\n                'date' => '2024-04-16',\n                'duration' => 172800,\n                'history' => [\n                    0 => 21600,\n                    1 => 21600,\n                    2 => 21600,\n                    3 => 21600,\n                    4 => 21600,\n                    5 => 21600,\n                    6 => 21600,\n                    7 => 21600,\n                ],\n            ],\n            2 => [\n                'date' => '2024-04-15',\n                'duration' => 172800,\n                'history' => [\n                    0 => 21600,\n                    1 => 21600,\n                    2 => 21600,\n                    3 => 21600,\n                    4 => 21600,\n                    5 => 21600,\n                    6 => 21600,\n                    7 => 21600,\n                ],\n            ],\n            3 => [\n                'date' => '2024-04-14',\n                'duration' => 172800,\n                'history' => [\n                    0 => 21600,\n                    1 => 21600,\n                    2 => 21600,\n                    3 => 21600,\n                    4 => 21600,\n                    5 => 21600,\n                    6 => 21600,\n                    7 => 21600,\n                ],\n            ],\n            4 => [\n                'date' => '2024-04-13',\n                'duration' => 172800,\n                'history' => [\n                    0 => 21600,\n                    1 => 21600,\n                    2 => 21600,\n                    3 => 21600,\n                    4 => 21600,\n                    5 => 21600,\n                    6 => 21600,\n                    7 => 21600,\n                ],\n            ],\n            5 => [\n                'date' => '2024-04-12',\n                'duration' => 172800,\n                'history' => [\n                    0 => 21600,\n                    1 => 21600,\n                    2 => 21600,\n                    3 => 21600,\n                    4 => 21600,\n                    5 => 21600,\n                    6 => 21600,\n                    7 => 21600,\n                ],\n            ],\n            6 => [\n                'date' => '2024-04-11',\n                'duration' => 172800,\n                'history' => [\n                    0 => 21600,\n                    1 => 21600,\n                    2 => 21600,\n                    3 => 21600,\n                    4 => 21600,\n                    5 => 21600,\n                    6 => 21600,\n                    7 => 21600,\n                ],\n            ],\n        ], $result);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/DeletionServiceTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service;\n\nuse App\\Enums\\Role;\nuse App\\Events\\BeforeOrganizationDeletion;\nuse App\\Exceptions\\Api\\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;\nuse App\\Models\\Client;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\ProjectMember;\nuse App\\Models\\Report;\nuse App\\Models\\Tag;\nuse App\\Models\\Task;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse App\\Service\\DeletionService;\nuse Illuminate\\Database\\QueryException;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Event;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Facades\\Storage;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCaseWithDatabase;\nuse TiMacDonald\\Log\\LogEntry;\n\n#[CoversClass(DeletionService::class)]\nclass DeletionServiceTest extends TestCaseWithDatabase\n{\n    private DeletionService $deletionService;\n\n    protected function setUp(): void\n    {\n        parent::setUp();\n        Event::fake([\n            BeforeOrganizationDeletion::class,\n        ]);\n        $this->deletionService = app(DeletionService::class);\n    }\n\n    /**\n     * Creates an organization with all relations.\n     * It is important that every relation has at least two entries, to test for possible lazy loading issues.\n     *\n     * @return object{\n     *     organization: Organization,\n     *     clients: Collection<Client>,\n     *     projects: Collection<Project>,\n     *     projectMembers: Collection<ProjectMember>,\n     *     tags: Collection<Tag>,\n     *     members: Collection<Member>,\n     *     tasks: Collection<Task>,\n     *     timeEntries: Collection<TimeEntry>,\n     *     owner: User,\n     *     reports: Collection<Report>\n     * }\n     */\n    private function createOrganizationWithAllRelations(): object\n    {\n        $userOwner = User::factory()->create();\n        $userEmployee = User::factory()->withProfilePicture()->create();\n        $userPlaceholder = User::factory()->placeholder()->create();\n\n        $organization = Organization::factory()->withOwner($userOwner)->create();\n\n        // Create a personal organization for the employee\n        $personalOrganizationOfEmployee = Organization::factory()->withOwner($userEmployee)->create();\n        $personalOrganizationMember = Member::factory()->forUser($userEmployee)->forOrganization($personalOrganizationOfEmployee)->create();\n\n        // Set the current organizations for the users\n        $userOwner->update(['current_team_id' => $organization->id]);\n        $userEmployee->update(['current_team_id' => $personalOrganizationOfEmployee->id]);\n        $userPlaceholder->update(['current_team_id' => null]);\n\n        $memberOwner = Member::factory()->forUser($userOwner)->forOrganization($organization)->role(Role::Owner)->create();\n        $memberEmployee = Member::factory()->forUser($userEmployee)->forOrganization($organization)->role(Role::Employee)->create();\n        $memberPlaceholder = Member::factory()->forUser($userPlaceholder)->forOrganization($organization)->role(Role::Placeholder)->create();\n        $members = collect([$memberOwner, $memberEmployee, $memberPlaceholder]);\n\n        $clients = Client::factory()->forOrganization($organization)->createMany(2);\n\n        $projectWithClient = Project::factory()->forClient($clients->get(0))->forOrganization($organization)->create();\n        $projectWithoutClient = Project::factory()->forOrganization($organization)->create();\n        $projects = collect([$projectWithClient, $projectWithoutClient]);\n\n        $projectMemberOwner = ProjectMember::factory()->forMember($memberOwner)->forProject($projectWithClient)->create();\n        $projectMemberEmployee = ProjectMember::factory()->forMember($memberEmployee)->forProject($projectWithClient)->create();\n        $projectMembers = collect([$projectMemberOwner, $projectMemberEmployee]);\n\n        $tags = Tag::factory()->forOrganization($organization)->createMany(2);\n\n        $task1 = Task::factory()->forProject($projectWithClient)->forOrganization($organization)->create();\n        $task2 = Task::factory()->forProject($projectWithoutClient)->forOrganization($organization)->create();\n        $tasks = collect([$task1, $task2]);\n\n        $report1 = Report::factory()->forOrganization($organization)->create();\n        $report2 = Report::factory()->forOrganization($organization)->create();\n        $reports = collect([$report1, $report2]);\n\n        $timeEntries = TimeEntry::factory()->forOrganization($organization)->forMember($memberOwner)->createMany(2);\n        $timeEntriesWithTask = TimeEntry::factory()->forTask($task1)->forOrganization($organization)->forMember($memberEmployee)->createMany(2);\n        $timeEntriesWithProject = TimeEntry::factory()->forProject($projectWithClient)->forOrganization($organization)->forMember($memberPlaceholder)->createMany(2);\n        $timeEntries = $timeEntries->merge($timeEntriesWithTask)->merge($timeEntriesWithProject);\n\n        return (object) [\n            'organization' => $organization,\n            'clients' => $clients,\n            'projects' => $projects,\n            'projectMembers' => $projectMembers,\n            'tags' => $tags,\n            'members' => $members,\n            'tasks' => $tasks,\n            'timeEntries' => $timeEntries,\n            'owner' => $userOwner,\n            'reports' => $reports,\n        ];\n    }\n\n    private function assertOrganizationDeleted(Organization $organization): void\n    {\n        Event::assertDispatched(function (BeforeOrganizationDeletion $event) use ($organization) {\n            return $event->organization->is($organization);\n        }, 1);\n        $this->assertSame(0, Organization::query()->where('id', $organization->id)->count());\n        $this->assertSame(0, Client::query()->whereBelongsTo($organization, 'organization')->count());\n        $this->assertSame(0, Project::query()->whereBelongsTo($organization, 'organization')->count());\n        $this->assertSame(0, ProjectMember::query()->whereBelongsToOrganization($organization)->count());\n        $this->assertSame(0, Tag::query()->whereBelongsTo($organization, 'organization')->count());\n        $this->assertSame(0, Member::query()->whereBelongsTo($organization, 'organization')->count());\n        $this->assertSame(0, Task::query()->whereBelongsTo($organization, 'organization')->count());\n        $this->assertSame(0, Report::query()->whereBelongsTo($organization, 'organization')->count());\n        $this->assertSame(0, TimeEntry::query()->whereBelongsTo($organization, 'organization')->count());\n    }\n\n    private function assertOrganizationNothingDeleted(Organization $organization, bool $specialCase = false): void\n    {\n        $this->assertSame(1, Organization::query()->where('id', $organization->id)->count());\n        $this->assertSame(2, Client::query()->whereBelongsTo($organization, 'organization')->count());\n        $this->assertSame(2, Project::query()->whereBelongsTo($organization, 'organization')->count());\n        $this->assertSame(2, ProjectMember::query()->whereBelongsToOrganization($organization)->count());\n        $this->assertSame(2, Tag::query()->whereBelongsTo($organization, 'organization')->count());\n        $this->assertSame(3, Member::query()->whereBelongsTo($organization, 'organization')->count());\n        $this->assertSame(2, Task::query()->whereBelongsTo($organization, 'organization')->count());\n        $this->assertSame(2, Report::query()->whereBelongsTo($organization, 'organization')->count());\n        $this->assertSame($specialCase ? 7 : 6, TimeEntry::query()->whereBelongsTo($organization, 'organization')->count());\n    }\n\n    public function test_delete_organization_resets_the_current_organization_of_users_that_had_the_deleted_organization_as_current_organization(): void\n    {\n        // Arrange\n        $userOwner = User::factory()->create();\n        $organization = Organization::factory()->withOwner($userOwner)->create();\n        $userOwner->currentOrganization()->associate($organization);\n        $userOwner->save();\n\n        // Act\n        $this->deletionService->deleteOrganization($organization);\n\n        // Assert\n        $this->assertOrganizationDeleted($organization);\n        $userOwner->refresh();\n        $this->assertNull($userOwner->current_team_id);\n        $this->assertNotSame($organization->id, $userOwner->current_team_id);\n    }\n\n    public function test_delete_organization_deletes_all_resources_of_the_organization_but_does_not_delete_other_resources(): void\n    {\n        // Arrange\n        $organization = $this->createOrganizationWithAllRelations();\n        $otherOrganization = $this->createOrganizationWithAllRelations();\n\n        // Act\n        $this->deletionService->deleteOrganization($organization->organization);\n\n        // Assert\n        $this->assertOrganizationDeleted($organization->organization);\n        $this->assertOrganizationNothingDeleted($otherOrganization->organization);\n        Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug'\n            && $log->message === 'Start deleting organization'\n            && $log->context['organization_id'] === $organization->organization->getKey(),\n            1\n        );\n        Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug'\n            && $log->message === 'Finished deleting organization'\n            && $log->context['organization_id'] === $organization->organization->getKey(),\n            1\n        );\n    }\n\n    public function test_delete_organization_rolls_back_on_error_if_transaction_is_active(): void\n    {\n        // Arrange\n        $organization = $this->createOrganizationWithAllRelations();\n        $otherOrganization = $this->createOrganizationWithAllRelations();\n        $brokenTimeEntry = TimeEntry::factory()->forOrganization($otherOrganization->organization)->forProject($organization->projects->get(0))->create();\n\n        // Act\n        try {\n            $this->deletionService->deleteOrganization($organization->organization);\n            $this->fail();\n        } catch (QueryException) {\n            $this->assertTrue(true);\n        }\n\n        // Assert\n        Event::assertNotDispatched(function (BeforeOrganizationDeletion $event) use ($otherOrganization): bool {\n            return $event->organization->is($otherOrganization->organization);\n        });\n        Event::assertDispatched(function (BeforeOrganizationDeletion $event) use ($organization): bool {\n            return $event->organization->is($organization->organization);\n        }, 1);\n        $this->assertOrganizationNothingDeleted($organization->organization);\n        $this->assertOrganizationNothingDeleted($otherOrganization->organization, true);\n        Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug'\n            && $log->message === 'Start deleting organization'\n            && $log->context['organization_id'] === $organization->organization->getKey(),\n            1\n        );\n        Log::assertNotLogged(fn (LogEntry $log) => $log->level === 'debug'\n            && $log->message === 'Finished deleting organization'\n            && $log->context['organization_id'] === $organization->organization->getKey()\n        );\n    }\n\n    public function test_delete_user_fails_if_user_is_owner_of_an_organization_with_multiple_members(): void\n    {\n        // Arrange\n        $organization = $this->createOrganizationWithAllRelations();\n        $memberOwner = $organization->owner;\n\n        // Act\n        try {\n            $this->deletionService->deleteUser($memberOwner);\n            $this->fail();\n        } catch (CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers $exception) {\n            // Assert\n            $this->assertTrue(true);\n        }\n    }\n\n    public function test_delete_user_rolls_back_on_error_if_transaction_is_active(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $organization = Organization::factory()->create();\n        $memberOwner = Member::factory()->forUser($user)->forOrganization($organization)->role(Role::Owner)->create();\n        $otherOrganization = Organization::factory()->create();\n\n        $brokenTimeEntry = TimeEntry::factory()->forMember($memberOwner)->forOrganization($otherOrganization)->create();\n\n        // Act\n        try {\n            $this->deletionService->deleteUser($user);\n            $this->fail();\n        } catch (QueryException) {\n            $this->assertTrue(true);\n        }\n\n        // Assert\n        $this->assertDatabaseHas(User::class, [\n            'id' => $user->getKey(),\n        ]);\n        $this->assertDatabaseHas(Organization::class, [\n            'id' => $organization->getKey(),\n        ]);\n        $this->assertDatabaseHas(Member::class, [\n            'id' => $memberOwner->getKey(),\n        ]);\n        $this->assertDatabaseHas(TimeEntry::class, [\n            'id' => $brokenTimeEntry->getKey(),\n        ]);\n        Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug'\n            && $log->message === 'Start deleting user'\n            && $log->context['id'] === $user->getKey(),\n            1\n        );\n        Log::assertNotLogged(fn (LogEntry $log) => $log->level === 'debug'\n            && $log->message === 'Finished deleting user'\n            && $log->context['id'] === $user->getKey()\n        );\n    }\n\n    public function test_delete_user_deletes_all_resources_of_the_user_but_does_not_delete_other_resources(): void\n    {\n        // Arrange\n        $this->mockPublicStorage();\n        $user = User::factory()->withProfilePicture()->withPersonalOrganization()->create();\n        $otherUser = User::factory()->withProfilePicture()->withPersonalOrganization()->create();\n        Storage::disk(config('filesystems.public'))->assertExists($user->profile_photo_path);\n        Storage::disk(config('filesystems.public'))->assertExists($otherUser->profile_photo_path);\n\n        // Act\n        $this->deletionService->deleteUser($user);\n\n        // Assert\n        $this->assertDatabaseMissing(User::class, [\n            'id' => $user->getKey(),\n        ]);\n        $this->assertDatabaseHas(User::class, [\n            'id' => $otherUser->getKey(),\n        ]);\n        $this->assertDatabaseMissing(Organization::class, [\n            'id' => $user->current_team_id,\n        ]);\n        $this->assertDatabaseHas(Organization::class, [\n            'id' => $otherUser->current_team_id,\n        ]);\n        $this->assertDatabaseHas(Member::class, [\n            'user_id' => $otherUser->getKey(),\n        ]);\n        $this->assertDatabaseMissing(Member::class, [\n            'user_id' => $user->getKey(),\n        ]);\n        Storage::disk(config('filesystems.public'))->assertMissing($user->profile_photo_path);\n        Storage::disk(config('filesystems.public'))->assertExists($otherUser->profile_photo_path);\n        Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug'\n            && $log->message === 'Start deleting user'\n            && $log->context['id'] === $user->getKey(),\n            1\n        );\n        Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug'\n            && $log->message === 'Finished deleting user'\n            && $log->context['id'] === $user->getKey(),\n            1\n        );\n    }\n\n    public function test_delete_user_deletes_owned_organizations_that_have_only_one_member_and_makes_makes_the_user_placeholder_in_not_owned_organizations(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $otherUser = User::factory()->create();\n        $organizationOwned = Organization::factory()->withOwner($user)->create();\n        $organizationNotOwned = Organization::factory()->withOwner($otherUser)->create();\n        $memberOwned = Member::factory()->forUser($user)->forOrganization($organizationOwned)->role(Role::Owner)->create();\n        $memberNotOwned = Member::factory()->forUser($user)->forOrganization($organizationNotOwned)->role(Role::Employee)->create();\n        TimeEntry::factory()->forOrganization($organizationOwned)->forMember($memberOwned)->createMany(2);\n        TimeEntry::factory()->forOrganization($organizationNotOwned)->forMember($memberNotOwned)->createMany(2);\n        $this->assertDatabaseCount(User::class, 2);\n\n        // Act\n        $this->deletionService->deleteUser($user);\n\n        // Assert\n        $this->assertDatabaseCount(Organization::class, 1);\n        $this->assertDatabaseCount(User::class, 2);\n        $this->assertDatabaseMissing(User::class, [\n            'id' => $user->getKey(),\n        ]);\n        $this->assertDatabaseHas(User::class, [\n            'id' => $otherUser->getKey(),\n            'is_placeholder' => false,\n        ]);\n        $this->assertDatabaseHas(User::class, [\n            'is_placeholder' => true,\n        ]);\n        $this->assertDatabaseMissing(Organization::class, [\n            'id' => $organizationOwned->getKey(),\n        ]);\n        $this->assertDatabaseHas(Organization::class, [\n            'id' => $organizationNotOwned->getKey(),\n        ]);\n        $this->assertDatabaseMissing(Member::class, [\n            'id' => $memberOwned->getKey(),\n        ]);\n        $this->assertDatabaseHas(Member::class, [\n            'id' => $memberNotOwned->getKey(),\n            'organization_id' => $organizationNotOwned->getKey(),\n            'role' => Role::Placeholder->value,\n        ]);\n        Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug'\n            && $log->message === 'Start deleting user'\n            && $log->context['id'] === $user->getKey(),\n            1\n        );\n        Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug'\n            && $log->message === 'Finished deleting user'\n            && $log->context['id'] === $user->getKey(),\n            1\n        );\n    }\n\n    public function test_delete_user_with_current_organization_set_to_owned_org_that_will_be_deleted_does_not_cause_foreign_key_violation(): void\n    {\n        // Arrange\n        // User A creates an organization and invites User B\n        $userA = User::factory()->create();\n        $userB = User::factory()->create();\n        $organizationOfA = Organization::factory()->withOwner($userA)->create();\n        $organizationOfB = Organization::factory()->withOwner($userB)->create();\n        Member::factory()->forUser($userA)->forOrganization($organizationOfA)->role(Role::Owner)->create();\n        Member::factory()->forUser($userB)->forOrganization($organizationOfB)->role(Role::Owner)->create();\n        $memberBInOrgA = Member::factory()->forUser($userB)->forOrganization($organizationOfA)->role(Role::Employee)->create();\n        TimeEntry::factory()->forOrganization($organizationOfA)->forMember($memberBInOrgA)->createMany(2);\n\n        // User B's current_organization_id points to their own org (the one that will be deleted)\n        $userB->update(['current_team_id' => $organizationOfB->getKey()]);\n\n        // Act\n        $this->deletionService->deleteUser($userB);\n\n        // Assert\n        $this->assertDatabaseMissing(User::class, [\n            'id' => $userB->getKey(),\n        ]);\n        $this->assertDatabaseMissing(Organization::class, [\n            'id' => $organizationOfB->getKey(),\n        ]);\n        $this->assertDatabaseHas(Organization::class, [\n            'id' => $organizationOfA->getKey(),\n        ]);\n        // The placeholder user should exist with current_team_id set to the org where they are a placeholder\n        $placeholderUser = User::query()->where('is_placeholder', true)->first();\n        $this->assertNotNull($placeholderUser);\n        $this->assertSame($organizationOfA->getKey(), $placeholderUser->current_team_id);\n        $this->assertDatabaseHas(Member::class, [\n            'id' => $memberBInOrgA->getKey(),\n            'user_id' => $placeholderUser->getKey(),\n            'organization_id' => $organizationOfA->getKey(),\n            'role' => Role::Placeholder->value,\n        ]);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/Export/ExportServiceTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service\\Export;\n\nuse App\\Models\\Client;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\OrganizationInvitation;\nuse App\\Models\\Project;\nuse App\\Models\\ProjectMember;\nuse App\\Models\\Tag;\nuse App\\Models\\Task;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse App\\Service\\Export\\ExportService;\nuse Illuminate\\Support\\Facades\\Storage;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCaseWithDatabase;\n\n#[CoversClass(ExportService::class)]\nclass ExportServiceTest extends TestCaseWithDatabase\n{\n    private function getFullOrganization(): Organization\n    {\n        $user1 = User::factory()->create();\n        $user2 = User::factory()->create();\n        $organization = Organization::factory()->withOwner($user1)->create();\n        OrganizationInvitation::factory()->forOrganization($organization)->create();\n        OrganizationInvitation::factory()->forOrganization($organization)->create();\n        $member1 = Member::factory()->forUser($user1)->forOrganization($organization)->create();\n        $member2 = Member::factory()->forUser($user2)->forOrganization($organization)->create();\n        $timeEntry1 = TimeEntry::factory()->forMember($member1)->create();\n        $timeEntry2 = TimeEntry::factory()->forMember($member1)->create();\n        $project1 = Project::factory()->forOrganization($organization)->create();\n        $project2 = Project::factory()->forOrganization($organization)->create();\n        $task1 = Task::factory()->forOrganization($organization)->forProject($project1)->create();\n        $task2 = Task::factory()->forOrganization($organization)->forProject($project1)->create();\n        $task3 = Task::factory()->forOrganization($organization)->forProject($project2)->create();\n        $projectMember1 = ProjectMember::factory()->forMember($member1)->forProject($project1)->create();\n        $projectMember2 = ProjectMember::factory()->forMember($member2)->forProject($project1)->create();\n        $client1 = Client::factory()->forOrganization($organization)->create();\n        $client2 = Client::factory()->forOrganization($organization)->create();\n        Tag::factory()->forOrganization($organization)->create();\n        Tag::factory()->forOrganization($organization)->create();\n\n        return $organization;\n    }\n\n    public function test_export_creates_zip_with_all_the_data_of_the_organization(): void\n    {\n        // Arrange\n        $this->mockPrivateStorage();\n        $organization1 = $this->getFullOrganization();\n        $organization2 = $this->getFullOrganization();\n\n        // Act\n        $exportService = app(ExportService::class);\n        $zip = $exportService->export($organization1);\n\n        // Assert\n        Storage::disk(config('filesystems.default'))->assertExists($zip);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/Import/ImportDatabaseHelperTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service\\Import;\n\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\User;\nuse App\\Service\\Import\\ImportDatabaseHelper;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Illuminate\\Support\\Str;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCase;\n\n#[CoversClass(ImportDatabaseHelper::class)]\nclass ImportDatabaseHelperTest extends TestCase\n{\n    use RefreshDatabase;\n\n    public function test_get_key_attach_to_existing_returns_key_for_identifier_without_creating_model(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $helper = new ImportDatabaseHelper(User::class, ['email'], true);\n\n        // Act\n        $key = $helper->getKey([\n            'email' => $user->email,\n        ], [\n            'name' => 'Test',\n        ]);\n\n        // Assert\n        $this->assertSame($user->getKey(), $key);\n    }\n\n    public function test_get_key_attach_to_existing_creates_model_if_not_existing(): void\n    {\n        // Arrange\n        $helper = new ImportDatabaseHelper(User::class, ['email'], true);\n\n        // Act\n        $key = $helper->getKey([\n            'email' => 'test@mail.test',\n        ], [\n            'name' => 'Test',\n            'timezone' => 'UTC',\n        ]);\n\n        // Assert\n        $this->assertNotNull($key);\n        $this->assertDatabaseHas(User::class, [\n            'email' => 'test@mail.test',\n            'name' => 'Test',\n        ]);\n    }\n\n    public function test_get_key_not_attach_to_existing_is_not_implemented_yet(): void\n    {\n        // Arrange\n        $project = Project::factory()->create();\n        $helper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], false);\n\n        // Act\n        try {\n            $key = $helper->getKey([\n                'name' => $project->name,\n                'organization_id' => $project->organization_id,\n            ], [\n                'color' => '#000000',\n            ]);\n        } catch (\\Exception $e) {\n            $this->assertSame('Not implemented', $e->getMessage());\n\n            return;\n        }\n\n        // Assert\n        $this->fail();\n    }\n\n    public function test_get_key_by_external_identifier_returns_key_for_external_identifier(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $project = Project::factory()->forOrganization($organization)->create();\n        $externalIdentifier1 = '12345';\n        $externalIdentifier2 = '54321';\n        $helper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true);\n        $helper->getKey([\n            'name' => $project->name,\n            'organization_id' => $organization->getKey(),\n        ], [\n            'color' => '#000000',\n        ], $externalIdentifier1);\n        $helper->getKey([\n            'name' => 'Not existing project',\n            'organization_id' => $organization->getKey(),\n        ], [\n            'color' => '#000000',\n        ], $externalIdentifier2);\n\n        // Act\n        $key1 = $helper->getKeyByExternalIdentifier($externalIdentifier1);\n        $key2 = $helper->getKeyByExternalIdentifier($externalIdentifier2);\n\n        // Assert\n        $this->assertSame($project->getKey(), $key1);\n        $this->assertSame(Project::where('name', '=', 'Not existing project')->first()->getKey(), $key2);\n    }\n\n    public function test_get_external_ids_returns_all_external_ids_that_were_temporary_stored_via_get_key(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $project = Project::factory()->forOrganization($organization)->create();\n        $externalIdentifier1 = '12345';\n        $externalIdentifier2 = '54321';\n        $helper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true);\n        $helper->getKey([\n            'name' => $project->name,\n            'organization_id' => $organization->getKey(),\n        ], [\n            'color' => '#000000',\n        ], $externalIdentifier1);\n        $helper->getKey([\n            'name' => 'Not existing project',\n            'organization_id' => $organization->getKey(),\n        ], [\n            'color' => '#000000',\n        ], $externalIdentifier2);\n\n        // Act\n        $externalKeys = $helper->getExternalIds();\n\n        // Assert\n        $this->assertCount(2, $externalKeys);\n        $this->assertContains($externalIdentifier1, $externalKeys);\n        $this->assertContains($externalIdentifier2, $externalKeys);\n    }\n\n    public function test_get_model_by_identifier_returns_model_for_identifier(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $helper = new ImportDatabaseHelper(User::class, ['email'], true);\n\n        // Act\n        $model = $helper->getModel([\n            'email' => $user->email,\n        ]);\n\n        // Assert\n        $this->assertSame($user->getKey(), $model->getKey());\n    }\n\n    public function test_get_model_by_identifier_returns_null_for_not_existing_identifier(): void\n    {\n        // Arrange\n        $helper = new ImportDatabaseHelper(User::class, ['email'], true);\n\n        // Act\n        $model = $helper->getModel([\n            'email' => '',\n        ]);\n\n        // Assert\n        $this->assertNull($model);\n    }\n\n    public function test_get_model_by_identifier_caches_result(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $helper = new ImportDatabaseHelper(User::class, ['email'], true);\n        $helper->getModel([\n            'email' => $user->email,\n        ]);\n        $user->delete();\n\n        // Act\n        $model1 = $helper->getModel([\n            'email' => $user->email,\n        ]);\n\n        // Assert\n        $this->assertSame($user->getKey(), $model1->getKey());\n    }\n\n    public function test_get_model_by_id_returns_model_for_id(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $helper = new ImportDatabaseHelper(User::class, ['email'], true);\n\n        // Act\n        $model = $helper->getModelById($user->getKey());\n\n        // Assert\n        $this->assertSame($user->getKey(), $model->getKey());\n    }\n\n    public function test_get_model_by_id_returns_null_for_not_existing_id(): void\n    {\n        // Arrange\n        $helper = new ImportDatabaseHelper(User::class, ['email'], true);\n\n        // Act\n        $model = $helper->getModelById(Str::uuid()->toString());\n\n        // Assert\n        $this->assertNull($model);\n    }\n\n    public function test_get_model_by_id_caches_result(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $helper = new ImportDatabaseHelper(User::class, ['email'], true);\n        $helper->getModelById($user->getKey());\n        $user->delete();\n\n        // Act\n        $model1 = $helper->getModelById($user->getKey());\n\n        // Assert\n        $this->assertSame($user->getKey(), $model1->getKey());\n    }\n\n    public function test_get_cached_models_returns_all_models_where_the_helper_already_fetched_the_model(): void\n    {\n        // Arrange\n        $user1 = User::factory()->create();\n        $user2 = User::factory()->create();\n        $helper = new ImportDatabaseHelper(User::class, ['email'], true);\n        $helper->getModelById($user1->getKey());\n        $helper->getModelById($user2->getKey());\n        $helper->getModelById($user1->getKey());\n\n        // Act\n        $models = $helper->getCachedModels();\n\n        // Assert\n        $this->assertCount(2, $models);\n        $this->assertContains($user1->getKey(), collect($models)->pluck('id')->toArray());\n        $this->assertContains($user2->getKey(), collect($models)->pluck('id')->toArray());\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/Import/ImportServiceTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service\\Import;\n\nuse App\\Models\\Organization;\nuse App\\Service\\Import\\Importers\\ImporterProvider;\nuse App\\Service\\Import\\Importers\\ImportException;\nuse App\\Service\\Import\\ImportService;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Storage;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCase;\n\n#[CoversClass(ImportService::class)]\n#[CoversClass(ImporterProvider::class)]\nclass ImportServiceTest extends TestCase\n{\n    use RefreshDatabase;\n\n    public function test_import_gets_importer_from_provider_runs_importer_and_returns_report(): void\n    {\n        // Arrange\n        Storage::fake(config('filesystems.default'));\n        $organization = Organization::factory()->create();\n        $timezone = 'Europe/Vienna';\n        $data = Storage::disk('testfiles')->get('toggl_time_entries_import_test_1.csv');\n\n        // Act\n        $importService = app(ImportService::class);\n        $report = $importService->import($organization, 'toggl_time_entries', $data, $timezone);\n\n        // Assert\n        $lock = Cache::lock('import:'.$organization->getKey());\n        $this->assertTrue($lock->get());\n        $this->assertSame(2, $report->timeEntriesCreated);\n        $this->assertSame(2, $report->tagsCreated);\n        $this->assertSame(1, $report->tasksCreated);\n        $this->assertSame(1, $report->usersCreated);\n        $this->assertSame(2, $report->projectsCreated);\n        $this->assertSame(1, $report->clientsCreated);\n    }\n\n    public function test_import_releases_lock_if_an_exception_happens_during_the_import(): void\n    {\n        // Arrange\n        Storage::fake(config('filesystems.default'));\n        $organization = Organization::factory()->create();\n        $timezone = 'Europe/Vienna';\n        $data = 'Invalid CSV data';\n\n        // Act\n        $importService = app(ImportService::class);\n        try {\n            $importService->import($organization, 'toggl_time_entries', $data, $timezone);\n        } catch (ImportException) {\n            // Assert\n            $lock = Cache::lock('import:'.$organization->getKey());\n            $this->assertTrue($lock->get());\n        }\n    }\n\n    public function test_import_throws_exception_if_import_is_already_in_progress(): void\n    {\n        // Arrange\n        Storage::fake(config('filesystems.default'));\n        $organization = Organization::factory()->create();\n        $timezone = 'Europe/Vienna';\n        $data = Storage::disk('testfiles')->get('toggl_time_entries_import_test_1.csv');\n        Cache::lock('import:'.$organization->getKey(), 10)->get();\n\n        // Act\n        $importService = app(ImportService::class);\n        try {\n            $importService->import($organization, 'toggl_time_entries', $data, $timezone);\n        } catch (ImportException $e) {\n            // Assert\n            $this->assertSame('Import is already in progress', $e->getMessage());\n        }\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/Import/Importers/ClockifyProjectsImporterTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service\\Import\\Importers;\n\nuse App\\Models\\Organization;\nuse App\\Service\\Import\\Importers\\ClockifyProjectsImporter;\nuse App\\Service\\Import\\Importers\\DefaultImporter;\nuse App\\Service\\Import\\Importers\\ImportException;\nuse Illuminate\\Support\\Facades\\Storage;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(ClockifyProjectsImporter::class)]\n#[CoversClass(ImportException::class)]\n#[CoversClass(DefaultImporter::class)]\nclass ClockifyProjectsImporterTest extends ImporterTestAbstract\n{\n    public function test_import_of_test_file_succeeds(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $timezone = 'Europe/Vienna';\n        $importer = new ClockifyProjectsImporter;\n        $importer->init($organization);\n        $data = Storage::disk('testfiles')->get('clockify_projects_import_test_1.csv');\n\n        // Act\n        $importer->importData($data, $timezone);\n\n        // Assert\n        $this->checkTestScenarioProjectsOnlyAfterImport();\n    }\n\n    public function test_import_of_test_file_twice_succeeds(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $timezone = 'Europe/Vienna';\n        $importer = new ClockifyProjectsImporter;\n        $importer->init($organization);\n        $data = Storage::disk('testfiles')->get('clockify_projects_import_test_1.csv');\n        $importer->importData($data, $timezone);\n        $importer = new ClockifyProjectsImporter;\n        $importer->init($organization);\n\n        // Act\n        $importer->importData($data, $timezone);\n\n        // Assert\n        $this->checkTestScenarioProjectsOnlyAfterImport();\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/Import/Importers/ClockifyTimeEntriesImporterTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service\\Import\\Importers;\n\nuse App\\Models\\Organization;\nuse App\\Models\\TimeEntry;\nuse App\\Service\\Import\\Importers\\ClockifyTimeEntriesImporter;\nuse App\\Service\\Import\\Importers\\DefaultImporter;\nuse App\\Service\\Import\\Importers\\ImportException;\nuse Illuminate\\Support\\Facades\\Storage;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(ClockifyTimeEntriesImporter::class)]\n#[CoversClass(ImportException::class)]\n#[CoversClass(DefaultImporter::class)]\nclass ClockifyTimeEntriesImporterTest extends ImporterTestAbstract\n{\n    public function test_import_of_test_file_succeeds(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $timezone = 'Europe/Vienna';\n        $importer = new ClockifyTimeEntriesImporter;\n        $importer->init($organization);\n        $data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_1.csv');\n\n        // Act\n        $importer->importData($data, $timezone);\n        $report = $importer->getReport();\n\n        // Assert\n        $testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries();\n        $this->checkTimeEntries($testScenario);\n        $this->assertSame(2, $report->timeEntriesCreated);\n        $this->assertSame(2, $report->tagsCreated);\n        $this->assertSame(1, $report->tasksCreated);\n        $this->assertSame(1, $report->usersCreated);\n        $this->assertSame(2, $report->projectsCreated);\n        $this->assertSame(1, $report->clientsCreated);\n    }\n\n    public function test_import_of_test_with_special_characters_description_succeeds(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $timezone = 'Europe/Vienna';\n        $importer = new ClockifyTimeEntriesImporter;\n        $importer->init($organization);\n        // Description: \\\\ 🔥 Special characters  \"\"\"`!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.''<>\\/?~ \\\\\\\n        $data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_2.csv');\n\n        // Act\n        $importer->importData($data, $timezone);\n        $report = $importer->getReport();\n\n        // Assert\n        $timeEntry = TimeEntry::first();\n        $this->assertSame('\\\\\\\\ 🔥 Special characters  \\'\\'\\'\\'\\'\\'`!@#$%^&*()_+\\-=\\[\\]{};\\':\\'\\'\\\\\\\\|,.\\'\\'<>\\/?~ \\\\\\\\\\\\', $timeEntry->description);\n        $this->assertSame(1, $report->timeEntriesCreated);\n        $this->assertSame(0, $report->tagsCreated);\n        $this->assertSame(1, $report->tasksCreated);\n        $this->assertSame(1, $report->usersCreated);\n        $this->assertSame(1, $report->projectsCreated);\n        $this->assertSame(1, $report->clientsCreated);\n    }\n\n    public function test_import_of_test_file_twice_succeeds(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $timezone = 'Europe/Vienna';\n        $importer = new ClockifyTimeEntriesImporter;\n        $importer->init($organization);\n        $data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_1.csv');\n        $importer->importData($data, $timezone);\n        $importer = new ClockifyTimeEntriesImporter;\n        $importer->init($organization);\n\n        // Act\n        $importer->importData($data, $timezone);\n        $report = $importer->getReport();\n\n        // Assert\n        $testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries();\n        $this->checkTimeEntries($testScenario, true);\n        $this->assertSame(2, $report->timeEntriesCreated);\n        $this->assertSame(0, $report->tagsCreated);\n        $this->assertSame(0, $report->tasksCreated);\n        $this->assertSame(0, $report->usersCreated);\n        $this->assertSame(0, $report->projectsCreated);\n        $this->assertSame(0, $report->clientsCreated);\n    }\n\n    public function test_import_fails_if_month_in_date_is_bigger_than_12(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $timezone = 'Europe/Vienna';\n        $importer = new ClockifyTimeEntriesImporter;\n        $importer->init($organization);\n        $data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_3.csv');\n\n        // Act\n        try {\n            $importer->importData($data, $timezone);\n        } catch (ImportException $e) {\n            // Assert\n            $this->assertSame('Start date (\"13/15/2024\") is invalid, please select the correct date format before exporting from Clockify', $e->getMessage());\n\n            return;\n        }\n        $this->fail();\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/Import/Importers/GenericProjectsImporterTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service\\Import\\Importers;\n\nuse App\\Models\\Client;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\Task;\nuse App\\Service\\ColorService;\nuse App\\Service\\Import\\Importers\\DefaultImporter;\nuse App\\Service\\Import\\Importers\\GenericProjectsImporter;\nuse App\\Service\\Import\\Importers\\ImportException;\nuse Illuminate\\Support\\Facades\\Storage;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(GenericProjectsImporter::class)]\n#[CoversClass(ImportException::class)]\n#[CoversClass(DefaultImporter::class)]\nclass GenericProjectsImporterTest extends ImporterTestAbstract\n{\n    public function test_import_of_test_file_succeeds(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $timezone = 'Europe/Vienna';\n        $importer = new GenericProjectsImporter;\n        $importer->init($organization);\n        $data = Storage::disk('testfiles')->get('generic_projects_import_test_1.csv');\n\n        // Act\n        $importer->importData($data, $timezone);\n        $report = $importer->getReport();\n\n        // Assert\n        $clients = Client::all();\n        $this->assertCount(2, $clients);\n        $client1 = $clients->firstWhere('name', 'Big Company');\n        $this->assertNotNull($client1);\n        $client2 = $clients->firstWhere('name', 'Some client');\n        $this->assertNotNull($client2);\n        $projects = Project::all();\n        $this->assertCount(3, $projects);\n        // Project 1\n        $project1 = $projects->firstWhere('name', 'Project for Big Company');\n        $this->assertNotNull($project1);\n        $this->assertTrue(app(ColorService::class)->isBuiltInColor($project1->color));\n        $this->assertSame(10001, $project1->billable_rate);\n        $this->assertFalse($project1->is_public);\n        $this->assertSame($client1->getKey(), $project1->client_id);\n        $this->assertTrue($project1->is_billable);\n        $this->assertSame(null, $project1->estimated_time);\n        $this->assertNull($project1->archived_at);\n        // Project 2\n        $project2 = $projects->firstWhere('name', 'Project without Client');\n        $this->assertNotNull($project2);\n        $this->assertSame('#ef5350', $project2->color);\n        $this->assertSame(null, $project2->billable_rate);\n        $this->assertFalse($project2->is_public);\n        $this->assertSame(null, $project2->client_id);\n        $this->assertFalse($project2->is_billable);\n        $this->assertSame(1000, $project2->estimated_time);\n        $this->assertSame(null, $project2->archived_at);\n        $project3 = $projects->firstWhere('name', 'Project (Archived)');\n        $this->assertNotNull($project3);\n        $this->assertSame('#6a407f', $project3->color);\n        $this->assertSame(null, $project3->billable_rate);\n        $this->assertTrue($project3->is_public);\n        $this->assertSame($client2->getKey(), $project3->client_id);\n        $this->assertTrue($project3->is_billable);\n        $this->assertSame(null, $project3->estimated_time);\n        $this->assertSame('2024-08-25T10:00:00Z', $project3->archived_at->toIso8601ZuluString());\n\n        $tasks = Task::all();\n        $this->assertCount(0, $tasks);\n\n        $this->assertSame(0, $report->timeEntriesCreated);\n        $this->assertSame(0, $report->tagsCreated);\n        $this->assertSame(0, $report->tasksCreated);\n        $this->assertSame(0, $report->usersCreated);\n        $this->assertSame(3, $report->projectsCreated);\n        $this->assertSame(2, $report->clientsCreated);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/Import/Importers/GenericTimeEntriesImporterTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service\\Import\\Importers;\n\nuse App\\Models\\Organization;\nuse App\\Service\\Import\\Importers\\DefaultImporter;\nuse App\\Service\\Import\\Importers\\GenericTimeEntriesImporter;\nuse App\\Service\\Import\\Importers\\ImportException;\nuse Illuminate\\Support\\Facades\\Storage;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(GenericTimeEntriesImporter::class)]\n#[CoversClass(ImportException::class)]\n#[CoversClass(DefaultImporter::class)]\nclass GenericTimeEntriesImporterTest extends ImporterTestAbstract\n{\n    public function test_import_of_test_file_succeeds(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $timezone = 'Europe/Vienna';\n        $importer = new GenericTimeEntriesImporter;\n        $importer->init($organization);\n        $data = Storage::disk('testfiles')->get('generic_time_entries_import_test_1.csv');\n\n        // Act\n        $importer->importData($data, $timezone);\n        $report = $importer->getReport();\n\n        // Assert\n        $testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries();\n        $this->checkTimeEntries($testScenario);\n        $this->assertSame(2, $report->timeEntriesCreated);\n        $this->assertSame(2, $report->tagsCreated);\n        $this->assertSame(1, $report->tasksCreated);\n        $this->assertSame(1, $report->usersCreated);\n        $this->assertSame(2, $report->projectsCreated);\n        $this->assertSame(1, $report->clientsCreated);\n    }\n\n    public function test_import_of_test_file_twice_succeeds(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $timezone = 'Europe/Vienna';\n        $importer = new GenericTimeEntriesImporter;\n        $importer->init($organization);\n        $data = Storage::disk('testfiles')->get('generic_time_entries_import_test_1.csv');\n        $importer->importData($data, $timezone);\n        $importer = new GenericTimeEntriesImporter;\n        $importer->init($organization);\n\n        // Act\n        $importer->importData($data, $timezone);\n        $report = $importer->getReport();\n\n        // Assert\n        $testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries();\n        $this->checkTimeEntries($testScenario, true);\n        $this->assertSame(2, $report->timeEntriesCreated);\n        $this->assertSame(0, $report->tagsCreated);\n        $this->assertSame(0, $report->tasksCreated);\n        $this->assertSame(0, $report->usersCreated);\n        $this->assertSame(0, $report->projectsCreated);\n        $this->assertSame(0, $report->clientsCreated);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/Import/Importers/HarvestClientsImporterTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service\\Import\\Importers;\n\nuse App\\Models\\Client;\nuse App\\Models\\Organization;\nuse App\\Service\\Import\\Importers\\DefaultImporter;\nuse App\\Service\\Import\\Importers\\HarvestClientsImporter;\nuse App\\Service\\Import\\Importers\\ImportException;\nuse Illuminate\\Support\\Facades\\Storage;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(HarvestClientsImporter::class)]\n#[CoversClass(ImportException::class)]\n#[CoversClass(DefaultImporter::class)]\nclass HarvestClientsImporterTest extends ImporterTestAbstract\n{\n    public function test_import_of_test_file_succeeds(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $timezone = 'Europe/Vienna';\n        $importer = new HarvestClientsImporter;\n        $importer->init($organization);\n        $data = Storage::disk('testfiles')->get('harvest_clients_import_test_1.csv');\n\n        // Act\n        $importer->importData($data, $timezone);\n\n        // Assert\n        $clients = Client::query()->whereBelongsTo($organization, 'organization')->get();\n        $this->assertCount(2, $clients);\n        $client1 = $clients->where('name', 'Example Client')->first();\n        $this->assertNotNull($client1);\n        // Client name in Harvest: \\\\ 🔥 Special characters \"\"\"`!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.''<>\\/?~ \\\\\\\n        $client2 = $clients->where('name', '\\\\\\\\ 🔥 Special characters  \"\"\"`!@#$%^&*()_+\\-=\\[\\]{};\\':\"\\\\\\\\|,.\\'\\'<>\\/?~ \\\\\\\\\\\\')->first();\n        $this->assertNotNull($client2);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/Import/Importers/HarvestProjectsImporterTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service\\Import\\Importers;\n\nuse App\\Models\\Client;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Service\\Import\\Importers\\DefaultImporter;\nuse App\\Service\\Import\\Importers\\HarvestProjectsImporter;\nuse App\\Service\\Import\\Importers\\ImportException;\nuse Illuminate\\Support\\Facades\\Storage;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(HarvestProjectsImporter::class)]\n#[CoversClass(ImportException::class)]\n#[CoversClass(DefaultImporter::class)]\nclass HarvestProjectsImporterTest extends ImporterTestAbstract\n{\n    public function test_import_of_test_file_succeeds(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $timezone = 'Europe/Vienna';\n        $importer = new HarvestProjectsImporter;\n        $importer->init($organization);\n        $data = Storage::disk('testfiles')->get('harvest_projects_import_test_1.csv');\n\n        // Act\n        $importer->importData($data, $timezone);\n\n        // Assert\n        $clients = Client::query()->whereBelongsTo($organization, 'organization')->get();\n        $this->assertCount(2, $clients);\n        /** @var Client|null $client1 */\n        $client1 = $clients->where('name', 'Example Client')->first();\n        $this->assertNotNull($client1);\n        // Client name in Harvest: \\\\ 🔥 Special characters client \"\"\"`!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.''<>\\/?~ \\\\\\\n        /** @var Client|null $client2 */\n        $client2 = $clients->where('name', '\\\\\\\\ 🔥 Special characters client \"\"\"`!@#$%^&*()_+\\-=\\[\\]{};\\':\"\\\\\\\\|,.\\'\\'<>\\/?~ \\\\\\\\\\\\')->first();\n        $this->assertNotNull($client2);\n\n        $projects = Project::query()->whereBelongsTo($organization, 'organization')->get();\n        $this->assertCount(2, $projects);\n        /** @var Project|null $project1 */\n        $project1 = $projects->where('name', 'Example Project')->first();\n        $this->assertNotNull($project1);\n        $this->assertSame($client1->getKey(), $project1->client_id);\n        $this->assertSame(50 * 60 * 60, $project1->estimated_time); // 50h\n        $this->assertSame(true, $project1->is_billable);\n        /** @var Project|null $project2 */\n        $project2 = $projects->where('name', '\\\\\\\\ 🔥 Special characters project \"\"\"`!@#$%^&*()_+\\-=\\[\\]{};\\':\"\\\\\\\\|,.\\'\\'<>\\/?~ \\\\\\\\\\\\')->first();\n        $this->assertNotNull($project2);\n        $this->assertSame($client2->getKey(), $project2->client_id);\n        $this->assertSame(null, $project2->estimated_time);\n        $this->assertSame(false, $project2->is_billable);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/Import/Importers/HarvestTimeEntriesImporterTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service\\Import\\Importers;\n\nuse App\\Enums\\Role;\nuse App\\Models\\Client;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\Tag;\nuse App\\Models\\Task;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse App\\Service\\Import\\Importers\\DefaultImporter;\nuse App\\Service\\Import\\Importers\\HarvestTimeEntriesImporter;\nuse App\\Service\\Import\\Importers\\ImportException;\nuse Illuminate\\Support\\Facades\\Storage;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(HarvestTimeEntriesImporter::class)]\n#[CoversClass(ImportException::class)]\n#[CoversClass(DefaultImporter::class)]\nclass HarvestTimeEntriesImporterTest extends ImporterTestAbstract\n{\n    public function test_import_of_test_file_succeeds(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $timezone = 'Europe/Vienna';\n        $importer = new HarvestTimeEntriesImporter;\n        $importer->init($organization);\n        $data = Storage::disk('testfiles')->get('harvest_time_entries_import_test_1.csv');\n\n        // Act\n        $importer->importData($data, $timezone);\n        $report = $importer->getReport();\n\n        // Assert\n        $users = User::all();\n        $this->assertCount(2, $users);\n        $user1 = $users->firstWhere('name', 'Peter Tester');\n        $this->assertNotNull($user1);\n        $this->assertSame(null, $user1->password);\n        $this->assertSame('Peter Tester', $user1->name);\n        $this->assertSame('peter.tester@solidtime-import.test', $user1->email);\n        $members = Member::all();\n        $this->assertCount(1, $members);\n        $member1 = $members->firstWhere('user_id', $user1->getKey());\n        $this->assertNotNull($member1);\n        $this->assertSame(Role::Placeholder->value, $member1->role);\n        $clients = Client::all();\n        $this->assertCount(1, $clients);\n        $client1 = $clients->firstWhere('name', 'Big Company');\n        $this->assertNotNull($client1);\n        $this->assertNull($client1->archived_at);\n        $projects = Project::with(['members'])->get();\n        $this->assertCount(2, $projects);\n        /** @var Project|null $project1 */\n        $project1 = $projects->firstWhere('name', 'Project without Client');\n        $this->assertNotNull($project1);\n        $this->assertNull($project1->client_id);\n        /** @var Project|null $project2 */\n        $project2 = $projects->firstWhere('name', 'Project for Big Company');\n        $this->assertNotNull($project2);\n        $this->assertSame($client1->getKey(), $project2->client_id);\n        $project3 = null;\n        // Project without Client\n        $this->assertSame(false, $project1->is_public);\n        // Project for Big Company\n        $this->assertSame(false, $project2->is_public);\n        $tasks = Task::all();\n        $this->assertCount(1, $tasks);\n        $task1 = $tasks->firstWhere('name', 'Task 1');\n        $this->assertNotNull($task1);\n        $this->assertNull($task1->done_at);\n        $this->assertSame($project2->getKey(), $task1->project_id);\n        $tags = Tag::all();\n        $this->assertCount(0, $tags);\n\n        $timeEntries = TimeEntry::all();\n        $this->assertCount(2, $timeEntries);\n        $timeEntry1 = $timeEntries->firstWhere('description', '');\n        $this->assertNotNull($timeEntry1);\n        $this->assertSame('', $timeEntry1->description);\n        $this->assertSame('2024-03-03 23:00:00', $timeEntry1->start->toDateTimeString());\n        $this->assertSame('2024-03-04 19:00:00', $timeEntry1->end->toDateTimeString());\n        $this->assertFalse($timeEntry1->billable);\n        $this->assertTrue($timeEntry1->is_imported);\n        $this->assertSame([], $timeEntry1->tags);\n        $timeEntry2 = $timeEntries->firstWhere('description', 'Working hard');\n        $this->assertNotNull($timeEntry2);\n        $this->assertSame('Working hard', $timeEntry2->description);\n        $this->assertSame('2024-03-03 23:00:00', $timeEntry2->start->toDateTimeString());\n        $this->assertSame('2024-03-03 23:00:36', $timeEntry2->end->toDateTimeString());\n        $this->assertTrue($timeEntry2->billable);\n        $this->assertTrue($timeEntry2->is_imported);\n        $this->assertSame([], $timeEntry2->tags);\n\n        $this->assertSame(2, $report->timeEntriesCreated);\n        $this->assertSame(0, $report->tagsCreated);\n        $this->assertSame(1, $report->tasksCreated);\n        $this->assertSame(1, $report->usersCreated);\n        $this->assertSame(2, $report->projectsCreated);\n        $this->assertSame(1, $report->clientsCreated);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/Import/Importers/ImporterProviderTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service\\Import\\Importers;\n\nuse App\\Service\\Import\\Importers\\ClockifyProjectsImporter;\nuse App\\Service\\Import\\Importers\\ImporterProvider;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCase;\n\n#[CoversClass(ImporterProvider::class)]\nclass ImporterProviderTest extends TestCase\n{\n    public function test_register_importer_can_register_a_new_importer_for_example_in_an_extension(): void\n    {\n        // Arrange\n        $provider = new ImporterProvider;\n\n        // Act\n        $provider->registerImporter('some_provider_importer', ClockifyProjectsImporter::class);\n\n        // Assert\n        $importer = $provider->getImporter('some_provider_importer');\n        $this->assertSame(ClockifyProjectsImporter::class, $importer::class);\n    }\n\n    public function test_get_importer_keys_return_the_keys_of_the_available_importers(): void\n    {\n        // Arrange\n        $provider = new ImporterProvider;\n\n        // Act\n        $keys = $provider->getImporterKeys();\n\n        // Assert\n        $this->assertSame([\n            'toggl_time_entries',\n            'toggl_data_importer',\n            'clockify_time_entries',\n            'clockify_projects',\n            'solidtime',\n            'harvest_projects',\n            'harvest_time_entries',\n            'harvest_clients',\n            'generic_projects',\n            'generic_time_entries',\n        ], $keys);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/Import/Importers/ImporterTestAbstract.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service\\Import\\Importers;\n\nuse App\\Enums\\Role;\nuse App\\Models\\Client;\nuse App\\Models\\Member;\nuse App\\Models\\Project;\nuse App\\Models\\Tag;\nuse App\\Models\\Task;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Illuminate\\Support\\Facades\\Storage;\nuse Illuminate\\Support\\Str;\nuse Spatie\\TemporaryDirectory\\TemporaryDirectory;\nuse Tests\\TestCase;\nuse ZipArchive;\n\nclass ImporterTestAbstract extends TestCase\n{\n    use RefreshDatabase;\n\n    /**\n     * @return object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag}\n     */\n    protected function checkTestScenarioAfterImportExcludingTimeEntries(bool $detailed = false): object\n    {\n        $users = User::all();\n        $this->assertCount(2, $users);\n        $user1 = $users->firstWhere('name', 'Peter Tester');\n        $this->assertNotNull($user1);\n        $this->assertSame(null, $user1->password);\n        $this->assertSame('Peter Tester', $user1->name);\n        $this->assertSame('peter.test@email.test', $user1->email);\n        $members = Member::all();\n        $this->assertCount(1, $members);\n        $member1 = $members->firstWhere('user_id', $user1->getKey());\n        $this->assertNotNull($member1);\n        $this->assertSame(Role::Placeholder->value, $member1->role);\n        $clients = Client::all();\n        if ($detailed) {\n            $this->assertCount(2, $clients);\n            $client1 = $clients->firstWhere('name', 'Big Company');\n            $this->assertNotNull($client1);\n            $this->assertNull($client1->archived_at);\n            $client2 = $clients->firstWhere('name', 'Other Company (Archived)');\n            $this->assertNotNull($client2);\n            $this->assertNotNull($client2->archived_at);\n        } else {\n            $this->assertCount(1, $clients);\n            $client1 = $clients->firstWhere('name', 'Big Company');\n            $this->assertNotNull($client1);\n            $this->assertNull($client1->archived_at);\n        }\n        $projects = Project::with(['members'])->get();\n        if ($detailed) {\n            $this->assertCount(3, $projects);\n        } else {\n            $this->assertCount(2, $projects);\n        }\n        /** @var Project|null $project1 */\n        $project1 = $projects->firstWhere('name', 'Project without Client');\n        $this->assertNotNull($project1);\n        $this->assertNull($project1->client_id);\n        /** @var Project|null $project2 */\n        $project2 = $projects->firstWhere('name', 'Project for Big Company');\n        $this->assertNotNull($project2);\n        $this->assertSame($client1->getKey(), $project2->client_id);\n        $project3 = null;\n        if ($detailed) {\n            /** @var Project|null $project3 */\n            $project3 = $projects->firstWhere('name', 'Project (Archived)');\n            $this->assertNotNull($project3);\n            // Project without Client\n            $this->assertSame(false, $project1->is_billable);\n            $this->assertSame(false, $project1->is_public);\n            $this->assertSame('#ef5350', $project1->color);\n            $this->assertSame(null, $project1->billable_rate);\n            // Project for Big Company\n            $this->assertSame(true, $project2->is_billable);\n            $this->assertSame(false, $project2->is_public);\n            $this->assertSame('#ec407a', $project2->color);\n            $this->assertSame(10001, $project2->billable_rate);\n            // Project (Archived)\n            $this->assertSame(true, $project3->is_billable);\n            $this->assertSame(true, $project3->is_public);\n            $this->assertSame('#6a407f', $project3->color);\n            $this->assertSame(null, $project3->billable_rate);\n            $this->assertSame($client2->getKey(), $project3->client_id);\n            // Project members\n            $projectMembersOfProject2 = $project2->members;\n            $this->assertCount(1, $projectMembersOfProject2);\n            $this->assertSame($user1->getKey(), $projectMembersOfProject2->first()->user_id);\n            $this->assertSame(10002, $projectMembersOfProject2->first()->billable_rate);\n        } else {\n            // Project without Client\n            $this->assertSame(false, $project1->is_public);\n            // Project for Big Company\n            $this->assertSame(false, $project2->is_public);\n        }\n        $tasks = Task::all();\n        if ($detailed) {\n            $this->assertCount(2, $tasks);\n        } else {\n            $this->assertCount(1, $tasks);\n        }\n        $task1 = $tasks->firstWhere('name', 'Task 1');\n        $this->assertNotNull($task1);\n        $this->assertNull($task1->done_at);\n        $this->assertSame($project2->getKey(), $task1->project_id);\n        if ($detailed) {\n            $task2 = $tasks->firstWhere('name', 'Task 2');\n            $this->assertNotNull($task1);\n            $this->assertSame($project2->getKey(), $task2->project_id);\n            $this->assertNotNull($task2->done_at);\n        }\n        $tags = Tag::all();\n        $this->assertCount(2, $tags);\n        $tag1 = $tags->firstWhere('name', 'Development');\n        $tag2 = $tags->firstWhere('name', 'Backend');\n        $this->assertNotNull($tag1);\n\n        return (object) [\n            'user1' => $user1,\n            'project1' => $project1,\n            'project2' => $project2,\n            'project3' => $project3,\n            'tag1' => $tag1,\n            'tag2' => $tag2,\n        ];\n    }\n\n    /**\n     * @return object{client1: Client, project1: Project, project2: Project, task1: Task}\n     */\n    protected function checkTestScenarioProjectsOnlyAfterImport(): object\n    {\n        $clients = Client::all();\n        $this->assertCount(1, $clients);\n        $client1 = $clients->firstWhere('name', 'Big Company');\n        $this->assertNotNull($client1);\n        $projects = Project::all();\n        $this->assertCount(2, $projects);\n        $project1 = $projects->firstWhere('name', 'Project without Client');\n        $this->assertNotNull($project1);\n        $this->assertNull($project1->client_id);\n        $this->assertSame(null, $project1->estimated_time);\n        $project2 = $projects->firstWhere('name', 'Project for Big Company');\n        $this->assertNotNull($project2);\n        $this->assertSame(10001, $project2->billable_rate);\n        $this->assertSame($client1->getKey(), $project2->client_id);\n        $this->assertSame(3603996, $project2->estimated_time);\n        $tasks = Task::all();\n        $this->assertCount(3, $tasks);\n        $task1 = $tasks->firstWhere('name', 'Task 1');\n        $this->assertNotNull($task1);\n        $this->assertSame($project2->getKey(), $task1->project_id);\n        $task2 = $tasks->firstWhere('name', 'Task 2');\n        $this->assertNotNull($task2);\n        $this->assertSame($project2->getKey(), $task2->project_id);\n        $task3 = $tasks->firstWhere('name', 'Task 3');\n        $this->assertNotNull($task3);\n        $this->assertSame($project2->getKey(), $task3->project_id);\n\n        return (object) [\n            'client1' => $client1,\n            'project1' => $project1,\n            'project2' => $project2,\n            'task1' => $task1,\n        ];\n    }\n\n    /**\n     * @param  object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag}  $testScenario\n     */\n    protected function checkTimeEntries(object $testScenario, bool $secondRun = false): void\n    {\n        $timeEntries = TimeEntry::all();\n        if ($secondRun) {\n            $this->assertCount(4, $timeEntries);\n        } else {\n            $this->assertCount(2, $timeEntries);\n        }\n        $timeEntry1 = $timeEntries->firstWhere('description', '');\n        $this->assertNotNull($timeEntry1);\n        $this->assertSame('', $timeEntry1->description);\n        $this->assertSame('2024-03-04 09:23:52', $timeEntry1->start->toDateTimeString());\n        $this->assertSame('2024-03-04 09:23:52', $timeEntry1->end->toDateTimeString());\n        $this->assertFalse($timeEntry1->billable);\n        $this->assertTrue($timeEntry1->is_imported);\n        $this->assertSame([$testScenario->tag1->getKey(), $testScenario->tag2->getKey()], $timeEntry1->tags);\n        $timeEntry2 = $timeEntries->firstWhere('description', 'Working hard');\n        $this->assertNotNull($timeEntry2);\n        $this->assertSame('Working hard', $timeEntry2->description);\n        $this->assertSame('2024-03-04 09:23:00', $timeEntry2->start->toDateTimeString());\n        $this->assertSame('2024-03-04 10:23:01', $timeEntry2->end->toDateTimeString());\n        $this->assertTrue($timeEntry2->billable);\n        $this->assertTrue($timeEntry2->is_imported);\n        $this->assertSame([], $timeEntry2->tags);\n    }\n\n    protected function createTestZip(string $folder): string\n    {\n        $tempDir = TemporaryDirectory::make();\n        $zipPath = $tempDir->path('test.zip');\n        $zip = new ZipArchive;\n        $zip->open($zipPath, ZipArchive::CREATE);\n        foreach (Storage::disk('testfiles')->allFiles($folder) as $file) {\n            $zip->addFile(Storage::disk('testfiles')->path($file), Str::of($file)->after($folder.'/')->value());\n        }\n        $zip->close();\n\n        return $zipPath;\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/Import/Importers/SolidtimeImporterTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service\\Import\\Importers;\n\nuse App\\Jobs\\RecalculateSpentTimeForProject;\nuse App\\Jobs\\RecalculateSpentTimeForTask;\nuse App\\Models\\Organization;\nuse App\\Service\\Import\\Importers\\DefaultImporter;\nuse App\\Service\\Import\\Importers\\ImportException;\nuse App\\Service\\Import\\Importers\\SolidtimeImporter;\nuse Exception;\nuse Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Support\\Facades\\Queue;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(SolidtimeImporter::class)]\n#[CoversClass(ImportException::class)]\n#[CoversClass(DefaultImporter::class)]\nclass SolidtimeImporterTest extends ImporterTestAbstract\n{\n    public function test_import_throws_exception_if_data_is_not_zip(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $timezone = 'Europe/Vienna';\n        $importer = new SolidtimeImporter;\n        $importer->init($organization);\n\n        // Act\n        try {\n            $importer->importData('not a zip', $timezone);\n        } catch (Exception $e) {\n            $this->assertInstanceOf(ImportException::class, $e);\n            $this->assertSame('Invalid ZIP, error code: 19', $e->getMessage());\n\n            return;\n        }\n        $this->fail();\n    }\n\n    public function test_import_of_test_file_succeeds(): void\n    {\n        // Arrange\n        $zipPath = $this->createTestZip('solidtime_import_test_1');\n        $timezone = 'Europe/Vienna';\n        $organization = Organization::factory()->create();\n        $importer = new SolidtimeImporter;\n        $importer->init($organization);\n        $data = file_get_contents($zipPath);\n        Queue::fake([\n            RecalculateSpentTimeForProject::class,\n            RecalculateSpentTimeForTask::class,\n        ]);\n\n        // Act\n        DB::enableQueryLog();\n        DB::flushQueryLog();\n        $importer->importData($data, $timezone);\n        $report = $importer->getReport();\n        $queryLog = DB::getQueryLog();\n\n        // Assert\n        $this->assertCount(25, $queryLog);\n        $testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries(true);\n        $this->checkTimeEntries($testScenario);\n        $this->assertSame(2, $report->timeEntriesCreated);\n        $this->assertSame(2, $report->tagsCreated);\n        $this->assertSame(2, $report->tasksCreated);\n        $this->assertSame(1, $report->usersCreated);\n        $this->assertSame(3, $report->projectsCreated);\n        $this->assertSame(2, $report->clientsCreated);\n        Queue::assertPushed(RecalculateSpentTimeForProject::class, 1);\n        Queue::assertPushed(RecalculateSpentTimeForTask::class, 1);\n    }\n\n    public function test_import_of_test_file_twice_succeeds(): void\n    {\n        // Arrange\n        $zipPath = $this->createTestZip('solidtime_import_test_1');\n        $timezone = 'Europe/Vienna';\n        $organization = Organization::factory()->create();\n        $importer = new SolidtimeImporter;\n        $importer->init($organization);\n        $data = file_get_contents($zipPath);\n        $importer->importData($data, $timezone);\n        $importer = new SolidtimeImporter;\n        $importer->init($organization);\n        Queue::fake([\n            RecalculateSpentTimeForProject::class,\n            RecalculateSpentTimeForTask::class,\n        ]);\n\n        // Act\n        DB::enableQueryLog();\n        DB::flushQueryLog();\n        $importer->importData($data, $timezone);\n        $report = $importer->getReport();\n        $queryLog = DB::getQueryLog();\n\n        // Assert\n        $this->assertCount(13, $queryLog);\n        $testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries(true);\n        $this->checkTimeEntries($testScenario, true);\n        $this->assertSame(2, $report->timeEntriesCreated);\n        $this->assertSame(0, $report->tagsCreated);\n        $this->assertSame(0, $report->tasksCreated);\n        $this->assertSame(0, $report->usersCreated);\n        $this->assertSame(0, $report->projectsCreated);\n        $this->assertSame(0, $report->clientsCreated);\n        Queue::assertPushed(RecalculateSpentTimeForProject::class, 1);\n        Queue::assertPushed(RecalculateSpentTimeForTask::class, 1);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/Import/Importers/TogglDataImporterTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service\\Import\\Importers;\n\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse App\\Service\\Import\\Importers\\DefaultImporter;\nuse App\\Service\\Import\\Importers\\ImportException;\nuse App\\Service\\Import\\Importers\\TogglDataImporter;\nuse Exception;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(TogglDataImporter::class)]\n#[CoversClass(ImportException::class)]\n#[CoversClass(DefaultImporter::class)]\nclass TogglDataImporterTest extends ImporterTestAbstract\n{\n    public function test_import_throws_exception_if_data_is_not_zip(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $timezone = 'Europe/Vienna';\n        $importer = new TogglDataImporter;\n        $importer->init($organization);\n\n        // Act\n        try {\n            $importer->importData('not a zip', $timezone);\n        } catch (Exception $e) {\n            $this->assertInstanceOf(ImportException::class, $e);\n            $this->assertSame('Invalid ZIP, error code: 19', $e->getMessage());\n\n            return;\n        }\n        $this->fail();\n    }\n\n    public function test_import_of_test_file_succeeds(): void\n    {\n        // Arrange\n        $zipPath = $this->createTestZip('toggl_data_import_test_1');\n        $timezone = 'Europe/Vienna';\n        $organization = Organization::factory()->create();\n        $importer = new TogglDataImporter;\n        $importer->init($organization);\n        $data = file_get_contents($zipPath);\n\n        // Act\n        $importer->importData($data, $timezone);\n        $report = $importer->getReport();\n\n        // Assert\n        $this->checkTestScenarioAfterImportExcludingTimeEntries(true);\n        $this->assertSame(0, $report->timeEntriesCreated);\n        $this->assertSame(2, $report->tagsCreated);\n        $this->assertSame(2, $report->tasksCreated);\n        $this->assertSame(1, $report->usersCreated);\n        $this->assertSame(3, $report->projectsCreated);\n        $this->assertSame(2, $report->clientsCreated);\n    }\n\n    public function test_import_of_test_file_twice_succeeds(): void\n    {\n        // Arrange\n        $zipPath = $this->createTestZip('toggl_data_import_test_1');\n        $timezone = 'Europe/Vienna';\n        $organization = Organization::factory()->create();\n        $importer = new TogglDataImporter;\n        $importer->init($organization);\n        $data = file_get_contents($zipPath);\n        $importer->importData($data, $timezone);\n        $importer = new TogglDataImporter;\n        $importer->init($organization);\n\n        // Act\n        $importer->importData($data, $timezone);\n        $report = $importer->getReport();\n\n        // Assert\n        $this->checkTestScenarioAfterImportExcludingTimeEntries(true);\n        $this->assertSame(0, $report->timeEntriesCreated);\n        $this->assertSame(0, $report->tagsCreated);\n        $this->assertSame(0, $report->tasksCreated);\n        $this->assertSame(0, $report->usersCreated);\n        $this->assertSame(0, $report->projectsCreated);\n        $this->assertSame(0, $report->clientsCreated);\n    }\n\n    public function test_import_of_user_with_unknown_timezone_will_be_mapped_to_utc(): void\n    {\n        // Arrange\n        $zipPath = $this->createTestZip('toggl_data_import_test_2');\n        $timezone = 'Europe/Vienna';\n        $organization = Organization::factory()->create();\n        $importer = new TogglDataImporter;\n        $importer->init($organization);\n        $data = file_get_contents($zipPath);\n\n        // Act\n        $importer->importData($data, $timezone);\n        $report = $importer->getReport();\n\n        // Assert\n        $this->assertSame(0, $report->timeEntriesCreated);\n        $this->assertSame(2, $report->tagsCreated);\n        $this->assertSame(2, $report->tasksCreated);\n        $this->assertSame(1, $report->usersCreated);\n        $this->assertSame(3, $report->projectsCreated);\n        $this->assertSame(2, $report->clientsCreated);\n        $user = User::query()->where('email', '=', 'peter.test@email.test')->first();\n        $this->assertSame('UTC', $user->timezone);\n        $this->assertTrue($user->is_placeholder);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/Import/Importers/TogglTimeEntriesImporterTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service\\Import\\Importers;\n\nuse App\\Jobs\\RecalculateSpentTimeForProject;\nuse App\\Jobs\\RecalculateSpentTimeForTask;\nuse App\\Models\\Organization;\nuse App\\Models\\TimeEntry;\nuse App\\Service\\Import\\Importers\\DefaultImporter;\nuse App\\Service\\Import\\Importers\\ImportException;\nuse App\\Service\\Import\\Importers\\TogglTimeEntriesImporter;\nuse Illuminate\\Support\\Facades\\DB;\nuse Illuminate\\Support\\Facades\\Queue;\nuse Illuminate\\Support\\Facades\\Storage;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\n\n#[CoversClass(TogglTimeEntriesImporter::class)]\n#[CoversClass(ImportException::class)]\n#[CoversClass(DefaultImporter::class)]\nclass TogglTimeEntriesImporterTest extends ImporterTestAbstract\n{\n    public function test_import_of_test_file_succeeds(): void\n    {\n        // Arrange\n        Queue::fake([\n            RecalculateSpentTimeForProject::class,\n            RecalculateSpentTimeForTask::class,\n        ]);\n        $organization = Organization::factory()->create();\n        $timezone = 'Europe/Vienna';\n        $importer = new TogglTimeEntriesImporter;\n        $importer->init($organization);\n        $data = Storage::disk('testfiles')->get('toggl_time_entries_import_test_1.csv');\n\n        // Act\n        DB::enableQueryLog();\n        DB::flushQueryLog();\n        $importer->importData($data, $timezone);\n        $report = $importer->getReport();\n        $queryLog = DB::getQueryLog();\n\n        // Assert\n        $this->assertCount(22, $queryLog);\n        $testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries();\n        $this->checkTimeEntries($testScenario);\n        $this->assertSame(2, $report->timeEntriesCreated);\n        $this->assertSame(2, $report->tagsCreated);\n        $this->assertSame(1, $report->tasksCreated);\n        $this->assertSame(1, $report->usersCreated);\n        $this->assertSame(2, $report->projectsCreated);\n        $this->assertSame(1, $report->clientsCreated);\n        Queue::assertPushed(RecalculateSpentTimeForProject::class, 2);\n        Queue::assertPushed(RecalculateSpentTimeForTask::class, 1);\n    }\n\n    public function test_import_of_test_with_special_characters_description_succeeds(): void\n    {\n        // Arrange\n        Queue::fake([\n            RecalculateSpentTimeForProject::class,\n            RecalculateSpentTimeForTask::class,\n        ]);\n        $organization = Organization::factory()->create();\n        $timezone = 'Europe/Vienna';\n        $importer = new TogglTimeEntriesImporter;\n        $importer->init($organization);\n        // Description: \\\\ 🔥 Special characters  \"\"\"`!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.''<>\\/?~ \\\\\\\n        $data = Storage::disk('testfiles')->get('toggl_time_entries_import_test_2.csv');\n\n        // Act\n        $importer->importData($data, $timezone);\n        $report = $importer->getReport();\n\n        // Assert\n        $timeEntry = TimeEntry::first();\n        $this->assertSame('\\\\\\\\ 🔥 Special characters  \"\"\"`!@#$%^&*()_+\\-=\\[\\]{};\\':\"\\\\\\\\|,.\\'\\'<>\\/?~ \\\\\\\\\\\\', $timeEntry->description);\n        $this->assertSame(1, $report->timeEntriesCreated);\n        $this->assertSame(0, $report->tagsCreated);\n        $this->assertSame(1, $report->tasksCreated);\n        $this->assertSame(1, $report->usersCreated);\n        $this->assertSame(1, $report->projectsCreated);\n        $this->assertSame(1, $report->clientsCreated);\n    }\n\n    public function test_import_of_test_file_twice_succeeds(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $timezone = 'Europe/Vienna';\n        $importer = new TogglTimeEntriesImporter;\n        $importer->init($organization);\n        $data = Storage::disk('testfiles')->get('toggl_time_entries_import_test_1.csv');\n        $importer->importData($data, $timezone);\n        $importer = new TogglTimeEntriesImporter;\n        $importer->init($organization);\n        Queue::fake([\n            RecalculateSpentTimeForProject::class,\n            RecalculateSpentTimeForTask::class,\n        ]);\n\n        // Act\n        DB::enableQueryLog();\n        DB::flushQueryLog();\n        $importer->importData($data, $timezone);\n        $report = $importer->getReport();\n        $queryLog = DB::getQueryLog();\n\n        // Assert\n        $this->assertCount(14, $queryLog);\n        $testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries();\n        $this->checkTimeEntries($testScenario, true);\n        $this->assertSame(2, $report->timeEntriesCreated);\n        $this->assertSame(0, $report->tagsCreated);\n        $this->assertSame(0, $report->tasksCreated);\n        $this->assertSame(0, $report->usersCreated);\n        $this->assertSame(0, $report->projectsCreated);\n        $this->assertSame(0, $report->clientsCreated);\n        Queue::assertPushed(RecalculateSpentTimeForProject::class, 2);\n        Queue::assertPushed(RecalculateSpentTimeForTask::class, 1);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/IntervalServiceTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service;\n\nuse App\\Service\\IntervalService;\nuse Carbon\\CarbonInterval;\nuse Tests\\TestCase;\n\nclass IntervalServiceTest extends TestCase\n{\n    public function test_format_returns_correctly_formatted_interval(): void\n    {\n        // Arrange\n        $intervalService = app(IntervalService::class);\n        $interval = CarbonInterval::seconds(123456789123);\n\n        // Act\n        $result = $intervalService->format($interval);\n\n        // Assert\n        $this->assertEquals('34293552:32:03', $result);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/LocalizationServiceTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service;\n\nuse App\\Enums\\CurrencyFormat;\nuse App\\Enums\\DateFormat;\nuse App\\Enums\\IntervalFormat;\nuse App\\Enums\\NumberFormat;\nuse App\\Enums\\TimeFormat;\nuse App\\Service\\LocalizationService;\nuse Brick\\Money\\Currency;\nuse Brick\\Money\\Money;\nuse Carbon\\CarbonInterval;\nuse Illuminate\\Support\\Carbon;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCaseWithDatabase;\n\n#[CoversClass(LocalizationService::class)]\nclass LocalizationServiceTest extends TestCaseWithDatabase\n{\n    private LocalizationService $localizationService;\n\n    protected function setUp(): void\n    {\n        parent::setUp();\n        $this->localizationService = new LocalizationService(\n            CurrencyFormat::SymbolAfterWithSpace,\n            DateFormat::PointSeparatedDMYYYY,\n            TimeFormat::TwelveHours,\n            NumberFormat::ThousandsPointDecimalComma,\n            IntervalFormat::Decimal,\n        );\n    }\n\n    public function test_format_interval_with_type_decimal_and_number_format_thousands_comma_decimal_point(): void\n    {\n        // Arrange\n        $interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001));\n        $this->localizationService->setIntervalFormat(IntervalFormat::Decimal);\n        $this->localizationService->setNumberFormat(NumberFormat::ThousandsCommaDecimalPoint);\n\n        // Act\n        $formatted = $this->localizationService->formatInterval($interval);\n\n        // Assert\n        $this->assertSame('30,001.05 h', $formatted);\n    }\n\n    public function test_format_interval_with_type_decimal_and_number_format_thousands_space_decimal_point(): void\n    {\n        // Arrange\n        $interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001));\n        $this->localizationService->setIntervalFormat(IntervalFormat::Decimal);\n        $this->localizationService->setNumberFormat(NumberFormat::ThousandsSpaceDecimalPoint);\n\n        // Act\n        $formatted = $this->localizationService->formatInterval($interval);\n\n        // Assert\n        $this->assertSame('30 001.05 h', $formatted);\n    }\n\n    public function test_format_interval_with_type_decimal_and_number_format_thousands_point_decimal_comma(): void\n    {\n        // Arrange\n        $interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001));\n        $this->localizationService->setIntervalFormat(IntervalFormat::Decimal);\n        $this->localizationService->setNumberFormat(NumberFormat::ThousandsPointDecimalComma);\n\n        // Act\n        $formatted = $this->localizationService->formatInterval($interval);\n\n        // Assert\n        $this->assertSame('30.001,05 h', $formatted);\n    }\n\n    public function test_format_interval_with_type_decimal_and_number_format_thousands_apostrophe_decimal_point(): void\n    {\n        // Arrange\n        $interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001));\n        $this->localizationService->setIntervalFormat(IntervalFormat::Decimal);\n        $this->localizationService->setNumberFormat(NumberFormat::ThousandsApostropheDecimalPoint);\n\n        // Act\n        $formatted = $this->localizationService->formatInterval($interval);\n\n        // Assert\n        $this->assertSame('30\\'001.05 h', $formatted);\n    }\n\n    public function test_format_interval_with_type_hours_minutes(): void\n    {\n        // Arrange\n        $interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001));\n        $this->localizationService->setIntervalFormat(IntervalFormat::HoursMinutes);\n\n        // Act\n        $formatted = $this->localizationService->formatInterval($interval);\n\n        // Assert\n        $this->assertSame('30001h 03m', $formatted);\n    }\n\n    public function test_format_interval_with_type_hours_minutes_colon_separated(): void\n    {\n        // Arrange\n        $interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001));\n        $this->localizationService->setIntervalFormat(IntervalFormat::HoursMinutesColonSeparated);\n\n        // Act\n        $formatted = $this->localizationService->formatInterval($interval);\n\n        // Assert\n        $this->assertSame('30001:03', $formatted);\n    }\n\n    public function test_format_interval_with_type_hours_minutes_seconds_colon_separated(): void\n    {\n        // Arrange\n        $interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001));\n        $this->localizationService->setIntervalFormat(IntervalFormat::HoursMinutesSecondsColonSeparated);\n\n        // Act\n        $formatted = $this->localizationService->formatInterval($interval);\n\n        // Assert\n        $this->assertSame('30001:03:04', $formatted);\n    }\n\n    public function test_format_currency_with_type_symbol_after_with_space_and_number_format_thousands_space_decimal_comma(): void\n    {\n        // Arrange\n        $this->localizationService->setCurrencyFormat(CurrencyFormat::SymbolAfterWithSpace);\n        $this->localizationService->setNumberFormat(NumberFormat::ThousandsSpaceDecimalComma);\n        $money = Money::of(1234567.89, Currency::of('EUR'));\n\n        // Act\n        $formatted = $this->localizationService->formatCurrency($money);\n\n        // Assert\n        $this->assertSame('1 234 567,89 €', $formatted);\n    }\n\n    public function test_format_currency_with_type_symbol_before_with_space_and_number_format_thousands_space_decimal_comma(): void\n    {\n        // Arrange\n        $this->localizationService->setCurrencyFormat(CurrencyFormat::SymbolBeforeWithSpace);\n        $this->localizationService->setNumberFormat(NumberFormat::ThousandsSpaceDecimalComma);\n        $money = Money::of(1234567.89, Currency::of('EUR'));\n\n        // Act\n        $formatted = $this->localizationService->formatCurrency($money);\n\n        // Assert\n        $this->assertSame('€ 1 234 567,89', $formatted);\n    }\n\n    public function test_format_currency_with_type_symbol_before_and_number_format_thousands_space_decimal_comma(): void\n    {\n        // Arrange\n        $this->localizationService->setCurrencyFormat(CurrencyFormat::SymbolBefore);\n        $this->localizationService->setNumberFormat(NumberFormat::ThousandsSpaceDecimalComma);\n        $money = Money::of(1234567.89, Currency::of('EUR'));\n\n        // Act\n        $formatted = $this->localizationService->formatCurrency($money);\n\n        // Assert\n        $this->assertSame('€1 234 567,89', $formatted);\n    }\n\n    public function test_format_currency_with_type_symbol_after_and_number_format_thousands_space_decimal_comma(): void\n    {\n        // Arrange\n        $this->localizationService->setCurrencyFormat(CurrencyFormat::SymbolAfter);\n        $this->localizationService->setNumberFormat(NumberFormat::ThousandsSpaceDecimalComma);\n        $money = Money::of(1234567.89, Currency::of('EUR'));\n\n        // Act\n        $formatted = $this->localizationService->formatCurrency($money);\n\n        // Assert\n        $this->assertSame('1 234 567,89€', $formatted);\n    }\n\n    public function test_format_currency_with_type_iso_code_after_with_space_and_number_format_thousands_space_decimal_comma(): void\n    {\n        // Arrange\n        $this->localizationService->setCurrencyFormat(CurrencyFormat::ISOCodeAfterWithSpace);\n        $this->localizationService->setNumberFormat(NumberFormat::ThousandsSpaceDecimalComma);\n        $money = Money::of(1234567.89, Currency::of('EUR'));\n\n        // Act\n        $formatted = $this->localizationService->formatCurrency($money);\n\n        // Assert\n        $this->assertSame('1 234 567,89 EUR', $formatted);\n    }\n\n    public function test_format_currency_with_type_iso_code_before_with_space_and_number_format_thousands_space_decimal_comma(): void\n    {\n        // Arrange\n        $this->localizationService->setCurrencyFormat(CurrencyFormat::ISOCodeBeforeWithSpace);\n        $this->localizationService->setNumberFormat(NumberFormat::ThousandsSpaceDecimalComma);\n        $money = Money::of(1234567.89, Currency::of('EUR'));\n\n        // Act\n        $formatted = $this->localizationService->formatCurrency($money);\n\n        // Assert\n        $this->assertSame('EUR 1 234 567,89', $formatted);\n    }\n\n    public function test_format_date_with_type_slash_separated_ddmmy(): void\n    {\n        // Arrange\n        $this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY);\n        $date = Carbon::createFromDate(2001, 2, 3);\n\n        // Act\n        $formatted = $this->localizationService->formatDate($date);\n\n        // Assert\n        $this->assertSame('03/02/2001', $formatted);\n    }\n\n    public function test_format_time_with_type_twelve_hours(): void\n    {\n        // Arrange\n        $this->localizationService->setTimeFormat(TimeFormat::TwelveHours);\n        $time = Carbon::createFromTime(19, 9, 8);\n\n        // Act\n        $formatted = $this->localizationService->formatTime($time);\n\n        // Assert\n        $this->assertSame('07:09 pm', $formatted);\n    }\n\n    public function test_format_time_with_type_twenty_four_hours(): void\n    {\n        // Arrange\n        $this->localizationService->setTimeFormat(TimeFormat::TwentyFourHours);\n        $time = Carbon::createFromTime(14, 9, 8);\n\n        // Act\n        $formatted = $this->localizationService->formatTime($time);\n\n        // Assert\n        $this->assertSame('14:09', $formatted);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/MemberServiceTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service;\n\nuse App\\Enums\\Role;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\ProjectMember;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse App\\Service\\MemberService;\nuse App\\Service\\UserService;\nuse InvalidArgumentException;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCaseWithDatabase;\n\n#[CoversClass(MemberService::class)]\n#[CoversClass(UserService::class)]\nclass MemberServiceTest extends TestCaseWithDatabase\n{\n    private MemberService $memberService;\n\n    protected function setUp(): void\n    {\n        parent::setUp();\n        $this->memberService = app(MemberService::class);\n    }\n\n    public function test_change_ownership_fails_if_member_is_not_part_of_organization(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $otherOrganization = Organization::factory()->create();\n        $newOwner = Member::factory()->forOrganization($otherOrganization)->create();\n\n        // Act\n        $this->expectException(InvalidArgumentException::class);\n        $this->memberService->changeOwnership($organization, $newOwner);\n\n        // Assert\n        $this->assertDatabaseHas(Organization::class, [\n            'id' => $organization->getKey(),\n            'user_id' => null,\n        ]);\n    }\n\n    public function test_change_ownership_changes_ownership_to_new_member(): void\n    {\n        $organization = Organization::factory()->create();\n        $newOwner = User::factory()->create();\n        $oldOwner = User::factory()->create();\n        $newOwnerMember = Member::factory()->forUser($newOwner)->forOrganization($organization)->role(Role::Admin)->create();\n        $oldOwnerMember = Member::factory()->forUser($oldOwner)->forOrganization($organization)->role(Role::Owner)->create();\n\n        // Act\n        $this->memberService->changeOwnership($organization, $newOwnerMember);\n\n        // Assert\n        $this->assertSame($newOwner->getKey(), $organization->refresh()->user_id);\n        $this->assertSame(Role::Owner->value, $newOwnerMember->refresh()->role);\n        $this->assertSame(Role::Admin->value, $oldOwnerMember->refresh()->role);\n    }\n\n    public function test_make_member_to_placeholder_creates_new_user_based_on_member_and_changes_member_to_placeholder(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $organization = Organization::factory()->create();\n        $member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create();\n        $timeEntry = TimeEntry::factory()->forOrganization($organization)->forMember($member)->create();\n        $project = Project::factory()->forOrganization($organization)->create();\n        $projectMember = ProjectMember::factory()->forProject($project)->forMember($member)->create();\n        // Note: create other user, organization, member, time entry and project member to check that they are not changed\n        $otherUser = User::factory()->create();\n        $otherOrganization = Organization::factory()->create();\n        $otherMember = Member::factory()->forOrganization($otherOrganization)->forUser($otherUser)->role(Role::Employee)->create();\n        $otherTimeEntry = TimeEntry::factory()->forOrganization($otherOrganization)->forMember($otherMember)->create();\n        $otherProject = Project::factory()->forOrganization($otherOrganization)->create();\n        $otherProjectMember = ProjectMember::factory()->forProject($otherProject)->forMember($otherMember)->create();\n\n        // Act\n        $this->memberService->makeMemberToPlaceholder($member);\n\n        // Assert\n        $member->refresh();\n        $timeEntry->refresh();\n        $projectMember->refresh();\n        $placeholderUser = $member->user;\n        $this->assertTrue($placeholderUser->is_placeholder);\n        $this->assertSame(Role::Placeholder->value, $member->role);\n        $this->assertSame($organization->getKey(), $member->organization_id);\n        $this->assertSame($placeholderUser->getKey(), $projectMember->user_id);\n        $this->assertSame($member->getKey(), $projectMember->member_id);\n        $this->assertSame($placeholderUser->getKey(), $timeEntry->user_id);\n        $this->assertSame($member->getKey(), $timeEntry->member_id);\n        $this->assertSame(1, $user->organizations()->count());\n        // Note: check that other user did not change\n        $otherMember->refresh();\n        $otherTimeEntry->refresh();\n        $otherProjectMember->refresh();\n        $otherUser->refresh();\n        $this->assertFalse($otherUser->is_placeholder);\n        $this->assertSame(Role::Employee->value, $otherMember->role);\n        $this->assertSame($otherOrganization->getKey(), $otherMember->organization_id);\n        $this->assertSame($otherUser->getKey(), $otherProjectMember->user_id);\n        $this->assertSame($otherMember->getKey(), $otherProjectMember->member_id);\n        $this->assertSame($otherUser->getKey(), $otherTimeEntry->user_id);\n        $this->assertSame($otherMember->getKey(), $otherTimeEntry->member_id);\n        $this->assertSame(1, $otherUser->organizations()->count());\n    }\n\n    public function test_make_member_to_placeholder_resets_current_organization_of_user_if_user_is_no_longer_member_to_newly_created_organization(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $user = User::factory()->forCurrentOrganization($organization)->create();\n        $member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create();\n\n        // Act\n        $this->memberService->makeMemberToPlaceholder($member);\n\n        // Assert\n        $user->refresh();\n        $this->assertNotNull($user->current_team_id);\n        $this->assertNotSame($organization->id, $user->current_team_id);\n    }\n\n    public function test_make_member_to_placeholder_resets_current_organization_of_user_if_user_is_no_longer_member_to_already_existing_other_organization(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $user = User::factory()->forCurrentOrganization($organization)->create();\n        $member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create();\n\n        $otherOrganization = Organization::factory()->create();\n        $otherMember = Member::factory()->forOrganization($otherOrganization)->forUser($user)->role(Role::Employee)->create();\n\n        // Act\n        $this->memberService->makeMemberToPlaceholder($member);\n\n        // Assert\n        $user->refresh();\n        $this->assertNotNull($user->current_team_id);\n        $this->assertSame($otherOrganization->id, $user->current_team_id);\n    }\n\n    public function test_assign_organization_entities_to_different_member_without_any_entries(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $project = Project::factory()->forOrganization($organization)->create();\n        $otherUser = User::factory()->create();\n        $fromUser = User::factory()->create();\n        $toUser = User::factory()->create();\n        $otherUserMember = Member::factory()->forOrganization($organization)->forUser($otherUser)->create();\n        $fromUserMember = Member::factory()->forOrganization($organization)->forUser($fromUser)->create();\n        $toUserMember = Member::factory()->forOrganization($organization)->forUser($toUser)->create();\n        TimeEntry::factory()->forOrganization($organization)->forMember($otherUserMember)->createMany(3);\n        TimeEntry::factory()->forOrganization($organization)->forMember($fromUserMember)->createMany(3);\n        ProjectMember::factory()->forProject($project)->forMember($otherUserMember)->create();\n        ProjectMember::factory()->forProject($project)->forMember($fromUserMember)->create();\n\n        // Act\n        $this->memberService->assignOrganizationEntitiesToDifferentMember($organization, $fromUserMember, $toUserMember);\n\n        // Assert\n        $this->assertSame(3, TimeEntry::query()->whereBelongsTo($toUser, 'user')->count());\n        $this->assertSame(3, TimeEntry::query()->whereBelongsTo($otherUser, 'user')->count());\n        $this->assertSame(0, TimeEntry::query()->whereBelongsTo($fromUser, 'user')->count());\n        $this->assertSame(1, ProjectMember::query()->whereBelongsTo($toUser, 'user')->count());\n        $this->assertSame(1, ProjectMember::query()->whereBelongsTo($otherUser, 'user')->count());\n        $this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUser, 'user')->count());\n\n        $this->assertSame(3, TimeEntry::query()->whereBelongsTo($toUserMember, 'member')->count());\n        $this->assertSame(3, TimeEntry::query()->whereBelongsTo($otherUserMember, 'member')->count());\n        $this->assertSame(0, TimeEntry::query()->whereBelongsTo($fromUserMember, 'member')->count());\n        $this->assertSame(1, ProjectMember::query()->whereBelongsTo($toUserMember, 'member')->count());\n        $this->assertSame(1, ProjectMember::query()->whereBelongsTo($otherUserMember, 'member')->count());\n        $this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUserMember, 'member')->count());\n    }\n\n    public function test_assign_organization_entities_to_different_member_with_entries(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $project = Project::factory()->forOrganization($organization)->create();\n        $otherUser = User::factory()->create();\n        $fromUser = User::factory()->create();\n        $toUser = User::factory()->create();\n        $otherUserMember = Member::factory()->forOrganization($organization)->forUser($otherUser)->create();\n        $fromUserMember = Member::factory()->forOrganization($organization)->forUser($fromUser)->create();\n        $toUserMember = Member::factory()->forOrganization($organization)->forUser($toUser)->create();\n        TimeEntry::factory()->forOrganization($organization)->forMember($otherUserMember)->createMany(3);\n        TimeEntry::factory()->forOrganization($organization)->forMember($fromUserMember)->createMany(3);\n        TimeEntry::factory()->forOrganization($organization)->forMember($toUserMember)->createMany(3);\n        ProjectMember::factory()->forProject($project)->forMember($otherUserMember)->create([\n            'billable_rate' => 1,\n        ]);\n        ProjectMember::factory()->forProject($project)->forMember($fromUserMember)->create([\n            'billable_rate' => 2,\n        ]);\n        ProjectMember::factory()->forProject($project)->forMember($toUserMember)->create([\n            'billable_rate' => 3,\n        ]);\n\n        // Act\n        $this->memberService->assignOrganizationEntitiesToDifferentMember($organization, $fromUserMember, $toUserMember);\n\n        // Assert\n        $this->assertSame(6, TimeEntry::query()->whereBelongsTo($toUser, 'user')->count());\n        $this->assertSame(3, TimeEntry::query()->whereBelongsTo($otherUser, 'user')->count());\n        $this->assertSame(0, TimeEntry::query()->whereBelongsTo($fromUser, 'user')->count());\n        $this->assertSame(1, ProjectMember::query()->whereBelongsTo($toUser, 'user')->count());\n        $this->assertSame(1, ProjectMember::query()->whereBelongsTo($otherUser, 'user')->count());\n        $this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUser, 'user')->count());\n\n        $this->assertSame(6, TimeEntry::query()->whereBelongsTo($toUserMember, 'member')->count());\n        $this->assertSame(3, TimeEntry::query()->whereBelongsTo($otherUserMember, 'member')->count());\n        $this->assertSame(0, TimeEntry::query()->whereBelongsTo($fromUserMember, 'member')->count());\n        $this->assertSame(1, ProjectMember::query()->whereBelongsTo($toUserMember, 'member')->count());\n        $this->assertSame(1, ProjectMember::query()->whereBelongsTo($otherUserMember, 'member')->count());\n        $this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUserMember, 'member')->count());\n\n        $this->assertDatabaseCount(ProjectMember::class, 2);\n        $this->assertDatabaseHas(ProjectMember::class, [\n            'project_id' => $project->id,\n            'member_id' => $toUserMember->id,\n            'billable_rate' => 3,\n        ]);\n        $this->assertDatabaseHas(ProjectMember::class, [\n            'project_id' => $project->id,\n            'member_id' => $otherUserMember->id,\n            'billable_rate' => 1,\n        ]);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/PermissionStoreTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service;\n\nuse App\\Enums\\Role;\nuse App\\Models\\Organization;\nuse App\\Models\\User;\nuse App\\Service\\PermissionStore;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Laravel\\Jetstream\\Jetstream;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCase;\n\n#[CoversClass(PermissionStore::class)]\nclass PermissionStoreTest extends TestCase\n{\n    use RefreshDatabase;\n\n    public function test_has_method_returns_false_when_user_is_not_authenticated(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $user = User::factory()->create();\n        $organization->users()->attach($user, ['role' => Role::Employee->value]);\n        $permissionStore = new PermissionStore;\n\n        // Act\n        $result = $permissionStore->has($organization, 'permission');\n\n        // Assert\n        $this->assertFalse($result);\n    }\n\n    public function test_has_method_returns_false_when_user_does_not_belong_to_organization(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $user = User::factory()->create();\n        $permissionStore = new PermissionStore;\n        $this->actingAs($user);\n\n        // Act\n        $result = $permissionStore->has($organization, 'permission');\n\n        // Assert\n        $this->assertFalse($result);\n    }\n\n    public function test_has_method_returns_false_when_user_does_not_have_permission(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $user = User::factory()->create();\n        $organization->users()->attach($user, ['role' => Role::Employee->value]);\n        $permissionStore = new PermissionStore;\n        $this->actingAs($user);\n\n        // Act\n        $result = $permissionStore->has($organization, 'permission');\n\n        // Assert\n        $this->assertFalse($result);\n    }\n\n    public function test_has_method_returns_true_when_user_has_permission(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $user = User::factory()->create();\n        $organization->users()->attach($user, ['role' => Role::Employee->value]);\n        $permissionStore = new PermissionStore;\n        $this->actingAs($user);\n\n        // Act\n        $result = $permissionStore->has($organization, 'time-entries:view:own');\n\n        // Assert\n        $this->assertTrue($result);\n    }\n\n    public function test_get_permissions_method_returns_empty_array_when_user_is_not_authenticated(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $user = User::factory()->create();\n        $organization->users()->attach($user, ['role' => Role::Employee->value]);\n        $permissionStore = new PermissionStore;\n\n        // Act\n        $result = $permissionStore->getPermissions($organization);\n\n        // Assert\n        $this->assertEmpty($result);\n    }\n\n    public function test_get_permissions_method_returns_empty_array_when_user_does_not_belong_to_organization(): void\n    {\n        $organization = Organization::factory()->create();\n        $user = User::factory()->create();\n        $permissionStore = new PermissionStore;\n        $this->actingAs($user);\n\n        // Act\n        $result = $permissionStore->getPermissions($organization);\n\n        // Assert\n        $this->assertEmpty($result);\n    }\n\n    public function test_get_permissions_method_returns_permissions_when_user_belongs_to_organization(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $user = User::factory()->create();\n        $organization->users()->attach($user, ['role' => Role::Employee->value]);\n        $permissionStore = new PermissionStore;\n        $this->actingAs($user);\n\n        // Act\n        $result = $permissionStore->getPermissions($organization);\n\n        // Assert\n        $this->assertSame(Jetstream::findRole(Role::Employee->value)->permissions, $result);\n    }\n\n    public function test_employee_does_not_have_task_permissions_by_default(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create([\n            'employees_can_manage_tasks' => false,\n        ]);\n        $user = User::factory()->create();\n        $organization->users()->attach($user, ['role' => Role::Employee->value]);\n        $permissionStore = new PermissionStore;\n        $this->actingAs($user);\n\n        // Act & Assert\n        $this->assertFalse($permissionStore->has($organization, 'tasks:create'));\n        $this->assertFalse($permissionStore->has($organization, 'tasks:update'));\n        $this->assertFalse($permissionStore->has($organization, 'tasks:delete'));\n        $this->assertFalse($permissionStore->has($organization, 'tasks:create:all'));\n        $this->assertFalse($permissionStore->has($organization, 'tasks:update:all'));\n        $this->assertFalse($permissionStore->has($organization, 'tasks:delete:all'));\n    }\n\n    public function test_employee_has_task_permissions_when_organization_allows_it(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create([\n            'employees_can_manage_tasks' => true,\n        ]);\n        $user = User::factory()->create();\n        $organization->users()->attach($user, ['role' => Role::Employee->value]);\n        $permissionStore = new PermissionStore;\n        $this->actingAs($user);\n\n        // Act & Assert\n        $this->assertTrue($permissionStore->has($organization, 'tasks:create'));\n        $this->assertTrue($permissionStore->has($organization, 'tasks:update'));\n        $this->assertTrue($permissionStore->has($organization, 'tasks:delete'));\n        // Should NOT have the :all permissions\n        $this->assertFalse($permissionStore->has($organization, 'tasks:create:all'));\n        $this->assertFalse($permissionStore->has($organization, 'tasks:update:all'));\n        $this->assertFalse($permissionStore->has($organization, 'tasks:delete:all'));\n    }\n\n    public function test_non_employee_roles_are_not_affected_by_employees_can_manage_tasks_setting(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create([\n            'employees_can_manage_tasks' => false,\n        ]);\n        $admin = User::factory()->create();\n        $organization->users()->attach($admin, ['role' => Role::Admin->value]);\n        $permissionStore = new PermissionStore;\n        $this->actingAs($admin);\n\n        // Act & Assert - Admin should have task permissions regardless of the setting\n        $this->assertTrue($permissionStore->has($organization, 'tasks:create'));\n        $this->assertTrue($permissionStore->has($organization, 'tasks:update'));\n        $this->assertTrue($permissionStore->has($organization, 'tasks:delete'));\n        $this->assertTrue($permissionStore->has($organization, 'tasks:create:all'));\n        $this->assertTrue($permissionStore->has($organization, 'tasks:update:all'));\n        $this->assertTrue($permissionStore->has($organization, 'tasks:delete:all'));\n    }\n\n    public function test_get_permissions_includes_task_permissions_for_employee_when_enabled(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create([\n            'employees_can_manage_tasks' => true,\n        ]);\n        $user = User::factory()->create();\n        $organization->users()->attach($user, ['role' => Role::Employee->value]);\n        $permissionStore = new PermissionStore;\n        $this->actingAs($user);\n\n        // Act\n        $result = $permissionStore->getPermissions($organization);\n\n        // Assert\n        $this->assertContains('tasks:create', $result);\n        $this->assertContains('tasks:update', $result);\n        $this->assertContains('tasks:delete', $result);\n        $this->assertNotContains('tasks:create:all', $result);\n        $this->assertNotContains('tasks:update:all', $result);\n        $this->assertNotContains('tasks:delete:all', $result);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/TimeEntryAggregationServiceTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service;\n\nuse App\\Enums\\TimeEntryAggregationType;\nuse App\\Enums\\TimeEntryRoundingType;\nuse App\\Enums\\Weekday;\nuse App\\Models\\Client;\nuse App\\Models\\Project;\nuse App\\Models\\Tag;\nuse App\\Models\\TimeEntry;\nuse App\\Service\\TimeEntryAggregationService;\nuse Illuminate\\Support\\Carbon;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCaseWithDatabase;\n\n#[CoversClass(TimeEntryAggregationService::class)]\nclass TimeEntryAggregationServiceTest extends TestCaseWithDatabase\n{\n    private TimeEntryAggregationService $service;\n\n    protected function setUp(): void\n    {\n        parent::setUp();\n        $this->service = app(TimeEntryAggregationService::class);\n    }\n\n    public function test_aggregate_time_entries_empty_state_by_day_and_project_returns_empty_array_if_no_time_entries_given(): void\n    {\n        // Arrange\n        $query = TimeEntry::query();\n\n        // Act\n        $result = $this->service->getAggregatedTimeEntries(\n            $query,\n            TimeEntryAggregationType::Day,\n            TimeEntryAggregationType::Project,\n            'Europe/Vienna',\n            Weekday::Monday,\n            false,\n            null,\n            null,\n            true,\n            null,\n            null\n        );\n\n        // Assert\n        $this->assertSame([\n            'seconds' => 0,\n            'cost' => 0,\n            'grouped_type' => 'day',\n            'grouped_data' => [],\n        ], $result);\n    }\n\n    public function test_aggregate_time_entries_by_project_and_description(): void\n    {\n        // Arrange\n        $project1 = Project::factory()->create([\n            // Note: To ensure deterministic order\n            'id' => '5de4e6df-9560-4675-95be-18d42c441bfc',\n        ]);\n        $project2 = Project::factory()->create([\n            // Note: To ensure deterministic order\n            'id' => '130bdf66-d370-4564-aec7-7171e9b415f7',\n        ]);\n        TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project1)->create([\n            'description' => 'Test',\n        ]);\n        TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project2)->create([\n            'description' => '',\n        ]);\n        TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project1)->create([\n            'description' => 'Test',\n        ]);\n        TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project2)->create([\n            'description' => 'Test',\n        ]);\n        $query = TimeEntry::query();\n\n        // Act\n        $result = $this->service->getAggregatedTimeEntries(\n            $query,\n            TimeEntryAggregationType::Project,\n            TimeEntryAggregationType::Description,\n            'Europe/Vienna',\n            Weekday::Monday,\n            false,\n            Carbon::now()->subDays(2)->utc(),\n            Carbon::now()->subDay()->utc(),\n            true,\n            null,\n            null\n        );\n\n        // Assert\n        $this->assertSame([\n            'seconds' => 40,\n            'cost' => 0,\n            'grouped_type' => 'project',\n            'grouped_data' => [\n                [\n                    'key' => $project2->getKey(),\n                    'seconds' => 20,\n                    'cost' => 0,\n                    'grouped_type' => 'description',\n                    'grouped_data' => [\n                        [\n                            'key' => null,\n                            'seconds' => 10,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                        [\n                            'key' => 'Test',\n                            'seconds' => 10,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                    ],\n                ],\n                [\n                    'key' => $project1->getKey(),\n                    'seconds' => 20,\n                    'cost' => 0,\n                    'grouped_type' => 'description',\n                    'grouped_data' => [\n                        [\n                            'key' => 'Test',\n                            'seconds' => 20,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                    ],\n                ],\n            ],\n        ], $result);\n    }\n\n    public function test_aggregate_time_entries_without_billable_amounts(): void\n    {\n        // Arrange\n        $project1 = Project::factory()->create([\n            // Note: To ensure deterministic order\n            'id' => '5de4e6df-9560-4675-95be-18d42c441bfc',\n        ]);\n        $project2 = Project::factory()->create([\n            // Note: To ensure deterministic order\n            'id' => '130bdf66-d370-4564-aec7-7171e9b415f7',\n        ]);\n        TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project1)->create([\n            'description' => 'Test',\n        ]);\n        TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project2)->create([\n            'description' => '',\n        ]);\n        TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project1)->create([\n            'description' => 'Test',\n        ]);\n        TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project2)->create([\n            'description' => 'Test',\n        ]);\n        $query = TimeEntry::query();\n\n        // Act\n        $result = $this->service->getAggregatedTimeEntries(\n            $query,\n            TimeEntryAggregationType::Project,\n            TimeEntryAggregationType::Description,\n            'Europe/Vienna',\n            Weekday::Monday,\n            false,\n            Carbon::now()->subDays(2)->utc(),\n            Carbon::now()->subDay()->utc(),\n            false,\n            null,\n            null\n        );\n\n        // Assert\n        $this->assertSame([\n            'seconds' => 40,\n            'cost' => null,\n            'grouped_type' => 'project',\n            'grouped_data' => [\n                [\n                    'key' => $project2->getKey(),\n                    'seconds' => 20,\n                    'cost' => null,\n                    'grouped_type' => 'description',\n                    'grouped_data' => [\n                        [\n                            'key' => null,\n                            'seconds' => 10,\n                            'cost' => null,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                        [\n                            'key' => 'Test',\n                            'seconds' => 10,\n                            'cost' => null,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                    ],\n                ],\n                [\n                    'key' => $project1->getKey(),\n                    'seconds' => 20,\n                    'cost' => null,\n                    'grouped_type' => 'description',\n                    'grouped_data' => [\n                        [\n                            'key' => 'Test',\n                            'seconds' => 20,\n                            'cost' => null,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                    ],\n                ],\n            ],\n        ], $result);\n    }\n\n    public function test_aggregate_time_entries_empty_state_by_day_and_project_with_filled_gaps(): void\n    {\n        // Arrange\n        $timezone = 'Europe/Vienna';\n        $query = TimeEntry::query();\n\n        // Act\n        $result = $this->service->getAggregatedTimeEntries(\n            $query,\n            TimeEntryAggregationType::Day,\n            TimeEntryAggregationType::Project,\n            $timezone,\n            Weekday::Monday,\n            true,\n            Carbon::now()->subDays(2)->utc(),\n            Carbon::now()->subDay()->utc(),\n            true,\n            null,\n            null\n        );\n\n        // Assert\n        $this->assertSame([\n            'seconds' => 0,\n            'cost' => 0,\n            'grouped_type' => 'day',\n            'grouped_data' => [\n                [\n                    'key' => Carbon::now()->subDays(2)->timezone($timezone)->format('Y-m-d'),\n                    'seconds' => 0,\n                    'cost' => 0,\n                    'grouped_type' => 'project',\n                    'grouped_data' => [],\n                ],\n                [\n                    'key' => Carbon::now()->subDay()->timezone($timezone)->format('Y-m-d'),\n                    'seconds' => 0,\n                    'cost' => 0,\n                    'grouped_type' => 'project',\n                    'grouped_data' => [],\n                ],\n            ],\n        ], $result);\n    }\n\n    public function test_aggregate_time_entries_empty_state_by_user_and_project_with_filled_gaps(): void\n    {\n        // Arrange\n        $query = TimeEntry::query();\n\n        // Act\n        $result = $this->service->getAggregatedTimeEntries(\n            $query,\n            TimeEntryAggregationType::User,\n            TimeEntryAggregationType::Project,\n            'Europe/Vienna',\n            Weekday::Monday,\n            true,\n            Carbon::now()->subDays(2),\n            Carbon::now()->subDay(),\n            true,\n            null,\n            null\n        );\n\n        // Assert\n        $this->assertSame([\n            'seconds' => 0,\n            'cost' => 0,\n            'grouped_type' => 'user',\n            'grouped_data' => [],\n        ], $result);\n    }\n\n    public function test_aggregate_time_entries_empty_state_by_user_and_day_with_filled_gaps(): void\n    {\n        // Arrange\n        $query = TimeEntry::query();\n\n        // Act\n        $result = $this->service->getAggregatedTimeEntries(\n            $query,\n            TimeEntryAggregationType::User,\n            TimeEntryAggregationType::Day,\n            'Europe/Vienna',\n            Weekday::Monday,\n            true,\n            Carbon::now()->subDays(2),\n            Carbon::now()->subDay(),\n            true,\n            null,\n            null\n        );\n\n        // Assert\n        $this->assertSame([\n            'seconds' => 0,\n            'cost' => 0,\n            'grouped_type' => 'user',\n            'grouped_data' => [],\n        ], $result);\n    }\n\n    public function test_aggregate_time_entries_by_client_and_project(): void\n    {\n        // Arrange\n        $client1 = Client::factory()->create();\n        $client2 = Client::factory()->create();\n        $project1 = Project::factory()->forClient($client1)->create();\n        $project2 = Project::factory()->forClient($client2)->create();\n        $project3 = Project::factory()->create();\n        TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project1)->create();\n        TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project2)->create();\n        TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project3)->create();\n        TimeEntry::factory()->startWithDuration(now(), 10)->create();\n        $query = TimeEntry::query();\n\n        // Act\n        $result = $this->service->getAggregatedTimeEntries(\n            $query,\n            TimeEntryAggregationType::Client,\n            TimeEntryAggregationType::Project,\n            'Europe/Vienna',\n            Weekday::Monday,\n            false,\n            null,\n            null,\n            true,\n            null,\n            null\n        );\n\n        // Assert\n        $this->assertEqualsCanonicalizing([\n            'seconds' => 40,\n            'cost' => 0,\n            'grouped_type' => 'client',\n            'grouped_data' => [\n                [\n                    'key' => null,\n                    'seconds' => 20,\n                    'cost' => 0,\n                    'grouped_type' => 'project',\n                    'grouped_data' => [\n                        [\n                            'key' => null,\n                            'seconds' => 10,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                        [\n                            'key' => $project3->getKey(),\n                            'seconds' => 10,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                    ],\n                ],\n                [\n                    'key' => $client1->getKey(),\n                    'seconds' => 10,\n                    'cost' => 0,\n                    'grouped_type' => 'project',\n                    'grouped_data' => [\n                        [\n                            'key' => $project1->getKey(),\n                            'seconds' => 10,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                    ],\n                ],\n                [\n                    'key' => $client2->getKey(),\n                    'seconds' => 10,\n                    'cost' => 0,\n                    'grouped_type' => 'project',\n                    'grouped_data' => [\n                        [\n                            'key' => $project2->getKey(),\n                            'seconds' => 10,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                    ],\n                ],\n            ],\n        ], $result);\n    }\n\n    public function test_aggregate_time_can_round_up_per_time_entry(): void\n    {\n        // Arrange\n        $client1 = Client::factory()->create();\n        $client2 = Client::factory()->create();\n        $project1 = Project::factory()->forClient($client1)->create();\n        $project2 = Project::factory()->forClient($client2)->create();\n        $project3 = Project::factory()->create();\n        TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 450)\n            ->forProject($project1)->create();\n        TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 449)\n            ->forProject($project1)->create();\n        TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 451)\n            ->forProject($project2)->create();\n        TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 450)\n            ->forProject($project3)\n            ->create();\n        TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 449)\n            ->create();\n        $query = TimeEntry::query();\n\n        // Act\n        $result = $this->service->getAggregatedTimeEntries(\n            $query,\n            TimeEntryAggregationType::Client,\n            TimeEntryAggregationType::Project,\n            'Europe/Vienna',\n            Weekday::Monday,\n            false,\n            null,\n            null,\n            true,\n            TimeEntryRoundingType::Up,\n            15\n        );\n\n        // Assert\n        $this->assertEqualsCanonicalizing([\n            'seconds' => 4500,\n            'cost' => 0,\n            'grouped_type' => 'client',\n            'grouped_data' => [\n                [\n                    'key' => null,\n                    'seconds' => 1800,\n                    'cost' => 0,\n                    'grouped_type' => 'project',\n                    'grouped_data' => [\n                        [\n                            'key' => null,\n                            'seconds' => 900,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                        [\n                            'key' => $project3->getKey(),\n                            'seconds' => 900,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                    ],\n                ],\n                [\n                    'key' => $client1->getKey(),\n                    'seconds' => 1800,\n                    'cost' => 0,\n                    'grouped_type' => 'project',\n                    'grouped_data' => [\n                        [\n                            'key' => $project1->getKey(),\n                            'seconds' => 1800,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                    ],\n                ],\n                [\n                    'key' => $client2->getKey(),\n                    'seconds' => 900,\n                    'cost' => 0,\n                    'grouped_type' => 'project',\n                    'grouped_data' => [\n                        [\n                            'key' => $project2->getKey(),\n                            'seconds' => 900,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                    ],\n                ],\n            ],\n        ], $result);\n    }\n\n    public function test_aggregate_time_can_round_down_per_time_entry(): void\n    {\n        // Arrange\n        $client1 = Client::factory()->create();\n        $client2 = Client::factory()->create();\n        $project1 = Project::factory()->forClient($client1)->create();\n        $project2 = Project::factory()->forClient($client2)->create();\n        $project3 = Project::factory()->create();\n        TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 450)\n            ->forProject($project1)->create();\n        TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 449)\n            ->forProject($project1)->create();\n        TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 451)\n            ->forProject($project2)->create();\n        TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 900 + 450)\n            ->forProject($project3)\n            ->create();\n        TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 900 + 449)\n            ->create();\n        $query = TimeEntry::query();\n\n        // Act\n        $result = $this->service->getAggregatedTimeEntries(\n            $query,\n            TimeEntryAggregationType::Client,\n            TimeEntryAggregationType::Project,\n            'Europe/Vienna',\n            Weekday::Monday,\n            false,\n            null,\n            null,\n            true,\n            TimeEntryRoundingType::Down,\n            15\n        );\n\n        // Assert\n        $this->assertEqualsCanonicalizing([\n            'seconds' => 1800,\n            'cost' => 0,\n            'grouped_type' => 'client',\n            'grouped_data' => [\n                [\n                    'key' => null,\n                    'seconds' => 1800,\n                    'cost' => 0,\n                    'grouped_type' => 'project',\n                    'grouped_data' => [\n                        [\n                            'key' => null,\n                            'seconds' => 900,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                        [\n                            'key' => $project3->getKey(),\n                            'seconds' => 900,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                    ],\n                ],\n                [\n                    'key' => $client1->getKey(),\n                    'seconds' => 0,\n                    'cost' => 0,\n                    'grouped_type' => 'project',\n                    'grouped_data' => [\n                        [\n                            'key' => $project1->getKey(),\n                            'seconds' => 0,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                    ],\n                ],\n                [\n                    'key' => $client2->getKey(),\n                    'seconds' => 0,\n                    'cost' => 0,\n                    'grouped_type' => 'project',\n                    'grouped_data' => [\n                        [\n                            'key' => $project2->getKey(),\n                            'seconds' => 0,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                    ],\n                ],\n            ],\n        ], $result);\n    }\n\n    public function test_aggregate_time_can_round_to_nearest_per_time_entry(): void\n    {\n        // Arrange\n        $client1 = Client::factory()->create();\n        $client2 = Client::factory()->create();\n        $project1 = Project::factory()->forClient($client1)->create();\n        $project2 = Project::factory()->forClient($client2)->create();\n        $project3 = Project::factory()->create();\n        TimeEntry::factory()->startWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00'), 449)\n            ->forProject($project1)->create();\n        TimeEntry::factory()->startWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00'), 450)\n            ->forProject($project1)->create();\n        TimeEntry::factory()->startWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00'), 450)\n            ->forProject($project2)->create();\n        TimeEntry::factory()->startWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00'), 450)\n            ->forProject($project3)\n            ->create();\n        TimeEntry::factory()->startWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00'), 450)\n            ->create();\n        $query = TimeEntry::query();\n\n        // Act\n        $result = $this->service->getAggregatedTimeEntries(\n            $query,\n            TimeEntryAggregationType::Client,\n            TimeEntryAggregationType::Project,\n            'Europe/Vienna',\n            Weekday::Monday,\n            false,\n            null,\n            null,\n            true,\n            TimeEntryRoundingType::Nearest,\n            15\n        );\n\n        // Assert\n        $this->assertEqualsCanonicalizing([\n            'seconds' => 3600,\n            'cost' => 0,\n            'grouped_type' => 'client',\n            'grouped_data' => [\n                [\n                    'key' => null,\n                    'seconds' => 1800,\n                    'cost' => 0,\n                    'grouped_type' => 'project',\n                    'grouped_data' => [\n                        [\n                            'key' => null,\n                            'seconds' => 900,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                        [\n                            'key' => $project3->getKey(),\n                            'seconds' => 900,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                    ],\n                ],\n                [\n                    'key' => $client1->getKey(),\n                    'seconds' => 900,\n                    'cost' => 0,\n                    'grouped_type' => 'project',\n                    'grouped_data' => [\n                        [\n                            'key' => $project1->getKey(),\n                            'seconds' => 900,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                    ],\n                ],\n                [\n                    'key' => $client2->getKey(),\n                    'seconds' => 900,\n                    'cost' => 0,\n                    'grouped_type' => 'project',\n                    'grouped_data' => [\n                        [\n                            'key' => $project2->getKey(),\n                            'seconds' => 900,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                    ],\n                ],\n            ],\n        ], $result);\n    }\n\n    // TODO: test with 1 minute\n\n    public function test_aggregate_time_entries_by_client_and_project_with_filled_gaps(): void\n    {\n        // Arrange\n        $client1 = Client::factory()->create();\n        $client2 = Client::factory()->create();\n        $project1 = Project::factory()->forClient($client1)->create();\n        $project2 = Project::factory()->forClient($client2)->create();\n        $project3 = Project::factory()->create();\n        TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project1)->create();\n        TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project2)->create();\n        TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project3)->create();\n        TimeEntry::factory()->startWithDuration(now(), 10)->create();\n        $query = TimeEntry::query();\n\n        // Act\n        $result = $this->service->getAggregatedTimeEntries(\n            $query,\n            TimeEntryAggregationType::Client,\n            TimeEntryAggregationType::Project,\n            'Europe/Vienna',\n            Weekday::Monday,\n            true,\n            null,\n            null,\n            true,\n            null,\n            null\n        );\n\n        // Assert\n        $this->assertEqualsCanonicalizing([\n            'seconds' => 40,\n            'cost' => 0,\n            'grouped_type' => 'client',\n            'grouped_data' => [\n                [\n                    'key' => null,\n                    'seconds' => 20,\n                    'cost' => 0,\n                    'grouped_type' => 'project',\n                    'grouped_data' => [\n                        [\n                            'key' => null,\n                            'seconds' => 10,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                        [\n                            'key' => $project3->getKey(),\n                            'seconds' => 10,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                    ],\n                ],\n                [\n                    'key' => $client1->getKey(),\n                    'seconds' => 10,\n                    'cost' => 0,\n                    'grouped_type' => 'project',\n                    'grouped_data' => [\n                        [\n                            'key' => $project1->getKey(),\n                            'seconds' => 10,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                    ],\n                ],\n                [\n                    'key' => $client2->getKey(),\n                    'seconds' => 10,\n                    'cost' => 0,\n                    'grouped_type' => 'project',\n                    'grouped_data' => [\n                        [\n                            'key' => $project2->getKey(),\n                            'seconds' => 10,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                    ],\n                ],\n            ],\n        ], $result);\n    }\n\n    public function test_aggregated_time_entries_with_descriptions_by_description_and_billable(): void\n    {\n        // Arrange\n        TimeEntry::factory()->startWithDuration(now(), 10)->create([\n            'description' => 'TEST 1',\n            'billable' => true,\n        ]);\n        TimeEntry::factory()->startWithDuration(now(), 10)->create([\n            'description' => '',\n            'billable' => false,\n        ]);\n        TimeEntry::factory()->startWithDuration(now(), 10)->create([\n            'description' => 'TEST 1',\n            'billable' => false,\n        ]);\n        TimeEntry::factory()->startWithDuration(now(), 10)->create([\n            'description' => '',\n            'billable' => false,\n        ]);\n        $query = TimeEntry::query();\n\n        // Act\n        $result = $this->service->getAggregatedTimeEntriesWithDescriptions(\n            $query,\n            TimeEntryAggregationType::Description,\n            TimeEntryAggregationType::Billable,\n            'Europe/Vienna',\n            Weekday::Monday,\n            false,\n            null,\n            null,\n            true,\n            null,\n            null,\n        );\n\n        // Assert\n        $this->assertSame([\n            'seconds' => 40,\n            'cost' => 0,\n            'grouped_type' => 'description',\n            'grouped_data' => [\n                [\n                    'key' => null,\n                    'seconds' => 20,\n                    'cost' => 0,\n                    'grouped_type' => 'billable',\n                    'grouped_data' => [\n                        [\n                            'key' => '0',\n                            'seconds' => 20,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                            'description' => 'Non-billable',\n                            'color' => null,\n                        ],\n                    ],\n                    'description' => null,\n                    'color' => null,\n                ],\n                [\n                    'key' => 'TEST 1',\n                    'seconds' => 20,\n                    'cost' => 0,\n                    'grouped_type' => 'billable',\n                    'grouped_data' => [\n                        [\n                            'key' => '0',\n                            'seconds' => 10,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                            'description' => 'Non-billable',\n                            'color' => null,\n                        ],\n                        [\n                            'key' => '1',\n                            'seconds' => 10,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                            'description' => 'Billable',\n                            'color' => null,\n                        ],\n                    ],\n                    'description' => 'TEST 1',\n                    'color' => null,\n                ],\n            ],\n        ], $result);\n    }\n\n    public function test_aggregated_time_entries_with_descriptions_by_client_and_project(): void\n    {\n        // Arrange\n        $client1 = Client::factory()->create();\n        $client2 = Client::factory()->create();\n        $project1 = Project::factory()->forClient($client1)->create();\n        $project2 = Project::factory()->forClient($client2)->create();\n        $project3 = Project::factory()->create();\n        TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project1)->create();\n        TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project2)->create();\n        TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project3)->create();\n        TimeEntry::factory()->startWithDuration(now(), 10)->create();\n        $query = TimeEntry::query();\n\n        // Act\n        $result = $this->service->getAggregatedTimeEntriesWithDescriptions(\n            $query,\n            TimeEntryAggregationType::Client,\n            TimeEntryAggregationType::Project,\n            'Europe/Vienna',\n            Weekday::Monday,\n            false,\n            null,\n            null,\n            true,\n            null,\n            null,\n        );\n\n        // Assert\n        $this->assertEqualsCanonicalizing([\n            'seconds' => 40,\n            'cost' => 0,\n            'grouped_type' => 'client',\n            'grouped_data' => [\n                [\n                    'key' => null,\n                    'seconds' => 20,\n                    'cost' => 0,\n                    'grouped_type' => 'project',\n                    'grouped_data' => [\n                        [\n                            'key' => null,\n                            'seconds' => 10,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                            'description' => null,\n                            'color' => null,\n                        ],\n                        [\n                            'key' => $project3->getKey(),\n                            'seconds' => 10,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                            'description' => $project3->name,\n                            'color' => $project3->color,\n                        ],\n                    ],\n                    'description' => null,\n                    'color' => null,\n                ],\n                [\n                    'key' => $client1->getKey(),\n                    'seconds' => 10,\n                    'cost' => 0,\n                    'grouped_type' => 'project',\n                    'grouped_data' => [\n                        [\n                            'key' => $project1->getKey(),\n                            'seconds' => 10,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                            'description' => $project1->name,\n                            'color' => $project1->color,\n                        ],\n                    ],\n                    'description' => $client1->name,\n                    'color' => null,\n                ],\n                [\n                    'key' => $client2->getKey(),\n                    'seconds' => 10,\n                    'cost' => 0,\n                    'grouped_type' => 'project',\n                    'grouped_data' => [\n                        [\n                            'key' => $project2->getKey(),\n                            'seconds' => 10,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                            'description' => $project2->name,\n                            'color' => $project2->color,\n                        ],\n                    ],\n                    'description' => $client2->name,\n                    'color' => null,\n                ],\n            ],\n        ], $result);\n    }\n\n    public function test_aggregate_time_entries_group_by_tag_includes_no_tag_and_avoids_double_counting_overall(): void\n    {\n        // Arrange\n        $tag1 = Tag::factory()->create();\n        $tag2 = Tag::factory()->create();\n        $start = Carbon::now();\n\n        // One entry with two tags (100s)\n        TimeEntry::factory()->startWithDuration($start, 100)->create([\n            'tags' => [$tag1->getKey(), $tag2->getKey()],\n        ]);\n        // One entry with one tag (50s)\n        TimeEntry::factory()->startWithDuration($start, 50)->create([\n            'tags' => [$tag1->getKey()],\n        ]);\n        // One entry with no tags (25s)\n        TimeEntry::factory()->startWithDuration($start, 25)->create([\n            'tags' => [],\n        ]);\n\n        $query = TimeEntry::query();\n\n        // Act\n        $result = $this->service->getAggregatedTimeEntries(\n            $query,\n            TimeEntryAggregationType::Tag,\n            null,\n            'Europe/Vienna',\n            Weekday::Monday,\n            false,\n            null,\n            null,\n            true,\n            null,\n            null\n        );\n\n        // Assert - overall total should be 175 and groups: null=25, tag1=150, tag2=100\n        $expected = [\n            'seconds' => 175,\n            'cost' => 0,\n            'grouped_type' => 'tag',\n            'grouped_data' => [\n                [\n                    'key' => null,\n                    'seconds' => 25,\n                    'cost' => 0,\n                    'grouped_type' => null,\n                    'grouped_data' => null,\n                ],\n                [\n                    'key' => $tag1->getKey(),\n                    'seconds' => 150,\n                    'cost' => 0,\n                    'grouped_type' => null,\n                    'grouped_data' => null,\n                ],\n                [\n                    'key' => $tag2->getKey(),\n                    'seconds' => 100,\n                    'cost' => 0,\n                    'grouped_type' => null,\n                    'grouped_data' => null,\n                ],\n            ],\n        ];\n        $this->assertEqualsCanonicalizing($expected, $result);\n    }\n\n    public function test_aggregate_time_entries_group_by_project_and_subgroup_tag(): void\n    {\n        // Arrange\n        $project = Project::factory()->create();\n        $tag1 = Tag::factory()->create();\n        $tag2 = Tag::factory()->create();\n        $start = Carbon::now();\n\n        TimeEntry::factory()->startWithDuration($start, 120)->forProject($project)->create([\n            'tags' => [$tag1->getKey()],\n        ]);\n        TimeEntry::factory()->startWithDuration($start, 60)->forProject($project)->create([\n            'tags' => [$tag2->getKey()],\n        ]);\n\n        $query = TimeEntry::query();\n\n        // Act\n        $result = $this->service->getAggregatedTimeEntries(\n            $query,\n            TimeEntryAggregationType::Project,\n            TimeEntryAggregationType::Tag,\n            'Europe/Vienna',\n            Weekday::Monday,\n            false,\n            null,\n            null,\n            true,\n            null,\n            null\n        );\n\n        // Assert\n        $expected = [\n            'seconds' => 180,\n            'cost' => 0,\n            'grouped_type' => 'project',\n            'grouped_data' => [\n                [\n                    'key' => $project->getKey(),\n                    'seconds' => 180,\n                    'cost' => 0,\n                    'grouped_type' => 'tag',\n                    'grouped_data' => [\n                        [\n                            'key' => $tag1->getKey(),\n                            'seconds' => 120,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                        [\n                            'key' => $tag2->getKey(),\n                            'seconds' => 60,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                    ],\n                ],\n            ],\n        ];\n        $this->assertEqualsCanonicalizing($expected, $result);\n    }\n\n    public function test_aggregate_time_entries_group_by_project_and_subgroup_tag_avoids_double_counting(): void\n    {\n        // Arrange\n        $project = Project::factory()->create();\n        $tag1 = Tag::factory()->create();\n        $tag2 = Tag::factory()->create();\n        $start = Carbon::now();\n\n        // One entry with two tags => subgroup rows show both tags, but project total should equal entry duration\n        TimeEntry::factory()->startWithDuration($start, 100)->forProject($project)->create([\n            'tags' => [$tag1->getKey(), $tag2->getKey()],\n        ]);\n\n        $query = TimeEntry::query();\n\n        // Act\n        $result = $this->service->getAggregatedTimeEntries(\n            $query,\n            TimeEntryAggregationType::Project,\n            TimeEntryAggregationType::Tag,\n            'Europe/Vienna',\n            Weekday::Monday,\n            false,\n            null,\n            null,\n            true,\n            null,\n            null\n        );\n\n        // Assert\n        $expected = [\n            'seconds' => 100,\n            'cost' => 0,\n            'grouped_type' => 'project',\n            'grouped_data' => [\n                [\n                    'key' => $project->getKey(),\n                    'seconds' => 100,\n                    'cost' => 0,\n                    'grouped_type' => 'tag',\n                    'grouped_data' => [\n                        [\n                            'key' => $tag1->getKey(),\n                            'seconds' => 100,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                        [\n                            'key' => $tag2->getKey(),\n                            'seconds' => 100,\n                            'cost' => 0,\n                            'grouped_type' => null,\n                            'grouped_data' => null,\n                        ],\n                    ],\n                ],\n            ],\n        ];\n        $this->assertEqualsCanonicalizing($expected, $result);\n    }\n\n    /**\n     * Test that rounding up does NOT add extra time when the entry is already on a 15-minute boundary.\n     * f.e. 13:00 - 14:30 (90 minutes) should stay at 90 minutes when rounding up with 15-minute interval.\n     */\n    public function test_aggregate_time_round_up_does_not_add_time_when_already_on_boundary(): void\n    {\n        // Arrange\n        // Create a time entry with duration exactly on a 15-minute boundary (90 minutes = 5400 seconds)\n        // This simulates 13:00 - 14:30 (or any 90-minute entry)\n        $project = Project::factory()->create();\n        TimeEntry::factory()->startWithDuration(\n            Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 13:00:00'),\n            5400 // 90 minutes = 1 hour 30 minutes, exactly on 15-minute boundary\n        )->forProject($project)->create();\n        $query = TimeEntry::query();\n\n        // Act\n        $result = $this->service->getAggregatedTimeEntries(\n            $query,\n            TimeEntryAggregationType::Project,\n            null,\n            'Europe/Vienna',\n            Weekday::Monday,\n            false,\n            null,\n            null,\n            true,\n            TimeEntryRoundingType::Up,\n            15\n        );\n\n        // Assert\n        // The entry is already on a 15-minute boundary (90 minutes), so it should stay at 90 minutes (5400 seconds)\n        $this->assertEqualsCanonicalizing([\n            'seconds' => 5400, // 90 minutes - should NOT be rounded to 105 minutes (6300 seconds)\n            'cost' => 0,\n            'grouped_type' => 'project',\n            'grouped_data' => [\n                [\n                    'key' => $project->getKey(),\n                    'seconds' => 5400, // 90 minutes\n                    'cost' => 0,\n                    'grouped_type' => null,\n                    'grouped_data' => null,\n                ],\n            ],\n        ], $result);\n    }\n\n    /**\n     * Test that rounding up works correctly for entries NOT on a boundary.\n     * Example: 13:00 - 13:48 (48 minutes) should round up to 13:00 - 14:00 (60 minutes).\n     */\n    public function test_aggregate_time_round_up_works_when_not_on_boundary(): void\n    {\n        // Arrange\n        // Create a time entry with duration NOT on a 15-minute boundary (48 minutes = 2880 seconds)\n        $project = Project::factory()->create();\n        TimeEntry::factory()->startWithDuration(\n            Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 13:00:00'),\n            2880 // 48 minutes, not on 15-minute boundary\n        )->forProject($project)->create();\n        $query = TimeEntry::query();\n\n        // Act\n        $result = $this->service->getAggregatedTimeEntries(\n            $query,\n            TimeEntryAggregationType::Project,\n            null,\n            'Europe/Vienna',\n            Weekday::Monday,\n            false,\n            null,\n            null,\n            true,\n            TimeEntryRoundingType::Up,\n            15\n        );\n\n        // Assert\n        // 48 minutes rounded up to 15-minute interval = 60 minutes (3600 seconds)\n        $this->assertEqualsCanonicalizing([\n            'seconds' => 3600, // 60 minutes\n            'cost' => 0,\n            'grouped_type' => 'project',\n            'grouped_data' => [\n                [\n                    'key' => $project->getKey(),\n                    'seconds' => 3600, // 60 minutes\n                    'cost' => 0,\n                    'grouped_type' => null,\n                    'grouped_data' => null,\n                ],\n            ],\n        ], $result);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/TimeEntryFilterTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service;\n\nuse App\\Models\\Client;\nuse App\\Models\\Project;\nuse App\\Models\\Tag;\nuse App\\Models\\Task;\nuse App\\Models\\TimeEntry;\nuse App\\Service\\TimeEntryFilter;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCaseWithDatabase;\n\n#[CoversClass(TimeEntryFilter::class)]\nclass TimeEntryFilterTest extends TestCaseWithDatabase\n{\n    public function test_add_tag_ids_filter_is_or(): void\n    {\n        // Arrange\n        $builder = TimeEntry::query();\n        $filter = new TimeEntryFilter($builder);\n        $timEntryNoTag = TimeEntry::factory()->create();\n        $tag1 = Tag::factory()->create();\n        $timeEntryWithTag1 = TimeEntry::factory()->create([\n            'tags' => [$tag1->getKey()],\n        ]);\n        $tag2 = Tag::factory()->create();\n        $timeEntryWithTag2 = TimeEntry::factory()->create([\n            'tags' => [$tag2->getKey()],\n        ]);\n        $timeEntryWithAllTags = TimeEntry::factory()->create([\n            'tags' => [$tag1->getKey(), $tag2->getKey()],\n        ]);\n\n        // Act\n        $filter->addTagIdsFilter([$tag1->getKey(), $tag2->getKey()]);\n\n        // Assert\n        $timeEntries = $builder->get();\n        $this->assertCount(3, $timeEntries);\n\n    }\n\n    public function test_add_project_ids_filter_with_none_returns_entries_without_project(): void\n    {\n        // Arrange\n        $project = Project::factory()->create();\n        $timeEntryWithProject = TimeEntry::factory()->create([\n            'project_id' => $project->getKey(),\n            'organization_id' => $project->organization_id,\n        ]);\n        $timeEntryWithoutProject = TimeEntry::factory()->create([\n            'project_id' => null,\n        ]);\n\n        $builder = TimeEntry::query();\n        $filter = new TimeEntryFilter($builder);\n\n        // Act\n        $filter->addProjectIdsFilter([TimeEntryFilter::NONE_VALUE]);\n\n        // Assert\n        $timeEntries = $builder->get();\n        $this->assertCount(1, $timeEntries);\n        $this->assertTrue($timeEntries->contains($timeEntryWithoutProject));\n        $this->assertFalse($timeEntries->contains($timeEntryWithProject));\n    }\n\n    public function test_add_project_ids_filter_with_none_and_ids_returns_both(): void\n    {\n        // Arrange\n        $project = Project::factory()->create();\n        $timeEntryWithProject = TimeEntry::factory()->create([\n            'project_id' => $project->getKey(),\n            'organization_id' => $project->organization_id,\n        ]);\n        $timeEntryWithoutProject = TimeEntry::factory()->create([\n            'project_id' => null,\n        ]);\n        $otherProject = Project::factory()->create();\n        $timeEntryWithOtherProject = TimeEntry::factory()->create([\n            'project_id' => $otherProject->getKey(),\n            'organization_id' => $otherProject->organization_id,\n        ]);\n\n        $builder = TimeEntry::query();\n        $filter = new TimeEntryFilter($builder);\n\n        // Act\n        $filter->addProjectIdsFilter([$project->getKey(), TimeEntryFilter::NONE_VALUE]);\n\n        // Assert\n        $timeEntries = $builder->get();\n        $this->assertCount(2, $timeEntries);\n        $this->assertTrue($timeEntries->contains($timeEntryWithProject));\n        $this->assertTrue($timeEntries->contains($timeEntryWithoutProject));\n        $this->assertFalse($timeEntries->contains($timeEntryWithOtherProject));\n    }\n\n    public function test_add_task_ids_filter_with_none_returns_entries_without_task(): void\n    {\n        // Arrange\n        $task = Task::factory()->create();\n        $timeEntryWithTask = TimeEntry::factory()->create([\n            'task_id' => $task->getKey(),\n            'organization_id' => $task->organization_id,\n        ]);\n        $timeEntryWithoutTask = TimeEntry::factory()->create([\n            'task_id' => null,\n        ]);\n\n        $builder = TimeEntry::query();\n        $filter = new TimeEntryFilter($builder);\n\n        // Act\n        $filter->addTaskIdsFilter([TimeEntryFilter::NONE_VALUE]);\n\n        // Assert\n        $timeEntries = $builder->get();\n        $this->assertCount(1, $timeEntries);\n        $this->assertTrue($timeEntries->contains($timeEntryWithoutTask));\n        $this->assertFalse($timeEntries->contains($timeEntryWithTask));\n    }\n\n    public function test_add_client_ids_filter_with_none_returns_entries_without_client(): void\n    {\n        // Arrange\n        $client = Client::factory()->create();\n        $timeEntryWithClient = TimeEntry::factory()->create([\n            'client_id' => $client->getKey(),\n            'organization_id' => $client->organization_id,\n        ]);\n        $timeEntryWithoutClient = TimeEntry::factory()->create([\n            'client_id' => null,\n        ]);\n\n        $builder = TimeEntry::query();\n        $filter = new TimeEntryFilter($builder);\n\n        // Act\n        $filter->addClientIdsFilter([TimeEntryFilter::NONE_VALUE]);\n\n        // Assert\n        $timeEntries = $builder->get();\n        $this->assertCount(1, $timeEntries);\n        $this->assertTrue($timeEntries->contains($timeEntryWithoutClient));\n        $this->assertFalse($timeEntries->contains($timeEntryWithClient));\n    }\n\n    public function test_add_tag_ids_filter_with_none_returns_entries_without_tags(): void\n    {\n        // Arrange\n        $tag = Tag::factory()->create();\n        $timeEntryWithTag = TimeEntry::factory()->create([\n            'tags' => [$tag->getKey()],\n        ]);\n        $timeEntryWithEmptyTags = TimeEntry::factory()->create([\n            'tags' => [],\n        ]);\n        $timeEntryWithNullTags = TimeEntry::factory()->create([\n            'tags' => null,\n        ]);\n\n        $builder = TimeEntry::query();\n        $filter = new TimeEntryFilter($builder);\n\n        // Act\n        $filter->addTagIdsFilter([TimeEntryFilter::NONE_VALUE]);\n\n        // Assert\n        $timeEntries = $builder->get();\n        $this->assertCount(2, $timeEntries);\n        $this->assertTrue($timeEntries->contains($timeEntryWithEmptyTags));\n        $this->assertTrue($timeEntries->contains($timeEntryWithNullTags));\n        $this->assertFalse($timeEntries->contains($timeEntryWithTag));\n    }\n\n    public function test_add_tag_ids_filter_with_none_and_ids_returns_both(): void\n    {\n        // Arrange\n        $tag1 = Tag::factory()->create();\n        $tag2 = Tag::factory()->create();\n        $timeEntryWithTag1 = TimeEntry::factory()->create([\n            'tags' => [$tag1->getKey()],\n        ]);\n        $timeEntryWithTag2 = TimeEntry::factory()->create([\n            'tags' => [$tag2->getKey()],\n        ]);\n        $timeEntryWithNoTags = TimeEntry::factory()->create([\n            'tags' => [],\n        ]);\n\n        $builder = TimeEntry::query();\n        $filter = new TimeEntryFilter($builder);\n\n        // Act\n        $filter->addTagIdsFilter([$tag1->getKey(), TimeEntryFilter::NONE_VALUE]);\n\n        // Assert\n        $timeEntries = $builder->get();\n        $this->assertCount(2, $timeEntries);\n        $this->assertTrue($timeEntries->contains($timeEntryWithTag1));\n        $this->assertTrue($timeEntries->contains($timeEntryWithNoTags));\n        $this->assertFalse($timeEntries->contains($timeEntryWithTag2));\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/TimezoneServiceTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service;\n\nuse App\\Models\\User;\nuse App\\Service\\TimezoneService;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse Illuminate\\Support\\Facades\\Log;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCase;\nuse TiMacDonald\\Log\\LogEntry;\n\n#[CoversClass(TimezoneService::class)]\nclass TimezoneServiceTest extends TestCase\n{\n    use RefreshDatabase;\n\n    public function test_get_timezones_returns_all_available_timezones(): void\n    {\n        // Arrange\n        $service = app(TimezoneService::class);\n\n        // Act\n        $result = $service->getTimezones();\n\n        // Assert\n        $this->assertIsArray($result);\n        $this->assertTrue(in_array(count($result), [418, 419], true));\n        $this->assertContains('Europe/Vienna', $result);\n        $this->assertContains('Europe/Berlin', $result);\n        $this->assertContains('Europe/London', $result);\n    }\n\n    public function test_get_timezone_from_user_returns_timezone_of_user_as_carbon_timezone(): void\n    {\n        // Arrange\n        $user = User::factory()->create([\n            'timezone' => 'Europe/Berlin',\n        ]);\n\n        /** @var TimezoneService $service */\n        $service = app(TimezoneService::class);\n\n        // Act\n        $result = $service->getTimezoneFromUser($user);\n\n        // Assert\n        $this->assertEquals('Europe/Berlin', $result->getName());\n    }\n\n    public function test_get_timezone_from_user_falls_back_to_utc_and_logs_this_failure_if_timezone_in_db_is_corrupt(): void\n    {\n        // Arrange\n        $corruptTimezone = 'Invalid/Timezone';\n        $user = User::factory()->create([\n            'timezone' => $corruptTimezone,\n        ]);\n\n        /** @var TimezoneService $service */\n        $service = app(TimezoneService::class);\n\n        // Act\n        $result = $service->getTimezoneFromUser($user);\n\n        // Assert\n        $this->assertEquals('UTC', $result->getName());\n        Log::assertLogged(fn (LogEntry $log) => $log->level === 'error'\n            && $log->message === 'User has a invalid timezone'\n        );\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/UserServiceTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Tests\\Unit\\Service;\n\nuse App\\Enums\\Role;\nuse App\\Models\\Member;\nuse App\\Models\\Organization;\nuse App\\Models\\Project;\nuse App\\Models\\ProjectMember;\nuse App\\Models\\TimeEntry;\nuse App\\Models\\User;\nuse App\\Service\\UserService;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\nuse PHPUnit\\Framework\\Attributes\\CoversClass;\nuse Tests\\TestCase;\n\n#[CoversClass(UserService::class)]\nclass UserServiceTest extends TestCase\n{\n    use RefreshDatabase;\n\n    private UserService $userService;\n\n    protected function setUp(): void\n    {\n        parent::setUp();\n        $this->userService = app(UserService::class);\n    }\n\n    public function test_assign_organization_entities_to_different_user(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $project = Project::factory()->forOrganization($organization)->create();\n        $otherUser = User::factory()->create();\n        $fromUser = User::factory()->create();\n        $toUser = User::factory()->create();\n        $otherUserMember = Member::factory()->forOrganization($organization)->forUser($otherUser)->create();\n        $fromUserMember = Member::factory()->forOrganization($organization)->forUser($fromUser)->create();\n        $toUserMember = Member::factory()->forOrganization($organization)->forUser($toUser)->create();\n        TimeEntry::factory()->forOrganization($organization)->forMember($otherUserMember)->createMany(3);\n        TimeEntry::factory()->forOrganization($organization)->forMember($fromUserMember)->createMany(3);\n        ProjectMember::factory()->forProject($project)->forMember($otherUserMember)->create();\n        ProjectMember::factory()->forProject($project)->forMember($fromUserMember)->create();\n\n        // Act\n        $this->userService->assignOrganizationEntitiesToDifferentUser($organization, $fromUser, $toUser);\n\n        // Assert\n        $this->assertSame(3, TimeEntry::query()->whereBelongsTo($toUser, 'user')->count());\n        $this->assertSame(3, TimeEntry::query()->whereBelongsTo($otherUser, 'user')->count());\n        $this->assertSame(0, TimeEntry::query()->whereBelongsTo($fromUser, 'user')->count());\n        $this->assertSame(1, ProjectMember::query()->whereBelongsTo($toUser, 'user')->count());\n        $this->assertSame(1, ProjectMember::query()->whereBelongsTo($otherUser, 'user')->count());\n        $this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUser, 'user')->count());\n    }\n\n    public function test_change_ownership_changes_ownership_of_organization_to_new_user(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $newOwner = User::factory()->create();\n        $oldOwner = User::factory()->create();\n        $organization->users()->attach($oldOwner->getKey(), [\n            'role' => Role::Owner->value,\n        ]);\n        $organization->users()->attach($newOwner->getKey(), [\n            'role' => Role::Admin->value,\n        ]);\n\n        // Act\n        $this->userService->changeOwnership($organization, $newOwner);\n\n        // Assert\n        $this->assertSame($newOwner->getKey(), $organization->refresh()->user_id);\n        $this->assertSame(Role::Owner->value, Member::whereBelongsTo($newOwner)->whereBelongsTo($organization)->firstOrFail()->role);\n        $this->assertSame(Role::Admin->value, Member::whereBelongsTo($oldOwner)->whereBelongsTo($organization)->firstOrFail()->role);\n    }\n\n    public function test_change_ownership_fails_if_new_user_is_not_member_of_organization(): void\n    {\n        // Arrange\n        $organization = Organization::factory()->create();\n        $newOwner = User::factory()->create();\n        $oldOwner = User::factory()->create();\n        $organization->users()->attach($oldOwner->getKey(), [\n            'role' => Role::Owner->value,\n        ]);\n\n        // Act\n        try {\n            $this->userService->changeOwnership($organization, $newOwner);\n        } catch (\\InvalidArgumentException $e) {\n            $this->assertSame('User is not a member of the organization', $e->getMessage());\n        }\n    }\n\n    public function test_make_sure_user_has_current_organization_sets_current_organization_for_user_if_null(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $organization = Organization::factory()->create();\n        $otherOrganization = Organization::factory()->create();\n        Member::factory()->forUser($user)->forOrganization($organization)->create();\n        $user->current_team_id = null;\n        $user->save();\n\n        // Act\n        $this->userService->makeSureUserHasCurrentOrganization($user);\n\n        // Assert\n        $this->assertSame($organization->getKey(), $user->refresh()->currentOrganization->getKey());\n    }\n\n    public function make_sure_user_has_at_least_one_organization_creates_organization_for_user_if_there_are_not_member_of_one(): void\n    {\n        // Arrange\n        $user = User::factory()->create();\n        $organization = Organization::factory()->create();\n\n        // Act\n        $this->userService->makeSureUserHasAtLeastOneOrganization($user);\n\n        // Assert\n        $user->refresh();\n        $this->assertSame(1, $user->organizations()->count());\n        $newOrganization = $user->organizations()->first();\n        $this->assertNotSame($organization->getKey(), $newOrganization->getKey());\n        $this->assertSame($user->name.\"'s Organization\", $newOrganization->name);\n        $this->assertTrue($newOrganization->personal_team);\n        $this->assertSame($user->getKey(), $newOrganization->user_id);\n        $newMember = Member::whereBelongsTo($user)->whereBelongsTo($newOrganization)->firstOrFail();\n        $this->assertSame(Role::Owner->value, $newMember->role);\n        $this->assertSame($newOrganization->getKey(), $user->currentOrganization->getKey());\n    }\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"extends\": \"@vue/tsconfig/tsconfig.json\",\n  \"exclude\": [\n    \"vendor/**/*\"\n  ],\n  \"compilerOptions\": {\n    \"paths\": {\n      \"@/*\": [\"./resources/js/*\"],\n      \"@solidtime/ui\": [\"./resources/js/packages/ui/src/index.ts\"]\n    }\n  },\n  \"skipLibCheck\": true,\n  \"typeRoots\": [\n    \"./node_modules/@types\",\n    \"resources/js/types\",\n  ],\n  \"include\": [\n    \"resources/js/**/*.ts\",\n    \"resources/js/**/*.d.ts\",\n    \"resources/js/**/*.vue\",\n    \"resources/js/ziggy.d.ts\",\n    \"extensions/Invoicing/resources/js/**/*.ts\",\n    \"extensions/Invoicing/resources/js/**/*.d.ts\",\n    \"extensions/Invoicing/resources/js/**/*.vue\",\n  ]\n}\n"
  },
  {
    "path": "vite-module-loader.js",
    "content": "import fs from 'fs/promises';\nimport path from 'path';\n\nasync function collectModuleAssetsPaths(modulesPath) {\n    return await getExportedModulesArrayAttributes(modulesPath, 'paths');\n}\n\nasync function collectModulePlugins(modulesPath) {\n    return await getExportedModulesArrayAttributes(modulesPath, 'plugins');\n}\n\nasync function getExportedModulesArrayAttributes(modulesPath, attribute) {\n    const result = [];\n    modulesPath = path.join(__dirname, modulesPath);\n\n    const moduleStatusesPath = path.join(__dirname, 'modules_statuses.json');\n\n    try {\n        // Read module_statuses.json\n        const moduleStatusesContent = await fs.readFile(moduleStatusesPath, 'utf-8');\n        const moduleStatuses = JSON.parse(moduleStatusesContent);\n\n        // Read module directories\n        const moduleDirectories = await fs.readdir(modulesPath);\n\n        for (const moduleDir of moduleDirectories) {\n            if (moduleDir === '.DS_Store') {\n                // Skip .DS_Store directory\n                continue;\n            }\n\n            // Check if the module is enabled (status is true)\n            if (moduleStatuses[moduleDir] === true) {\n                const viteConfigPath = path.join(modulesPath, moduleDir, 'vite.config.js');\n                const stat = await fs.stat(viteConfigPath);\n\n                if (stat.isFile()) {\n                    // Import the module-specific Vite configuration\n                    const moduleConfig = await import(viteConfigPath);\n\n                    if (moduleConfig[attribute] && Array.isArray(moduleConfig[attribute])) {\n                        result.push(...moduleConfig[attribute]);\n                    }\n                }\n            }\n        }\n    } catch (error) {\n        console.error(`Error reading module statuses or module configurations: ${error}`);\n    }\n\n    return result;\n}\n\nexport { collectModuleAssetsPaths, collectModulePlugins };\n"
  },
  {
    "path": "vite.config.js",
    "content": "import { defineConfig } from 'vite';\nimport laravel from 'laravel-vite-plugin';\nimport vue from '@vitejs/plugin-vue';\nimport checker from 'vite-plugin-checker';\nimport { collectModuleAssetsPaths, collectModulePlugins } from './vite-module-loader.js';\n\nasync function getConfig() {\n    const paths = [\n        'resources/js/app.ts',\n        'resources/css/app.css',\n        'resources/css/filament/admin/theme.css',\n    ];\n    const modulePaths = await collectModuleAssetsPaths('extensions');\n    const additionalPlugins = await collectModulePlugins('extensions');\n\n    return defineConfig({\n        build: {\n            sourcemap: true, // Source map generation must be turned on\n        },\n        plugins: [\n            laravel({\n                input: [...paths, ...modulePaths],\n                refresh: true,\n            }),\n            vue({\n                template: {\n                    transformAssetUrls: {\n                        base: null,\n                        includeAbsolute: false,\n                    },\n                },\n            }),\n            checker({\n                // e.g. use TypeScript check\n                typescript: true,\n                vueTsc: true,\n                lintCommand: 'eslint \"./**/*.{ts,vue}\"',\n            }),\n            ...additionalPlugins,\n        ],\n        server: {\n            host: true,\n            hmr: {\n                host: process.env.VITE_HOST_NAME,\n                clientPort: 80,\n            },\n        },\n    });\n}\n\nexport default getConfig();\n"
  }
]