mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 16:22:16 +01:00
Compare commits
7 Commits
feature/ch
...
feature/ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8dba13eba | ||
|
|
91d6ff7392 | ||
|
|
427c904747 | ||
|
|
861b6c2642 | ||
|
|
51f7ba0509 | ||
|
|
e0506fa3e3 | ||
|
|
a9d9c13846 |
47
.github/ISSUE_TEMPLATE/1_bug_report.yml
vendored
47
.github/ISSUE_TEMPLATE/1_bug_report.yml
vendored
@@ -1,47 +0,0 @@
|
|||||||
name: Bug Report
|
|
||||||
description: "Report a bug"
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Before creating a new bug report, please check that there isn't already a similar issue.
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Description
|
|
||||||
description: A clear and concise description of what the bug is.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: "Steps To Reproduce"
|
|
||||||
description: How do you trigger this bug? Please walk us through it step by step.
|
|
||||||
value: |
|
|
||||||
1.
|
|
||||||
2.
|
|
||||||
3.
|
|
||||||
...
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: dropdown
|
|
||||||
attributes:
|
|
||||||
label: "Self-hosted or Cloud?"
|
|
||||||
options:
|
|
||||||
- Self-Hosted
|
|
||||||
- solidtime Cloud
|
|
||||||
- Both
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
attributes:
|
|
||||||
label: "Version of solidtime: (for self-hosted)"
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
attributes:
|
|
||||||
label: "solidtime self-hosting guide: (for self-hosted)"
|
|
||||||
description: "Did you use the official guide to self-host solidtime? If yes, which one?"
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
8
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,8 +0,0 @@
|
|||||||
blank_issues_enabled: false
|
|
||||||
contact_links:
|
|
||||||
- name: 🚀 Feature Request
|
|
||||||
url: https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests
|
|
||||||
about: Share ideas for new features
|
|
||||||
- name: ❓ Ask a Question
|
|
||||||
url: https://github.com/solidtime-io/solidtime/discussions/new?category=general
|
|
||||||
about: Ask the community for help
|
|
||||||
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,8 +0,0 @@
|
|||||||
<!--
|
|
||||||
This project is early stage. The structure and APIs are still subject to change and not stable.
|
|
||||||
Therefore, we do not currently accept any contributions, unless you are a member of the team.
|
|
||||||
|
|
||||||
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
|
|
||||||
|
|
||||||
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
|
|
||||||
-->
|
|
||||||
143
.github/workflows/build-public.yml
vendored
143
.github/workflows/build-public.yml
vendored
@@ -11,21 +11,10 @@ on:
|
|||||||
- 'docker/prod/**'
|
- 'docker/prod/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
|
||||||
DOCKERHUB_REPO: solidtime/solidtime
|
|
||||||
GHCR_REPO: ghcr.io/solidtime-io/solidtime
|
|
||||||
|
|
||||||
name: Build - Public
|
name: Build - Public
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
strategy:
|
runs-on: ubuntu-22.04
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- runs-on: "ubuntu-24.04-arm"
|
|
||||||
platform: "linux/arm64"
|
|
||||||
- runs-on: "ubuntu-24.04"
|
|
||||||
platform: "linux/amd64"
|
|
||||||
runs-on: ${{ matrix.runs-on }}
|
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
contents: read
|
contents: read
|
||||||
@@ -40,7 +29,7 @@ jobs:
|
|||||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||||
|
|
||||||
- name: "Get build"
|
- name: "Get build"
|
||||||
id: release-build
|
id: build
|
||||||
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
|
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: "Get Previous tag (normal push)"
|
- name: "Get Previous tag (normal push)"
|
||||||
@@ -51,7 +40,7 @@ jobs:
|
|||||||
prefix: "v"
|
prefix: "v"
|
||||||
|
|
||||||
- name: "Get version"
|
- name: "Get version"
|
||||||
id: release-version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
|
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
|
||||||
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
|
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
|
||||||
@@ -72,23 +61,21 @@ jobs:
|
|||||||
rm .env.production .env.ci .env.example
|
rm .env.production .env.ci .env.example
|
||||||
|
|
||||||
- name: "Add version to .env"
|
- name: "Add version to .env"
|
||||||
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.release-version.outputs.app_version }}/g' .env
|
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.version.outputs.app_version }}/g' .env
|
||||||
|
|
||||||
- name: "Add build to .env"
|
- name: "Add build to .env"
|
||||||
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.release-build.outputs.build }}/g' .env
|
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.build.outputs.build }}/g' .env
|
||||||
|
|
||||||
- name: "Output .env"
|
- name: "Output .env"
|
||||||
run: cat .env
|
run: cat .env
|
||||||
|
|
||||||
- name: "Setup PHP with PECL extension"
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: '8.3'
|
|
||||||
extensions: mbstring, dom, fileinfo, pgsql
|
|
||||||
|
|
||||||
- name: "Install dependencies"
|
- name: "Install dependencies"
|
||||||
run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
uses: php-actions/composer@v6
|
||||||
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
||||||
|
with:
|
||||||
|
command: install
|
||||||
|
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
||||||
|
php_version: 8.3
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -101,31 +88,29 @@ jobs:
|
|||||||
- name: "Build"
|
- name: "Build"
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: "Prepare"
|
- name: "Login to GitHub Container Registry"
|
||||||
run: |
|
|
||||||
platform=${{ matrix.platform }}
|
|
||||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: "Docker meta"
|
|
||||||
id: "meta"
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
${{ env.DOCKERHUB_REPO }}
|
|
||||||
${{ env.GHCR_REPO }}
|
|
||||||
|
|
||||||
- name: "Login to Docker Hub Container Registry"
|
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: "Login to GitHub Container Registry"
|
- name: "Login to GitHub Container Registry"
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: "Docker meta"
|
||||||
|
id: "meta"
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
solidtime/solidtime
|
||||||
|
ghcr.io/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
|
||||||
- name: "Set up QEMU"
|
- name: "Set up QEMU"
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
@@ -133,90 +118,16 @@ jobs:
|
|||||||
- name: "Set up Docker Buildx"
|
- name: "Set up Docker Buildx"
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: "Build and push by digest"
|
- name: "Build and push"
|
||||||
id: build
|
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/prod/Dockerfile
|
file: docker/prod/Dockerfile
|
||||||
build-args: |
|
build-args: |
|
||||||
DOCKER_FILES_BASE_PATH=docker/prod/
|
DOCKER_FILES_BASE_PATH=docker/prod/
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
outputs: type=image,"name=${{ env.DOCKERHUB_REPO }},${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
|
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
- name: "Export digest"
|
|
||||||
run: |
|
|
||||||
mkdir -p ${{ runner.temp }}/digests
|
|
||||||
digest="${{ steps.build.outputs.digest }}"
|
|
||||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
|
||||||
|
|
||||||
- name: "Upload digest"
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: digests-${{ env.PLATFORM_PAIR }}
|
|
||||||
path: ${{ runner.temp }}/digests/*
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
merge:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
packages: write
|
|
||||||
contents: read
|
|
||||||
attestations: write
|
|
||||||
id-token: write
|
|
||||||
timeout-minutes: 90
|
|
||||||
needs:
|
|
||||||
- build
|
|
||||||
steps:
|
|
||||||
- name: "Download digests"
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
path: ${{ runner.temp }}/digests
|
|
||||||
pattern: digests-*
|
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- name: "Login to Docker Hub"
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: "Login to GHCR"
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: "Set up Docker Buildx"
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: "Docker meta"
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
${{ env.DOCKERHUB_REPO }}
|
|
||||||
${{ env.GHCR_REPO }}
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=pr
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
|
|
||||||
- name: "Create manifest list and push"
|
|
||||||
working-directory: ${{ runner.temp }}/digests
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
|
||||||
$(printf '${{ env.DOCKERHUB_REPO }}@sha256:%s ' *)
|
|
||||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
|
||||||
$(printf '${{ env.GHCR_REPO }}@sha256:%s ' *)
|
|
||||||
|
|
||||||
- name: "Inspect image"
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools inspect ${{ env.DOCKERHUB_REPO }}:${{ steps.meta.outputs.version }}
|
|
||||||
docker buildx imagetools inspect ${{ env.GHCR_REPO }}:${{ steps.meta.outputs.version }}
|
|
||||||
|
|||||||
2
.github/workflows/phpunit.yml
vendored
2
.github/workflows/phpunit.yml
vendored
@@ -63,7 +63,7 @@ jobs:
|
|||||||
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
|
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
|
||||||
|
|
||||||
- name: "Upload coverage reports to Codecov"
|
- name: "Upload coverage reports to Codecov"
|
||||||
uses: codecov/codecov-action@v5.4.0
|
uses: codecov/codecov-action@v5.3.1
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
slug: solidtime-io/solidtime
|
slug: solidtime-io/solidtime
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Console\Commands\Correction;
|
|
||||||
|
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Models\Member;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
class CorrectionPlaceholderMembersCommand extends Command
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The name and signature of the console command.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected $signature = 'correction:placeholder-members '.
|
|
||||||
' { --dry-run : Do not actually save anything to the database, just output what would happen }';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The console command description.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected $description = 'Sets all members who belong to a placeholder user to role placeholder';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the console command.
|
|
||||||
*/
|
|
||||||
public function handle(): int
|
|
||||||
{
|
|
||||||
$this->comment('Sets all members who belong to a placeholder user to role placeholder...');
|
|
||||||
$dryRun = (bool) $this->option('dry-run');
|
|
||||||
if ($dryRun) {
|
|
||||||
$this->comment('Running in dry-run mode. Nothing will be saved to the database.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$members = Member::query()
|
|
||||||
->where('role', '!=', Role::Placeholder->value)
|
|
||||||
->whereHas('user', function (Builder $builder): void {
|
|
||||||
/** @var Builder<User> $builder */
|
|
||||||
$builder->where('is_placeholder', '=', true);
|
|
||||||
})
|
|
||||||
->get();
|
|
||||||
foreach ($members as $member) {
|
|
||||||
/** @var Member $member */
|
|
||||||
$member->role = Role::Placeholder->value;
|
|
||||||
if (! $dryRun) {
|
|
||||||
$member->save();
|
|
||||||
}
|
|
||||||
$this->line('Set role of member (id='.$member->getKey().') to placeholder');
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Exceptions\Api;
|
|
||||||
|
|
||||||
class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException
|
|
||||||
{
|
|
||||||
public const string KEY = 'changing_role_of_placeholder_is_not_allowed';
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Exceptions\Api;
|
|
||||||
|
|
||||||
class OnlyPlaceholdersCanBeMergedIntoAnotherMember extends ApiException
|
|
||||||
{
|
|
||||||
public const string KEY = 'only_placeholders_can_be_merged_into_another_member';
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Exceptions\Api;
|
|
||||||
|
|
||||||
class ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException extends ApiException
|
|
||||||
{
|
|
||||||
public const string KEY = 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception';
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
|
||||||
|
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Models\Organization;
|
|
||||||
use App\Service\DashboardService;
|
|
||||||
use App\Service\PermissionStore;
|
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
|
|
||||||
class ChartController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @throws AuthorizationException
|
|
||||||
*
|
|
||||||
* @operationId weeklyProjectOverview
|
|
||||||
*
|
|
||||||
* @response array<int, array{value: int, name: string, color: string}>
|
|
||||||
*/
|
|
||||||
public function weeklyProjectOverview(Organization $organization, DashboardService $dashboardService): JsonResponse
|
|
||||||
{
|
|
||||||
$this->checkPermission($organization, 'charts:view:own');
|
|
||||||
$user = $this->user();
|
|
||||||
|
|
||||||
$weeklyProjectOverview = $dashboardService->weeklyProjectOverview($user, $organization);
|
|
||||||
|
|
||||||
return response()->json($weeklyProjectOverview);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws AuthorizationException
|
|
||||||
*
|
|
||||||
* @operationId latestTasks
|
|
||||||
*
|
|
||||||
* @response array<int, array{task_id: string, name: string, description: string|null, status: bool, time_entry_id: string|null}>
|
|
||||||
*/
|
|
||||||
public function latestTasks(Organization $organization, DashboardService $dashboardService): JsonResponse
|
|
||||||
{
|
|
||||||
$this->checkPermission($organization, 'charts:view:own');
|
|
||||||
$user = $this->user();
|
|
||||||
|
|
||||||
$latestTasks = $dashboardService->latestTasks($user, $organization);
|
|
||||||
|
|
||||||
return response()->json($latestTasks);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws AuthorizationException
|
|
||||||
*
|
|
||||||
* @operationId lastSevenDays
|
|
||||||
*
|
|
||||||
* @response array<int, array{ date: string, duration: int, history: array<int> }>
|
|
||||||
*/
|
|
||||||
public function lastSevenDays(Organization $organization, DashboardService $dashboardService): JsonResponse
|
|
||||||
{
|
|
||||||
$this->checkPermission($organization, 'charts:view:own');
|
|
||||||
$user = $this->user();
|
|
||||||
|
|
||||||
$lastSevenDays = $dashboardService->lastSevenDays($user, $organization);
|
|
||||||
|
|
||||||
return response()->json($lastSevenDays);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws AuthorizationException
|
|
||||||
*
|
|
||||||
* @operationId latestTeamActivity
|
|
||||||
*
|
|
||||||
* @response array<int, array{member_id: string, name: string, description: string|null, time_entry_id: string, task_id: string|null, status: bool }>
|
|
||||||
*/
|
|
||||||
public function latestTeamActivity(Organization $organization, DashboardService $dashboardService, PermissionStore $permissionStore): JsonResponse
|
|
||||||
{
|
|
||||||
$this->checkPermission($organization, 'charts:view:all');
|
|
||||||
|
|
||||||
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
|
|
||||||
|
|
||||||
return response()->json($latestTeamActivity);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws AuthorizationException
|
|
||||||
*
|
|
||||||
* @operationId dailyTrackedHours
|
|
||||||
*
|
|
||||||
* @response array<int, array{date: string, duration: int}>
|
|
||||||
*/
|
|
||||||
public function dailyTrackedHours(Organization $organization, DashboardService $dashboardService): JsonResponse
|
|
||||||
{
|
|
||||||
$this->checkPermission($organization, 'charts:view:own');
|
|
||||||
$user = $this->user();
|
|
||||||
|
|
||||||
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
|
|
||||||
|
|
||||||
return response()->json($dailyTrackedHours);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws AuthorizationException
|
|
||||||
*
|
|
||||||
* @operationId totalWeeklyTime
|
|
||||||
*
|
|
||||||
* @response int
|
|
||||||
*/
|
|
||||||
public function totalWeeklyTime(Organization $organization, DashboardService $dashboardService): JsonResponse
|
|
||||||
{
|
|
||||||
$this->checkPermission($organization, 'charts:view:own');
|
|
||||||
$user = $this->user();
|
|
||||||
|
|
||||||
$totalWeeklyTime = $dashboardService->totalWeeklyTime($user, $organization);
|
|
||||||
|
|
||||||
return response()->json($totalWeeklyTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws AuthorizationException
|
|
||||||
*
|
|
||||||
* @operationId totalWeeklyBillableTime
|
|
||||||
*
|
|
||||||
* @response int
|
|
||||||
*/
|
|
||||||
public function totalWeeklyBillableTime(Organization $organization, DashboardService $dashboardService): JsonResponse
|
|
||||||
{
|
|
||||||
$this->checkPermission($organization, 'charts:view:own');
|
|
||||||
$user = $this->user();
|
|
||||||
|
|
||||||
$totalWeeklyBillableTime = $dashboardService->totalWeeklyBillableTime($user, $organization);
|
|
||||||
|
|
||||||
return response()->json($totalWeeklyBillableTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws AuthorizationException
|
|
||||||
*
|
|
||||||
* @operationId totalWeeklyBillableAmount
|
|
||||||
*
|
|
||||||
* @response array{value: int, currency: string}
|
|
||||||
*/
|
|
||||||
public function totalWeeklyBillableAmount(Organization $organization, DashboardService $dashboardService): JsonResponse
|
|
||||||
{
|
|
||||||
$this->checkPermission($organization, 'charts:view:own');
|
|
||||||
$user = $this->user();
|
|
||||||
|
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
|
||||||
if (! $showBillableRate) {
|
|
||||||
throw new AuthorizationException('You do not have permission to view billable rates.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$totalWeeklyBillableAmount = $dashboardService->totalWeeklyBillableAmount($user, $organization);
|
|
||||||
|
|
||||||
return response()->json($totalWeeklyBillableAmount);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws AuthorizationException
|
|
||||||
*
|
|
||||||
* @operationId weeklyHistory
|
|
||||||
*
|
|
||||||
* @response array<int, array{date: string, duration: int}>
|
|
||||||
*/
|
|
||||||
public function weeklyHistory(Organization $organization, DashboardService $dashboardService): JsonResponse
|
|
||||||
{
|
|
||||||
$this->checkPermission($organization, 'charts:view:own');
|
|
||||||
$user = $this->user();
|
|
||||||
|
|
||||||
$weeklyHistory = $dashboardService->getWeeklyHistory($user, $organization);
|
|
||||||
|
|
||||||
return response()->json($weeklyHistory);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,17 +7,12 @@ namespace App\Http\Controllers\Api\V1;
|
|||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
use App\Events\MemberMadeToPlaceholder;
|
use App\Events\MemberMadeToPlaceholder;
|
||||||
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||||
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
|
||||||
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
||||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||||
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
||||||
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
|
|
||||||
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
||||||
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
|
||||||
use App\Exceptions\Api\UserNotPlaceholderApiException;
|
use App\Exceptions\Api\UserNotPlaceholderApiException;
|
||||||
use App\Http\Requests\V1\Member\MemberIndexRequest;
|
use App\Http\Requests\V1\Member\MemberIndexRequest;
|
||||||
use App\Http\Requests\V1\Member\MemberMergeIntoRequest;
|
|
||||||
use App\Http\Requests\V1\Member\MemberUpdateRequest;
|
use App\Http\Requests\V1\Member\MemberUpdateRequest;
|
||||||
use App\Http\Resources\V1\Member\MemberCollection;
|
use App\Http\Resources\V1\Member\MemberCollection;
|
||||||
use App\Http\Resources\V1\Member\MemberResource;
|
use App\Http\Resources\V1\Member\MemberResource;
|
||||||
@@ -29,8 +24,6 @@ use App\Service\MemberService;
|
|||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class MemberController extends Controller
|
class MemberController extends Controller
|
||||||
{
|
{
|
||||||
@@ -70,7 +63,6 @@ class MemberController extends Controller
|
|||||||
* @throws OrganizationNeedsAtLeastOneOwner
|
* @throws OrganizationNeedsAtLeastOneOwner
|
||||||
* @throws OnlyOwnerCanChangeOwnership
|
* @throws OnlyOwnerCanChangeOwnership
|
||||||
* @throws ChangingRoleToPlaceholderIsNotAllowed
|
* @throws ChangingRoleToPlaceholderIsNotAllowed
|
||||||
* @throws ChangingRoleOfPlaceholderIsNotAllowed
|
|
||||||
*
|
*
|
||||||
* @operationId updateMember
|
* @operationId updateMember
|
||||||
*/
|
*/
|
||||||
@@ -113,9 +105,7 @@ class MemberController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Make a member a placeholder member
|
* Make a member a placeholder member
|
||||||
*
|
*
|
||||||
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization|ChangingRoleOfPlaceholderIsNotAllowed
|
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization
|
||||||
*
|
|
||||||
* @operationId makePlaceholder
|
|
||||||
*/
|
*/
|
||||||
public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse
|
public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -124,9 +114,6 @@ class MemberController extends Controller
|
|||||||
if ($member->role === Role::Owner->value) {
|
if ($member->role === Role::Owner->value) {
|
||||||
throw new CanNotRemoveOwnerFromOrganization;
|
throw new CanNotRemoveOwnerFromOrganization;
|
||||||
}
|
}
|
||||||
if ($member->role === Role::Placeholder->value) {
|
|
||||||
throw new ChangingRoleOfPlaceholderIsNotAllowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
$memberService->makeMemberToPlaceholder($member);
|
$memberService->makeMemberToPlaceholder($member);
|
||||||
|
|
||||||
@@ -135,39 +122,10 @@ class MemberController extends Controller
|
|||||||
return response()->json(null, 204);
|
return response()->json(null, 204);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws AuthorizationException
|
|
||||||
* @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember
|
|
||||||
* @throws \Throwable
|
|
||||||
*
|
|
||||||
* @operationId mergeMember
|
|
||||||
*/
|
|
||||||
public function mergeInto(Organization $organization, Member $member, MemberMergeIntoRequest $request, MemberService $memberService): JsonResponse
|
|
||||||
{
|
|
||||||
$this->checkPermission($organization, 'members:merge-into', $member);
|
|
||||||
|
|
||||||
$user = $member->user;
|
|
||||||
if ($member->role !== Role::Placeholder->value || ! $user->is_placeholder) {
|
|
||||||
throw new OnlyPlaceholdersCanBeMergedIntoAnotherMember;
|
|
||||||
}
|
|
||||||
$memberTo = Member::findOrFail($request->getMemberId());
|
|
||||||
|
|
||||||
DB::transaction(function () use ($organization, $member, $user, $memberTo, $memberService): void {
|
|
||||||
$memberService->assignOrganizationEntitiesToDifferentMember($organization, $member, $memberTo);
|
|
||||||
$member->delete();
|
|
||||||
$user->delete();
|
|
||||||
});
|
|
||||||
|
|
||||||
return response()->json(null, 204);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invite a placeholder member to become a real member of the organization
|
* Invite a placeholder member to become a real member of the organization
|
||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException|UserNotPlaceholderApiException
|
||||||
* @throws UserNotPlaceholderApiException
|
|
||||||
* @throws UserIsAlreadyMemberOfOrganizationApiException
|
|
||||||
* @throws ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException
|
|
||||||
*
|
*
|
||||||
* @operationId invitePlaceholder
|
* @operationId invitePlaceholder
|
||||||
*/
|
*/
|
||||||
@@ -180,10 +138,6 @@ class MemberController extends Controller
|
|||||||
throw new UserNotPlaceholderApiException;
|
throw new UserNotPlaceholderApiException;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Str::endsWith($user->email, '@solidtime-import.test')) {
|
|
||||||
throw new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
|
||||||
}
|
|
||||||
|
|
||||||
$invitationService->inviteUser($organization, $user->email, Role::Employee);
|
$invitationService->inviteUser($organization, $user->email, Role::Employee);
|
||||||
|
|
||||||
return response()->json(null, 204);
|
return response()->json(null, 204);
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ class ReportController extends Controller
|
|||||||
false,
|
false,
|
||||||
$report->properties->start,
|
$report->properties->start,
|
||||||
$report->properties->end,
|
$report->properties->end,
|
||||||
true
|
|
||||||
);
|
);
|
||||||
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
||||||
$timeEntriesQuery->clone(),
|
$timeEntriesQuery->clone(),
|
||||||
@@ -84,7 +83,6 @@ class ReportController extends Controller
|
|||||||
true,
|
true,
|
||||||
$report->properties->start,
|
$report->properties->start,
|
||||||
$report->properties->end,
|
$report->properties->end,
|
||||||
true
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return new DetailedWithDataReportResource($report, $data, $historyData);
|
return new DetailedWithDataReportResource($report, $data, $historyData);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Enums\ExportFormat;
|
use App\Enums\ExportFormat;
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
||||||
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
||||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||||
@@ -181,7 +180,6 @@ class TimeEntryController extends Controller
|
|||||||
}
|
}
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
$timezone = $user->timezone;
|
$timezone = $user->timezone;
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
|
||||||
|
|
||||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
|
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
|
||||||
$timeEntriesQuery->with([
|
$timeEntriesQuery->with([
|
||||||
@@ -213,8 +211,7 @@ class TimeEntryController extends Controller
|
|||||||
$user->week_start,
|
$user->week_start,
|
||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
null,
|
null
|
||||||
$showBillableRate
|
|
||||||
);
|
);
|
||||||
$html = Blade::render($viewFile, [
|
$html = Blade::render($viewFile, [
|
||||||
'timeEntries' => $timeEntriesQuery->get(),
|
'timeEntries' => $timeEntriesQuery->get(),
|
||||||
@@ -288,18 +285,18 @@ class TimeEntryController extends Controller
|
|||||||
* grouped_data: null|array<array{
|
* grouped_data: null|array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null,
|
* cost: int,
|
||||||
* grouped_type: string|null,
|
* grouped_type: string|null,
|
||||||
* grouped_data: null|array<array{
|
* grouped_data: null|array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null,
|
* cost: int,
|
||||||
* grouped_type: null,
|
* grouped_type: null,
|
||||||
* grouped_data: null
|
* grouped_data: null
|
||||||
* }>
|
* }>
|
||||||
* }>,
|
* }>,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null
|
* cost: int
|
||||||
* }
|
* }
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
@@ -315,7 +312,6 @@ class TimeEntryController extends Controller
|
|||||||
$this->checkPermission($organization, 'time-entries:view:all');
|
$this->checkPermission($organization, 'time-entries:view:all');
|
||||||
}
|
}
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
|
||||||
|
|
||||||
$group1Type = $request->getGroup();
|
$group1Type = $request->getGroup();
|
||||||
$group2Type = $request->getSubGroup();
|
$group2Type = $request->getSubGroup();
|
||||||
@@ -329,8 +325,7 @@ class TimeEntryController extends Controller
|
|||||||
$user->week_start,
|
$user->week_start,
|
||||||
$request->getFillGapsInTimeGroups(),
|
$request->getFillGapsInTimeGroups(),
|
||||||
$request->getStart(),
|
$request->getStart(),
|
||||||
$request->getEnd(),
|
$request->getEnd()
|
||||||
$showBillableRate
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -364,7 +359,6 @@ class TimeEntryController extends Controller
|
|||||||
}
|
}
|
||||||
$debug = $request->getDebug();
|
$debug = $request->getDebug();
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
|
||||||
|
|
||||||
$group = $request->getGroup();
|
$group = $request->getGroup();
|
||||||
$subGroup = $request->getSubGroup();
|
$subGroup = $request->getSubGroup();
|
||||||
@@ -378,8 +372,7 @@ class TimeEntryController extends Controller
|
|||||||
$user->week_start,
|
$user->week_start,
|
||||||
false,
|
false,
|
||||||
$request->getStart(),
|
$request->getStart(),
|
||||||
$request->getEnd(),
|
$request->getEnd()
|
||||||
$showBillableRate
|
|
||||||
);
|
);
|
||||||
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
|
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||||
$timeEntriesAggregateQuery->clone(),
|
$timeEntriesAggregateQuery->clone(),
|
||||||
@@ -389,8 +382,7 @@ class TimeEntryController extends Controller
|
|||||||
$user->week_start,
|
$user->week_start,
|
||||||
true,
|
true,
|
||||||
$request->getStart(),
|
$request->getStart(),
|
||||||
$request->getEnd(),
|
$request->getEnd()
|
||||||
$showBillableRate
|
|
||||||
);
|
);
|
||||||
$currency = $organization->currency;
|
$currency = $organization->currency;
|
||||||
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
|
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Web;
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Service\DashboardService;
|
use App\Service\DashboardService;
|
||||||
use App\Service\PermissionStore;
|
use App\Service\PermissionStore;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
@@ -20,14 +19,30 @@ class DashboardController extends Controller
|
|||||||
{
|
{
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
$organization = $this->currentOrganization();
|
$organization = $this->currentOrganization();
|
||||||
|
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
|
||||||
|
$weeklyHistory = $dashboardService->getWeeklyHistory($user, $organization);
|
||||||
|
$totalWeeklyTime = $dashboardService->totalWeeklyTime($user, $organization);
|
||||||
|
$totalWeeklyBillableTime = $dashboardService->totalWeeklyBillableTime($user, $organization);
|
||||||
|
$totalWeeklyBillableAmount = $dashboardService->totalWeeklyBillableAmount($user, $organization);
|
||||||
|
$weeklyProjectOverview = $dashboardService->weeklyProjectOverview($user, $organization);
|
||||||
|
$latestTasks = $dashboardService->latestTasks($user, $organization);
|
||||||
|
$lastSevenDays = $dashboardService->lastSevenDays($user, $organization);
|
||||||
|
|
||||||
$latestTeamActivity = null;
|
$latestTeamActivity = null;
|
||||||
if ($permissionStore->has($organization, 'time-entries:view:all')) {
|
if ($permissionStore->has($organization, 'time-entries:view:all')) {
|
||||||
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
|
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
|
||||||
}
|
}
|
||||||
|
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
return Inertia::render('Dashboard', [
|
||||||
|
'weeklyProjectOverview' => $weeklyProjectOverview,
|
||||||
return Inertia::render('Dashboard');
|
'latestTasks' => $latestTasks,
|
||||||
|
'lastSevenDays' => $lastSevenDays,
|
||||||
|
'latestTeamActivity' => $latestTeamActivity,
|
||||||
|
'dailyTrackedHours' => $dailyTrackedHours,
|
||||||
|
'totalWeeklyTime' => $totalWeeklyTime,
|
||||||
|
'totalWeeklyBillableTime' => $totalWeeklyBillableTime,
|
||||||
|
'totalWeeklyBillableAmount' => $totalWeeklyBillableAmount,
|
||||||
|
'weeklyHistory' => $weeklyHistory,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Requests\V1\Member;
|
|
||||||
|
|
||||||
use App\Models\Member;
|
|
||||||
use App\Models\Organization;
|
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property Organization $organization
|
|
||||||
*/
|
|
||||||
class MemberMergeIntoRequest extends FormRequest
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Get the validation rules that apply to the request.
|
|
||||||
*
|
|
||||||
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
// ID of the member to which the data should be transferred (destination)
|
|
||||||
'member_id' => [
|
|
||||||
'string',
|
|
||||||
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
|
|
||||||
/** @var Builder<Member> $builder */
|
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
|
||||||
})->uuid(),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getMemberId(): string
|
|
||||||
{
|
|
||||||
return (string) $this->input('member_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -18,20 +18,20 @@ use Illuminate\Http\Request;
|
|||||||
* description: string|null,
|
* description: string|null,
|
||||||
* color: string|null,
|
* color: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null,
|
* cost: int,
|
||||||
* grouped_type: string|null,
|
* grouped_type: string|null,
|
||||||
* grouped_data: null|array<array{
|
* grouped_data: null|array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* description: string|null,
|
* description: string|null,
|
||||||
* color: string|null,
|
* color: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null,
|
* cost: int,
|
||||||
* grouped_type: null,
|
* grouped_type: null,
|
||||||
* grouped_data: null
|
* grouped_data: null
|
||||||
* }>
|
* }>
|
||||||
* }>,
|
* }>,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null
|
* cost: int
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
class DetailedWithDataReportResource extends BaseResource
|
class DetailedWithDataReportResource extends BaseResource
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ namespace App\Listeners;
|
|||||||
|
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\MemberService;
|
use App\Service\UserService;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
use Laravel\Jetstream\Events\TeamMemberAdded;
|
||||||
|
|
||||||
@@ -17,11 +17,8 @@ class RemovePlaceholder
|
|||||||
*/
|
*/
|
||||||
public function handle(TeamMemberAdded $event): void
|
public function handle(TeamMemberAdded $event): void
|
||||||
{
|
{
|
||||||
$memberService = app(MemberService::class);
|
/** @var UserService $userService */
|
||||||
$member = Member::query()
|
$userService = app(UserService::class);
|
||||||
->whereBelongsTo($event->team, 'organization')
|
|
||||||
->whereBelongsTo($event->user, 'user')
|
|
||||||
->firstOrFail();
|
|
||||||
$placeholders = Member::query()
|
$placeholders = Member::query()
|
||||||
->whereHas('user', function (Builder $query) use ($event): void {
|
->whereHas('user', function (Builder $query) use ($event): void {
|
||||||
/** @var Builder<User> $query */
|
/** @var Builder<User> $query */
|
||||||
@@ -35,7 +32,7 @@ class RemovePlaceholder
|
|||||||
foreach ($placeholders as $placeholder) {
|
foreach ($placeholders as $placeholder) {
|
||||||
/** @var Member $placeholder */
|
/** @var Member $placeholder */
|
||||||
$placeholderUser = $placeholder->user;
|
$placeholderUser = $placeholder->user;
|
||||||
$memberService->assignOrganizationEntitiesToDifferentMember($event->team, $placeholder, $member);
|
$userService->assignOrganizationEntitiesToDifferentUser($event->team, $placeholderUser, $event->user);
|
||||||
$placeholder->delete();
|
$placeholder->delete();
|
||||||
$placeholderUser->delete();
|
$placeholderUser->delete();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ namespace App\Models;
|
|||||||
use App\Models\Concerns\CustomAuditable;
|
use App\Models\Concerns\CustomAuditable;
|
||||||
use App\Models\Concerns\HasUuids;
|
use App\Models\Concerns\HasUuids;
|
||||||
use Database\Factories\MemberFactory;
|
use Database\Factories\MemberFactory;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
@@ -25,8 +24,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
* @property-read Organization $organization
|
* @property-read Organization $organization
|
||||||
* @property-read User $user
|
* @property-read User $user
|
||||||
* @property-read Collection<ProjectMember> $projectMembers
|
|
||||||
* @property-read Collection<TimeEntry> $timeEntries
|
|
||||||
*
|
*
|
||||||
* @method static MemberFactory factory()
|
* @method static MemberFactory factory()
|
||||||
*/
|
*/
|
||||||
@@ -62,14 +59,6 @@ class Member extends JetstreamMembership implements AuditableContract
|
|||||||
return $this->belongsTo(Organization::class, 'organization_id');
|
return $this->belongsTo(Organization::class, 'organization_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasMany<TimeEntry>
|
|
||||||
*/
|
|
||||||
public function timeEntries(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(TimeEntry::class, 'member_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return HasMany<ProjectMember>
|
* @return HasMany<ProjectMember>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -80,8 +80,6 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
Jetstream::defaultApiTokenPermissions([]);
|
Jetstream::defaultApiTokenPermissions([]);
|
||||||
|
|
||||||
Jetstream::role(Role::Owner->value, 'Owner', [
|
Jetstream::role(Role::Owner->value, 'Owner', [
|
||||||
'charts:view:own',
|
|
||||||
'charts:view:all',
|
|
||||||
'projects:view',
|
'projects:view',
|
||||||
'projects:view:all',
|
'projects:view:all',
|
||||||
'projects:create',
|
'projects:create',
|
||||||
@@ -125,7 +123,6 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'members:invite-placeholder',
|
'members:invite-placeholder',
|
||||||
'members:change-ownership',
|
'members:change-ownership',
|
||||||
'members:make-placeholder',
|
'members:make-placeholder',
|
||||||
'members:merge-into',
|
|
||||||
'members:update',
|
'members:update',
|
||||||
'members:delete',
|
'members:delete',
|
||||||
'billing',
|
'billing',
|
||||||
@@ -136,8 +133,6 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
])->description('Owner users can perform any action. There is only one owner per organization.');
|
])->description('Owner users can perform any action. There is only one owner per organization.');
|
||||||
|
|
||||||
Jetstream::role(Role::Admin->value, 'Administrator', [
|
Jetstream::role(Role::Admin->value, 'Administrator', [
|
||||||
'charts:view:own',
|
|
||||||
'charts:view:all',
|
|
||||||
'projects:view',
|
'projects:view',
|
||||||
'projects:view:all',
|
'projects:view:all',
|
||||||
'projects:create',
|
'projects:create',
|
||||||
@@ -177,10 +172,8 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'invitations:resend',
|
'invitations:resend',
|
||||||
'invitations:remove',
|
'invitations:remove',
|
||||||
'members:view',
|
'members:view',
|
||||||
'members:invite-placeholder',
|
|
||||||
'members:make-placeholder',
|
|
||||||
'members:merge-into',
|
|
||||||
'members:update',
|
'members:update',
|
||||||
|
'members:invite-placeholder',
|
||||||
'reports:view',
|
'reports:view',
|
||||||
'reports:create',
|
'reports:create',
|
||||||
'reports:update',
|
'reports:update',
|
||||||
@@ -188,8 +181,6 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
])->description('Administrator users can perform any action, except accessing the billing dashboard.');
|
])->description('Administrator users can perform any action, except accessing the billing dashboard.');
|
||||||
|
|
||||||
Jetstream::role(Role::Manager->value, 'Manager', [
|
Jetstream::role(Role::Manager->value, 'Manager', [
|
||||||
'charts:view:own',
|
|
||||||
'charts:view:all',
|
|
||||||
'projects:view',
|
'projects:view',
|
||||||
'projects:view:all',
|
'projects:view:all',
|
||||||
'projects:create',
|
'projects:create',
|
||||||
@@ -230,7 +221,6 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
])->description('Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).');
|
])->description('Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).');
|
||||||
|
|
||||||
Jetstream::role(Role::Employee->value, 'Employee', [
|
Jetstream::role(Role::Employee->value, 'Employee', [
|
||||||
'charts:view:own',
|
|
||||||
'projects:view',
|
'projects:view',
|
||||||
'tags:view',
|
'tags:view',
|
||||||
'tasks:view',
|
'tasks:view',
|
||||||
|
|||||||
@@ -33,11 +33,6 @@ class ColorService
|
|||||||
|
|
||||||
private const string VALID_REGEX = '/^#[0-9a-f]{6}$/';
|
private const string VALID_REGEX = '/^#[0-9a-f]{6}$/';
|
||||||
|
|
||||||
public function isBuiltInColor(string $color): bool
|
|
||||||
{
|
|
||||||
return in_array($color, self::COLORS, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRandomColor(?string $seed = null): string
|
public function getRandomColor(?string $seed = null): string
|
||||||
{
|
{
|
||||||
if ($seed !== null) {
|
if ($seed !== null) {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use App\Models\TimeEntry;
|
|||||||
use Carbon\Exceptions\InvalidFormatException;
|
use Carbon\Exceptions\InvalidFormatException;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use League\Csv\Exception as CsvException;
|
use League\Csv\Exception as CsvException;
|
||||||
use League\Csv\Reader;
|
use League\Csv\Reader;
|
||||||
|
|
||||||
@@ -24,7 +23,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
*/
|
*/
|
||||||
private function getTags(string $tags): array
|
private function getTags(string $tags): array
|
||||||
{
|
{
|
||||||
if (Str::trim($tags) === '') {
|
if (trim($tags) === '') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
$tagsParsed = explode(', ', $tags);
|
$tagsParsed = explode(', ', $tags);
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Service\Import\Importers;
|
|
||||||
|
|
||||||
use App\Service\ColorService;
|
|
||||||
use Carbon\Exceptions\InvalidFormatException;
|
|
||||||
use Exception;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use League\Csv\Exception as CsvException;
|
|
||||||
use League\Csv\Reader;
|
|
||||||
use Override;
|
|
||||||
|
|
||||||
class GenericProjectsImporter extends DefaultImporter
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var array<string>
|
|
||||||
*/
|
|
||||||
private const array REQUIRED_FIELDS = [
|
|
||||||
'name',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws ImportException
|
|
||||||
*/
|
|
||||||
#[Override]
|
|
||||||
public function importData(string $data, string $timezone): void
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$reader = Reader::createFromString($data);
|
|
||||||
$reader->setHeaderOffset(0);
|
|
||||||
$reader->setDelimiter(',');
|
|
||||||
$reader->setEnclosure('"');
|
|
||||||
$reader->setEscape('');
|
|
||||||
$header = $reader->getHeader();
|
|
||||||
$this->validateHeader($header);
|
|
||||||
$records = $reader->getRecords();
|
|
||||||
foreach ($records as $record) {
|
|
||||||
$clientId = null;
|
|
||||||
if (isset($record['client']) && $record['client'] !== '') {
|
|
||||||
$clientId = $this->clientImportHelper->getKey([
|
|
||||||
'name' => $record['client'],
|
|
||||||
'organization_id' => $this->organization->id,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
if ($record['name'] !== '') {
|
|
||||||
$archivedAt = null;
|
|
||||||
if (isset($record['archived_at']) && $record['archived_at'] !== '') {
|
|
||||||
try {
|
|
||||||
$archivedAt = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $record['archived_at'], 'UTC');
|
|
||||||
} catch (InvalidFormatException) {
|
|
||||||
throw new ImportException('Value of archived_at ("'.$record['archived_at'].'") is invalid');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$this->projectImportHelper->getKey([
|
|
||||||
'name' => $record['name'],
|
|
||||||
'organization_id' => $this->organization->id,
|
|
||||||
], [
|
|
||||||
'color' => isset($record['color']) && $record['color'] !== '' ? $record['color'] : app(ColorService::class)->getRandomColor(),
|
|
||||||
'billable_rate' => isset($record['billable_rate']) && $record['billable_rate'] !== '' ? (int) $record['billable_rate'] : null,
|
|
||||||
'is_public' => isset($record['is_public']) && $record['is_public'] === 'true',
|
|
||||||
'client_id' => $clientId,
|
|
||||||
'is_billable' => isset($record['billable_default']) && $record['billable_default'] === 'true',
|
|
||||||
'estimated_time' => isset($record['estimated_time']) && $record['estimated_time'] !== '' && is_numeric($record['estimated_time']) && ((int) $record['estimated_time'] !== 0) ? (int) $record['estimated_time'] : null,
|
|
||||||
'archived_at' => $archivedAt,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (ImportException $exception) {
|
|
||||||
throw $exception;
|
|
||||||
} catch (CsvException $exception) {
|
|
||||||
throw new ImportException('Invalid CSV data');
|
|
||||||
} catch (Exception $exception) {
|
|
||||||
report($exception);
|
|
||||||
throw new ImportException('Unknown error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string> $header
|
|
||||||
*
|
|
||||||
* @throws ImportException
|
|
||||||
*/
|
|
||||||
private function validateHeader(array $header): void
|
|
||||||
{
|
|
||||||
foreach (self::REQUIRED_FIELDS as $requiredField) {
|
|
||||||
if (! in_array($requiredField, $header, true)) {
|
|
||||||
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Override]
|
|
||||||
public function getName(): string
|
|
||||||
{
|
|
||||||
return __('importer.generic_projects.name');
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Override]
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return __('importer.generic_projects.description');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Service\Import\Importers;
|
|
||||||
|
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Jobs\RecalculateSpentTimeForProject;
|
|
||||||
use App\Jobs\RecalculateSpentTimeForTask;
|
|
||||||
use App\Models\TimeEntry;
|
|
||||||
use Carbon\Exceptions\InvalidFormatException;
|
|
||||||
use Exception;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use League\Csv\Exception as CsvException;
|
|
||||||
use League\Csv\Reader;
|
|
||||||
|
|
||||||
class GenericTimeEntriesImporter extends DefaultImporter
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var array<string>
|
|
||||||
*/
|
|
||||||
private const array REQUIRED_FIELDS = [
|
|
||||||
'description',
|
|
||||||
'billable',
|
|
||||||
'client',
|
|
||||||
'project',
|
|
||||||
'tags',
|
|
||||||
'start',
|
|
||||||
'end',
|
|
||||||
'task',
|
|
||||||
'user_name',
|
|
||||||
'user_email',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string>
|
|
||||||
*
|
|
||||||
* @throws ImportException
|
|
||||||
*/
|
|
||||||
private function getTags(string $tags): array
|
|
||||||
{
|
|
||||||
if (Str::trim($tags) === '') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
$tagsParsed = explode(',', $tags);
|
|
||||||
$tagIds = [];
|
|
||||||
foreach ($tagsParsed as $tagParsed) {
|
|
||||||
$tagId = $this->tagImportHelper->getKey([
|
|
||||||
'name' => Str::trim($tagParsed),
|
|
||||||
'organization_id' => $this->organization->id,
|
|
||||||
]);
|
|
||||||
$tagIds[] = $tagId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $tagIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws ImportException
|
|
||||||
*/
|
|
||||||
#[\Override]
|
|
||||||
public function importData(string $data, string $timezone): void
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$reader = Reader::createFromString($data);
|
|
||||||
$reader->setHeaderOffset(0);
|
|
||||||
$reader->setDelimiter(',');
|
|
||||||
$reader->setEnclosure('"');
|
|
||||||
$reader->setEscape('');
|
|
||||||
$header = $reader->getHeader();
|
|
||||||
$this->validateHeader($header);
|
|
||||||
$records = $reader->getRecords();
|
|
||||||
foreach ($records as $record) {
|
|
||||||
$userId = $this->userImportHelper->getKey([
|
|
||||||
'email' => $record['user_email'],
|
|
||||||
], [
|
|
||||||
'name' => $record['user_name'],
|
|
||||||
'timezone' => 'UTC',
|
|
||||||
'is_placeholder' => true,
|
|
||||||
]);
|
|
||||||
$memberId = $this->memberImportHelper->getKey([
|
|
||||||
'user_id' => $userId,
|
|
||||||
'organization_id' => $this->organization->getKey(),
|
|
||||||
], [
|
|
||||||
'role' => Role::Placeholder->value,
|
|
||||||
]);
|
|
||||||
$member = $this->memberImportHelper->getModelById($memberId);
|
|
||||||
$clientId = null;
|
|
||||||
if ($record['client'] !== '') {
|
|
||||||
$clientId = $this->clientImportHelper->getKey([
|
|
||||||
'name' => $record['client'],
|
|
||||||
'organization_id' => $this->organization->id,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
$projectId = null;
|
|
||||||
$project = null;
|
|
||||||
$projectMember = null;
|
|
||||||
if ($record['project'] !== '') {
|
|
||||||
$projectId = $this->projectImportHelper->getKey([
|
|
||||||
'name' => $record['project'],
|
|
||||||
'organization_id' => $this->organization->id,
|
|
||||||
], [
|
|
||||||
'client_id' => $clientId,
|
|
||||||
'is_billable' => false,
|
|
||||||
'color' => $this->colorService->getRandomColor(),
|
|
||||||
]);
|
|
||||||
$project = $this->projectImportHelper->getModelById($projectId);
|
|
||||||
$projectMember = $this->projectMemberImportHelper->getModel([
|
|
||||||
'project_id' => $projectId,
|
|
||||||
'member_id' => $memberId,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
$taskId = null;
|
|
||||||
if ($record['task'] !== '') {
|
|
||||||
$taskId = $this->taskImportHelper->getKey([
|
|
||||||
'name' => $record['task'],
|
|
||||||
'project_id' => $projectId,
|
|
||||||
'organization_id' => $this->organization->id,
|
|
||||||
]);
|
|
||||||
$this->taskImportHelper->getModelById($taskId);
|
|
||||||
}
|
|
||||||
$timeEntry = new TimeEntry;
|
|
||||||
$timeEntry->disableAuditing();
|
|
||||||
$timeEntry->user_id = $userId;
|
|
||||||
$timeEntry->member_id = $memberId;
|
|
||||||
$timeEntry->task_id = $taskId;
|
|
||||||
$timeEntry->project_id = $projectId;
|
|
||||||
$timeEntry->client_id = $clientId;
|
|
||||||
$timeEntry->organization_id = $this->organization->id;
|
|
||||||
$timeEntry->description = $record['description'];
|
|
||||||
if (! in_array($record['billable'], ['true', 'false'], true)) {
|
|
||||||
throw new ImportException('Invalid billable value');
|
|
||||||
}
|
|
||||||
$timeEntry->billable = $record['billable'] === 'true';
|
|
||||||
$timeEntry->tags = $this->getTags($record['tags']);
|
|
||||||
$timeEntry->is_imported = true;
|
|
||||||
try {
|
|
||||||
$start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $record['start'], 'UTC');
|
|
||||||
} catch (InvalidFormatException) {
|
|
||||||
throw new ImportException('Value of start ("'.$record['start'].'") is invalid');
|
|
||||||
}
|
|
||||||
if ($start === null) {
|
|
||||||
throw new ImportException('Value of start ("'.$record['start'].'") is invalid');
|
|
||||||
}
|
|
||||||
$timeEntry->start = $start->utc();
|
|
||||||
|
|
||||||
try {
|
|
||||||
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $record['end'], 'UTC');
|
|
||||||
} catch (InvalidFormatException) {
|
|
||||||
throw new ImportException('Value of end ("'.$record['end'].'") is invalid');
|
|
||||||
}
|
|
||||||
if ($end === null) {
|
|
||||||
throw new ImportException('Value of end ("'.$record['end'].'") is invalid');
|
|
||||||
}
|
|
||||||
$timeEntry->end = $end->utc();
|
|
||||||
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
|
|
||||||
$timeEntry,
|
|
||||||
$projectMember,
|
|
||||||
$project,
|
|
||||||
$member,
|
|
||||||
$this->organization
|
|
||||||
);
|
|
||||||
$timeEntry->save();
|
|
||||||
$this->timeEntriesCreated++;
|
|
||||||
}
|
|
||||||
foreach ($this->projectImportHelper->getCachedModels() as $usedProject) {
|
|
||||||
RecalculateSpentTimeForProject::dispatch($usedProject);
|
|
||||||
}
|
|
||||||
foreach ($this->taskImportHelper->getCachedModels() as $usedTask) {
|
|
||||||
RecalculateSpentTimeForTask::dispatch($usedTask);
|
|
||||||
}
|
|
||||||
} catch (ImportException $exception) {
|
|
||||||
throw $exception;
|
|
||||||
} catch (CsvException $exception) {
|
|
||||||
throw new ImportException('Invalid CSV data');
|
|
||||||
} catch (Exception $exception) {
|
|
||||||
report($exception);
|
|
||||||
throw new ImportException('Unknown error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string> $header
|
|
||||||
*
|
|
||||||
* @throws ImportException
|
|
||||||
*/
|
|
||||||
private function validateHeader(array $header): void
|
|
||||||
{
|
|
||||||
foreach (self::REQUIRED_FIELDS as $requiredField) {
|
|
||||||
if (! in_array($requiredField, $header, true)) {
|
|
||||||
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[\Override]
|
|
||||||
public function getName(): string
|
|
||||||
{
|
|
||||||
return __('importer.generic_time_entries.name');
|
|
||||||
}
|
|
||||||
|
|
||||||
#[\Override]
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return __('importer.generic_time_entries.description');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Service\Import\Importers;
|
|
||||||
|
|
||||||
use Exception;
|
|
||||||
use League\Csv\Exception as CsvException;
|
|
||||||
use League\Csv\Reader;
|
|
||||||
|
|
||||||
class HarvestClientsImporter extends DefaultImporter
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var array<string>
|
|
||||||
*/
|
|
||||||
private const array REQUIRED_FIELDS = [
|
|
||||||
'Client Name',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws ImportException
|
|
||||||
*/
|
|
||||||
#[\Override]
|
|
||||||
public function importData(string $data, string $timezone): void
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$reader = Reader::createFromString($data);
|
|
||||||
$reader->setHeaderOffset(0);
|
|
||||||
$reader->setDelimiter(',');
|
|
||||||
$reader->setEnclosure('"');
|
|
||||||
$reader->setEscape('');
|
|
||||||
$header = $reader->getHeader();
|
|
||||||
$this->validateHeader($header);
|
|
||||||
$records = $reader->getRecords();
|
|
||||||
foreach ($records as $record) {
|
|
||||||
$this->clientImportHelper->getKey([
|
|
||||||
'name' => $record['Client Name'],
|
|
||||||
'organization_id' => $this->organization->id,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} catch (ImportException $exception) {
|
|
||||||
throw $exception;
|
|
||||||
} catch (CsvException $exception) {
|
|
||||||
throw new ImportException('Invalid CSV data');
|
|
||||||
} catch (Exception $exception) {
|
|
||||||
report($exception);
|
|
||||||
throw new ImportException('Unknown error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string> $header
|
|
||||||
*
|
|
||||||
* @throws ImportException
|
|
||||||
*/
|
|
||||||
private function validateHeader(array $header): void
|
|
||||||
{
|
|
||||||
foreach (self::REQUIRED_FIELDS as $requiredField) {
|
|
||||||
if (! in_array($requiredField, $header, true)) {
|
|
||||||
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[\Override]
|
|
||||||
public function getName(): string
|
|
||||||
{
|
|
||||||
return __('importer.harvest_clients.name');
|
|
||||||
}
|
|
||||||
|
|
||||||
#[\Override]
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return __('importer.harvest_clients.description');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Service\Import\Importers;
|
|
||||||
|
|
||||||
use Exception;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use League\Csv\Exception as CsvException;
|
|
||||||
use League\Csv\Reader;
|
|
||||||
|
|
||||||
class HarvestProjectsImporter extends DefaultImporter
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var array<string>
|
|
||||||
*/
|
|
||||||
private const array REQUIRED_FIELDS = [
|
|
||||||
'Client',
|
|
||||||
'Project',
|
|
||||||
'Budget',
|
|
||||||
'Billable Hours',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws ImportException
|
|
||||||
*/
|
|
||||||
#[\Override]
|
|
||||||
public function importData(string $data, string $timezone): void
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$reader = Reader::createFromString($data);
|
|
||||||
$reader->setHeaderOffset(0);
|
|
||||||
$reader->setDelimiter(',');
|
|
||||||
$reader->setEnclosure('"');
|
|
||||||
$reader->setEscape('');
|
|
||||||
$header = $reader->getHeader();
|
|
||||||
$this->validateHeader($header);
|
|
||||||
$records = $reader->getRecords();
|
|
||||||
foreach ($records as $record) {
|
|
||||||
$clientId = null;
|
|
||||||
if ($record['Client'] !== '') {
|
|
||||||
$clientId = $this->clientImportHelper->getKey([
|
|
||||||
'name' => $record['Client'],
|
|
||||||
'organization_id' => $this->organization->id,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
if ($record['Project'] !== '') {
|
|
||||||
if (! isset($record['Budget']) || ! is_string($record['Budget'])) {
|
|
||||||
throw new ImportException('The value for "Budget" is invalid');
|
|
||||||
}
|
|
||||||
$estimatedTimeField = Str::replace(',', '.', $record['Budget']);
|
|
||||||
$estimatedTime = $estimatedTimeField !== '' && is_numeric($estimatedTimeField) ? (int) (((float) $estimatedTimeField) * 60 * 60) : null;
|
|
||||||
if ($estimatedTime === 0) {
|
|
||||||
$estimatedTime = null;
|
|
||||||
}
|
|
||||||
if (! isset($record['Billable Hours']) || ! is_string($record['Billable Hours'])) {
|
|
||||||
throw new ImportException('The value for "Billable Hours" is invalid');
|
|
||||||
}
|
|
||||||
$billableHoursField = Str::replace(',', '.', $record['Billable Hours']);
|
|
||||||
$billableHours = $billableHoursField !== '' && is_numeric($billableHoursField) ? (int) ((float) $billableHoursField) : null;
|
|
||||||
$this->projectImportHelper->getKey([
|
|
||||||
'name' => $record['Project'],
|
|
||||||
'organization_id' => $this->organization->id,
|
|
||||||
], [
|
|
||||||
'color' => $this->colorService->getRandomColor(),
|
|
||||||
'client_id' => $clientId,
|
|
||||||
'estimated_time' => $estimatedTime,
|
|
||||||
'is_billable' => $billableHours > 0,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (ImportException $exception) {
|
|
||||||
throw $exception;
|
|
||||||
} catch (CsvException $exception) {
|
|
||||||
throw new ImportException('Invalid CSV data');
|
|
||||||
} catch (Exception $exception) {
|
|
||||||
report($exception);
|
|
||||||
throw new ImportException('Unknown error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string> $header
|
|
||||||
*
|
|
||||||
* @throws ImportException
|
|
||||||
*/
|
|
||||||
private function validateHeader(array $header): void
|
|
||||||
{
|
|
||||||
foreach (self::REQUIRED_FIELDS as $requiredField) {
|
|
||||||
if (! in_array($requiredField, $header, true)) {
|
|
||||||
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[\Override]
|
|
||||||
public function getName(): string
|
|
||||||
{
|
|
||||||
return __('importer.harvest_projects.name');
|
|
||||||
}
|
|
||||||
|
|
||||||
#[\Override]
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return __('importer.harvest_projects.description');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Service\Import\Importers;
|
|
||||||
|
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Jobs\RecalculateSpentTimeForProject;
|
|
||||||
use App\Jobs\RecalculateSpentTimeForTask;
|
|
||||||
use App\Models\TimeEntry;
|
|
||||||
use Carbon\Exceptions\InvalidFormatException;
|
|
||||||
use Exception;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use League\Csv\Exception as CsvException;
|
|
||||||
use League\Csv\Reader;
|
|
||||||
use Override;
|
|
||||||
|
|
||||||
class HarvestTimeEntriesImporter extends DefaultImporter
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var array<string>
|
|
||||||
*/
|
|
||||||
private const array REQUIRED_FIELDS = [
|
|
||||||
'Date',
|
|
||||||
'Hours',
|
|
||||||
'Client',
|
|
||||||
'Project',
|
|
||||||
'Task',
|
|
||||||
'Billable?',
|
|
||||||
'First Name',
|
|
||||||
'Last Name',
|
|
||||||
'Notes',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws ImportException
|
|
||||||
*/
|
|
||||||
#[Override]
|
|
||||||
public function importData(string $data, string $timezone): void
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$reader = Reader::createFromString($data);
|
|
||||||
$reader->setHeaderOffset(0);
|
|
||||||
$reader->setDelimiter(',');
|
|
||||||
$reader->setEnclosure('"');
|
|
||||||
$reader->setEscape('');
|
|
||||||
$header = $reader->getHeader();
|
|
||||||
$this->validateHeader($header);
|
|
||||||
$records = $reader->getRecords();
|
|
||||||
foreach ($records as $record) {
|
|
||||||
$firstname = $record['First Name'];
|
|
||||||
$lastname = $record['Last Name'];
|
|
||||||
$userId = $this->userImportHelper->getKey([
|
|
||||||
'email' => Str::slug($firstname).'.'.Str::slug($lastname).'@solidtime-import.test',
|
|
||||||
], [
|
|
||||||
'name' => $firstname.' '.$lastname,
|
|
||||||
'timezone' => 'UTC',
|
|
||||||
'is_placeholder' => true,
|
|
||||||
]);
|
|
||||||
$memberId = $this->memberImportHelper->getKey([
|
|
||||||
'user_id' => $userId,
|
|
||||||
'organization_id' => $this->organization->getKey(),
|
|
||||||
], [
|
|
||||||
'role' => Role::Placeholder->value,
|
|
||||||
]);
|
|
||||||
$member = $this->memberImportHelper->getModelById($memberId);
|
|
||||||
$clientId = null;
|
|
||||||
if ($record['Client'] !== '') {
|
|
||||||
$clientId = $this->clientImportHelper->getKey([
|
|
||||||
'name' => $record['Client'],
|
|
||||||
'organization_id' => $this->organization->id,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
$projectId = null;
|
|
||||||
$project = null;
|
|
||||||
$projectMember = null;
|
|
||||||
if ($record['Project'] !== '') {
|
|
||||||
$projectId = $this->projectImportHelper->getKey([
|
|
||||||
'name' => $record['Project'],
|
|
||||||
'organization_id' => $this->organization->id,
|
|
||||||
], [
|
|
||||||
'client_id' => $clientId,
|
|
||||||
'color' => $this->colorService->getRandomColor(),
|
|
||||||
'is_billable' => true,
|
|
||||||
]);
|
|
||||||
$project = $this->projectImportHelper->getModelById($projectId);
|
|
||||||
$projectMember = $this->projectMemberImportHelper->getModel([
|
|
||||||
'project_id' => $projectId,
|
|
||||||
'member_id' => $memberId,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
$taskId = null;
|
|
||||||
if ($record['Task'] !== '') {
|
|
||||||
$taskId = $this->taskImportHelper->getKey([
|
|
||||||
'name' => $record['Task'],
|
|
||||||
'project_id' => $projectId,
|
|
||||||
'organization_id' => $this->organization->id,
|
|
||||||
]);
|
|
||||||
$this->taskImportHelper->getModelById($taskId);
|
|
||||||
}
|
|
||||||
$timeEntry = new TimeEntry;
|
|
||||||
$timeEntry->disableAuditing();
|
|
||||||
$timeEntry->user_id = $userId;
|
|
||||||
$timeEntry->member_id = $memberId;
|
|
||||||
$timeEntry->task_id = $taskId;
|
|
||||||
$timeEntry->project_id = $projectId;
|
|
||||||
$timeEntry->client_id = $clientId;
|
|
||||||
$timeEntry->organization_id = $this->organization->id;
|
|
||||||
if (strlen($record['Notes']) > 500) {
|
|
||||||
throw new ImportException('Time entry note is too long');
|
|
||||||
}
|
|
||||||
$timeEntry->description = $record['Notes'];
|
|
||||||
if (! in_array($record['Billable?'], ['Yes', 'No'], true)) {
|
|
||||||
throw new ImportException('Invalid billable value');
|
|
||||||
}
|
|
||||||
$timeEntry->billable = $record['Billable?'] === 'Yes';
|
|
||||||
$timeEntry->tags = [];
|
|
||||||
$timeEntry->is_imported = true;
|
|
||||||
|
|
||||||
// Start & End
|
|
||||||
try {
|
|
||||||
$date = Carbon::createFromFormat('Y-m-d', $record['Date'], $timezone);
|
|
||||||
} catch (InvalidFormatException) {
|
|
||||||
throw new ImportException('Date ("'.$record['Date'].'") is invalid');
|
|
||||||
}
|
|
||||||
if ($date === null) {
|
|
||||||
throw new ImportException('Date ("'.$record['Date'].'") is invalid');
|
|
||||||
}
|
|
||||||
if (! isset($record['Hours']) || ! is_string($record['Hours'])) {
|
|
||||||
throw new ImportException('Hours ("'.($record['Hours'] ?? '<null>').'") is invalid');
|
|
||||||
}
|
|
||||||
$hoursField = Str::replace(',', '.', $record['Hours']);
|
|
||||||
if (! is_numeric($hoursField)) {
|
|
||||||
throw new ImportException('Hours ("'.$record['Hours'].'") is invalid');
|
|
||||||
}
|
|
||||||
$hours = (float) $hoursField;
|
|
||||||
$timeEntry->start = $date->copy()->startOfDay()->utc();
|
|
||||||
$timeEntry->end = $date->copy()->startOfDay()->addHours($hours)->utc();
|
|
||||||
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
|
|
||||||
$timeEntry,
|
|
||||||
$projectMember,
|
|
||||||
$project,
|
|
||||||
$member,
|
|
||||||
$this->organization
|
|
||||||
);
|
|
||||||
$timeEntry->save();
|
|
||||||
$this->timeEntriesCreated++;
|
|
||||||
}
|
|
||||||
foreach ($this->projectImportHelper->getCachedModels() as $usedProject) {
|
|
||||||
RecalculateSpentTimeForProject::dispatch($usedProject);
|
|
||||||
}
|
|
||||||
foreach ($this->taskImportHelper->getCachedModels() as $usedTask) {
|
|
||||||
RecalculateSpentTimeForTask::dispatch($usedTask);
|
|
||||||
}
|
|
||||||
} catch (ImportException $exception) {
|
|
||||||
throw $exception;
|
|
||||||
} catch (CsvException $exception) {
|
|
||||||
throw new ImportException('Invalid CSV data');
|
|
||||||
} catch (Exception $exception) {
|
|
||||||
report($exception);
|
|
||||||
throw new ImportException('Unknown error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string> $header
|
|
||||||
*
|
|
||||||
* @throws ImportException
|
|
||||||
*/
|
|
||||||
private function validateHeader(array $header): void
|
|
||||||
{
|
|
||||||
foreach (self::REQUIRED_FIELDS as $requiredField) {
|
|
||||||
if (! in_array($requiredField, $header, true)) {
|
|
||||||
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Override]
|
|
||||||
public function getName(): string
|
|
||||||
{
|
|
||||||
return __('importer.harvest_time_entries.name');
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Override]
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return __('importer.harvest_time_entries.description');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,11 +15,6 @@ class ImporterProvider
|
|||||||
'clockify_time_entries' => ClockifyTimeEntriesImporter::class,
|
'clockify_time_entries' => ClockifyTimeEntriesImporter::class,
|
||||||
'clockify_projects' => ClockifyProjectsImporter::class,
|
'clockify_projects' => ClockifyProjectsImporter::class,
|
||||||
'solidtime' => SolidtimeImporter::class,
|
'solidtime' => SolidtimeImporter::class,
|
||||||
'harvest_projects' => HarvestProjectsImporter::class,
|
|
||||||
'harvest_time_entries' => HarvestTimeEntriesImporter::class,
|
|
||||||
'harvest_clients' => HarvestClientsImporter::class,
|
|
||||||
'generic_projects' => GenericProjectsImporter::class,
|
|
||||||
'generic_time_entries' => GenericTimeEntriesImporter::class,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ class SolidtimeImporter extends DefaultImporter
|
|||||||
*/
|
*/
|
||||||
private function getTags(string $tags): array
|
private function getTags(string $tags): array
|
||||||
{
|
{
|
||||||
if (Str::trim($tags) === '') {
|
if (trim($tags) === '') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
$tagsParsed = json_decode($tags);
|
$tagsParsed = json_decode($tags);
|
||||||
|
|||||||
@@ -5,11 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace App\Service\Import\Importers;
|
namespace App\Service\Import\Importers;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
use App\Service\TimezoneService;
|
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Override;
|
use Override;
|
||||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||||
use ValueError;
|
use ValueError;
|
||||||
@@ -96,22 +93,11 @@ class TogglDataImporter extends DefaultImporter
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($workspaceUsers as $workspaceUser) {
|
foreach ($workspaceUsers as $workspaceUser) {
|
||||||
$timezone = Str::trim($workspaceUser->timezone);
|
|
||||||
if ($timezone === '') {
|
|
||||||
$timezone = 'UTC';
|
|
||||||
}
|
|
||||||
if (! app(TimezoneService::class)->isValid($timezone)) {
|
|
||||||
Log::warning('TogglDateImporter: Invalid timezone', [
|
|
||||||
'timezone' => $timezone,
|
|
||||||
]);
|
|
||||||
$timezone = 'UTC';
|
|
||||||
}
|
|
||||||
|
|
||||||
$userId = $this->userImportHelper->getKey([
|
$userId = $this->userImportHelper->getKey([
|
||||||
'email' => $workspaceUser->email,
|
'email' => $workspaceUser->email,
|
||||||
], [
|
], [
|
||||||
'name' => $workspaceUser->name,
|
'name' => $workspaceUser->name,
|
||||||
'timezone' => $timezone,
|
'timezone' => $workspaceUser->timezone ?? 'UTC',
|
||||||
'is_placeholder' => true,
|
'is_placeholder' => true,
|
||||||
], (string) $workspaceUser->uid);
|
], (string) $workspaceUser->uid);
|
||||||
$this->memberImportHelper->getKey([
|
$this->memberImportHelper->getKey([
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use App\Models\TimeEntry;
|
|||||||
use Carbon\Exceptions\InvalidFormatException;
|
use Carbon\Exceptions\InvalidFormatException;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use League\Csv\Exception as CsvException;
|
use League\Csv\Exception as CsvException;
|
||||||
use League\Csv\Reader;
|
use League\Csv\Reader;
|
||||||
|
|
||||||
@@ -24,7 +23,7 @@ class TogglTimeEntriesImporter extends DefaultImporter
|
|||||||
*/
|
*/
|
||||||
private function getTags(string $tags): array
|
private function getTags(string $tags): array
|
||||||
{
|
{
|
||||||
if (Str::trim($tags) === '') {
|
if (trim($tags) === '') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
$tagsParsed = explode(', ', $tags);
|
$tagsParsed = explode(', ', $tags);
|
||||||
|
|||||||
@@ -7,18 +7,15 @@ namespace App\Service;
|
|||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
use App\Events\MemberRemoved;
|
use App\Events\MemberRemoved;
|
||||||
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||||
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
|
||||||
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
||||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||||
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
||||||
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\Project;
|
|
||||||
use App\Models\ProjectMember;
|
use App\Models\ProjectMember;
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Laravel\Jetstream\Events\AddingTeamMember;
|
use Laravel\Jetstream\Events\AddingTeamMember;
|
||||||
@@ -78,7 +75,6 @@ class MemberService
|
|||||||
* @throws ChangingRoleToPlaceholderIsNotAllowed
|
* @throws ChangingRoleToPlaceholderIsNotAllowed
|
||||||
* @throws OnlyOwnerCanChangeOwnership
|
* @throws OnlyOwnerCanChangeOwnership
|
||||||
* @throws OrganizationNeedsAtLeastOneOwner
|
* @throws OrganizationNeedsAtLeastOneOwner
|
||||||
* @throws ChangingRoleOfPlaceholderIsNotAllowed
|
|
||||||
*/
|
*/
|
||||||
public function changeRole(Member $member, Organization $organization, Role $newRole, bool $allowOwnerChange): void
|
public function changeRole(Member $member, Organization $organization, Role $newRole, bool $allowOwnerChange): void
|
||||||
{
|
{
|
||||||
@@ -86,9 +82,6 @@ class MemberService
|
|||||||
if ($oldRole === Role::Owner) {
|
if ($oldRole === Role::Owner) {
|
||||||
throw new OrganizationNeedsAtLeastOneOwner;
|
throw new OrganizationNeedsAtLeastOneOwner;
|
||||||
}
|
}
|
||||||
if ($oldRole === Role::Placeholder) {
|
|
||||||
throw new ChangingRoleOfPlaceholderIsNotAllowed;
|
|
||||||
}
|
|
||||||
if ($newRole === Role::Placeholder) {
|
if ($newRole === Role::Placeholder) {
|
||||||
throw new ChangingRoleToPlaceholderIsNotAllowed;
|
throw new ChangingRoleToPlaceholderIsNotAllowed;
|
||||||
}
|
}
|
||||||
@@ -103,39 +96,6 @@ class MemberService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function assignOrganizationEntitiesToDifferentMember(Organization $organization, Member $fromMember, Member $toMember): void
|
|
||||||
{
|
|
||||||
// Time entries
|
|
||||||
TimeEntry::query()
|
|
||||||
->whereBelongsTo($organization, 'organization')
|
|
||||||
->whereBelongsTo($fromMember, 'member')
|
|
||||||
->update([
|
|
||||||
'user_id' => $toMember->user_id,
|
|
||||||
'member_id' => $toMember->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Project members
|
|
||||||
ProjectMember::query()
|
|
||||||
->whereBelongsToOrganization($organization)
|
|
||||||
->whereBelongsTo($fromMember, 'member')
|
|
||||||
->whereDoesntHave('project', function (Builder $builder) use ($toMember): void {
|
|
||||||
/** @var Builder<Project> $builder */
|
|
||||||
$builder->whereHas('members', function (Builder $builder) use ($toMember): void {
|
|
||||||
/** @var Builder<ProjectMember> $builder */
|
|
||||||
$builder->where('member_id', $toMember->getKey());
|
|
||||||
});
|
|
||||||
})
|
|
||||||
->update([
|
|
||||||
'user_id' => $toMember->user_id,
|
|
||||||
'member_id' => $toMember->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProjectMember::query()
|
|
||||||
->whereBelongsToOrganization($organization)
|
|
||||||
->whereBelongsTo($fromMember, 'member')
|
|
||||||
->delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change the ownership of an organization to a new user.
|
* Change the ownership of an organization to a new user.
|
||||||
* The previous owner will be demoted to an admin.
|
* The previous owner will be demoted to an admin.
|
||||||
@@ -172,7 +132,7 @@ class MemberService
|
|||||||
$member->role = Role::Placeholder->value;
|
$member->role = Role::Placeholder->value;
|
||||||
$member->save();
|
$member->save();
|
||||||
|
|
||||||
$this->userService->assignOrganizationEntitiesToDifferentUser($member->organization, $user, $placeholderUser);
|
$this->userService->assignOrganizationEntitiesToDifferentMember($member->organization, $user, $placeholderUser, $member);
|
||||||
if ($makeSureUserHasAtLeastOneOrganization) {
|
if ($makeSureUserHasAtLeastOneOrganization) {
|
||||||
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
|
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,18 +22,18 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
|
|||||||
* grouped_data: null|array<array{
|
* grouped_data: null|array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null,
|
* cost: int,
|
||||||
* grouped_type: string|null,
|
* grouped_type: string|null,
|
||||||
* grouped_data: null|array<array{
|
* grouped_data: null|array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null,
|
* cost: int,
|
||||||
* grouped_type: null,
|
* grouped_type: null,
|
||||||
* grouped_data: null
|
* grouped_data: null
|
||||||
* }>
|
* }>
|
||||||
* }>,
|
* }>,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null
|
* cost: int
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
private array $data;
|
private array $data;
|
||||||
@@ -52,18 +52,18 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
|
|||||||
* grouped_data: null|array<array{
|
* grouped_data: null|array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null,
|
* cost: int,
|
||||||
* grouped_type: string|null,
|
* grouped_type: string|null,
|
||||||
* grouped_data: null|array<array{
|
* grouped_data: null|array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null,
|
* cost: int,
|
||||||
* grouped_type: null,
|
* grouped_type: null,
|
||||||
* grouped_data: null
|
* grouped_data: null
|
||||||
* }>
|
* }>
|
||||||
* }>,
|
* }>,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null
|
* cost: int
|
||||||
* } $data
|
* } $data
|
||||||
*/
|
*/
|
||||||
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup)
|
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup)
|
||||||
|
|||||||
@@ -27,21 +27,21 @@ class TimeEntryAggregationService
|
|||||||
* grouped_data: null|array<array{
|
* grouped_data: null|array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null,
|
* cost: int,
|
||||||
* grouped_type: string|null,
|
* grouped_type: string|null,
|
||||||
* grouped_data: null|array<array{
|
* grouped_data: null|array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null,
|
* cost: int,
|
||||||
* grouped_type: null,
|
* grouped_type: null,
|
||||||
* grouped_data: null
|
* grouped_data: null
|
||||||
* }>
|
* }>
|
||||||
* }>,
|
* }>,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null
|
* cost: int
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
|
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end): array
|
||||||
{
|
{
|
||||||
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
|
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
|
||||||
$group1Select = null;
|
$group1Select = null;
|
||||||
@@ -96,7 +96,7 @@ class TimeEntryAggregationService
|
|||||||
$group2Response[] = [
|
$group2Response[] = [
|
||||||
'key' => $group2 === '' ? null : (string) $group2,
|
'key' => $group2 === '' ? null : (string) $group2,
|
||||||
'seconds' => (int) $aggregate->get(0)->aggregate,
|
'seconds' => (int) $aggregate->get(0)->aggregate,
|
||||||
'cost' => $showBillableRate ? (int) $aggregate->get(0)->cost : null,
|
'cost' => (int) $aggregate->get(0)->cost,
|
||||||
'grouped_type' => null,
|
'grouped_type' => null,
|
||||||
'grouped_data' => null,
|
'grouped_data' => null,
|
||||||
];
|
];
|
||||||
@@ -113,7 +113,7 @@ class TimeEntryAggregationService
|
|||||||
$group1Response[] = [
|
$group1Response[] = [
|
||||||
'key' => $group1 === '' ? null : (string) $group1,
|
'key' => $group1 === '' ? null : (string) $group1,
|
||||||
'seconds' => $group2ResponseSum,
|
'seconds' => $group2ResponseSum,
|
||||||
'cost' => $showBillableRate ? $group2ResponseCost : null,
|
'cost' => $group2ResponseCost,
|
||||||
'grouped_type' => $group2Type?->value,
|
'grouped_type' => $group2Type?->value,
|
||||||
'grouped_data' => $group2Response,
|
'grouped_data' => $group2Response,
|
||||||
];
|
];
|
||||||
@@ -133,7 +133,7 @@ class TimeEntryAggregationService
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'seconds' => $group1ResponseSum,
|
'seconds' => $group1ResponseSum,
|
||||||
'cost' => $showBillableRate ? $group1ResponseCost : null,
|
'cost' => $group1ResponseCost,
|
||||||
'grouped_type' => $group1Type?->value,
|
'grouped_type' => $group1Type?->value,
|
||||||
'grouped_data' => $group1Response,
|
'grouped_data' => $group1Response,
|
||||||
];
|
];
|
||||||
@@ -148,25 +148,25 @@ class TimeEntryAggregationService
|
|||||||
* description: string|null,
|
* description: string|null,
|
||||||
* color: string|null,
|
* color: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null,
|
* cost: int,
|
||||||
* grouped_type: string|null,
|
* grouped_type: string|null,
|
||||||
* grouped_data: null|array<array{
|
* grouped_data: null|array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* description: string|null,
|
* description: string|null,
|
||||||
* color: string|null,
|
* color: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null,
|
* cost: int,
|
||||||
* grouped_type: null,
|
* grouped_type: null,
|
||||||
* grouped_data: null
|
* grouped_data: null
|
||||||
* }>
|
* }>
|
||||||
* }>,
|
* }>,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null
|
* cost: int
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
|
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end): array
|
||||||
{
|
{
|
||||||
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate);
|
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end);
|
||||||
|
|
||||||
$keysGroup1 = [];
|
$keysGroup1 = [];
|
||||||
$keysGroup2 = [];
|
$keysGroup2 = [];
|
||||||
@@ -289,12 +289,12 @@ class TimeEntryAggregationService
|
|||||||
* @param array<array{
|
* @param array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null,
|
* cost: int,
|
||||||
* grouped_type: string|null,
|
* grouped_type: string|null,
|
||||||
* grouped_data: null|array<array{
|
* grouped_data: null|array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null,
|
* cost: int,
|
||||||
* grouped_type: null|mixed,
|
* grouped_type: null|mixed,
|
||||||
* grouped_data: null|mixed
|
* grouped_data: null|mixed
|
||||||
* }>
|
* }>
|
||||||
@@ -302,12 +302,12 @@ class TimeEntryAggregationService
|
|||||||
* @return array<array{
|
* @return array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null,
|
* cost: int,
|
||||||
* grouped_type: string|null,
|
* grouped_type: string|null,
|
||||||
* grouped_data: null|array<array{
|
* grouped_data: null|array<array{
|
||||||
* key: string|null,
|
* key: string|null,
|
||||||
* seconds: int,
|
* seconds: int,
|
||||||
* cost: int|null,
|
* cost: int,
|
||||||
* grouped_type: null|mixed,
|
* grouped_type: null|mixed,
|
||||||
* grouped_data: null|mixed
|
* grouped_data: null|mixed
|
||||||
* }>
|
* }>
|
||||||
|
|||||||
@@ -49,10 +49,24 @@ class UserService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This does NOT change the member id.
|
* Assign all organization entities (time entries, project members) from one user to another.
|
||||||
* This should only be used in if you want to change a member to a placeholder!
|
* This is useful when a placeholder user is replaced with a real user.
|
||||||
*/
|
*/
|
||||||
public function assignOrganizationEntitiesToDifferentUser(Organization $organization, User $fromUser, User $toUser): void
|
public function assignOrganizationEntitiesToDifferentUser(Organization $organization, User $fromUser, User $toUser): void
|
||||||
|
{
|
||||||
|
/** @var Member|null $toMember */
|
||||||
|
$toMember = Member::query()
|
||||||
|
->whereBelongsTo($organization, 'organization')
|
||||||
|
->whereBelongsTo($toUser, 'user')
|
||||||
|
->first();
|
||||||
|
if ($toMember === null) {
|
||||||
|
throw new \InvalidArgumentException('User is not a member of the organization');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assignOrganizationEntitiesToDifferentMember($organization, $fromUser, $toUser, $toMember);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assignOrganizationEntitiesToDifferentMember(Organization $organization, User $fromUser, User $toUser, Member $toMember): void
|
||||||
{
|
{
|
||||||
// Time entries
|
// Time entries
|
||||||
TimeEntry::query()
|
TimeEntry::query()
|
||||||
@@ -60,6 +74,7 @@ class UserService
|
|||||||
->whereBelongsTo($fromUser, 'user')
|
->whereBelongsTo($fromUser, 'user')
|
||||||
->update([
|
->update([
|
||||||
'user_id' => $toUser->getKey(),
|
'user_id' => $toUser->getKey(),
|
||||||
|
'member_id' => $toMember->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Project members
|
// Project members
|
||||||
@@ -68,6 +83,7 @@ class UserService
|
|||||||
->whereBelongsTo($fromUser, 'user')
|
->whereBelongsTo($fromUser, 'user')
|
||||||
->update([
|
->update([
|
||||||
'user_id' => $toUser->getKey(),
|
'user_id' => $toUser->getKey(),
|
||||||
|
'member_id' => $toMember->getKey(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'guard' => 'web',
|
'guard' => 'api',
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini /lib/ph
|
|||||||
# && composer clear-cache
|
# && composer clear-cache
|
||||||
|
|
||||||
RUN cat .env
|
RUN cat .env
|
||||||
#RUN php artisan env
|
RUN php artisan env
|
||||||
RUN php artisan storage:link
|
RUN php artisan storage:link
|
||||||
|
|
||||||
RUN chmod +x /usr/local/bin/start-container
|
RUN chmod +x /usr/local/bin/start-container
|
||||||
|
|||||||
@@ -136,7 +136,6 @@ test('test that starting and updating the time while running works', async ({
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.waitForResponse(async (response) => {
|
page.waitForResponse(async (response) => {
|
||||||
return (
|
return (
|
||||||
response.url().includes('/time-entries') &&
|
|
||||||
response.status() === 200 &&
|
response.status() === 200 &&
|
||||||
(await response.headerValue('Content-Type')) ===
|
(await response.headerValue('Content-Type')) ===
|
||||||
'application/json' &&
|
'application/json' &&
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export function newTimeEntryResponse(
|
|||||||
) {
|
) {
|
||||||
return page.waitForResponse(async (response) => {
|
return page.waitForResponse(async (response) => {
|
||||||
return (
|
return (
|
||||||
response.url().includes('/time-entries') &&
|
|
||||||
response.status() === status &&
|
response.status() === status &&
|
||||||
(await response.headerValue('Content-Type')) ===
|
(await response.headerValue('Content-Type')) ===
|
||||||
'application/json' &&
|
'application/json' &&
|
||||||
|
|||||||
@@ -4,18 +4,15 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||||
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||||
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
|
||||||
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
||||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||||
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
||||||
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
|
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
|
||||||
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
||||||
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
|
|
||||||
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
|
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
|
||||||
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
||||||
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
||||||
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
|
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
|
||||||
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
|
||||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
@@ -42,9 +39,6 @@ return [
|
|||||||
PdfRendererIsNotConfiguredException::KEY => 'PDF renderer is not configured',
|
PdfRendererIsNotConfiguredException::KEY => 'PDF renderer is not configured',
|
||||||
FeatureIsNotAvailableInFreePlanApiException::KEY => 'Feature is not available in free plan',
|
FeatureIsNotAvailableInFreePlanApiException::KEY => 'Feature is not available in free plan',
|
||||||
PersonalAccessClientIsNotConfiguredException::KEY => 'Personal access client is not configured',
|
PersonalAccessClientIsNotConfiguredException::KEY => 'Personal access client is not configured',
|
||||||
ChangingRoleOfPlaceholderIsNotAllowed::KEY => 'Changing role of placeholder is not allowed',
|
|
||||||
OnlyPlaceholdersCanBeMergedIntoAnotherMember::KEY => 'Only placeholders can be merged into another member',
|
|
||||||
ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',
|
|
||||||
],
|
],
|
||||||
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
|
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -13,14 +13,6 @@ return [
|
|||||||
'<br> 4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table left of the printer symbol. '.
|
'<br> 4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table left of the printer symbol. '.
|
||||||
'<br><br>Before you import make sure that the Timezone settings in Clockify are the same as in solidtime.',
|
'<br><br>Before you import make sure that the Timezone settings in Clockify are the same as in solidtime.',
|
||||||
],
|
],
|
||||||
'generic_projects' => [
|
|
||||||
'name' => 'Generic Projects',
|
|
||||||
'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>',
|
|
||||||
],
|
|
||||||
'generic_time_entries' => [
|
|
||||||
'name' => 'Generic Time Entries',
|
|
||||||
'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>',
|
|
||||||
],
|
|
||||||
'clockify_projects' => [
|
'clockify_projects' => [
|
||||||
'name' => 'Clockify Projects',
|
'name' => 'Clockify Projects',
|
||||||
'description' => '1. Make sure to set the language of Clockify to English in "Preferences -> General".<br>'.
|
'description' => '1. Make sure to set the language of Clockify to English in "Preferences -> General".<br>'.
|
||||||
@@ -46,22 +38,4 @@ return [
|
|||||||
'name' => 'Solidtime',
|
'name' => 'Solidtime',
|
||||||
'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.',
|
'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.',
|
||||||
],
|
],
|
||||||
'harvest_clients' => [
|
|
||||||
'name' => 'Harvest Clients',
|
|
||||||
'description' => '1. Go to "Manage" (top navigation)<br>2. Click on the "Clients"'.
|
|
||||||
'<br>3. Click on "Import/Export" and in the dropdown "Export clients to CSV" '.
|
|
||||||
'<br>',
|
|
||||||
],
|
|
||||||
'harvest_projects' => [
|
|
||||||
'name' => 'Harvest Projects',
|
|
||||||
'description' => '1. Go to "Projects" (top navigation)<br>2. Click on the "Export" button'.
|
|
||||||
'<br>3. Select which projects you would like to export and select CSV format '.
|
|
||||||
'<br><br>Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.',
|
|
||||||
],
|
|
||||||
'harvest_time_entries' => [
|
|
||||||
'name' => 'Harvest Time Entries',
|
|
||||||
'description' => '1. Go to Settings (right top corner)<br>2. Click on "Import/Export" in the left navigation'.
|
|
||||||
'<br>3. Now click on "Export all time" '.
|
|
||||||
'<br><br>Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.',
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
|
||||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
|
||||||
import {ref} from 'vue';
|
|
||||||
import {api, type Member} from '@/packages/api/src';
|
|
||||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
|
||||||
import {useMutation} from '@tanstack/vue-query';
|
|
||||||
import {getCurrentOrganizationId} from "@/utils/useUser";
|
|
||||||
import {useNotificationsStore} from "@/utils/notification";
|
|
||||||
import {useMembersStore} from "@/utils/useMembers";
|
|
||||||
|
|
||||||
const {handleApiRequestNotifications} = useNotificationsStore();
|
|
||||||
|
|
||||||
const show = defineModel('show', {default: false});
|
|
||||||
const saving = ref(false);
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
member: Member;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const turnToPlaceholderMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const organizationId = getCurrentOrganizationId();
|
|
||||||
if (organizationId === null) {
|
|
||||||
throw new Error('No current organization id - create report');
|
|
||||||
}
|
|
||||||
return await api.makePlaceholder(undefined, {
|
|
||||||
params: {
|
|
||||||
organization: organizationId,
|
|
||||||
member: props.member.id
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
saving.value = true;
|
|
||||||
await handleApiRequestNotifications(
|
|
||||||
() =>
|
|
||||||
turnToPlaceholderMutation.mutateAsync(),
|
|
||||||
'Deactivating the member was successful!',
|
|
||||||
'There was an error deactivating the user.',
|
|
||||||
() => {
|
|
||||||
show.value = false;
|
|
||||||
useMembersStore().fetchMembers()
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
|
||||||
<template #title>
|
|
||||||
<div class="flex space-x-2">
|
|
||||||
<span> Deactivate User </span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #content>
|
|
||||||
<p>
|
|
||||||
Deactivating the user <strong>{{ member.name }} </strong> will remove the user's access to
|
|
||||||
the organization. You will not be billed for inactive users and all time entries will be preserved.
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
<template #footer>
|
|
||||||
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
|
|
||||||
|
|
||||||
<PrimaryButton
|
|
||||||
class="ms-3"
|
|
||||||
:class="{ 'opacity-25': saving }"
|
|
||||||
:disabled="saving"
|
|
||||||
@click="submit()">
|
|
||||||
Deactivate
|
|
||||||
</PrimaryButton>
|
|
||||||
</template>
|
|
||||||
</DialogModal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
|
||||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import {api, type Member} from '@/packages/api/src';
|
|
||||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
|
||||||
import MemberCombobox from "@/Components/Common/Member/MemberCombobox.vue";
|
|
||||||
import {UserIcon, ArrowRightIcon} from "@heroicons/vue/24/solid";
|
|
||||||
import {Badge} from "@/packages/ui/src";
|
|
||||||
import { useMutation } from '@tanstack/vue-query';
|
|
||||||
import {getCurrentOrganizationId} from "@/utils/useUser";
|
|
||||||
import {useNotificationsStore} from "@/utils/notification";
|
|
||||||
const { handleApiRequestNotifications, addNotification } = useNotificationsStore();
|
|
||||||
|
|
||||||
const show = defineModel('show', { default: false });
|
|
||||||
const saving = ref(false);
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
member: Member;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const newMember = ref<string>('');
|
|
||||||
|
|
||||||
const mergeMember = useMutation({
|
|
||||||
mutationFn: async (newMemberId: string) => {
|
|
||||||
const organizationId = getCurrentOrganizationId();
|
|
||||||
if (organizationId === null) {
|
|
||||||
throw new Error('No current organization id - create report');
|
|
||||||
}
|
|
||||||
return await api.mergeMember({
|
|
||||||
member_id: newMemberId,
|
|
||||||
}, {
|
|
||||||
params: {
|
|
||||||
organization: organizationId,
|
|
||||||
member: props.member.id
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
const newMemberId = newMember.value;
|
|
||||||
if(newMemberId !== ''){
|
|
||||||
saving.value = true;
|
|
||||||
await handleApiRequestNotifications(
|
|
||||||
() =>
|
|
||||||
mergeMember.mutateAsync(newMemberId),
|
|
||||||
'Members successfully merged!',
|
|
||||||
'There was an error merging the members.',
|
|
||||||
() => {
|
|
||||||
show.value = false;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
addNotification(
|
|
||||||
'error',
|
|
||||||
'Please select a member to merge into.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DialogModal closeable :show="show" @close="show = false">
|
|
||||||
<template #title>
|
|
||||||
<div class="flex space-x-2">
|
|
||||||
<span> Merge Member </span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #content>
|
|
||||||
<p>Merging the user <strong>{{ member.name }} </strong> into another one will transfer all time entries to the new user. <strong>This cannot be reverted!</strong></p>
|
|
||||||
<div class="py-5 flex flex-col md:flex-row gap-6 items-center">
|
|
||||||
<div class="flex-1">
|
|
||||||
<Badge class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary font-normal cursor py-1.5">
|
|
||||||
<UserIcon class="relative z-10 w-4 text-muted"></UserIcon>
|
|
||||||
<div class="flex-1 font-medium truncate">
|
|
||||||
{{ member.name }}
|
|
||||||
</div>
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<ArrowRightIcon class="relative z-10 w-4 text-muted"></ArrowRightIcon>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<MemberCombobox
|
|
||||||
v-model="newMember"
|
|
||||||
></MemberCombobox>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #footer>
|
|
||||||
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
|
|
||||||
|
|
||||||
<PrimaryButton
|
|
||||||
class="ms-3"
|
|
||||||
:class="{ 'opacity-25': saving }"
|
|
||||||
:disabled="saving"
|
|
||||||
@click="submit()">
|
|
||||||
Merge Member
|
|
||||||
</PrimaryButton>
|
|
||||||
</template>
|
|
||||||
</DialogModal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -1,19 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { TrashIcon, UserCircleIcon, PencilSquareIcon, ArrowDownOnSquareStackIcon } from '@heroicons/vue/20/solid';
|
import { TrashIcon, PencilSquareIcon } from '@heroicons/vue/20/solid';
|
||||||
import type { Member } from '@/packages/api/src';
|
import type { Member } from '@/packages/api/src';
|
||||||
import {canDeleteMembers, canMakeMembersPlaceholders, canMergeMembers, canUpdateMembers} from '@/utils/permissions';
|
import { canDeleteMembers, canUpdateMembers } from '@/utils/permissions';
|
||||||
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
|
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
delete: [];
|
delete: [];
|
||||||
edit: [];
|
edit: [];
|
||||||
merge: [];
|
|
||||||
makePlaceholder: [];
|
|
||||||
}>();
|
}>();
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
member: Member;
|
member: Member;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -39,23 +36,6 @@ const props = defineProps<{
|
|||||||
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
|
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
|
||||||
<span>Delete</span>
|
<span>Delete</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
v-if="props.member.role === 'placeholder' && canMergeMembers()"
|
|
||||||
:aria-label="'Merge Member ' + props.member.name"
|
|
||||||
data-testid="member_merge"
|
|
||||||
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
|
|
||||||
@click="emit('merge')">
|
|
||||||
<ArrowDownOnSquareStackIcon class="w-5 text-icon-active"></ArrowDownOnSquareStackIcon>
|
|
||||||
<span>Merge</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="props.member.role !== 'placeholder' && canMakeMembersPlaceholders()"
|
|
||||||
:aria-label="'Make Member ' + props.member.name + ' a placeholder'"
|
|
||||||
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
|
|
||||||
@click="emit('makePlaceholder')">
|
|
||||||
<UserCircleIcon class="w-5 text-icon-active"></UserCircleIcon>
|
|
||||||
<span>Deactivate</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</MoreOptionsDropdown>
|
</MoreOptionsDropdown>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -10,20 +10,16 @@ import { getCurrentOrganizationId } from '@/utils/useUser';
|
|||||||
import { useNotificationsStore } from '@/utils/notification';
|
import { useNotificationsStore } from '@/utils/notification';
|
||||||
import { canInvitePlaceholderMembers } from '@/utils/permissions';
|
import { canInvitePlaceholderMembers } from '@/utils/permissions';
|
||||||
import { useMembersStore } from '@/utils/useMembers';
|
import { useMembersStore } from '@/utils/useMembers';
|
||||||
import {computed, ref} from 'vue';
|
import { ref } from 'vue';
|
||||||
import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
|
import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import { formatCents } from '@/packages/ui/src/utils/money';
|
import { formatCents } from '@/packages/ui/src/utils/money';
|
||||||
import MemberMergeModal from "@/Components/Common/Member/MemberMergeModal.vue";
|
|
||||||
import MemberMakePlaceholderModal from "@/Components/Common/Member/MemberMakePlaceholderModal.vue";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
member: Member;
|
member: Member;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const showEditMemberModal = ref(false);
|
const showEditMemberModal = ref(false);
|
||||||
const showMergeMemberModal = ref(false);
|
|
||||||
const showMakeMemberPlaceholderModal = ref(false);
|
|
||||||
|
|
||||||
function removeMember() {
|
function removeMember() {
|
||||||
useMembersStore().removeMember(props.member.id);
|
useMembersStore().removeMember(props.member.id);
|
||||||
@@ -49,11 +45,6 @@ async function invitePlaceholder(id: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const userHasValidMailAddress = computed(() => {
|
|
||||||
return !props.member.email.endsWith('@solidtime-import.test');
|
|
||||||
})
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -96,8 +87,7 @@ const userHasValidMailAddress = computed(() => {
|
|||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
v-if="
|
v-if="
|
||||||
member.is_placeholder === true &&
|
member.is_placeholder === true &&
|
||||||
canInvitePlaceholderMembers() &&
|
canInvitePlaceholderMembers()
|
||||||
userHasValidMailAddress
|
|
||||||
"
|
"
|
||||||
size="small"
|
size="small"
|
||||||
@click="invitePlaceholder(member.id)"
|
@click="invitePlaceholder(member.id)"
|
||||||
@@ -106,16 +96,11 @@ const userHasValidMailAddress = computed(() => {
|
|||||||
<MemberMoreOptionsDropdown
|
<MemberMoreOptionsDropdown
|
||||||
:member="member"
|
:member="member"
|
||||||
@edit="showEditMemberModal = true"
|
@edit="showEditMemberModal = true"
|
||||||
@delete="removeMember"
|
@delete="removeMember"></MemberMoreOptionsDropdown>
|
||||||
@merge="showMergeMemberModal = true"
|
|
||||||
@make-placeholder="showMakeMemberPlaceholderModal = true"
|
|
||||||
></MemberMoreOptionsDropdown>
|
|
||||||
</div>
|
</div>
|
||||||
<MemberEditModal
|
<MemberEditModal
|
||||||
v-model:show="showEditMemberModal"
|
v-model:show="showEditMemberModal"
|
||||||
:member="member"></MemberEditModal>
|
:member="member"></MemberEditModal>
|
||||||
<MemberMergeModal v-model:show="showMergeMemberModal" :member="member"></MemberMergeModal>
|
|
||||||
<MemberMakePlaceholderModal v-model:show="showMakeMemberPlaceholderModal" :member="member"></MemberMakePlaceholderModal>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type AggregatedGroupedData = GroupedData & {
|
|||||||
|
|
||||||
type GroupedData = {
|
type GroupedData = {
|
||||||
seconds: number;
|
seconds: number;
|
||||||
cost: number | null;
|
cost: number;
|
||||||
description: string | null | undefined;
|
description: string | null | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ const expanded = ref(false);
|
|||||||
{{ formatHumanReadableDuration(entry.seconds) }}
|
{{ formatHumanReadableDuration(entry.seconds) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="justify-end pr-6 flex items-center">
|
<div class="justify-end pr-6 flex items-center">
|
||||||
{{entry.cost ? formatCents(entry.cost, getOrganizationCurrencyString()) : '--' }}
|
{{ formatCents(entry.cost, getOrganizationCurrencyString()) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -2,13 +2,9 @@
|
|||||||
import { router } from '@inertiajs/vue3';
|
import { router } from '@inertiajs/vue3';
|
||||||
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
|
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
|
||||||
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
|
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
|
||||||
import {canViewReport} from "@/utils/permissions";
|
|
||||||
import {computed} from "vue";
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
active: 'reporting' | 'detailed' | 'shared';
|
active: 'reporting' | 'detailed' | 'shared';
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const showSharedReports = computed(() => canViewReport());
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -24,7 +20,6 @@ const showSharedReports = computed(() => canViewReport());
|
|||||||
>Detailed</TabBarItem
|
>Detailed</TabBarItem
|
||||||
>
|
>
|
||||||
<TabBarItem
|
<TabBarItem
|
||||||
v-if="showSharedReports"
|
|
||||||
:active="active === 'shared'"
|
:active="active === 'shared'"
|
||||||
@click="router.visit(route('reporting.shared'))"
|
@click="router.visit(route('reporting.shared'))"
|
||||||
>Shared</TabBarItem
|
>Shared</TabBarItem
|
||||||
|
|||||||
@@ -1,45 +1,29 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import VChart, { THEME_KEY } from "vue-echarts";
|
import VChart, { THEME_KEY } from 'vue-echarts';
|
||||||
import { provide, computed } from "vue";
|
import { provide, ref } from 'vue';
|
||||||
import { use } from "echarts/core";
|
import { use } from 'echarts/core';
|
||||||
import DashboardCard from "@/Components/Dashboard/DashboardCard.vue";
|
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
|
||||||
import { BoltIcon } from "@heroicons/vue/20/solid";
|
import { BoltIcon } from '@heroicons/vue/20/solid';
|
||||||
import { HeatmapChart } from "echarts/charts";
|
import { HeatmapChart } from 'echarts/charts';
|
||||||
import {
|
import {
|
||||||
CalendarComponent,
|
CalendarComponent,
|
||||||
TitleComponent,
|
TitleComponent,
|
||||||
TooltipComponent,
|
TooltipComponent,
|
||||||
VisualMapComponent
|
VisualMapComponent,
|
||||||
} from "echarts/components";
|
} from 'echarts/components';
|
||||||
import { CanvasRenderer } from "echarts/renderers";
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
import dayjs from "dayjs";
|
import dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
firstDayIndex,
|
firstDayIndex,
|
||||||
formatDate,
|
formatDate,
|
||||||
formatHumanReadableDuration,
|
formatHumanReadableDuration,
|
||||||
getDayJsInstance
|
getDayJsInstance,
|
||||||
} from "@/packages/ui/src/utils/time";
|
} from '@/packages/ui/src/utils/time';
|
||||||
import { useCssVar } from "@vueuse/core";
|
import { useCssVar } from '@vueuse/core';
|
||||||
import { useQuery } from "@tanstack/vue-query";
|
|
||||||
import { getCurrentOrganizationId } from "@/utils/useUser";
|
|
||||||
import { api } from "@/packages/api/src";
|
|
||||||
import { LoadingSpinner } from "@/packages/ui/src";
|
|
||||||
|
|
||||||
// Get the organization ID using the utility function
|
const props = defineProps<{
|
||||||
const organizationId = computed(() => getCurrentOrganizationId());
|
dailyHoursTracked: { duration: number; date: string }[];
|
||||||
|
}>();
|
||||||
|
|
||||||
const { data: dailyHoursTracked, isLoading } = useQuery({
|
|
||||||
queryKey: ["dailyTrackedHours", organizationId],
|
|
||||||
queryFn: () => {
|
|
||||||
return api.dailyTrackedHours({
|
|
||||||
params: {
|
|
||||||
organization: organizationId.value!
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
enabled: computed(() => !!organizationId.value)
|
|
||||||
});
|
|
||||||
|
|
||||||
use([
|
use([
|
||||||
TitleComponent,
|
TitleComponent,
|
||||||
@@ -47,113 +31,89 @@ use([
|
|||||||
VisualMapComponent,
|
VisualMapComponent,
|
||||||
CalendarComponent,
|
CalendarComponent,
|
||||||
HeatmapChart,
|
HeatmapChart,
|
||||||
CanvasRenderer
|
CanvasRenderer,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
provide(THEME_KEY, "dark");
|
provide(THEME_KEY, 'dark');
|
||||||
|
|
||||||
const max = computed(() => {
|
const max = Math.max(
|
||||||
if (!isLoading.value && dailyHoursTracked.value) {
|
Math.max(...props.dailyHoursTracked.map((el) => el.duration)),
|
||||||
return Math.max(
|
1
|
||||||
Math.max(...dailyHoursTracked.value.map((el) => el.duration)),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const backgroundColor = useCssVar("--color-bg-secondary");
|
const backgroundColor = useCssVar('--color-bg-secondary');
|
||||||
const itemBackgroundColor = useCssVar("--color-bg-tertiary");
|
const itemBackgroundColor = useCssVar('--color-bg-tertiary');
|
||||||
const option = computed(() => {
|
const option = ref({
|
||||||
return {
|
tooltip: {},
|
||||||
tooltip: {},
|
visualMap: {
|
||||||
visualMap: {
|
min: 0,
|
||||||
min: 0,
|
max: max,
|
||||||
max: max.value,
|
type: 'piecewise',
|
||||||
type: "piecewise",
|
orient: 'horizontal',
|
||||||
orient: "horizontal",
|
left: 'center',
|
||||||
left: "center",
|
top: 'center',
|
||||||
top: "center",
|
inRange: {
|
||||||
inRange: {
|
color: [itemBackgroundColor.value, '#2DBE45'],
|
||||||
color: [itemBackgroundColor.value, "#2DBE45"]
|
},
|
||||||
},
|
show: false,
|
||||||
show: false
|
},
|
||||||
|
calendar: {
|
||||||
|
top: 40,
|
||||||
|
bottom: 20,
|
||||||
|
left: 40,
|
||||||
|
right: 10,
|
||||||
|
cellSize: [40, 40],
|
||||||
|
dayLabel: {
|
||||||
|
firstDay: firstDayIndex.value,
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
range: [
|
||||||
|
dayjs().format('YYYY-MM-DD'),
|
||||||
|
getDayJsInstance()()
|
||||||
|
.subtract(50, 'day')
|
||||||
|
.startOf('week')
|
||||||
|
.format('YYYY-MM-DD'),
|
||||||
|
],
|
||||||
|
itemStyle: {
|
||||||
|
color: 'transparent',
|
||||||
|
borderWidth: 8,
|
||||||
|
borderColor: backgroundColor.value,
|
||||||
|
},
|
||||||
|
yearLabel: { show: false },
|
||||||
|
},
|
||||||
|
series: {
|
||||||
|
type: 'heatmap',
|
||||||
|
coordinateSystem: 'calendar',
|
||||||
|
data: props.dailyHoursTracked.map((el) => [el.date, el.duration]),
|
||||||
|
itemStyle: {
|
||||||
|
borderRadius: 5,
|
||||||
|
borderColor: 'rgba(255,255,255,0.05)',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
valueFormatter: (value: number, dataIndex: number) => {
|
||||||
|
return (
|
||||||
|
formatDate(props.dailyHoursTracked[dataIndex].date) +
|
||||||
|
': ' +
|
||||||
|
formatHumanReadableDuration(value)
|
||||||
|
);
|
||||||
},
|
},
|
||||||
calendar: {
|
},
|
||||||
top: 40,
|
},
|
||||||
bottom: 20,
|
backgroundColor: 'transparent',
|
||||||
left: 40,
|
});
|
||||||
right: 10,
|
|
||||||
cellSize: [40, 40],
|
|
||||||
dayLabel: {
|
|
||||||
firstDay: firstDayIndex.value
|
|
||||||
},
|
|
||||||
splitLine: {
|
|
||||||
show: false
|
|
||||||
},
|
|
||||||
range: [
|
|
||||||
dayjs().format("YYYY-MM-DD"),
|
|
||||||
getDayJsInstance()()
|
|
||||||
.subtract(50, "day")
|
|
||||||
.startOf("week")
|
|
||||||
.format("YYYY-MM-DD")
|
|
||||||
],
|
|
||||||
itemStyle: {
|
|
||||||
color: "transparent",
|
|
||||||
borderWidth: 8,
|
|
||||||
borderColor: backgroundColor.value
|
|
||||||
},
|
|
||||||
yearLabel: { show: false }
|
|
||||||
},
|
|
||||||
series: {
|
|
||||||
type: "heatmap",
|
|
||||||
coordinateSystem: "calendar",
|
|
||||||
data: dailyHoursTracked?.value?.map((el) => [el.date, el.duration]) ?? [],
|
|
||||||
itemStyle: {
|
|
||||||
borderRadius: 5,
|
|
||||||
borderColor: "rgba(255,255,255,0.05)",
|
|
||||||
borderWidth: 1
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
valueFormatter: (value: number, dataIndex: number) => {
|
|
||||||
if(dailyHoursTracked?.value){
|
|
||||||
return (
|
|
||||||
formatDate(dailyHoursTracked?.value[dataIndex].date) +
|
|
||||||
": " +
|
|
||||||
formatHumanReadableDuration(value)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
backgroundColor: "transparent"
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DashboardCard title="Activity Graph" :icon="BoltIcon">
|
<DashboardCard title="Activity Graph" :icon="BoltIcon">
|
||||||
<div class="px-2">
|
<div class="px-2">
|
||||||
<div v-if="isLoading" class="flex justify-center items-center h-40">
|
<v-chart
|
||||||
<LoadingSpinner />
|
class="chart"
|
||||||
</div>
|
:autoresize="true"
|
||||||
<div v-else-if="dailyHoursTracked">
|
:option="option"
|
||||||
<v-chart
|
style="height: 260px; background-color: transparent" />
|
||||||
class="chart"
|
|
||||||
:autoresize="true"
|
|
||||||
:option="option"
|
|
||||||
style="height: 260px; background-color: transparent" />
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-center text-gray-500 py-8">
|
|
||||||
No activity data available
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</DashboardCard>
|
</DashboardCard>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,53 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useQuery } from "@tanstack/vue-query";
|
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
|
||||||
import { computed } from "vue";
|
import DayOverviewCardEntry from '@/Components/Dashboard/DayOverviewCardEntry.vue';
|
||||||
import DashboardCard from "@/Components/Dashboard/DashboardCard.vue";
|
import { CalendarIcon } from '@heroicons/vue/20/solid';
|
||||||
import DayOverviewCardEntry from "@/Components/Dashboard/DayOverviewCardEntry.vue";
|
defineProps<{
|
||||||
import { CalendarIcon } from "@heroicons/vue/20/solid";
|
last7Days: {
|
||||||
import { getCurrentOrganizationId } from "@/utils/useUser";
|
date: string;
|
||||||
import { api } from "@/packages/api/src";
|
duration: number; // Total duration in seconds
|
||||||
import { LoadingSpinner } from "@/packages/ui/src";
|
history: number[]; // Array representing the duration in seconds of the 3h windows for the day
|
||||||
|
}[];
|
||||||
// Get the organization ID using the utility function
|
}>();
|
||||||
const organizationId = computed(() => getCurrentOrganizationId());
|
|
||||||
|
|
||||||
|
|
||||||
// Set up the query
|
|
||||||
const { data: last7Days, isLoading } = useQuery({
|
|
||||||
queryKey: ["lastSevenDays", organizationId],
|
|
||||||
queryFn: () => {
|
|
||||||
return api.lastSevenDays({
|
|
||||||
params: {
|
|
||||||
organization: organizationId.value!
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
enabled: computed(() => !!organizationId.value),
|
|
||||||
placeholderData: Array.from({ length: 7 }, (_, i) => ({
|
|
||||||
date: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString().split("T")[0],
|
|
||||||
duration: 0,
|
|
||||||
history: Array(8).fill(0)
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DashboardCard title="Last 7 Days" :icon="CalendarIcon">
|
<DashboardCard title="Last 7 Days" :icon="CalendarIcon">
|
||||||
<div v-if="isLoading" class="flex justify-center items-center h-40">
|
<DayOverviewCardEntry
|
||||||
<LoadingSpinner />
|
v-for="day in last7Days"
|
||||||
</div>
|
:key="day.date"
|
||||||
<div v-else-if="last7Days">
|
:class="last7Days.length === 7 ? 'last:border-0 first:pt-3' : ''"
|
||||||
<DayOverviewCardEntry
|
:date="day.date"
|
||||||
v-for="day in last7Days"
|
:history="day.history"
|
||||||
:key="day.date"
|
:duration="day.duration"></DayOverviewCardEntry>
|
||||||
:class="last7Days.length === 7 ? 'last:border-0 first:pt-3' : ''"
|
|
||||||
:date="day.date"
|
|
||||||
:history="day.history"
|
|
||||||
:duration="day.duration"></DayOverviewCardEntry>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-center text-gray-500 py-8">
|
|
||||||
No data available
|
|
||||||
</div>
|
|
||||||
</DashboardCard>
|
</DashboardCard>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,83 +1,32 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useQuery } from "@tanstack/vue-query";
|
import RecentlyTrackedTasksCardEntry from '@/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue';
|
||||||
import { computed } from "vue";
|
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
|
||||||
import RecentlyTrackedTasksCardEntry from "@/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue";
|
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
|
||||||
import DashboardCard from "@/Components/Dashboard/DashboardCard.vue";
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
import { CheckCircleIcon } from "@heroicons/vue/20/solid";
|
import { PlusCircleIcon } from '@heroicons/vue/24/solid';
|
||||||
import SecondaryButton from "@/packages/ui/src/Buttons/SecondaryButton.vue";
|
import { router } from '@inertiajs/vue3';
|
||||||
import { PlusCircleIcon } from "@heroicons/vue/24/solid";
|
|
||||||
import { router } from "@inertiajs/vue3";
|
|
||||||
import { getCurrentMembershipId, getCurrentOrganizationId } from "@/utils/useUser";
|
|
||||||
import { api } from "@/packages/api/src";
|
|
||||||
import { LoadingSpinner } from "@/packages/ui/src";
|
|
||||||
|
|
||||||
// Get the organization ID using the utility function
|
const props = defineProps<{
|
||||||
const organizationId = computed(() => getCurrentOrganizationId());
|
latestTasks: {
|
||||||
|
id: string;
|
||||||
// Function to fetch latest tasks using the API client
|
name: string;
|
||||||
|
project_name: string;
|
||||||
// Set up the query
|
project_id: string;
|
||||||
const { data: timeEntriesResponse, isLoading, refetch } = useQuery({
|
}[];
|
||||||
queryKey: ["timeEntries", organizationId],
|
}>();
|
||||||
queryFn: () => {
|
|
||||||
return api.getTimeEntries({
|
|
||||||
params: {
|
|
||||||
organization: organizationId.value!
|
|
||||||
},
|
|
||||||
queries: {
|
|
||||||
member_id: getCurrentMembershipId()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
enabled: computed(() => !!organizationId.value)
|
|
||||||
});
|
|
||||||
|
|
||||||
const latestTasks = computed(() => {
|
|
||||||
if (!timeEntriesResponse.value) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return timeEntriesResponse.value.data;
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredLatestTasks = computed(() => {
|
|
||||||
// do not include running time entries
|
|
||||||
const finishedTimeEntries = latestTasks.value.filter((item) => item.end !== null);
|
|
||||||
|
|
||||||
// filter out duplicates based on description, task, project, tags and billable
|
|
||||||
return finishedTimeEntries.filter((item, index, self) => {
|
|
||||||
return index === self.findIndex((t) => (
|
|
||||||
t.description === item.description &&
|
|
||||||
t.task_id === item.task_id &&
|
|
||||||
t.project_id === item.project_id &&
|
|
||||||
t.tags.length === item.tags.length &&
|
|
||||||
t.tags.every((tag) => item.tags.includes(tag)) &&
|
|
||||||
t.billable === item.billable
|
|
||||||
));
|
|
||||||
}).slice(0, 4);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// Listen for dashboard refresh events
|
|
||||||
window.addEventListener("dashboard:refresh", () => {
|
|
||||||
refetch();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DashboardCard title="Recent Time Entries" :icon="CheckCircleIcon">
|
<DashboardCard title="Recently Tracked Tasks" :icon="CheckCircleIcon">
|
||||||
<div v-if="isLoading" class="flex justify-center items-center h-40">
|
<RecentlyTrackedTasksCardEntry
|
||||||
<LoadingSpinner />
|
v-for="lastTask in props.latestTasks"
|
||||||
</div>
|
:key="lastTask.id"
|
||||||
<div v-else-if="filteredLatestTasks && filteredLatestTasks.length > 0">
|
:class="props.latestTasks.length === 4 ? 'last:border-0' : ''"
|
||||||
<RecentlyTrackedTasksCardEntry
|
:project_id="lastTask.project_id"
|
||||||
v-for="lastTask in filteredLatestTasks"
|
:task_id="lastTask.id"
|
||||||
:key="lastTask.id"
|
:title="lastTask.name"></RecentlyTrackedTasksCardEntry>
|
||||||
:time-entry="lastTask"
|
|
||||||
:class="filteredLatestTasks.length === 4 ? 'last:border-0' : ''"></RecentlyTrackedTasksCardEntry>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
v-else
|
v-if="props.latestTasks.length === 0"
|
||||||
class="text-center flex flex-1 justify-center items-center">
|
class="text-center flex flex-1 justify-center items-center">
|
||||||
<div>
|
<div>
|
||||||
<PlusCircleIcon
|
<PlusCircleIcon
|
||||||
@@ -87,12 +36,12 @@ window.addEventListener("dashboard:refresh", () => {
|
|||||||
</h3>
|
</h3>
|
||||||
<p class="pb-5 text-sm">Create tasks inside of a project!</p>
|
<p class="pb-5 text-sm">Create tasks inside of a project!</p>
|
||||||
<SecondaryButton @click="router.visit(route('projects'))"
|
<SecondaryButton @click="router.visit(route('projects'))"
|
||||||
>Go to Projects
|
>Go to Projects
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="latestTasks && latestTasks.length === 1"
|
v-if="props.latestTasks.length === 1"
|
||||||
class="text-center flex flex-1 justify-center items-center text-sm">
|
class="text-center flex flex-1 justify-center items-center text-sm">
|
||||||
<div>
|
<div>
|
||||||
<PlusCircleIcon
|
<PlusCircleIcon
|
||||||
@@ -100,7 +49,7 @@ window.addEventListener("dashboard:refresh", () => {
|
|||||||
<h3 class="text-white font-semibold">Add more tasks</h3>
|
<h3 class="text-white font-semibold">Add more tasks</h3>
|
||||||
<p class="pb-5">Create tasks inside of a project!</p>
|
<p class="pb-5">Create tasks inside of a project!</p>
|
||||||
<SecondaryButton @click="router.visit(route('projects'))"
|
<SecondaryButton @click="router.visit(route('projects'))"
|
||||||
>Go to Projects
|
>Go to Projects
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,16 +6,17 @@ import { storeToRefs } from 'pinia';
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||||
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||||
import type { TimeEntry } from "@/packages/api/src";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
timeEntry: TimeEntry
|
title: string;
|
||||||
|
project_id: string;
|
||||||
|
task_id: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { projects } = storeToRefs(useProjectsStore());
|
const { projects } = storeToRefs(useProjectsStore());
|
||||||
|
|
||||||
const project = computed(() => {
|
const project = computed(() => {
|
||||||
return projects.value.find((project) => project.id === props.timeEntry.project_id);
|
return projects.value.find((project) => project.id === props.project_id);
|
||||||
});
|
});
|
||||||
|
|
||||||
const { currentTimeEntry } = storeToRefs(useCurrentTimeEntryStore());
|
const { currentTimeEntry } = storeToRefs(useCurrentTimeEntryStore());
|
||||||
@@ -25,28 +26,23 @@ async function startTaskTimer() {
|
|||||||
if (currentTimeEntry.value.id) {
|
if (currentTimeEntry.value.id) {
|
||||||
await setActiveState(false);
|
await setActiveState(false);
|
||||||
}
|
}
|
||||||
currentTimeEntry.value.description = props.timeEntry.description;
|
currentTimeEntry.value.project_id = props.project_id;
|
||||||
currentTimeEntry.value.project_id = props.timeEntry.project_id;
|
currentTimeEntry.value.task_id = props.task_id;
|
||||||
currentTimeEntry.value.task_id = props.timeEntry.task_id;
|
|
||||||
currentTimeEntry.value.tags = props.timeEntry.tags;
|
|
||||||
currentTimeEntry.value.billable = props.timeEntry.billable;
|
|
||||||
currentTimeEntry.value.start = getDayJsInstance().utc().format();
|
currentTimeEntry.value.start = getDayJsInstance().utc().format();
|
||||||
await setActiveState(true);
|
await setActiveState(true);
|
||||||
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
|
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="px-3.5 py-2 grid grid-cols-5 border-b border-b-card-background-separator">
|
class="px-3.5 py-2 grid grid-cols-5 border-b border-b-card-background-separator">
|
||||||
<div class="col-span-4">
|
<div class="col-span-4">
|
||||||
<p class="font-medium text-white text-sm pb-1 truncate">
|
<p class="font-semibold text-white text-sm pb-1 overflow-ellipsis">
|
||||||
<span v-if="timeEntry.description"> {{ timeEntry.description }}</span>
|
{{ title }}
|
||||||
<span v-else class="text-text-tertiary">No description</span>
|
|
||||||
</p>
|
</p>
|
||||||
<ProjectBadge
|
<ProjectBadge
|
||||||
:name="project?.name ?? 'No Project'"
|
:name="project?.name"
|
||||||
:color="project?.color"></ProjectBadge>
|
:color="project?.color"></ProjectBadge>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
|
|||||||
@@ -1,52 +1,33 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useQuery } from '@tanstack/vue-query';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
|
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
|
||||||
import TeamActivityCardEntry from '@/Components/Dashboard/TeamActivityCardEntry.vue';
|
import TeamActivityCardEntry from '@/Components/Dashboard/TeamActivityCardEntry.vue';
|
||||||
import { UserGroupIcon } from '@heroicons/vue/20/solid';
|
import { UserGroupIcon } from '@heroicons/vue/20/solid';
|
||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
|
||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
|
||||||
import { api } from '@/packages/api/src';
|
|
||||||
import { LoadingSpinner } from "@/packages/ui/src";
|
|
||||||
import { router } from '@inertiajs/vue3';
|
import { router } from '@inertiajs/vue3';
|
||||||
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
|
|
||||||
// Get the organization ID using the utility function
|
defineProps<{
|
||||||
const organizationId = computed(() => getCurrentOrganizationId());
|
latestTeamActivity: {
|
||||||
|
user_id: string;
|
||||||
// Set up the query
|
name: string;
|
||||||
const { data: latestTeamActivity, isLoading } = useQuery({
|
description: string;
|
||||||
queryKey: ['latestTeamActivity', organizationId],
|
time_entry_id: string;
|
||||||
queryFn: () => {
|
task_id: string;
|
||||||
return api.latestTeamActivity({
|
status: boolean;
|
||||||
params: {
|
}[];
|
||||||
organization: organizationId.value!
|
}>();
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
enabled: computed(() => !!organizationId.value),
|
|
||||||
});
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DashboardCard title="Team Activity" :icon="UserGroupIcon">
|
<DashboardCard title="Team Activity" :icon="UserGroupIcon">
|
||||||
<div v-if="isLoading" class="flex justify-center items-center h-40">
|
<TeamActivityCardEntry
|
||||||
<LoadingSpinner />
|
v-for="activity in latestTeamActivity"
|
||||||
</div>
|
:key="activity.user_id"
|
||||||
<div v-else-if="latestTeamActivity">
|
:class="latestTeamActivity.length === 4 ? 'last:border-0' : ''"
|
||||||
<TeamActivityCardEntry
|
:name="activity.name"
|
||||||
v-for="activity in latestTeamActivity"
|
:description="activity.description"
|
||||||
:key="activity.time_entry_id"
|
:working="activity.status"></TeamActivityCardEntry>
|
||||||
:class="latestTeamActivity.length === 4 ? 'last:border-0' : ''"
|
|
||||||
:name="activity.name"
|
|
||||||
:description="activity.description"
|
|
||||||
:working="activity.status"></TeamActivityCardEntry>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-center text-gray-500 py-8">
|
|
||||||
No team activity found
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
v-if="latestTeamActivity && latestTeamActivity.length <= 1"
|
v-if="latestTeamActivity.length <= 1"
|
||||||
class="text-center flex flex-1 justify-center items-center">
|
class="text-center flex flex-1 justify-center items-center">
|
||||||
<div>
|
<div>
|
||||||
<UserGroupIcon
|
<UserGroupIcon
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
defineProps<{
|
defineProps<{
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string;
|
||||||
working?: boolean;
|
working?: boolean;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,28 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { use } from "echarts/core";
|
import { use } from 'echarts/core';
|
||||||
import { CanvasRenderer } from "echarts/renderers";
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
import { BarChart } from "echarts/charts";
|
import { BarChart } from 'echarts/charts';
|
||||||
import {
|
import {
|
||||||
GridComponent,
|
GridComponent,
|
||||||
LegendComponent,
|
LegendComponent,
|
||||||
TitleComponent,
|
TitleComponent,
|
||||||
TooltipComponent
|
TooltipComponent,
|
||||||
} from "echarts/components";
|
} from 'echarts/components';
|
||||||
import VChart, { THEME_KEY } from "vue-echarts";
|
import VChart, { THEME_KEY } from 'vue-echarts';
|
||||||
import { computed, provide } from "vue";
|
import { computed, provide, ref } from 'vue';
|
||||||
import StatCard from "@/Components/Common/StatCard.vue";
|
import StatCard from '@/Components/Common/StatCard.vue';
|
||||||
import { ClockIcon } from "@heroicons/vue/20/solid";
|
import { ClockIcon } from '@heroicons/vue/20/solid';
|
||||||
import CardTitle from "@/packages/ui/src/CardTitle.vue";
|
import CardTitle from '@/packages/ui/src/CardTitle.vue';
|
||||||
import LinearGradient from "zrender/lib/graphic/LinearGradient";
|
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
|
||||||
import ProjectsChartCard from "@/Components/Dashboard/ProjectsChartCard.vue";
|
import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue';
|
||||||
import { formatHumanReadableDuration } from "@/packages/ui/src/utils/time";
|
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
|
||||||
import { formatCents } from "@/packages/ui/src/utils/money";
|
import { formatCents } from '@/packages/ui/src/utils/money';
|
||||||
import { getWeekStart } from "@/packages/ui/src/utils/settings";
|
import { getWeekStart } from '@/packages/ui/src/utils/settings';
|
||||||
import { useCssVar } from "@vueuse/core";
|
import { useCssVar } from '@vueuse/core';
|
||||||
import { getOrganizationCurrencyString } from "@/utils/money";
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import { useQuery } from "@tanstack/vue-query";
|
|
||||||
import { getCurrentOrganizationId } from "@/utils/useUser";
|
|
||||||
import { api } from "@/packages/api/src";
|
|
||||||
|
|
||||||
use([
|
use([
|
||||||
CanvasRenderer,
|
CanvasRenderer,
|
||||||
@@ -30,22 +27,85 @@ use([
|
|||||||
TitleComponent,
|
TitleComponent,
|
||||||
GridComponent,
|
GridComponent,
|
||||||
TooltipComponent,
|
TooltipComponent,
|
||||||
LegendComponent
|
LegendComponent,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
provide(THEME_KEY, "dark");
|
provide(THEME_KEY, 'dark');
|
||||||
const accentColor = useCssVar("--color-accent-quaternary");
|
|
||||||
|
const props = defineProps<{
|
||||||
|
weeklyProjectOverview: {
|
||||||
|
value: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}[];
|
||||||
|
totalWeeklyTime: number;
|
||||||
|
totalWeeklyBillableTime: number;
|
||||||
|
totalWeeklyBillableAmount: {
|
||||||
|
value: number;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
weeklyHistory: {
|
||||||
|
date: string;
|
||||||
|
duration: number;
|
||||||
|
}[];
|
||||||
|
}>();
|
||||||
|
const accentColor = useCssVar('--color-accent-quaternary');
|
||||||
|
|
||||||
|
const seriesData = computed(() => {
|
||||||
|
return props.weeklyHistory.map((el) => {
|
||||||
|
return {
|
||||||
|
value: el.duration,
|
||||||
|
...{
|
||||||
|
itemStyle: {
|
||||||
|
borderColor: new LinearGradient(0, 0, 0, 1, [
|
||||||
|
{
|
||||||
|
offset: 0,
|
||||||
|
color: 'rgba(' + accentColor.value + ',0.7)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: 1,
|
||||||
|
color: 'rgba(' + accentColor.value + ',0.5)',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
emphasis: {
|
||||||
|
color: new LinearGradient(0, 0, 0, 1, [
|
||||||
|
{
|
||||||
|
offset: 0,
|
||||||
|
color: 'rgba(' + accentColor.value + ',0.9)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: 1,
|
||||||
|
color: 'rgba(' + accentColor.value + ',0.7)',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
borderRadius: [12, 12, 0, 0],
|
||||||
|
color: new LinearGradient(0, 0, 0, 1, [
|
||||||
|
{
|
||||||
|
offset: 0,
|
||||||
|
color: 'rgba(' + accentColor.value + ',0.7)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: 1,
|
||||||
|
color: 'rgba(' + accentColor.value + ',0.5)',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const weekdays = computed(() => {
|
const weekdays = computed(() => {
|
||||||
const daysOrder = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
const daysOrder = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||||
const dayMapping: Record<string, string> = {
|
const dayMapping: Record<string, string> = {
|
||||||
monday: "Mon",
|
monday: 'Mon',
|
||||||
tuesday: "Tue",
|
tuesday: 'Tue',
|
||||||
wednesday: "Wed",
|
wednesday: 'Wed',
|
||||||
thursday: "Thu",
|
thursday: 'Thu',
|
||||||
friday: "Fri",
|
friday: 'Fri',
|
||||||
saturday: "Sat",
|
saturday: 'Sat',
|
||||||
sunday: "Sun"
|
sunday: 'Sun',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (dayMapping[getWeekStart()]) {
|
if (dayMapping[getWeekStart()]) {
|
||||||
@@ -62,179 +122,59 @@ const weekdays = computed(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const markLineColor = useCssVar("--color-border-secondary");
|
const markLineColor = useCssVar('--color-border-secondary');
|
||||||
|
|
||||||
|
const option = ref({
|
||||||
// Get the organization ID using the utility function
|
tooltip: {
|
||||||
const organizationId = computed(() => getCurrentOrganizationId());
|
trigger: 'item',
|
||||||
|
|
||||||
|
|
||||||
// Set up the queries
|
|
||||||
const { data: weeklyProjectOverview } = useQuery({
|
|
||||||
queryKey: ["weeklyProjectOverview", organizationId],
|
|
||||||
queryFn: () => {
|
|
||||||
return api.weeklyProjectOverview({
|
|
||||||
params: {
|
|
||||||
organization: organizationId.value!
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
enabled: computed(() => !!organizationId.value)
|
grid: {
|
||||||
});
|
top: 0,
|
||||||
|
right: 0,
|
||||||
const { data: totalWeeklyTime } = useQuery({
|
bottom: 50,
|
||||||
queryKey: ["totalWeeklyTime", organizationId],
|
left: 0,
|
||||||
queryFn: () => {
|
|
||||||
return api.totalWeeklyTime({
|
|
||||||
params: {
|
|
||||||
organization: organizationId.value!
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
enabled: computed(() => !!organizationId.value)
|
backgroundColor: 'transparent',
|
||||||
});
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
const { data: totalWeeklyBillableTime } = useQuery({
|
data: weekdays.value,
|
||||||
queryKey: ["totalWeeklyBillableTime", organizationId],
|
axisLine: {
|
||||||
queryFn: () => {
|
|
||||||
return api.totalWeeklyBillableTime({
|
|
||||||
params: {
|
|
||||||
organization: organizationId.value!
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
enabled: computed(() => !!organizationId.value)
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: totalWeeklyBillableAmount } = useQuery({
|
|
||||||
queryKey: ["totalWeeklyBillableAmount", organizationId],
|
|
||||||
queryFn: () => {
|
|
||||||
return api.totalWeeklyBillableAmount({
|
|
||||||
params: {
|
|
||||||
organization: organizationId.value!
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
enabled: computed(() => !!organizationId.value)
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: weeklyHistory } = useQuery({
|
|
||||||
queryKey: ["weeklyHistory", organizationId],
|
|
||||||
queryFn: () => {
|
|
||||||
return api.weeklyHistory({
|
|
||||||
params: {
|
|
||||||
organization: organizationId.value!
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
enabled: computed(() => !!organizationId.value)
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const seriesData = computed(() => {
|
|
||||||
if (!weeklyHistory.value) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return weeklyHistory.value?.map((el) => {
|
|
||||||
return {
|
|
||||||
value: el.duration,
|
|
||||||
...{
|
|
||||||
itemStyle: {
|
|
||||||
borderColor: new LinearGradient(0, 0, 0, 1, [
|
|
||||||
{
|
|
||||||
offset: 0,
|
|
||||||
color: "rgba(" + accentColor.value + ",0.7)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
offset: 1,
|
|
||||||
color: "rgba(" + accentColor.value + ",0.5)"
|
|
||||||
}
|
|
||||||
]),
|
|
||||||
emphasis: {
|
|
||||||
color: new LinearGradient(0, 0, 0, 1, [
|
|
||||||
{
|
|
||||||
offset: 0,
|
|
||||||
color: "rgba(" + accentColor.value + ",0.9)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
offset: 1,
|
|
||||||
color: "rgba(" + accentColor.value + ",0.7)"
|
|
||||||
}
|
|
||||||
])
|
|
||||||
},
|
|
||||||
borderRadius: [12, 12, 0, 0],
|
|
||||||
color: new LinearGradient(0, 0, 0, 1, [
|
|
||||||
{
|
|
||||||
offset: 0,
|
|
||||||
color: "rgba(" + accentColor.value + ",0.7)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
offset: 1,
|
|
||||||
color: "rgba(" + accentColor.value + ",0.5)"
|
|
||||||
}
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const option = computed(() => {
|
|
||||||
return {
|
|
||||||
tooltip: {
|
|
||||||
trigger: "item"
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 50,
|
|
||||||
left: 0
|
|
||||||
},
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
xAxis: {
|
|
||||||
type: "category",
|
|
||||||
data: weekdays.value,
|
|
||||||
axisLine: {
|
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
color: "transparent" // Set desired color here
|
color: 'transparent', // Set desired color here
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
margin: 24,
|
margin: 24,
|
||||||
fontFamily: "Outfit, sans-serif"
|
fontFamily: 'Outfit, sans-serif',
|
||||||
},
|
},
|
||||||
axisTick: {
|
axisTick: {
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
color: "transparent" // Set desired color here
|
color: 'transparent', // Set desired color here
|
||||||
}
|
},
|
||||||
}
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
type: "value",
|
|
||||||
splitLine: {
|
|
||||||
lineStyle: {
|
|
||||||
color: markLineColor.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
series: [
|
},
|
||||||
{
|
yAxis: {
|
||||||
data: seriesData.value,
|
type: 'value',
|
||||||
type: "bar",
|
splitLine: {
|
||||||
tooltip: {
|
lineStyle: {
|
||||||
valueFormatter: (value: number) => {
|
color: markLineColor.value,
|
||||||
return formatHumanReadableDuration(value);
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
series: [
|
||||||
]
|
{
|
||||||
}
|
data: seriesData,
|
||||||
|
type: 'bar',
|
||||||
|
tooltip: {
|
||||||
|
valueFormatter: (value: number) => {
|
||||||
|
return formatHumanReadableDuration(value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -245,35 +185,28 @@ const option = computed(() => {
|
|||||||
title="This Week"
|
title="This Week"
|
||||||
class="pb-8"
|
class="pb-8"
|
||||||
:icon="ClockIcon"></CardTitle>
|
:icon="ClockIcon"></CardTitle>
|
||||||
<v-chart
|
<v-chart :autoresize="true" class="chart" :option="option" />
|
||||||
v-if="weeklyHistory"
|
|
||||||
:autoresize="true" class="chart" :option="option" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Spent Time"
|
title="Spent Time"
|
||||||
:value="
|
:value="formatHumanReadableDuration(props.totalWeeklyTime)" />
|
||||||
totalWeeklyTime ?
|
|
||||||
formatHumanReadableDuration(totalWeeklyTime) : '--'" />
|
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Billable Time"
|
title="Billable Time"
|
||||||
:value="
|
:value="
|
||||||
totalWeeklyBillableTime ?
|
formatHumanReadableDuration(props.totalWeeklyBillableTime)
|
||||||
formatHumanReadableDuration(totalWeeklyBillableTime) : '--'
|
|
||||||
" />
|
" />
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Billable Amount"
|
title="Billable Amount"
|
||||||
:value="
|
:value="
|
||||||
totalWeeklyBillableAmount ?
|
|
||||||
formatCents(
|
formatCents(
|
||||||
totalWeeklyBillableAmount.value,
|
props.totalWeeklyBillableAmount.value,
|
||||||
getOrganizationCurrencyString()
|
getOrganizationCurrencyString()
|
||||||
) : '--'
|
)
|
||||||
" />
|
" />
|
||||||
<ProjectsChartCard
|
<ProjectsChartCard
|
||||||
v-if="weeklyProjectOverview"
|
|
||||||
:weekly-project-overview="
|
:weekly-project-overview="
|
||||||
weeklyProjectOverview
|
props.weeklyProjectOverview
|
||||||
"></ProjectsChartCard>
|
"></ProjectsChartCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const props = defineProps<{
|
|||||||
icon?: Component;
|
icon?: Component;
|
||||||
current?: boolean;
|
current?: boolean;
|
||||||
href: string;
|
href: string;
|
||||||
subItems?: { title: string; route: string, show: boolean }[];
|
subItems?: { title: string; route: string }[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const open = useSessionStorage('nav-collapse-state-' + props.title, true);
|
const open = useSessionStorage('nav-collapse-state-' + props.title, true);
|
||||||
@@ -66,7 +66,6 @@ const open = useSessionStorage('nav-collapse-state-' + props.title, true);
|
|||||||
:key="subItem.title"
|
:key="subItem.title"
|
||||||
class="w-full relative">
|
class="w-full relative">
|
||||||
<NavigationSidebarLink
|
<NavigationSidebarLink
|
||||||
v-if="subItem.show"
|
|
||||||
:title="subItem.title"
|
:title="subItem.title"
|
||||||
:current="route().current(subItem.route)"
|
:current="route().current(subItem.route)"
|
||||||
:href="
|
:href="
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
canUpdateOrganization,
|
canUpdateOrganization,
|
||||||
canViewClients,
|
canViewClients,
|
||||||
canViewMembers,
|
canViewMembers,
|
||||||
canViewProjects, canViewReport,
|
canViewProjects,
|
||||||
canViewTags,
|
canViewTags,
|
||||||
} from '@/utils/permissions';
|
} from '@/utils/permissions';
|
||||||
import { isBillingActivated } from '@/utils/billing';
|
import { isBillingActivated } from '@/utils/billing';
|
||||||
@@ -118,17 +118,14 @@ const page = usePage<{
|
|||||||
{
|
{
|
||||||
title: 'Overview',
|
title: 'Overview',
|
||||||
route: 'reporting',
|
route: 'reporting',
|
||||||
show: true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Detailed',
|
title: 'Detailed',
|
||||||
route: 'reporting.detailed',
|
route: 'reporting.detailed',
|
||||||
show: true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Shared',
|
title: 'Shared',
|
||||||
route: 'reporting.shared',
|
route: 'reporting.shared',
|
||||||
show: canViewReport()
|
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
:current="
|
:current="
|
||||||
|
|||||||
@@ -1,51 +1,100 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||||
import TimeTracker from "@/Components/TimeTracker.vue";
|
import TimeTracker from '@/Components/TimeTracker.vue';
|
||||||
import RecentlyTrackedTasksCard from "@/Components/Dashboard/RecentlyTrackedTasksCard.vue";
|
import RecentlyTrackedTasksCard from '@/Components/Dashboard/RecentlyTrackedTasksCard.vue';
|
||||||
import LastSevenDaysCard from "@/Components/Dashboard/LastSevenDaysCard.vue";
|
import LastSevenDaysCard from '@/Components/Dashboard/LastSevenDaysCard.vue';
|
||||||
import TeamActivityCard from "@/Components/Dashboard/TeamActivityCard.vue";
|
import TeamActivityCard from '@/Components/Dashboard/TeamActivityCard.vue';
|
||||||
import ThisWeekOverview from "@/Components/Dashboard/ThisWeekOverview.vue";
|
import ThisWeekOverview from '@/Components/Dashboard/ThisWeekOverview.vue';
|
||||||
import ActivityGraphCard from "@/Components/Dashboard/ActivityGraphCard.vue";
|
import ActivityGraphCard from '@/Components/Dashboard/ActivityGraphCard.vue';
|
||||||
import MainContainer from "@/packages/ui/src/MainContainer.vue";
|
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||||
import { canViewMembers } from "@/utils/permissions";
|
import { canViewMembers } from '@/utils/permissions';
|
||||||
import { useQueryClient } from "@tanstack/vue-query";
|
import { router } from '@inertiajs/vue3';
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const props = defineProps<{
|
||||||
|
latestTasks: {
|
||||||
const refreshDashboardData = () => {
|
id: string;
|
||||||
// Invalidate all dashboard queries to trigger refetching
|
name: string;
|
||||||
queryClient.invalidateQueries({ queryKey: ["latestTasks"] });
|
project_name: string;
|
||||||
queryClient.invalidateQueries({ queryKey: ["lastSevenDays"] });
|
project_id: string;
|
||||||
queryClient.invalidateQueries({ queryKey: ["dailyTrackedHours"] });
|
}[];
|
||||||
queryClient.invalidateQueries({ queryKey: ["latestTeamActivity"] });
|
latestTeamActivity: {
|
||||||
queryClient.invalidateQueries({ queryKey: ["weeklyProjectOverview"] });
|
user_id: string;
|
||||||
queryClient.invalidateQueries({ queryKey: ["totalWeeklyTime"] });
|
name: string;
|
||||||
queryClient.invalidateQueries({ queryKey: ["totalWeeklyBillableTime"] });
|
description: string;
|
||||||
queryClient.invalidateQueries({ queryKey: ["totalWeeklyBillableAmount"] });
|
time_entry_id: string;
|
||||||
queryClient.invalidateQueries({ queryKey: ["weeklyHistory"] });
|
task_id: string;
|
||||||
};
|
status: boolean;
|
||||||
|
}[];
|
||||||
|
lastSevenDays: {
|
||||||
|
date: string;
|
||||||
|
duration: number; // Total duration in seconds
|
||||||
|
history: number[]; // Array representing the duration in seconds of the 3h windows for the day
|
||||||
|
}[];
|
||||||
|
dailyTrackedHours: { duration: number; date: string }[];
|
||||||
|
weeklyProjectOverview: {
|
||||||
|
value: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}[];
|
||||||
|
totalWeeklyTime: number;
|
||||||
|
totalWeeklyBillableTime: number;
|
||||||
|
totalWeeklyBillableAmount: {
|
||||||
|
value: number;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
weeklyHistory: {
|
||||||
|
date: string;
|
||||||
|
duration: number;
|
||||||
|
}[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function refreshDashboardData() {
|
||||||
|
router.reload({
|
||||||
|
only: [
|
||||||
|
'latestTasks',
|
||||||
|
'latestTeamActivity',
|
||||||
|
'lastSevenDays',
|
||||||
|
'dailyTrackedHours',
|
||||||
|
'weeklyProjectOverview',
|
||||||
|
'totalWeeklyTime',
|
||||||
|
'totalWeeklyBillableTime',
|
||||||
|
'totalWeeklyBillableAmount',
|
||||||
|
'weeklyHistory',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout title="Dashboard" data-testid="dashboard_view">
|
<AppLayout title="Dashboard" data-testid="dashboard_view">
|
||||||
<MainContainer
|
<MainContainer
|
||||||
class="pt-5 sm:pt-8 pb-4 sm:pb-6 border-b border-default-background-separator">
|
class="pt-5 sm:pt-8 pb-4 sm:pb-6 border-b border-default-background-separator">
|
||||||
<TimeTracker @change="refreshDashboardData"></TimeTracker>
|
<TimeTracker @change="refreshDashboardData"></TimeTracker>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
|
<MainContainer
|
||||||
<MainContainer
|
class="grid gap-5 sm:gap-6 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">
|
||||||
class="grid gap-5 sm:gap-6 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">
|
<RecentlyTrackedTasksCard
|
||||||
<RecentlyTrackedTasksCard></RecentlyTrackedTasksCard>
|
:latest-tasks="props.latestTasks"></RecentlyTrackedTasksCard>
|
||||||
<LastSevenDaysCard></LastSevenDaysCard>
|
<LastSevenDaysCard
|
||||||
<ActivityGraphCard></ActivityGraphCard>
|
:last7-days="props.lastSevenDays"></LastSevenDaysCard>
|
||||||
<TeamActivityCard
|
<ActivityGraphCard
|
||||||
v-if="canViewMembers()"
|
:daily-hours-tracked="
|
||||||
class="flex lg:hidden xl:flex">
|
props.dailyTrackedHours
|
||||||
</TeamActivityCard>
|
"></ActivityGraphCard>
|
||||||
</MainContainer>
|
<TeamActivityCard
|
||||||
<MainContainer class="py-5">
|
v-if="canViewMembers()"
|
||||||
<ThisWeekOverview></ThisWeekOverview>
|
class="flex lg:hidden xl:flex"
|
||||||
</MainContainer>
|
:latest-team-activity="
|
||||||
|
props.latestTeamActivity
|
||||||
|
"></TeamActivityCard>
|
||||||
|
</MainContainer>
|
||||||
|
<MainContainer class="py-5">
|
||||||
|
<ThisWeekOverview
|
||||||
|
:weekly-project-overview="props.weeklyProjectOverview"
|
||||||
|
:total-weekly-billable-amount="props.totalWeeklyBillableAmount"
|
||||||
|
:total-weekly-billable-time="props.totalWeeklyBillableTime"
|
||||||
|
:total-weekly-time="props.totalWeeklyTime"
|
||||||
|
:weekly-history="props.weeklyHistory"></ThisWeekOverview>
|
||||||
|
</MainContainer>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ async function createApiToken(){
|
|||||||
(response) => {
|
(response) => {
|
||||||
createApiTokenForm.name = '';
|
createApiTokenForm.name = '';
|
||||||
displayingToken.value = true;
|
displayingToken.value = true;
|
||||||
|
// @ts-expect-error temporary fix until openapi docs type is fixed
|
||||||
newToken.value = response.data.access_token;
|
newToken.value = response.data.access_token;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -116,7 +117,7 @@ const deleteApiTokenMutation = useMutation({
|
|||||||
mutationFn: async (apiTokenId: string) => {
|
mutationFn: async (apiTokenId: string) => {
|
||||||
return await api.deleteApiToken(undefined, {
|
return await api.deleteApiToken(undefined, {
|
||||||
params: {
|
params: {
|
||||||
apiToken: apiTokenId,
|
apiTokenId: apiTokenId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -129,7 +130,7 @@ const revokeApiTokenMutation = useMutation({
|
|||||||
mutationFn: async (apiTokenId: string) => {
|
mutationFn: async (apiTokenId: string) => {
|
||||||
return await api.revokeApiToken(undefined, {
|
return await api.revokeApiToken(undefined, {
|
||||||
params: {
|
params: {
|
||||||
apiToken: apiTokenId,
|
apiTokenId: apiTokenId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ onMounted(() => {
|
|||||||
if (canViewProjectMembers()) {
|
if (canViewProjectMembers()) {
|
||||||
useProjectMembersStore().fetchProjectMembers(projectId);
|
useProjectMembersStore().fetchProjectMembers(projectId);
|
||||||
}
|
}
|
||||||
useTasksStore().fetchTasks();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const showEditProjectModal = ref(false);
|
const showEditProjectModal = ref(false);
|
||||||
|
|||||||
@@ -464,11 +464,10 @@ const tableData = computed(() => {
|
|||||||
<div
|
<div
|
||||||
class="justify-end pr-6 flex items-center font-medium">
|
class="justify-end pr-6 flex items-center font-medium">
|
||||||
{{
|
{{
|
||||||
aggregatedTableTimeEntries.cost ?
|
|
||||||
formatCents(
|
formatCents(
|
||||||
aggregatedTableTimeEntries.cost,
|
aggregatedTableTimeEntries.cost,
|
||||||
getOrganizationCurrencyString()
|
getOrganizationCurrencyString()
|
||||||
) : '--'
|
)
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ import {
|
|||||||
PaginationRoot,
|
PaginationRoot,
|
||||||
} from 'radix-vue';
|
} from 'radix-vue';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/vue-query';
|
import { useQuery, useQueryClient } from '@tanstack/vue-query';
|
||||||
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
||||||
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
||||||
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
|
import ReportingExportButton from '@/Components/Common/Reporting/ReportingExportButton.vue';
|
||||||
@@ -66,7 +66,7 @@ import type { ExportFormat } from '@/types/reporting';
|
|||||||
import { useNotificationsStore } from '@/utils/notification';
|
import { useNotificationsStore } from '@/utils/notification';
|
||||||
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
|
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
import {canCreateProjects, canViewAllTimeEntries} from '@/utils/permissions';
|
import { canCreateProjects } from '@/utils/permissions';
|
||||||
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
|
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
|
||||||
|
|
||||||
const startDate = useSessionStorage<string>(
|
const startDate = useSessionStorage<string>(
|
||||||
@@ -98,7 +98,6 @@ function getFilterAttributes() {
|
|||||||
};
|
};
|
||||||
const params = {
|
const params = {
|
||||||
...defaultParams,
|
...defaultParams,
|
||||||
member_id: !canViewAllTimeEntries() ? getCurrentMembershipId() : undefined,
|
|
||||||
member_ids:
|
member_ids:
|
||||||
selectedMembers.value.length > 0
|
selectedMembers.value.length > 0
|
||||||
? selectedMembers.value
|
? selectedMembers.value
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ const ApiTokenResource = z
|
|||||||
.object({
|
.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
revoked: z.boolean(),
|
revoked: z.string(),
|
||||||
scopes: z.array(z.string()),
|
scopes: z.string(),
|
||||||
created_at: z.string(),
|
created_at: z.union([z.string(), z.null()]),
|
||||||
expires_at: z.union([z.string(), z.null()]),
|
expires_at: z.union([z.string(), z.null()]),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
@@ -15,17 +15,7 @@ const ApiTokenCollection = z.array(ApiTokenResource);
|
|||||||
const ApiTokenStoreRequest = z
|
const ApiTokenStoreRequest = z
|
||||||
.object({ name: z.string().min(1).max(255) })
|
.object({ name: z.string().min(1).max(255) })
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const ApiTokenWithAccessTokenResource = z
|
const ApiTokenWithAccessTokenResource = z.string();
|
||||||
.object({
|
|
||||||
id: z.string(),
|
|
||||||
name: z.string(),
|
|
||||||
revoked: z.boolean(),
|
|
||||||
scopes: z.array(z.string()),
|
|
||||||
created_at: z.string(),
|
|
||||||
expires_at: z.union([z.string(), z.null()]),
|
|
||||||
access_token: z.string(),
|
|
||||||
})
|
|
||||||
.passthrough();
|
|
||||||
const ClientResource = z
|
const ClientResource = z
|
||||||
.object({
|
.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -73,10 +63,6 @@ const MemberUpdateRequest = z
|
|||||||
.object({ role: Role, billable_rate: z.union([z.number(), z.null()]) })
|
.object({ role: Role, billable_rate: z.union([z.number(), z.null()]) })
|
||||||
.partial()
|
.partial()
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const MemberMergeIntoRequest = z
|
|
||||||
.object({ member_id: z.string() })
|
|
||||||
.partial()
|
|
||||||
.passthrough();
|
|
||||||
const OrganizationResource = z
|
const OrganizationResource = z
|
||||||
.object({
|
.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -500,7 +486,6 @@ export const schemas = {
|
|||||||
MemberResource,
|
MemberResource,
|
||||||
Role,
|
Role,
|
||||||
MemberUpdateRequest,
|
MemberUpdateRequest,
|
||||||
MemberMergeIntoRequest,
|
|
||||||
OrganizationResource,
|
OrganizationResource,
|
||||||
OrganizationUpdateRequest,
|
OrganizationUpdateRequest,
|
||||||
ProjectResource,
|
ProjectResource,
|
||||||
@@ -611,332 +596,6 @@ const endpoints = makeApi([
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
method: 'get',
|
|
||||||
path: '/v1/organizations/:organization/charts/daily-tracked-hours',
|
|
||||||
alias: 'dailyTrackedHours',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.array(
|
|
||||||
z
|
|
||||||
.object({ date: z.string(), duration: z.number().int() })
|
|
||||||
.passthrough()
|
|
||||||
),
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
description: `Unauthenticated`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
description: `Authorization error`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
description: `Not found`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'get',
|
|
||||||
path: '/v1/organizations/:organization/charts/last-seven-days',
|
|
||||||
alias: 'lastSevenDays',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.array(
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
date: z.string(),
|
|
||||||
duration: z.number().int(),
|
|
||||||
history: z.array(z.number().int()),
|
|
||||||
})
|
|
||||||
.passthrough()
|
|
||||||
),
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
description: `Unauthenticated`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
description: `Authorization error`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
description: `Not found`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'get',
|
|
||||||
path: '/v1/organizations/:organization/charts/latest-tasks',
|
|
||||||
alias: 'latestTasks',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.array(
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
task_id: z.string(),
|
|
||||||
name: z.string(),
|
|
||||||
description: z.union([z.string(), z.null()]),
|
|
||||||
status: z.boolean(),
|
|
||||||
time_entry_id: z.union([z.string(), z.null()]),
|
|
||||||
})
|
|
||||||
.passthrough()
|
|
||||||
),
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
description: `Unauthenticated`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
description: `Authorization error`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
description: `Not found`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'get',
|
|
||||||
path: '/v1/organizations/:organization/charts/latest-team-activity',
|
|
||||||
alias: 'latestTeamActivity',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.array(
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
member_id: z.string(),
|
|
||||||
name: z.string(),
|
|
||||||
description: z.union([z.string(), z.null()]),
|
|
||||||
time_entry_id: z.string(),
|
|
||||||
task_id: z.union([z.string(), z.null()]),
|
|
||||||
status: z.boolean(),
|
|
||||||
})
|
|
||||||
.passthrough()
|
|
||||||
),
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
description: `Unauthenticated`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
description: `Authorization error`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
description: `Not found`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'get',
|
|
||||||
path: '/v1/organizations/:organization/charts/total-weekly-billable-amount',
|
|
||||||
alias: 'totalWeeklyBillableAmount',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z
|
|
||||||
.object({ value: z.number().int(), currency: z.string() })
|
|
||||||
.passthrough(),
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
description: `Unauthenticated`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
description: `Authorization error`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
description: `Not found`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'get',
|
|
||||||
path: '/v1/organizations/:organization/charts/total-weekly-billable-time',
|
|
||||||
alias: 'totalWeeklyBillableTime',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.number().int(),
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
description: `Unauthenticated`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
description: `Authorization error`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
description: `Not found`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'get',
|
|
||||||
path: '/v1/organizations/:organization/charts/total-weekly-time',
|
|
||||||
alias: 'totalWeeklyTime',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.number().int(),
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
description: `Unauthenticated`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
description: `Authorization error`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
description: `Not found`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'get',
|
|
||||||
path: '/v1/organizations/:organization/charts/weekly-history',
|
|
||||||
alias: 'weeklyHistory',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.array(
|
|
||||||
z
|
|
||||||
.object({ date: z.string(), duration: z.number().int() })
|
|
||||||
.passthrough()
|
|
||||||
),
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
description: `Unauthenticated`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
description: `Authorization error`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
description: `Not found`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'get',
|
|
||||||
path: '/v1/organizations/:organization/charts/weekly-project-overview',
|
|
||||||
alias: 'weeklyProjectOverview',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.array(
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
value: z.number().int(),
|
|
||||||
name: z.string(),
|
|
||||||
color: z.string(),
|
|
||||||
})
|
|
||||||
.passthrough()
|
|
||||||
),
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
description: `Unauthenticated`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
description: `Authorization error`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
description: `Not found`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
method: 'get',
|
method: 'get',
|
||||||
path: '/v1/organizations/:organization/clients',
|
path: '/v1/organizations/:organization/clients',
|
||||||
@@ -1501,71 +1160,6 @@ const endpoints = makeApi([
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
method: 'post',
|
|
||||||
path: '/v1/organizations/:organization/member/:member/merge-into',
|
|
||||||
alias: 'mergeMember',
|
|
||||||
requestFormat: 'json',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'body',
|
|
||||||
type: 'Body',
|
|
||||||
schema: z
|
|
||||||
.object({ member_id: z.string() })
|
|
||||||
.partial()
|
|
||||||
.passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'organization',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'member',
|
|
||||||
type: 'Path',
|
|
||||||
schema: z.string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response: z.null(),
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
description: `API exception`,
|
|
||||||
schema: z
|
|
||||||
.object({
|
|
||||||
error: z.boolean(),
|
|
||||||
key: z.string(),
|
|
||||||
message: z.string(),
|
|
||||||
})
|
|
||||||
.passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
description: `Unauthenticated`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
description: `Authorization error`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
description: `Not found`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 422,
|
|
||||||
description: `Validation error`,
|
|
||||||
schema: z
|
|
||||||
.object({
|
|
||||||
message: z.string(),
|
|
||||||
errors: z.record(z.array(z.string())),
|
|
||||||
})
|
|
||||||
.passthrough(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
method: 'get',
|
method: 'get',
|
||||||
path: '/v1/organizations/:organization/members',
|
path: '/v1/organizations/:organization/members',
|
||||||
@@ -1798,7 +1392,7 @@ const endpoints = makeApi([
|
|||||||
{
|
{
|
||||||
method: 'post',
|
method: 'post',
|
||||||
path: '/v1/organizations/:organization/members/:member/make-placeholder',
|
path: '/v1/organizations/:organization/members/:member/make-placeholder',
|
||||||
alias: 'makePlaceholder',
|
alias: 'v1.members.make-placeholder',
|
||||||
requestFormat: 'json',
|
requestFormat: 'json',
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
@@ -3451,7 +3045,7 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
.object({
|
.object({
|
||||||
key: z.union([z.string(), z.null()]),
|
key: z.union([z.string(), z.null()]),
|
||||||
seconds: z.number().int(),
|
seconds: z.number().int(),
|
||||||
cost: z.union([z.number(), z.null()]),
|
cost: z.number().int(),
|
||||||
grouped_type: z.union([
|
grouped_type: z.union([
|
||||||
z.string(),
|
z.string(),
|
||||||
z.null(),
|
z.null(),
|
||||||
@@ -3467,10 +3061,7 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
seconds: z
|
seconds: z
|
||||||
.number()
|
.number()
|
||||||
.int(),
|
.int(),
|
||||||
cost: z.union([
|
cost: z.number().int(),
|
||||||
z.number(),
|
|
||||||
z.null(),
|
|
||||||
]),
|
|
||||||
grouped_type: z.null(),
|
grouped_type: z.null(),
|
||||||
grouped_data: z.null(),
|
grouped_data: z.null(),
|
||||||
})
|
})
|
||||||
@@ -3484,7 +3075,7 @@ If the group parameters are all set to `null` or are all missing, the
|
|||||||
z.null(),
|
z.null(),
|
||||||
]),
|
]),
|
||||||
seconds: z.number().int(),
|
seconds: z.number().int(),
|
||||||
cost: z.union([z.number(), z.null()]),
|
cost: z.number().int(),
|
||||||
})
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
})
|
})
|
||||||
@@ -3880,17 +3471,6 @@ Please note that the access token is only shown in this response and cannot be r
|
|||||||
.object({ data: ApiTokenWithAccessTokenResource })
|
.object({ data: ApiTokenWithAccessTokenResource })
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
description: `API exception`,
|
|
||||||
schema: z
|
|
||||||
.object({
|
|
||||||
error: z.boolean(),
|
|
||||||
key: z.string(),
|
|
||||||
message: z.string(),
|
|
||||||
})
|
|
||||||
.passthrough(),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
description: `Unauthenticated`,
|
description: `Unauthenticated`,
|
||||||
@@ -3915,29 +3495,18 @@ Please note that the access token is only shown in this response and cannot be r
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'delete',
|
method: 'delete',
|
||||||
path: '/v1/users/me/api-tokens/:apiToken',
|
path: '/v1/users/me/api-tokens/:apiTokenId',
|
||||||
alias: 'deleteApiToken',
|
alias: 'deleteApiToken',
|
||||||
requestFormat: 'json',
|
requestFormat: 'json',
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
name: 'apiToken',
|
name: 'apiTokenId',
|
||||||
type: 'Path',
|
type: 'Path',
|
||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response: z.null(),
|
response: z.null(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
description: `API exception`,
|
|
||||||
schema: z
|
|
||||||
.object({
|
|
||||||
error: z.boolean(),
|
|
||||||
key: z.string(),
|
|
||||||
message: z.string(),
|
|
||||||
})
|
|
||||||
.passthrough(),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
description: `Unauthenticated`,
|
description: `Unauthenticated`,
|
||||||
@@ -3948,38 +3517,22 @@ Please note that the access token is only shown in this response and cannot be r
|
|||||||
description: `Authorization error`,
|
description: `Authorization error`,
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
description: `Not found`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'post',
|
method: 'post',
|
||||||
path: '/v1/users/me/api-tokens/:apiToken/revoke',
|
path: '/v1/users/me/api-tokens/:apiTokenId/revoke',
|
||||||
alias: 'revokeApiToken',
|
alias: 'revokeApiToken',
|
||||||
requestFormat: 'json',
|
requestFormat: 'json',
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
name: 'apiToken',
|
name: 'apiTokenId',
|
||||||
type: 'Path',
|
type: 'Path',
|
||||||
schema: z.string(),
|
schema: z.string(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response: z.null(),
|
response: z.null(),
|
||||||
errors: [
|
errors: [
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
description: `API exception`,
|
|
||||||
schema: z
|
|
||||||
.object({
|
|
||||||
error: z.boolean(),
|
|
||||||
key: z.string(),
|
|
||||||
message: z.string(),
|
|
||||||
})
|
|
||||||
.passthrough(),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
description: `Unauthenticated`,
|
description: `Unauthenticated`,
|
||||||
@@ -3990,11 +3543,6 @@ Please note that the access token is only shown in this response and cannot be r
|
|||||||
description: `Authorization error`,
|
description: `Authorization error`,
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
schema: z.object({ message: z.string() }).passthrough(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
description: `Not found`,
|
|
||||||
schema: z.object({ message: z.string() }).passthrough(),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { twMerge } from 'tailwind-merge';
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
class?: string;
|
class?: string;
|
||||||
tabindex?: string;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// This has to be a localized timestamp, not UTC
|
// This has to be a localized timestamp, not UTC
|
||||||
@@ -51,7 +50,6 @@ const emit = defineEmits(['changed']);
|
|||||||
<input
|
<input
|
||||||
id="start"
|
id="start"
|
||||||
ref="datePicker"
|
ref="datePicker"
|
||||||
:tabindex="tabindex"
|
|
||||||
:class="
|
:class="
|
||||||
twMerge(
|
twMerge(
|
||||||
'bg-input-background border text-white border-input-border focus-visible:outline-0 focus-visible:border-input-border-active focus-visible:ring-0 rounded-md',
|
'bg-input-background border text-white border-input-border focus-visible:outline-0 focus-visible:border-input-border-active focus-visible:ring-0 rounded-md',
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
|
|||||||
import MultiselectDropdownItem from '@/packages/ui/src/Input/MultiselectDropdownItem.vue';
|
import MultiselectDropdownItem from '@/packages/ui/src/Input/MultiselectDropdownItem.vue';
|
||||||
import type { Tag } from '@/packages/api/src';
|
import type { Tag } from '@/packages/api/src';
|
||||||
import type { Placement } from '@floating-ui/vue';
|
import type { Placement } from '@floating-ui/vue';
|
||||||
import {UseFocusTrap} from "@vueuse/integrations/useFocusTrap/component";
|
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -178,50 +177,46 @@ const showCreateTagModal = ref(false);
|
|||||||
<slot name="trigger"></slot>
|
<slot name="trigger"></slot>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<UseFocusTrap
|
<input
|
||||||
v-if="open"
|
ref="searchInput"
|
||||||
:options="{ immediate: true, allowOutsideClick: true }">
|
:value="searchValue"
|
||||||
<input
|
data-testid="tag_dropdown_search"
|
||||||
ref="searchInput"
|
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
||||||
:value="searchValue"
|
placeholder="Search for a Tag..."
|
||||||
data-testid="tag_dropdown_search"
|
@input="updateSearchValue"
|
||||||
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
|
@keydown.enter="addTagIfNoneExists"
|
||||||
placeholder="Search for a Tag..."
|
@keydown.up.prevent="moveHighlightUp"
|
||||||
@input="updateSearchValue"
|
@keydown.down.prevent="moveHighlightDown" />
|
||||||
@keydown.enter="addTagIfNoneExists"
|
<div ref="dropdownViewport" class="w-60 max-h-60 overflow-y-scroll">
|
||||||
@keydown.up.prevent="moveHighlightUp"
|
<div
|
||||||
@keydown.down.prevent="moveHighlightDown" />
|
v-for="tag in filteredTags"
|
||||||
<div ref="dropdownViewport" class="w-60 max-h-60 overflow-y-scroll">
|
:key="tag.id"
|
||||||
<div
|
role="option"
|
||||||
v-for="tag in filteredTags"
|
:value="tag.id"
|
||||||
:key="tag.id"
|
:class="{
|
||||||
role="option"
|
|
||||||
:value="tag.id"
|
|
||||||
:class="{
|
|
||||||
'bg-card-background-active':
|
'bg-card-background-active':
|
||||||
tag.id === highlightedItemId,
|
tag.id === highlightedItemId,
|
||||||
}"
|
}"
|
||||||
data-testid="tag_dropdown_entries"
|
data-testid="tag_dropdown_entries"
|
||||||
:data-tag-id="tag.id">
|
:data-tag-id="tag.id">
|
||||||
<MultiselectDropdownItem
|
<MultiselectDropdownItem
|
||||||
:selected="isTagSelected(tag.id)"
|
:selected="isTagSelected(tag.id)"
|
||||||
:name="tag.name"
|
:name="tag.name"
|
||||||
@click="toggleTag(tag.id)"></MultiselectDropdownItem>
|
@click="toggleTag(tag.id)"></MultiselectDropdownItem>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="hover:bg-card-background-active rounded-b-lg">
|
</div>
|
||||||
<button
|
<div class="hover:bg-card-background-active rounded-b-lg">
|
||||||
class="text-white w-full flex space-x-3 items-center px-4 py-3 text-xs font-semibold border-t border-card-background-separator"
|
<button
|
||||||
@click="
|
class="text-white w-full flex space-x-3 items-center px-4 py-3 text-xs font-semibold border-t border-card-background-separator"
|
||||||
|
@click="
|
||||||
open = false;
|
open = false;
|
||||||
showCreateTagModal = true;
|
showCreateTagModal = true;
|
||||||
">
|
">
|
||||||
<PlusCircleIcon
|
<PlusCircleIcon
|
||||||
class="w-5 flex-shrink-0 text-icon-default"></PlusCircleIcon>
|
class="w-5 flex-shrink-0 text-icon-default"></PlusCircleIcon>
|
||||||
<span>Create new Tag</span>
|
<span>Create new Tag</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</UseFocusTrap>
|
|
||||||
</template>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
Client,
|
Client,
|
||||||
CreateTimeEntryBody,
|
CreateTimeEntryBody,
|
||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
|
import TimePicker from '@/packages/ui/src/Input/TimePicker.vue';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import { canCreateProjects } from '@/utils/permissions';
|
import { canCreateProjects } from '@/utils/permissions';
|
||||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||||
@@ -29,7 +30,6 @@ import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
|
|||||||
|
|
||||||
import { InformationCircleIcon } from '@heroicons/vue/20/solid';
|
import { InformationCircleIcon } from '@heroicons/vue/20/solid';
|
||||||
import type { Tag, Task } from '@/packages/api/src';
|
import type { Tag, Task } from '@/packages/api/src';
|
||||||
import TimePickerSimple from "@/packages/ui/src/Input/TimePickerSimple.vue";
|
|
||||||
|
|
||||||
const show = defineModel('show', { default: false });
|
const show = defineModel('show', { default: false });
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
@@ -244,34 +244,30 @@ type BillableOption = {
|
|||||||
<div class="">
|
<div class="">
|
||||||
<InputLabel>Start</InputLabel>
|
<InputLabel>Start</InputLabel>
|
||||||
<div class="flex flex-col items-center space-y-2 mt-1">
|
<div class="flex flex-col items-center space-y-2 mt-1">
|
||||||
<TimePickerSimple
|
<TimePicker
|
||||||
|
|
||||||
v-model="localStart"
|
v-model="localStart"
|
||||||
size="large"></TimePickerSimple>
|
size="large"></TimePicker>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
v-model="localStart"
|
v-model="localStart"
|
||||||
tabindex="1"
|
|
||||||
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
|
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="">
|
<div class="">
|
||||||
<InputLabel>End</InputLabel>
|
<InputLabel>End</InputLabel>
|
||||||
<div class="flex flex-col items-center space-y-2 mt-1">
|
<div class="flex flex-col items-center space-y-2 mt-1">
|
||||||
<TimePickerSimple
|
<TimePicker
|
||||||
v-model="localEnd"
|
v-model="localEnd"
|
||||||
size="large"></TimePickerSimple>
|
size="large"></TimePicker>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
v-model="localEnd"
|
v-model="localEnd"
|
||||||
tabindex="1"
|
|
||||||
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
|
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<SecondaryButton tabindex="2" @click="show = false"> Cancel</SecondaryButton>
|
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
tabindex="2"
|
|
||||||
class="ms-3"
|
class="ms-3"
|
||||||
:class="{ 'opacity-25': saving }"
|
:class="{ 'opacity-25': saving }"
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
|
|||||||
@@ -77,14 +77,6 @@ export function canDeleteMembers() {
|
|||||||
return currentUserHasPermission('members:delete');
|
return currentUserHasPermission('members:delete');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canMergeMembers() {
|
|
||||||
return currentUserHasPermission('members:merge-into');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canMakeMembersPlaceholders() {
|
|
||||||
return currentUserHasPermission('members:make-placeholder');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canInvitePlaceholderMembers() {
|
export function canInvitePlaceholderMembers() {
|
||||||
return currentUserHasPermission('members:invite-placeholder');
|
return currentUserHasPermission('members:invite-placeholder');
|
||||||
}
|
}
|
||||||
@@ -109,16 +101,9 @@ export function canManageBilling() {
|
|||||||
return currentUserHasPermission('billing');
|
return currentUserHasPermission('billing');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canViewReport() {
|
|
||||||
return currentUserHasPermission('reports:view');
|
|
||||||
}
|
|
||||||
export function canUpdateReport() {
|
export function canUpdateReport() {
|
||||||
return currentUserHasPermission('reports:update');
|
return currentUserHasPermission('reports:update');
|
||||||
}
|
}
|
||||||
export function canDeleteReport() {
|
export function canDeleteReport() {
|
||||||
return currentUserHasPermission('reports:delete');
|
return currentUserHasPermission('reports:delete');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canViewAllTimeEntries() {
|
|
||||||
return currentUserHasPermission('time-entries:view:all');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
name,color,billable_rate,is_public,client,billable_default,estimated_time,archived_at
|
|
||||||
"Project for Big Company",,10001,false,"Big Company",true,,
|
|
||||||
"Project without Client",#ef5350,,false,,false,1000,
|
|
||||||
"Project (Archived)",#6a407f,,true,"Some client",true,0,2024-08-25T10:00:00Z
|
|
||||||
|
@@ -1,3 +0,0 @@
|
|||||||
description,billable,client,project,tags,start,end,task,user_name,user_email
|
|
||||||
"","false","","Project without Client","Development, Backend","2024-03-04T09:23:52Z","2024-03-04T09:23:52Z","","Peter Tester","peter.test@email.test"
|
|
||||||
"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"
|
|
||||||
|
@@ -1,3 +0,0 @@
|
|||||||
Client Name,Address
|
|
||||||
Example Client,""
|
|
||||||
"\\ 🔥 Special characters """"""`!@#$%^&*()_+\-=\[\]{};':""\\|,.''<>\/?~ \\\",""
|
|
||||||
|
@@ -1,3 +0,0 @@
|
|||||||
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
|
|
||||||
Example 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"
|
|
||||||
"\\ 🔥 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"
|
|
||||||
|
@@ -1,3 +0,0 @@
|
|||||||
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
|
|
||||||
2024-03-04,,Project without Client,,,"","20,0",No,No,No,Peter,Tester,,Yes,"100,0","2.000,0","0,0","0,0",Euro - EUR,
|
|
||||||
2024-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,
|
|
||||||
|
@@ -1,16 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"archived": false,
|
|
||||||
"creator_id": 201,
|
|
||||||
"id": 301,
|
|
||||||
"name": "Big Company",
|
|
||||||
"wid": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"archived": true,
|
|
||||||
"creator_id": 201,
|
|
||||||
"id": 302,
|
|
||||||
"name": "Other Company (Archived)",
|
|
||||||
"wid": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"active": true,
|
|
||||||
"actual_hours": null,
|
|
||||||
"actual_seconds": null,
|
|
||||||
"auto_estimates": false,
|
|
||||||
"billable": false,
|
|
||||||
"cid": null,
|
|
||||||
"client_id": null,
|
|
||||||
"color": "#ef5350",
|
|
||||||
"currency": "EUR",
|
|
||||||
"estimated_hours": null,
|
|
||||||
"estimated_seconds": null,
|
|
||||||
"fixed_fee": null,
|
|
||||||
"guid": "",
|
|
||||||
"id": 401,
|
|
||||||
"is_private": true,
|
|
||||||
"name": "Project without Client",
|
|
||||||
"rate": null,
|
|
||||||
"rate_last_updated": null,
|
|
||||||
"recurring": false,
|
|
||||||
"recurring_parameters": null,
|
|
||||||
"start_date": "2020-01-01",
|
|
||||||
"status": "active",
|
|
||||||
"template": false,
|
|
||||||
"template_id": null,
|
|
||||||
"wid": 0,
|
|
||||||
"workspace_id": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"active": true,
|
|
||||||
"actual_hours": null,
|
|
||||||
"actual_seconds": null,
|
|
||||||
"auto_estimates": false,
|
|
||||||
"billable": true,
|
|
||||||
"cid": 301,
|
|
||||||
"client_id": 301,
|
|
||||||
"color": "#ec407a",
|
|
||||||
"currency": null,
|
|
||||||
"estimated_hours": null,
|
|
||||||
"estimated_seconds": null,
|
|
||||||
"fixed_fee": null,
|
|
||||||
"guid": "",
|
|
||||||
"id": 402,
|
|
||||||
"is_private": true,
|
|
||||||
"name": "Project for Big Company",
|
|
||||||
"rate": 100.01,
|
|
||||||
"rate_last_updated": null,
|
|
||||||
"recurring": false,
|
|
||||||
"recurring_parameters": null,
|
|
||||||
"start_date": "2020-01-01",
|
|
||||||
"status": "active",
|
|
||||||
"template": false,
|
|
||||||
"template_id": null,
|
|
||||||
"wid": 0,
|
|
||||||
"workspace_id": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"active": false,
|
|
||||||
"actual_hours": null,
|
|
||||||
"actual_seconds": null,
|
|
||||||
"auto_estimates": false,
|
|
||||||
"billable": true,
|
|
||||||
"cid": 302,
|
|
||||||
"client_id": 302,
|
|
||||||
"color": "#6a407f",
|
|
||||||
"currency": null,
|
|
||||||
"estimated_hours": null,
|
|
||||||
"estimated_seconds": null,
|
|
||||||
"fixed_fee": null,
|
|
||||||
"guid": "",
|
|
||||||
"id": 403,
|
|
||||||
"is_private": false,
|
|
||||||
"name": "Project (Archived)",
|
|
||||||
"rate": null,
|
|
||||||
"rate_last_updated": null,
|
|
||||||
"recurring": false,
|
|
||||||
"recurring_parameters": null,
|
|
||||||
"start_date": "2020-01-01",
|
|
||||||
"status": "active",
|
|
||||||
"template": false,
|
|
||||||
"template_id": null,
|
|
||||||
"wid": 0,
|
|
||||||
"workspace_id": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
[]
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"gid": null,
|
|
||||||
"group_id": null,
|
|
||||||
"id": 801,
|
|
||||||
"labour_cost": null,
|
|
||||||
"manager": true,
|
|
||||||
"project_id": 402,
|
|
||||||
"rate": 100.02,
|
|
||||||
"rate_last_updated": null,
|
|
||||||
"user_id": 2001,
|
|
||||||
"workspace_id": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
[]
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"creator_id": 0,
|
|
||||||
"id": 501,
|
|
||||||
"name": "Development",
|
|
||||||
"workspace_id": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"creator_id": 0,
|
|
||||||
"id": 502,
|
|
||||||
"name": "Backend",
|
|
||||||
"workspace_id": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
[]
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"active": true,
|
|
||||||
"estimated_seconds": 0,
|
|
||||||
"id": 601,
|
|
||||||
"name": "Task 1",
|
|
||||||
"project_id": 402,
|
|
||||||
"recurring": false,
|
|
||||||
"tracked_seconds": 0,
|
|
||||||
"user_id": null,
|
|
||||||
"workspace_id": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"active": false,
|
|
||||||
"estimated_seconds": 0,
|
|
||||||
"id": 602,
|
|
||||||
"name": "Task 2",
|
|
||||||
"project_id": 403,
|
|
||||||
"recurring": false,
|
|
||||||
"tracked_seconds": 0,
|
|
||||||
"user_id": null,
|
|
||||||
"workspace_id": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
[]
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"active": true,
|
|
||||||
"admin": true,
|
|
||||||
"email": "peter.test@email.test",
|
|
||||||
"group_ids": [],
|
|
||||||
"id": 201,
|
|
||||||
"inactive": false,
|
|
||||||
"labour_cost": null,
|
|
||||||
"name": "Peter Tester",
|
|
||||||
"rate": null,
|
|
||||||
"rate_last_updated": null,
|
|
||||||
"role": "admin",
|
|
||||||
"timezone": "Etc/UTC",
|
|
||||||
"uid": 2001,
|
|
||||||
"wid": 0,
|
|
||||||
"working_hours_in_minutes": null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Http\Controllers\Api\V1\ApiTokenController;
|
use App\Http\Controllers\Api\V1\ApiTokenController;
|
||||||
use App\Http\Controllers\Api\V1\ChartController;
|
|
||||||
use App\Http\Controllers\Api\V1\ClientController;
|
use App\Http\Controllers\Api\V1\ClientController;
|
||||||
use App\Http\Controllers\Api\V1\ExportController;
|
use App\Http\Controllers\Api\V1\ExportController;
|
||||||
use App\Http\Controllers\Api\V1\ImportController;
|
use App\Http\Controllers\Api\V1\ImportController;
|
||||||
@@ -52,7 +51,6 @@ Route::prefix('v1')->name('v1.')->group(static function (): void {
|
|||||||
Route::delete('/members/{member}', [MemberController::class, 'destroy'])->name('destroy');
|
Route::delete('/members/{member}', [MemberController::class, 'destroy'])->name('destroy');
|
||||||
Route::post('/members/{member}/invite-placeholder', [MemberController::class, 'invitePlaceholder'])->name('invite-placeholder');
|
Route::post('/members/{member}/invite-placeholder', [MemberController::class, 'invitePlaceholder'])->name('invite-placeholder');
|
||||||
Route::post('/members/{member}/make-placeholder', [MemberController::class, 'makePlaceholder'])->name('make-placeholder');
|
Route::post('/members/{member}/make-placeholder', [MemberController::class, 'makePlaceholder'])->name('make-placeholder');
|
||||||
Route::post('member/{member}/merge-into', [MemberController::class, 'mergeInto'])->name('merge-into');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// User routes
|
// User routes
|
||||||
@@ -124,19 +122,6 @@ Route::prefix('v1')->name('v1.')->group(static function (): void {
|
|||||||
Route::delete('/reports/{report}', [ReportController::class, 'destroy'])->name('destroy');
|
Route::delete('/reports/{report}', [ReportController::class, 'destroy'])->name('destroy');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Chart routes
|
|
||||||
Route::name('charts.')->prefix('/organizations/{organization}/charts')->group(static function (): void {
|
|
||||||
Route::get('/weekly-project-overview', [ChartController::class, 'weeklyProjectOverview'])->name('weekly-project-overview');
|
|
||||||
Route::get('/latest-tasks', [ChartController::class, 'latestTasks'])->name('latest-tasks');
|
|
||||||
Route::get('/last-seven-days', [ChartController::class, 'lastSevenDays'])->name('last-seven-days');
|
|
||||||
Route::get('/latest-team-activity', [ChartController::class, 'latestTeamActivity'])->name('latest-team-activity');
|
|
||||||
Route::get('/daily-tracked-hours', [ChartController::class, 'dailyTrackedHours'])->name('daily-tracked-hours');
|
|
||||||
Route::get('/total-weekly-time', [ChartController::class, 'totalWeeklyTime'])->name('total-weekly-time');
|
|
||||||
Route::get('/total-weekly-billable-time', [ChartController::class, 'totalWeeklyBillableTime'])->name('total-weekly-billable-time');
|
|
||||||
Route::get('/total-weekly-billable-amount', [ChartController::class, 'totalWeeklyBillableAmount'])->name('total-weekly-billable-amount');
|
|
||||||
Route::get('/weekly-history', [ChartController::class, 'weeklyHistory'])->name('weekly-history');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Tag routes
|
// Tag routes
|
||||||
Route::name('tags.')->prefix('/organizations/{organization}')->group(static function (): void {
|
Route::name('tags.')->prefix('/organizations/{organization}')->group(static function (): void {
|
||||||
Route::get('/tags', [TagController::class, 'index'])->name('index');
|
Route::get('/tags', [TagController::class, 'index'])->name('index');
|
||||||
|
|||||||
@@ -53,9 +53,6 @@ abstract class TestCaseWithDatabase extends TestCase
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return object{user: User, organization: Organization, member: Member, owner: User, ownerMember: Member}
|
|
||||||
*/
|
|
||||||
public function createUserWithRole(Role $role): object
|
public function createUserWithRole(Role $role): object
|
||||||
{
|
{
|
||||||
$owner = User::factory()->create();
|
$owner = User::factory()->create();
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Unit\Console\Commands\Correction;
|
|
||||||
|
|
||||||
use App\Console\Commands\Correction\CorrectionPlaceholderMembersCommand;
|
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Models\Member;
|
|
||||||
use App\Models\Organization;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
use Illuminate\Support\Facades\Artisan;
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
|
||||||
use PHPUnit\Framework\Attributes\UsesClass;
|
|
||||||
use Tests\TestCaseWithDatabase;
|
|
||||||
|
|
||||||
#[CoversClass(CorrectionPlaceholderMembersCommand::class)]
|
|
||||||
#[UsesClass(CorrectionPlaceholderMembersCommand::class)]
|
|
||||||
class CorrectionPlaceholderMembersCommandTest extends TestCaseWithDatabase
|
|
||||||
{
|
|
||||||
public function test_sets_member_role_to_placeholder_if_user_is_placeholder(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
$user1 = User::factory()->placeholder()->create();
|
|
||||||
$member1 = Member::factory()->forOrganization($organization)->forUser($user1)->role(Role::Admin)->create();
|
|
||||||
$user2 = User::factory()->create();
|
|
||||||
$member2 = Member::factory()->forOrganization($organization)->forUser($user2)->role(Role::Admin)->create();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$exitCode = $this->withoutMockingConsoleOutput()->artisan('correction:placeholder-members');
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertSame(Command::SUCCESS, $exitCode);
|
|
||||||
$output = Artisan::output();
|
|
||||||
$member1->refresh();
|
|
||||||
$this->assertSame(Role::Placeholder->value, $member1->role);
|
|
||||||
$member2->refresh();
|
|
||||||
$this->assertSame(Role::Admin->value, $member2->role);
|
|
||||||
$this->assertSame("Sets all members who belong to a placeholder user to role placeholder...\n".
|
|
||||||
'Set role of member (id='.$member1->getKey().") to placeholder\n", $output);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_sets_member_role_to_placeholder_if_user_is_placeholder_dry_run(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
$user1 = User::factory()->placeholder()->create();
|
|
||||||
$member1 = Member::factory()->forOrganization($organization)->forUser($user1)->role(Role::Admin)->create();
|
|
||||||
$user2 = User::factory()->create();
|
|
||||||
$member2 = Member::factory()->forOrganization($organization)->forUser($user2)->role(Role::Admin)->create();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$exitCode = $this->withoutMockingConsoleOutput()->artisan('correction:placeholder-members --dry-run');
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertSame(Command::SUCCESS, $exitCode);
|
|
||||||
$output = Artisan::output();
|
|
||||||
$member1->refresh();
|
|
||||||
$this->assertSame(Role::Admin->value, $member1->role);
|
|
||||||
$member2->refresh();
|
|
||||||
$this->assertSame(Role::Admin->value, $member2->role);
|
|
||||||
$this->assertSame("Sets all members who belong to a placeholder user to role placeholder...\n".
|
|
||||||
"Running in dry-run mode. Nothing will be saved to the database.\n".
|
|
||||||
'Set role of member (id='.$member1->getKey().") to placeholder\n", $output);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Unit\Endpoint\Api\V1;
|
|
||||||
|
|
||||||
use App\Enums\Role;
|
|
||||||
use Laravel\Passport\Passport;
|
|
||||||
use Tests\Unit\Endpoint\Web\EndpointTestAbstract;
|
|
||||||
|
|
||||||
class ChartEndpointTest extends EndpointTestAbstract
|
|
||||||
{
|
|
||||||
public function test_weekly_project_overview_endpoint_fails_if_user_has_no_permission_to_view_chart(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = $this->createUserWithPermission();
|
|
||||||
Passport::actingAs($user->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.charts.weekly-project-overview', [
|
|
||||||
'organization' => $user->organization,
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_weekly_project_overview_endpoint_returns_chart_data(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = $this->createUserWithPermission(['charts:view:own']);
|
|
||||||
Passport::actingAs($user->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.charts.weekly-project-overview', [
|
|
||||||
'organization' => $user->organization,
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_latest_tasks_endpoint_fails_if_user_has_no_permission_to_view_chart(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = $this->createUserWithPermission();
|
|
||||||
Passport::actingAs($user->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.charts.latest-tasks', [
|
|
||||||
'organization' => $user->organization,
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_latest_tasks_endpoint_returns_chart_data(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = $this->createUserWithPermission(['charts:view:own']);
|
|
||||||
Passport::actingAs($user->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.charts.latest-tasks', [
|
|
||||||
'organization' => $user->organization,
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_last_seven_days_endpoint_fails_if_user_has_no_permission_to_view_chart(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = $this->createUserWithPermission();
|
|
||||||
Passport::actingAs($user->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.charts.last-seven-days', [
|
|
||||||
'organization' => $user->organization,
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_last_seven_days_endpoint_returns_chart_data(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = $this->createUserWithPermission(['charts:view:own']);
|
|
||||||
Passport::actingAs($user->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.charts.last-seven-days', [
|
|
||||||
'organization' => $user->organization,
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_latest_team_activity_endpoint_fails_if_user_has_no_permission_to_view_chart_for_the_whole_orgnaization(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = $this->createUserWithPermission();
|
|
||||||
Passport::actingAs($user->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.charts.latest-team-activity', [
|
|
||||||
'organization' => $user->organization,
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_latest_team_activity_endpoint_returns_chart_data(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = $this->createUserWithPermission(['charts:view:all']);
|
|
||||||
Passport::actingAs($user->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.charts.latest-team-activity', [
|
|
||||||
'organization' => $user->organization,
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_daily_tracked_hours_endpoint_fails_if_user_has_no_permission_to_view_chart(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = $this->createUserWithPermission();
|
|
||||||
Passport::actingAs($user->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.charts.daily-tracked-hours', [
|
|
||||||
'organization' => $user->organization,
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_daily_tracked_hours_endpoint_returns_chart_data(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = $this->createUserWithPermission(['charts:view:own']);
|
|
||||||
Passport::actingAs($user->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.charts.daily-tracked-hours', [
|
|
||||||
'organization' => $user->organization,
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_total_weekly_time_endpoint_fails_if_user_has_no_permission_to_view_chart(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = $this->createUserWithPermission();
|
|
||||||
Passport::actingAs($user->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.charts.total-weekly-time', [
|
|
||||||
'organization' => $user->organization,
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_total_weekly_time_endpoint_returns_chart_data(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = $this->createUserWithPermission(['charts:view:own']);
|
|
||||||
Passport::actingAs($user->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.charts.total-weekly-time', [
|
|
||||||
'organization' => $user->organization,
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_total_weekly_billable_time_endpoint_fails_if_user_has_no_permission_to_view_chart(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = $this->createUserWithPermission();
|
|
||||||
Passport::actingAs($user->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.charts.total-weekly-billable-time', [
|
|
||||||
'organization' => $user->organization,
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_total_weekly_billable_time_endpoint_returns_chart_data(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = $this->createUserWithPermission(['charts:view:own']);
|
|
||||||
Passport::actingAs($user->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.charts.total-weekly-billable-time', [
|
|
||||||
'organization' => $user->organization,
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_total_weekly_billable_amount_endpoint_fails_if_user_has_no_permission_to_view_chart(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = $this->createUserWithPermission();
|
|
||||||
Passport::actingAs($user->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.charts.total-weekly-billable-amount', [
|
|
||||||
'organization' => $user->organization,
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = $this->createUserWithRole(Role::Employee);
|
|
||||||
$organization = $user->organization;
|
|
||||||
$organization->employees_can_see_billable_rates = false;
|
|
||||||
$organization->save();
|
|
||||||
Passport::actingAs($user->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.charts.total-weekly-billable-amount', [
|
|
||||||
'organization' => $organization,
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_total_weekly_billable_amount_endpoint_returns_chart_data(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = $this->createUserWithRole(Role::Employee);
|
|
||||||
$organization = $user->organization;
|
|
||||||
$organization->employees_can_see_billable_rates = true;
|
|
||||||
$organization->save();
|
|
||||||
Passport::actingAs($user->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.charts.total-weekly-billable-amount', [
|
|
||||||
'organization' => $user->organization,
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_weekly_history_endpoint_fails_if_user_has_no_permission_to_view_chart(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = $this->createUserWithPermission();
|
|
||||||
Passport::actingAs($user->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.charts.weekly-history', [
|
|
||||||
'organization' => $user->organization,
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_weekly_history_endpoint_returns_chart_data(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$user = $this->createUserWithPermission(['charts:view:own']);
|
|
||||||
Passport::actingAs($user->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.charts.weekly-history', [
|
|
||||||
'organization' => $user->organization,
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertOk();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -194,216 +194,6 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response->assertJsonPath('message', 'Only owner can change ownership');
|
$response->assertJsonPath('message', 'Only owner can change ownership');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_update_member_fails_if_user_tries_to_change_the_role_of_a_placeholder(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'members:update',
|
|
||||||
]);
|
|
||||||
$user = User::factory()->placeholder()->create();
|
|
||||||
$member = Member::factory()->forOrganization($data->organization)->forUser($user)->role(Role::Placeholder)->create();
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $member->getKey()]), [
|
|
||||||
'role' => Role::Admin->value,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(400);
|
|
||||||
$response->assertExactJson([
|
|
||||||
'error' => true,
|
|
||||||
'key' => 'changing_role_of_placeholder_is_not_allowed',
|
|
||||||
'message' => 'Changing role of placeholder is not allowed',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_merge_into_fails_if_url_member_is_not_part_of_organization(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'members:merge-into',
|
|
||||||
]);
|
|
||||||
$userSource = User::factory()->placeholder()->create();
|
|
||||||
$memberSource = Member::factory()->forUser($userSource)->role(Role::Placeholder)->create();
|
|
||||||
|
|
||||||
$userDestination = User::factory()->create();
|
|
||||||
$memberDestination = Member::factory()->forUser($userDestination)->forOrganization($data->organization)->role(Role::Admin)->create();
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->postJson(route('api.v1.members.merge-into', [$data->organization->getKey(), $memberSource->getKey()]), [
|
|
||||||
'member_id' => $memberDestination->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertForbidden();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_merge_into_returns_validation_error_if_member_in_body_does_not_belong_to_organization(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'members:merge-into',
|
|
||||||
]);
|
|
||||||
$userSource = User::factory()->placeholder()->create();
|
|
||||||
$memberSource = Member::factory()->forUser($userSource)->forOrganization($data->organization)->role(Role::Placeholder)->create();
|
|
||||||
|
|
||||||
$userDestination = User::factory()->create();
|
|
||||||
$memberDestination = Member::factory()->forUser($userDestination)->role(Role::Admin)->create();
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->postJson(route('api.v1.members.merge-into', [$data->organization->getKey(), $memberSource->getKey()]), [
|
|
||||||
'member_id' => $memberDestination->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(422);
|
|
||||||
$response->assertExactJson([
|
|
||||||
'errors' => [
|
|
||||||
'member_id' => [
|
|
||||||
'The resource does not exist.',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'message' => 'The resource does not exist.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_merge_into_fails_if_from_member_is_not_a_placeholder(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'members:merge-into',
|
|
||||||
]);
|
|
||||||
$userSource = User::factory()->placeholder()->create();
|
|
||||||
$memberSource = Member::factory()->forUser($userSource)->forOrganization($data->organization)->role(Role::Admin)->create();
|
|
||||||
|
|
||||||
$userDestination = User::factory()->create();
|
|
||||||
$memberDestination = Member::factory()->forUser($userDestination)->forOrganization($data->organization)->role(Role::Admin)->create();
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->postJson(route('api.v1.members.merge-into', [$data->organization->getKey(), $memberSource->getKey()]), [
|
|
||||||
'member_id' => $memberDestination->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(400);
|
|
||||||
$response->assertExactJson([
|
|
||||||
'error' => true,
|
|
||||||
'key' => 'only_placeholders_can_be_merged_into_another_member',
|
|
||||||
'message' => 'Only placeholders can be merged into another member',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_merge_into_fails_if_user_has_no_permission_to_merge_members(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$data = $this->createUserWithPermission([]);
|
|
||||||
$userSource = User::factory()->placeholder()->create();
|
|
||||||
$memberSource = Member::factory()->forUser($userSource)->forOrganization($data->organization)->role(Role::Placeholder)->create();
|
|
||||||
|
|
||||||
$userDestination = User::factory()->create();
|
|
||||||
$memberDestination = Member::factory()->forUser($userDestination)->forOrganization($data->organization)->role(Role::Admin)->create();
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->postJson(route('api.v1.members.merge-into', [$data->organization->getKey(), $memberSource->getKey()]), [
|
|
||||||
'member_id' => $memberDestination->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertForbidden();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_merge_into_assigns_resources_of_source_member_to_destination_member_and_deletes_member(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'members:merge-into',
|
|
||||||
]);
|
|
||||||
$userSource = User::factory()->placeholder()->create();
|
|
||||||
$memberSource = Member::factory()->forUser($userSource)->forOrganization($data->organization)->role(Role::Placeholder)->create();
|
|
||||||
TimeEntry::factory()->forMember($memberSource)->createMany(3);
|
|
||||||
$project = Project::factory()->forOrganization($data->organization)->create();
|
|
||||||
ProjectMember::factory()->forMember($memberSource)->forProject($project)->create();
|
|
||||||
|
|
||||||
$userDestination = User::factory()->create();
|
|
||||||
$memberDestination = Member::factory()->forUser($userDestination)->forOrganization($data->organization)->role(Role::Admin)->create();
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->postJson(route('api.v1.members.merge-into', [$data->organization->getKey(), $memberSource->getKey()]), [
|
|
||||||
'member_id' => $memberDestination->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(204);
|
|
||||||
$this->assertSame('', $response->getContent());
|
|
||||||
$this->assertDatabaseMissing(Member::class, [
|
|
||||||
'id' => $memberSource->getKey(),
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseMissing(User::class, [
|
|
||||||
'id' => $userSource->getKey(),
|
|
||||||
]);
|
|
||||||
$memberDestination->refresh();
|
|
||||||
$this->assertCount(3, $memberDestination->timeEntries);
|
|
||||||
$this->assertCount(1, $memberDestination->projectMembers);
|
|
||||||
$this->assertDatabaseHas(ProjectMember::class, [
|
|
||||||
'project_id' => $project->getKey(),
|
|
||||||
'member_id' => $memberDestination->getKey(),
|
|
||||||
'user_id' => $userDestination->getKey(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_merge_into_assigns_resources_of_source_member_to_destination_member_and_deletes_member_with_existing_destination_resources(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'members:merge-into',
|
|
||||||
]);
|
|
||||||
$userSource = User::factory()->placeholder()->create();
|
|
||||||
$memberSource = Member::factory()->forUser($userSource)->forOrganization($data->organization)->role(Role::Placeholder)->create();
|
|
||||||
TimeEntry::factory()->forMember($memberSource)->createMany(3);
|
|
||||||
$project = Project::factory()->forOrganization($data->organization)->create();
|
|
||||||
ProjectMember::factory()->forMember($memberSource)->forProject($project)->create([
|
|
||||||
'billable_rate' => 32100,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$userDestination = User::factory()->create();
|
|
||||||
$memberDestination = Member::factory()->forUser($userDestination)->forOrganization($data->organization)->role(Role::Admin)->create();
|
|
||||||
ProjectMember::factory()->forMember($memberDestination)->forProject($project)->create([
|
|
||||||
'billable_rate' => 12300,
|
|
||||||
]);
|
|
||||||
TimeEntry::factory()->forMember($memberDestination)->createMany(3);
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->withoutExceptionHandling()->postJson(route('api.v1.members.merge-into', [$data->organization->getKey(), $memberSource->getKey()]), [
|
|
||||||
'member_id' => $memberDestination->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(204);
|
|
||||||
$this->assertSame('', $response->getContent());
|
|
||||||
$this->assertDatabaseMissing(Member::class, [
|
|
||||||
'id' => $memberSource->getKey(),
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseMissing(User::class, [
|
|
||||||
'id' => $userSource->getKey(),
|
|
||||||
]);
|
|
||||||
$memberDestination->refresh();
|
|
||||||
$this->assertCount(6, $memberDestination->timeEntries);
|
|
||||||
$this->assertCount(1, $memberDestination->projectMembers);
|
|
||||||
$this->assertDatabaseHas(ProjectMember::class, [
|
|
||||||
'project_id' => $project->getKey(),
|
|
||||||
'billable_rate' => 12300,
|
|
||||||
'member_id' => $memberDestination->getKey(),
|
|
||||||
'user_id' => $userDestination->getKey(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_update_member_fails_if_user_tries_to_change_role_of_the_current_owner(): void
|
public function test_update_member_fails_if_user_tries_to_change_role_of_the_current_owner(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -491,7 +281,6 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
|||||||
|
|
||||||
public function test_invite_placeholder_succeeds_if_data_is_valid(): void
|
public function test_invite_placeholder_succeeds_if_data_is_valid(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
|
||||||
$data = $this->createUserWithPermission([
|
$data = $this->createUserWithPermission([
|
||||||
'members:invite-placeholder',
|
'members:invite-placeholder',
|
||||||
]);
|
]);
|
||||||
@@ -512,38 +301,6 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response->assertStatus(204);
|
$response->assertStatus(204);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_invite_placeholder_fails_if_the_placeholder_has_a_invalid_email_from_an_import(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'members:invite-placeholder',
|
|
||||||
]);
|
|
||||||
$user = User::factory()->create([
|
|
||||||
'is_placeholder' => true,
|
|
||||||
'email' => 'some.user@solidtime-import.test',
|
|
||||||
]);
|
|
||||||
$member = Member::factory()
|
|
||||||
->forUser($user)
|
|
||||||
->forOrganization($data->organization)
|
|
||||||
->role(Role::Placeholder)
|
|
||||||
->create();
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->postJson(route('api.v1.members.invite-placeholder', [
|
|
||||||
'organization' => $data->organization->getKey(),
|
|
||||||
'member' => $member->getKey(),
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(400);
|
|
||||||
$response->assertExactJson([
|
|
||||||
'error' => true,
|
|
||||||
'key' => 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception',
|
|
||||||
'message' => 'This placeholder can not be invited use the merge tool instead',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_destroy_member_fails_if_user_has_no_permission_to_delete_members(): void
|
public function test_destroy_member_fails_if_user_has_no_permission_to_delete_members(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -698,34 +455,6 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
|||||||
Event::assertNotDispatched(MemberMadeToPlaceholder::class);
|
Event::assertNotDispatched(MemberMadeToPlaceholder::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_make_placeholder_fails_if_user_is_already_a_placeholder(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
Event::fake([
|
|
||||||
MemberMadeToPlaceholder::class,
|
|
||||||
]);
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'members:make-placeholder',
|
|
||||||
]);
|
|
||||||
$user = User::factory()->placeholder()->create();
|
|
||||||
$member = Member::factory()->forUser($user)->forOrganization($data->organization)->role(Role::Placeholder)->create();
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->postJson(route('api.v1.members.make-placeholder', [
|
|
||||||
'organization' => $data->organization->getKey(),
|
|
||||||
'member' => $member->getKey(),
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(400);
|
|
||||||
$response->assertExactJson([
|
|
||||||
'error' => true,
|
|
||||||
'key' => 'changing_role_of_placeholder_is_not_allowed',
|
|
||||||
'message' => 'Changing role of placeholder is not allowed',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_make_placeholder_fails_if_member_is_owner(): void
|
public function test_make_placeholder_fails_if_member_is_owner(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use PHPUnit\Framework\Attributes\UsesClass;
|
|||||||
#[UsesClass(DashboardController::class)]
|
#[UsesClass(DashboardController::class)]
|
||||||
class DashboardEndpointTest extends EndpointTestAbstract
|
class DashboardEndpointTest extends EndpointTestAbstract
|
||||||
{
|
{
|
||||||
public function test_showing_dashboard_succeeds_for_empty_user(): void
|
public function test_showing_dashboard_succeeds_for_empty_user_with_no_data_entries(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
$user = User::factory()->withPersonalOrganization()->create();
|
$user = User::factory()->withPersonalOrganization()->create();
|
||||||
@@ -27,9 +27,30 @@ class DashboardEndpointTest extends EndpointTestAbstract
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertSuccessful();
|
$response->assertSuccessful();
|
||||||
|
$response->assertInertia(fn (Assert $page) => $page
|
||||||
|
->has('weeklyProjectOverview')
|
||||||
|
->has('latestTasks')
|
||||||
|
->has('lastSevenDays')
|
||||||
|
->has('latestTeamActivity')
|
||||||
|
->has('dailyTrackedHours')
|
||||||
|
->has('totalWeeklyTime')
|
||||||
|
->has('totalWeeklyBillableTime')
|
||||||
|
->has('totalWeeklyBillableAmount')
|
||||||
|
->has('weeklyHistory')
|
||||||
|
->whereNot('weeklyProjectOverview', null)
|
||||||
|
->whereNot('latestTasks', null)
|
||||||
|
->whereNot('lastSevenDays', null)
|
||||||
|
->whereNot('latestTeamActivity', null)
|
||||||
|
->whereNot('dailyTrackedHours', null)
|
||||||
|
->whereNot('totalWeeklyTime', null)
|
||||||
|
->whereNot('totalWeeklyBillableTime', null)
|
||||||
|
->whereNot('totalWeeklyBillableAmount', null)
|
||||||
|
->whereNot('weeklyHistory', null)
|
||||||
|
->whereNot('latestTeamActivity', null)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_showing_dashboard_succeeds_for_user_with_employee_role(): void
|
public function test_showing_dashboard_succeeds_with_less_data_for_user_with_employee_role(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
$organization = Organization::factory()->create();
|
$organization = Organization::factory()->create();
|
||||||
@@ -42,5 +63,25 @@ class DashboardEndpointTest extends EndpointTestAbstract
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$response->assertSuccessful();
|
$response->assertSuccessful();
|
||||||
|
$response->assertInertia(fn (Assert $page) => $page
|
||||||
|
->has('weeklyProjectOverview')
|
||||||
|
->has('latestTasks')
|
||||||
|
->has('lastSevenDays')
|
||||||
|
->has('latestTeamActivity')
|
||||||
|
->has('dailyTrackedHours')
|
||||||
|
->has('totalWeeklyTime')
|
||||||
|
->has('totalWeeklyBillableTime')
|
||||||
|
->has('totalWeeklyBillableAmount')
|
||||||
|
->has('weeklyHistory')
|
||||||
|
->whereNot('weeklyProjectOverview', null)
|
||||||
|
->whereNot('latestTasks', null)
|
||||||
|
->whereNot('lastSevenDays', null)
|
||||||
|
->where('latestTeamActivity', null)
|
||||||
|
->whereNot('dailyTrackedHours', null)
|
||||||
|
->whereNot('totalWeeklyTime', null)
|
||||||
|
->whereNot('totalWeeklyBillableTime', null)
|
||||||
|
->whereNot('totalWeeklyBillableAmount', null)
|
||||||
|
->whereNot('weeklyHistory', null)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Unit\Service\Import\Importers;
|
|
||||||
|
|
||||||
use App\Models\Client;
|
|
||||||
use App\Models\Organization;
|
|
||||||
use App\Models\Project;
|
|
||||||
use App\Models\Task;
|
|
||||||
use App\Service\ColorService;
|
|
||||||
use App\Service\Import\Importers\DefaultImporter;
|
|
||||||
use App\Service\Import\Importers\GenericProjectsImporter;
|
|
||||||
use App\Service\Import\Importers\ImportException;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
|
||||||
use PHPUnit\Framework\Attributes\UsesClass;
|
|
||||||
|
|
||||||
#[CoversClass(GenericProjectsImporter::class)]
|
|
||||||
#[CoversClass(ImportException::class)]
|
|
||||||
#[CoversClass(DefaultImporter::class)]
|
|
||||||
#[UsesClass(GenericProjectsImporter::class)]
|
|
||||||
class GenericProjectsImporterTest extends ImporterTestAbstract
|
|
||||||
{
|
|
||||||
public function test_import_of_test_file_succeeds(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
$timezone = 'Europe/Vienna';
|
|
||||||
$importer = new GenericProjectsImporter;
|
|
||||||
$importer->init($organization);
|
|
||||||
$data = Storage::disk('testfiles')->get('generic_projects_import_test_1.csv');
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$importer->importData($data, $timezone);
|
|
||||||
$report = $importer->getReport();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$clients = Client::all();
|
|
||||||
$this->assertCount(2, $clients);
|
|
||||||
$client1 = $clients->firstWhere('name', 'Big Company');
|
|
||||||
$this->assertNotNull($client1);
|
|
||||||
$client2 = $clients->firstWhere('name', 'Some client');
|
|
||||||
$this->assertNotNull($client2);
|
|
||||||
$projects = Project::all();
|
|
||||||
$this->assertCount(3, $projects);
|
|
||||||
// Project 1
|
|
||||||
$project1 = $projects->firstWhere('name', 'Project for Big Company');
|
|
||||||
$this->assertNotNull($project1);
|
|
||||||
$this->assertTrue(app(ColorService::class)->isBuiltInColor($project1->color));
|
|
||||||
$this->assertSame(10001, $project1->billable_rate);
|
|
||||||
$this->assertFalse($project1->is_public);
|
|
||||||
$this->assertSame($client1->getKey(), $project1->client_id);
|
|
||||||
$this->assertTrue($project1->is_billable);
|
|
||||||
$this->assertSame(null, $project1->estimated_time);
|
|
||||||
$this->assertNull($project1->archived_at);
|
|
||||||
// Project 2
|
|
||||||
$project2 = $projects->firstWhere('name', 'Project without Client');
|
|
||||||
$this->assertNotNull($project2);
|
|
||||||
$this->assertSame('#ef5350', $project2->color);
|
|
||||||
$this->assertSame(null, $project2->billable_rate);
|
|
||||||
$this->assertFalse($project2->is_public);
|
|
||||||
$this->assertSame(null, $project2->client_id);
|
|
||||||
$this->assertFalse($project2->is_billable);
|
|
||||||
$this->assertSame(1000, $project2->estimated_time);
|
|
||||||
$this->assertSame(null, $project2->archived_at);
|
|
||||||
$project3 = $projects->firstWhere('name', 'Project (Archived)');
|
|
||||||
$this->assertNotNull($project3);
|
|
||||||
$this->assertSame('#6a407f', $project3->color);
|
|
||||||
$this->assertSame(null, $project3->billable_rate);
|
|
||||||
$this->assertTrue($project3->is_public);
|
|
||||||
$this->assertSame($client2->getKey(), $project3->client_id);
|
|
||||||
$this->assertTrue($project3->is_billable);
|
|
||||||
$this->assertSame(null, $project3->estimated_time);
|
|
||||||
$this->assertSame('2024-08-25T10:00:00Z', $project3->archived_at->toIso8601ZuluString());
|
|
||||||
|
|
||||||
$tasks = Task::all();
|
|
||||||
$this->assertCount(0, $tasks);
|
|
||||||
|
|
||||||
$this->assertSame(0, $report->timeEntriesCreated);
|
|
||||||
$this->assertSame(0, $report->tagsCreated);
|
|
||||||
$this->assertSame(0, $report->tasksCreated);
|
|
||||||
$this->assertSame(0, $report->usersCreated);
|
|
||||||
$this->assertSame(3, $report->projectsCreated);
|
|
||||||
$this->assertSame(2, $report->clientsCreated);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Unit\Service\Import\Importers;
|
|
||||||
|
|
||||||
use App\Models\Organization;
|
|
||||||
use App\Service\Import\Importers\DefaultImporter;
|
|
||||||
use App\Service\Import\Importers\GenericTimeEntriesImporter;
|
|
||||||
use App\Service\Import\Importers\ImportException;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
|
||||||
use PHPUnit\Framework\Attributes\UsesClass;
|
|
||||||
|
|
||||||
#[CoversClass(GenericTimeEntriesImporter::class)]
|
|
||||||
#[CoversClass(ImportException::class)]
|
|
||||||
#[CoversClass(DefaultImporter::class)]
|
|
||||||
#[UsesClass(GenericTimeEntriesImporter::class)]
|
|
||||||
class GenericTimeEntriesImporterTest extends ImporterTestAbstract
|
|
||||||
{
|
|
||||||
public function test_import_of_test_file_succeeds(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
$timezone = 'Europe/Vienna';
|
|
||||||
$importer = new GenericTimeEntriesImporter;
|
|
||||||
$importer->init($organization);
|
|
||||||
$data = Storage::disk('testfiles')->get('generic_time_entries_import_test_1.csv');
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$importer->importData($data, $timezone);
|
|
||||||
$report = $importer->getReport();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries();
|
|
||||||
$this->checkTimeEntries($testScenario);
|
|
||||||
$this->assertSame(2, $report->timeEntriesCreated);
|
|
||||||
$this->assertSame(2, $report->tagsCreated);
|
|
||||||
$this->assertSame(1, $report->tasksCreated);
|
|
||||||
$this->assertSame(1, $report->usersCreated);
|
|
||||||
$this->assertSame(2, $report->projectsCreated);
|
|
||||||
$this->assertSame(1, $report->clientsCreated);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_import_of_test_file_twice_succeeds(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
$timezone = 'Europe/Vienna';
|
|
||||||
$importer = new GenericTimeEntriesImporter;
|
|
||||||
$importer->init($organization);
|
|
||||||
$data = Storage::disk('testfiles')->get('generic_time_entries_import_test_1.csv');
|
|
||||||
$importer->importData($data, $timezone);
|
|
||||||
$importer = new GenericTimeEntriesImporter;
|
|
||||||
$importer->init($organization);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$importer->importData($data, $timezone);
|
|
||||||
$report = $importer->getReport();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries();
|
|
||||||
$this->checkTimeEntries($testScenario, true);
|
|
||||||
$this->assertSame(2, $report->timeEntriesCreated);
|
|
||||||
$this->assertSame(0, $report->tagsCreated);
|
|
||||||
$this->assertSame(0, $report->tasksCreated);
|
|
||||||
$this->assertSame(0, $report->usersCreated);
|
|
||||||
$this->assertSame(0, $report->projectsCreated);
|
|
||||||
$this->assertSame(0, $report->clientsCreated);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Unit\Service\Import\Importers;
|
|
||||||
|
|
||||||
use App\Models\Client;
|
|
||||||
use App\Models\Organization;
|
|
||||||
use App\Service\Import\Importers\DefaultImporter;
|
|
||||||
use App\Service\Import\Importers\HarvestClientsImporter;
|
|
||||||
use App\Service\Import\Importers\ImportException;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
|
||||||
use PHPUnit\Framework\Attributes\UsesClass;
|
|
||||||
|
|
||||||
#[CoversClass(HarvestClientsImporter::class)]
|
|
||||||
#[CoversClass(ImportException::class)]
|
|
||||||
#[CoversClass(DefaultImporter::class)]
|
|
||||||
#[UsesClass(HarvestClientsImporter::class)]
|
|
||||||
class HarvestClientsImporterTest extends ImporterTestAbstract
|
|
||||||
{
|
|
||||||
public function test_import_of_test_file_succeeds(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
$timezone = 'Europe/Vienna';
|
|
||||||
$importer = new HarvestClientsImporter;
|
|
||||||
$importer->init($organization);
|
|
||||||
$data = Storage::disk('testfiles')->get('harvest_clients_import_test_1.csv');
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$importer->importData($data, $timezone);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$clients = Client::query()->whereBelongsTo($organization, 'organization')->get();
|
|
||||||
$this->assertCount(2, $clients);
|
|
||||||
$client1 = $clients->where('name', 'Example Client')->first();
|
|
||||||
$this->assertNotNull($client1);
|
|
||||||
// Client name in Harvest: \\ 🔥 Special characters """`!@#$%^&*()_+\-=\[\]{};':"\\|,.''<>\/?~ \\\
|
|
||||||
$client2 = $clients->where('name', '\\\\ 🔥 Special characters """`!@#$%^&*()_+\-=\[\]{};\':"\\\\|,.\'\'<>\/?~ \\\\\\')->first();
|
|
||||||
$this->assertNotNull($client2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Unit\Service\Import\Importers;
|
|
||||||
|
|
||||||
use App\Models\Client;
|
|
||||||
use App\Models\Organization;
|
|
||||||
use App\Models\Project;
|
|
||||||
use App\Service\Import\Importers\DefaultImporter;
|
|
||||||
use App\Service\Import\Importers\HarvestProjectsImporter;
|
|
||||||
use App\Service\Import\Importers\ImportException;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
|
||||||
use PHPUnit\Framework\Attributes\UsesClass;
|
|
||||||
|
|
||||||
#[CoversClass(HarvestProjectsImporter::class)]
|
|
||||||
#[CoversClass(ImportException::class)]
|
|
||||||
#[CoversClass(DefaultImporter::class)]
|
|
||||||
#[UsesClass(HarvestProjectsImporter::class)]
|
|
||||||
class HarvestProjectsImporterTest extends ImporterTestAbstract
|
|
||||||
{
|
|
||||||
public function test_import_of_test_file_succeeds(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
$timezone = 'Europe/Vienna';
|
|
||||||
$importer = new HarvestProjectsImporter;
|
|
||||||
$importer->init($organization);
|
|
||||||
$data = Storage::disk('testfiles')->get('harvest_projects_import_test_1.csv');
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$importer->importData($data, $timezone);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$clients = Client::query()->whereBelongsTo($organization, 'organization')->get();
|
|
||||||
$this->assertCount(2, $clients);
|
|
||||||
/** @var Client|null $client1 */
|
|
||||||
$client1 = $clients->where('name', 'Example Client')->first();
|
|
||||||
$this->assertNotNull($client1);
|
|
||||||
// Client name in Harvest: \\ 🔥 Special characters client """`!@#$%^&*()_+\-=\[\]{};':"\\|,.''<>\/?~ \\\
|
|
||||||
/** @var Client|null $client2 */
|
|
||||||
$client2 = $clients->where('name', '\\\\ 🔥 Special characters client """`!@#$%^&*()_+\-=\[\]{};\':"\\\\|,.\'\'<>\/?~ \\\\\\')->first();
|
|
||||||
$this->assertNotNull($client2);
|
|
||||||
|
|
||||||
$projects = Project::query()->whereBelongsTo($organization, 'organization')->get();
|
|
||||||
$this->assertCount(2, $projects);
|
|
||||||
/** @var Project|null $project1 */
|
|
||||||
$project1 = $projects->where('name', 'Example Project')->first();
|
|
||||||
$this->assertNotNull($project1);
|
|
||||||
$this->assertSame($client1->getKey(), $project1->client_id);
|
|
||||||
$this->assertSame(50 * 60 * 60, $project1->estimated_time); // 50h
|
|
||||||
$this->assertSame(true, $project1->is_billable);
|
|
||||||
/** @var Project|null $project2 */
|
|
||||||
$project2 = $projects->where('name', '\\\\ 🔥 Special characters project """`!@#$%^&*()_+\-=\[\]{};\':"\\\\|,.\'\'<>\/?~ \\\\\\')->first();
|
|
||||||
$this->assertNotNull($project2);
|
|
||||||
$this->assertSame($client2->getKey(), $project2->client_id);
|
|
||||||
$this->assertSame(null, $project2->estimated_time);
|
|
||||||
$this->assertSame(false, $project2->is_billable);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Unit\Service\Import\Importers;
|
|
||||||
|
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Models\Client;
|
|
||||||
use App\Models\Member;
|
|
||||||
use App\Models\Organization;
|
|
||||||
use App\Models\Project;
|
|
||||||
use App\Models\Tag;
|
|
||||||
use App\Models\Task;
|
|
||||||
use App\Models\TimeEntry;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Service\Import\Importers\DefaultImporter;
|
|
||||||
use App\Service\Import\Importers\HarvestTimeEntriesImporter;
|
|
||||||
use App\Service\Import\Importers\ImportException;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
|
||||||
use PHPUnit\Framework\Attributes\UsesClass;
|
|
||||||
|
|
||||||
#[CoversClass(HarvestTimeEntriesImporter::class)]
|
|
||||||
#[CoversClass(ImportException::class)]
|
|
||||||
#[CoversClass(DefaultImporter::class)]
|
|
||||||
#[UsesClass(HarvestTimeEntriesImporter::class)]
|
|
||||||
class HarvestTimeEntriesImporterTest extends ImporterTestAbstract
|
|
||||||
{
|
|
||||||
public function test_import_of_test_file_succeeds(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
$timezone = 'Europe/Vienna';
|
|
||||||
$importer = new HarvestTimeEntriesImporter;
|
|
||||||
$importer->init($organization);
|
|
||||||
$data = Storage::disk('testfiles')->get('harvest_time_entries_import_test_1.csv');
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$importer->importData($data, $timezone);
|
|
||||||
$report = $importer->getReport();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$users = User::all();
|
|
||||||
$this->assertCount(2, $users);
|
|
||||||
$user1 = $users->firstWhere('name', 'Peter Tester');
|
|
||||||
$this->assertNotNull($user1);
|
|
||||||
$this->assertSame(null, $user1->password);
|
|
||||||
$this->assertSame('Peter Tester', $user1->name);
|
|
||||||
$this->assertSame('peter.tester@solidtime-import.test', $user1->email);
|
|
||||||
$members = Member::all();
|
|
||||||
$this->assertCount(1, $members);
|
|
||||||
$member1 = $members->firstWhere('user_id', $user1->getKey());
|
|
||||||
$this->assertNotNull($member1);
|
|
||||||
$this->assertSame(Role::Placeholder->value, $member1->role);
|
|
||||||
$clients = Client::all();
|
|
||||||
$this->assertCount(1, $clients);
|
|
||||||
$client1 = $clients->firstWhere('name', 'Big Company');
|
|
||||||
$this->assertNotNull($client1);
|
|
||||||
$this->assertNull($client1->archived_at);
|
|
||||||
$projects = Project::with(['members'])->get();
|
|
||||||
$this->assertCount(2, $projects);
|
|
||||||
/** @var Project|null $project1 */
|
|
||||||
$project1 = $projects->firstWhere('name', 'Project without Client');
|
|
||||||
$this->assertNotNull($project1);
|
|
||||||
$this->assertNull($project1->client_id);
|
|
||||||
/** @var Project|null $project2 */
|
|
||||||
$project2 = $projects->firstWhere('name', 'Project for Big Company');
|
|
||||||
$this->assertNotNull($project2);
|
|
||||||
$this->assertSame($client1->getKey(), $project2->client_id);
|
|
||||||
$project3 = null;
|
|
||||||
// Project without Client
|
|
||||||
$this->assertSame(false, $project1->is_public);
|
|
||||||
// Project for Big Company
|
|
||||||
$this->assertSame(false, $project2->is_public);
|
|
||||||
$tasks = Task::all();
|
|
||||||
$this->assertCount(1, $tasks);
|
|
||||||
$task1 = $tasks->firstWhere('name', 'Task 1');
|
|
||||||
$this->assertNotNull($task1);
|
|
||||||
$this->assertNull($task1->done_at);
|
|
||||||
$this->assertSame($project2->getKey(), $task1->project_id);
|
|
||||||
$tags = Tag::all();
|
|
||||||
$this->assertCount(0, $tags);
|
|
||||||
|
|
||||||
$timeEntries = TimeEntry::all();
|
|
||||||
$this->assertCount(2, $timeEntries);
|
|
||||||
$timeEntry1 = $timeEntries->firstWhere('description', '');
|
|
||||||
$this->assertNotNull($timeEntry1);
|
|
||||||
$this->assertSame('', $timeEntry1->description);
|
|
||||||
$this->assertSame('2024-03-03 23:00:00', $timeEntry1->start->toDateTimeString());
|
|
||||||
$this->assertSame('2024-03-04 19:00:00', $timeEntry1->end->toDateTimeString());
|
|
||||||
$this->assertFalse($timeEntry1->billable);
|
|
||||||
$this->assertTrue($timeEntry1->is_imported);
|
|
||||||
$this->assertSame([], $timeEntry1->tags);
|
|
||||||
$timeEntry2 = $timeEntries->firstWhere('description', 'Working hard');
|
|
||||||
$this->assertNotNull($timeEntry2);
|
|
||||||
$this->assertSame('Working hard', $timeEntry2->description);
|
|
||||||
$this->assertSame('2024-03-03 23:00:00', $timeEntry2->start->toDateTimeString());
|
|
||||||
$this->assertSame('2024-03-03 23:00:36', $timeEntry2->end->toDateTimeString());
|
|
||||||
$this->assertTrue($timeEntry2->billable);
|
|
||||||
$this->assertTrue($timeEntry2->is_imported);
|
|
||||||
$this->assertSame([], $timeEntry2->tags);
|
|
||||||
|
|
||||||
$this->assertSame(2, $report->timeEntriesCreated);
|
|
||||||
$this->assertSame(0, $report->tagsCreated);
|
|
||||||
$this->assertSame(1, $report->tasksCreated);
|
|
||||||
$this->assertSame(1, $report->usersCreated);
|
|
||||||
$this->assertSame(2, $report->projectsCreated);
|
|
||||||
$this->assertSame(1, $report->clientsCreated);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -42,11 +42,6 @@ class ImporterProviderTest extends TestCase
|
|||||||
'clockify_time_entries',
|
'clockify_time_entries',
|
||||||
'clockify_projects',
|
'clockify_projects',
|
||||||
'solidtime',
|
'solidtime',
|
||||||
'harvest_projects',
|
|
||||||
'harvest_time_entries',
|
|
||||||
'harvest_clients',
|
|
||||||
'generic_projects',
|
|
||||||
'generic_time_entries',
|
|
||||||
], $keys);
|
], $keys);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace Tests\Unit\Service\Import\Importers;
|
namespace Tests\Unit\Service\Import\Importers;
|
||||||
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
|
||||||
use App\Service\Import\Importers\DefaultImporter;
|
use App\Service\Import\Importers\DefaultImporter;
|
||||||
use App\Service\Import\Importers\ImportException;
|
use App\Service\Import\Importers\ImportException;
|
||||||
use App\Service\Import\Importers\TogglDataImporter;
|
use App\Service\Import\Importers\TogglDataImporter;
|
||||||
@@ -89,30 +88,4 @@ class TogglDataImporterTest extends ImporterTestAbstract
|
|||||||
$this->assertSame(0, $report->projectsCreated);
|
$this->assertSame(0, $report->projectsCreated);
|
||||||
$this->assertSame(0, $report->clientsCreated);
|
$this->assertSame(0, $report->clientsCreated);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_import_of_user_with_unknown_timezone_will_be_mapped_to_utc(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$zipPath = $this->createTestZip('toggl_data_import_test_2');
|
|
||||||
$timezone = 'Europe/Vienna';
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
$importer = new TogglDataImporter;
|
|
||||||
$importer->init($organization);
|
|
||||||
$data = file_get_contents($zipPath);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$importer->importData($data, $timezone);
|
|
||||||
$report = $importer->getReport();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertSame(0, $report->timeEntriesCreated);
|
|
||||||
$this->assertSame(2, $report->tagsCreated);
|
|
||||||
$this->assertSame(2, $report->tasksCreated);
|
|
||||||
$this->assertSame(1, $report->usersCreated);
|
|
||||||
$this->assertSame(3, $report->projectsCreated);
|
|
||||||
$this->assertSame(2, $report->clientsCreated);
|
|
||||||
$user = User::query()->where('email', '=', 'peter.test@email.test')->first();
|
|
||||||
$this->assertSame('UTC', $user->timezone);
|
|
||||||
$this->assertTrue($user->is_placeholder);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,94 +113,4 @@ class MemberServiceTest extends TestCaseWithDatabase
|
|||||||
$this->assertSame($otherMember->getKey(), $otherTimeEntry->member_id);
|
$this->assertSame($otherMember->getKey(), $otherTimeEntry->member_id);
|
||||||
$this->assertSame(1, $otherUser->organizations()->count());
|
$this->assertSame(1, $otherUser->organizations()->count());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_assign_organization_entities_to_different_member_without_any_entries(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
$project = Project::factory()->forOrganization($organization)->create();
|
|
||||||
$otherUser = User::factory()->create();
|
|
||||||
$fromUser = User::factory()->create();
|
|
||||||
$toUser = User::factory()->create();
|
|
||||||
$otherUserMember = Member::factory()->forOrganization($organization)->forUser($otherUser)->create();
|
|
||||||
$fromUserMember = Member::factory()->forOrganization($organization)->forUser($fromUser)->create();
|
|
||||||
$toUserMember = Member::factory()->forOrganization($organization)->forUser($toUser)->create();
|
|
||||||
TimeEntry::factory()->forOrganization($organization)->forMember($otherUserMember)->createMany(3);
|
|
||||||
TimeEntry::factory()->forOrganization($organization)->forMember($fromUserMember)->createMany(3);
|
|
||||||
ProjectMember::factory()->forProject($project)->forMember($otherUserMember)->create();
|
|
||||||
ProjectMember::factory()->forProject($project)->forMember($fromUserMember)->create();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$this->memberService->assignOrganizationEntitiesToDifferentMember($organization, $fromUserMember, $toUserMember);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertSame(3, TimeEntry::query()->whereBelongsTo($toUser, 'user')->count());
|
|
||||||
$this->assertSame(3, TimeEntry::query()->whereBelongsTo($otherUser, 'user')->count());
|
|
||||||
$this->assertSame(0, TimeEntry::query()->whereBelongsTo($fromUser, 'user')->count());
|
|
||||||
$this->assertSame(1, ProjectMember::query()->whereBelongsTo($toUser, 'user')->count());
|
|
||||||
$this->assertSame(1, ProjectMember::query()->whereBelongsTo($otherUser, 'user')->count());
|
|
||||||
$this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUser, 'user')->count());
|
|
||||||
|
|
||||||
$this->assertSame(3, TimeEntry::query()->whereBelongsTo($toUserMember, 'member')->count());
|
|
||||||
$this->assertSame(3, TimeEntry::query()->whereBelongsTo($otherUserMember, 'member')->count());
|
|
||||||
$this->assertSame(0, TimeEntry::query()->whereBelongsTo($fromUserMember, 'member')->count());
|
|
||||||
$this->assertSame(1, ProjectMember::query()->whereBelongsTo($toUserMember, 'member')->count());
|
|
||||||
$this->assertSame(1, ProjectMember::query()->whereBelongsTo($otherUserMember, 'member')->count());
|
|
||||||
$this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUserMember, 'member')->count());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_assign_organization_entities_to_different_member_with_entries(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
$project = Project::factory()->forOrganization($organization)->create();
|
|
||||||
$otherUser = User::factory()->create();
|
|
||||||
$fromUser = User::factory()->create();
|
|
||||||
$toUser = User::factory()->create();
|
|
||||||
$otherUserMember = Member::factory()->forOrganization($organization)->forUser($otherUser)->create();
|
|
||||||
$fromUserMember = Member::factory()->forOrganization($organization)->forUser($fromUser)->create();
|
|
||||||
$toUserMember = Member::factory()->forOrganization($organization)->forUser($toUser)->create();
|
|
||||||
TimeEntry::factory()->forOrganization($organization)->forMember($otherUserMember)->createMany(3);
|
|
||||||
TimeEntry::factory()->forOrganization($organization)->forMember($fromUserMember)->createMany(3);
|
|
||||||
TimeEntry::factory()->forOrganization($organization)->forMember($toUserMember)->createMany(3);
|
|
||||||
ProjectMember::factory()->forProject($project)->forMember($otherUserMember)->create([
|
|
||||||
'billable_rate' => 1,
|
|
||||||
]);
|
|
||||||
ProjectMember::factory()->forProject($project)->forMember($fromUserMember)->create([
|
|
||||||
'billable_rate' => 2,
|
|
||||||
]);
|
|
||||||
ProjectMember::factory()->forProject($project)->forMember($toUserMember)->create([
|
|
||||||
'billable_rate' => 3,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$this->memberService->assignOrganizationEntitiesToDifferentMember($organization, $fromUserMember, $toUserMember);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertSame(6, TimeEntry::query()->whereBelongsTo($toUser, 'user')->count());
|
|
||||||
$this->assertSame(3, TimeEntry::query()->whereBelongsTo($otherUser, 'user')->count());
|
|
||||||
$this->assertSame(0, TimeEntry::query()->whereBelongsTo($fromUser, 'user')->count());
|
|
||||||
$this->assertSame(1, ProjectMember::query()->whereBelongsTo($toUser, 'user')->count());
|
|
||||||
$this->assertSame(1, ProjectMember::query()->whereBelongsTo($otherUser, 'user')->count());
|
|
||||||
$this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUser, 'user')->count());
|
|
||||||
|
|
||||||
$this->assertSame(6, TimeEntry::query()->whereBelongsTo($toUserMember, 'member')->count());
|
|
||||||
$this->assertSame(3, TimeEntry::query()->whereBelongsTo($otherUserMember, 'member')->count());
|
|
||||||
$this->assertSame(0, TimeEntry::query()->whereBelongsTo($fromUserMember, 'member')->count());
|
|
||||||
$this->assertSame(1, ProjectMember::query()->whereBelongsTo($toUserMember, 'member')->count());
|
|
||||||
$this->assertSame(1, ProjectMember::query()->whereBelongsTo($otherUserMember, 'member')->count());
|
|
||||||
$this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUserMember, 'member')->count());
|
|
||||||
|
|
||||||
$this->assertDatabaseCount(ProjectMember::class, 2);
|
|
||||||
$this->assertDatabaseHas(ProjectMember::class, [
|
|
||||||
'project_id' => $project->id,
|
|
||||||
'member_id' => $toUserMember->id,
|
|
||||||
'billable_rate' => 3,
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas(ProjectMember::class, [
|
|
||||||
'project_id' => $project->id,
|
|
||||||
'member_id' => $otherUserMember->id,
|
|
||||||
'billable_rate' => 1,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
Weekday::Monday,
|
Weekday::Monday,
|
||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
null,
|
null
|
||||||
true
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -89,7 +88,6 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
false,
|
false,
|
||||||
Carbon::now()->subDays(2)->utc(),
|
Carbon::now()->subDays(2)->utc(),
|
||||||
Carbon::now()->subDay()->utc(),
|
Carbon::now()->subDay()->utc(),
|
||||||
true
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -139,91 +137,6 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
], $result);
|
], $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_aggregate_time_entries_without_billable_amounts(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$project1 = Project::factory()->create([
|
|
||||||
// Note: To ensure deterministic order
|
|
||||||
'id' => '5de4e6df-9560-4675-95be-18d42c441bfc',
|
|
||||||
]);
|
|
||||||
$project2 = Project::factory()->create([
|
|
||||||
// Note: To ensure deterministic order
|
|
||||||
'id' => '130bdf66-d370-4564-aec7-7171e9b415f7',
|
|
||||||
]);
|
|
||||||
TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project1)->create([
|
|
||||||
'description' => 'Test',
|
|
||||||
]);
|
|
||||||
TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project2)->create([
|
|
||||||
'description' => '',
|
|
||||||
]);
|
|
||||||
TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project1)->create([
|
|
||||||
'description' => 'Test',
|
|
||||||
]);
|
|
||||||
TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project2)->create([
|
|
||||||
'description' => 'Test',
|
|
||||||
]);
|
|
||||||
$query = TimeEntry::query();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$result = $this->service->getAggregatedTimeEntries(
|
|
||||||
$query,
|
|
||||||
TimeEntryAggregationType::Project,
|
|
||||||
TimeEntryAggregationType::Description,
|
|
||||||
'Europe/Vienna',
|
|
||||||
Weekday::Monday,
|
|
||||||
false,
|
|
||||||
Carbon::now()->subDays(2)->utc(),
|
|
||||||
Carbon::now()->subDay()->utc(),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertSame([
|
|
||||||
'seconds' => 40,
|
|
||||||
'cost' => null,
|
|
||||||
'grouped_type' => 'project',
|
|
||||||
'grouped_data' => [
|
|
||||||
[
|
|
||||||
'key' => $project2->getKey(),
|
|
||||||
'seconds' => 20,
|
|
||||||
'cost' => null,
|
|
||||||
'grouped_type' => 'description',
|
|
||||||
'grouped_data' => [
|
|
||||||
[
|
|
||||||
'key' => null,
|
|
||||||
'seconds' => 10,
|
|
||||||
'cost' => null,
|
|
||||||
'grouped_type' => null,
|
|
||||||
'grouped_data' => null,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => 'Test',
|
|
||||||
'seconds' => 10,
|
|
||||||
'cost' => null,
|
|
||||||
'grouped_type' => null,
|
|
||||||
'grouped_data' => null,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => $project1->getKey(),
|
|
||||||
'seconds' => 20,
|
|
||||||
'cost' => null,
|
|
||||||
'grouped_type' => 'description',
|
|
||||||
'grouped_data' => [
|
|
||||||
[
|
|
||||||
'key' => 'Test',
|
|
||||||
'seconds' => 20,
|
|
||||||
'cost' => null,
|
|
||||||
'grouped_type' => null,
|
|
||||||
'grouped_data' => null,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
], $result);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_aggregate_time_entries_empty_state_by_day_and_project_with_filled_gaps(): void
|
public function test_aggregate_time_entries_empty_state_by_day_and_project_with_filled_gaps(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -240,7 +153,6 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
true,
|
true,
|
||||||
Carbon::now()->subDays(2)->utc(),
|
Carbon::now()->subDays(2)->utc(),
|
||||||
Carbon::now()->subDay()->utc(),
|
Carbon::now()->subDay()->utc(),
|
||||||
true
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -282,7 +194,6 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
true,
|
true,
|
||||||
Carbon::now()->subDays(2),
|
Carbon::now()->subDays(2),
|
||||||
Carbon::now()->subDay(),
|
Carbon::now()->subDay(),
|
||||||
true
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -309,7 +220,6 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
true,
|
true,
|
||||||
Carbon::now()->subDays(2),
|
Carbon::now()->subDays(2),
|
||||||
Carbon::now()->subDay(),
|
Carbon::now()->subDay(),
|
||||||
true
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -344,8 +254,7 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
Weekday::Monday,
|
Weekday::Monday,
|
||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
null,
|
null
|
||||||
true
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -433,8 +342,7 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
Weekday::Monday,
|
Weekday::Monday,
|
||||||
true,
|
true,
|
||||||
null,
|
null,
|
||||||
null,
|
null
|
||||||
true
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|||||||
@@ -59,6 +59,22 @@ class UserServiceTest extends TestCase
|
|||||||
$this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUser, 'user')->count());
|
$this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUser, 'user')->count());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_assign_organization_entities_to_different_user_fails_if_new_user_is_not_member_of_organization(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$fromUser = User::factory()->create();
|
||||||
|
$toUser = User::factory()->create();
|
||||||
|
$fromUserMember = Member::factory()->forOrganization($organization)->forUser($fromUser)->create();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
$this->userService->assignOrganizationEntitiesToDifferentUser($organization, $fromUser, $toUser);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
$this->assertSame('User is not a member of the organization', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function test_change_ownership_changes_ownership_of_organization_to_new_user(): void
|
public function test_change_ownership_changes_ownership_of_organization_to_new_user(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
Reference in New Issue
Block a user