Compare commits

...

26 Commits

Author SHA1 Message Date
Gregor Vostrak
9070f6cd7e change dashboard ui to use api instead of inertia props 2025-03-19 14:54:36 +01:00
Constantin Graf
919399e828 Add chart endpoints 2025-03-14 12:34:31 +01:00
Constantin Graf
aa3c64e496 Allow members:make-placeholder for admins 2025-03-10 16:26:08 +01:00
Gregor Vostrak
eee13897c9 add frontend to deactivate user 2025-03-10 15:43:08 +01:00
Gregor Vostrak
ac6e2b8079 fetch tasks on project show page, fixes #253 2025-03-10 15:43:08 +01:00
Gregor Vostrak
50cc7053e4 hide total billable amounts from employees when employees_can_see_billable_rates is disabled 2025-03-10 15:43:08 +01:00
Constantin Graf
73ce5f793d Fixed problem with merge into when project members already exist in destination member 2025-03-10 15:42:43 +01:00
Constantin Graf
02a716897d Fixed bug in merge into 2025-03-06 15:38:35 -05:00
Gregor Vostrak
e5ec11af44 add member merge frontend modal 2025-03-06 14:44:11 -05:00
Constantin Graf
ab263e725f Fixed bugs in member endpoints; Added merge-into member endpoint 2025-03-06 14:44:11 -05:00
Constantin Graf
f93c5370bf Add harvest and generic imports 2025-03-06 14:44:11 -05:00
dependabot[bot]
9faa8fe6e1 Bump codecov/codecov-action from 5.3.1 to 5.4.0
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.3.1 to 5.4.0.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5.3.1...v5.4.0)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-05 16:03:57 -05:00
Gregor Vostrak
9948cb1fc1 add focus loop to tag dropdown to improve focus management 2025-03-05 12:03:37 +01:00
Gregor Vostrak
3026edd27b fix datepicker dropdown and taborder in create time entry 2025-03-05 11:22:57 +01:00
Constantin Graf
b6bbcd7097 Fixed bug in toggl data importer if import contains invalid timezone 2025-03-04 17:08:28 -05:00
Constantin Graf
0d4ffa1061 Fixed GitHub issue templates 2025-02-18 12:21:53 -05:00
Constantin Graf
b7abe3738e Added GitHub issue templates 2025-02-18 11:55:48 -05:00
Constantin Graf
128a21ba63 Fix docker for ARM 2025-02-18 11:55:48 -05:00
Constantin Graf
e25461a439 Fix desktop auth 2025-02-14 10:55:20 -05:00
Gregor Vostrak
ba8751c7c4 add api key e2e tests and improve labels 2025-02-13 17:04:18 -05:00
Gregor Vostrak
21b33a0028 add api token expiry information notices 2025-02-13 17:04:18 -05:00
Gregor Vostrak
97585b5771 fix inconsistencies in dropdown highlighted item, indirectly fix flaky project member test 2025-02-13 17:04:18 -05:00
Constantin Graf
ae76135373 Add filament resource for tokens; Ignore non-personal tokens in API token endpoints 2025-02-13 17:04:18 -05:00
Constantin Graf
69a8c8bb2b Fixed api token endpoint documentation 2025-02-13 17:04:18 -05:00
Gregor Vostrak
4ea55e5867 add frontend support for api token create, delete and revoke 2025-02-13 17:04:18 -05:00
Constantin Graf
bbed618fdc Added API endpoints for user API tokens 2025-02-13 17:04:18 -05:00
133 changed files with 5917 additions and 732 deletions

41
.env.ci
View File

@@ -1,3 +1,4 @@
# Application
APP_NAME=solidtime
APP_ENV=local
APP_KEY=
@@ -19,35 +20,39 @@ DB_TEST_DATABASE=laravel
DB_TEST_USERNAME=root
DB_TEST_PASSWORD=root
BROADCAST_DRIVER=log
# Broadcasting
BROADCAST_DRIVER=null
# Cache
CACHE_DRIVER=file
# Queue
QUEUE_CONNECTION=sync
# Session
SESSION_DRIVER=database
SESSION_LIFETIME=120
# Mail
MAIL_MAILER=log
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
MAIL_FROM_NAME="solidtime"
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
MAIL_REPLY_TO_NAME="solidtime"
# Filesystems
FILESYSTEM_DISK=local
PUBLIC_FILESYSTEM_DISK=public
# Passport
PASSPORT_PERSONAL_ACCESS_CLIENT_ID="9e27f54d-5dfb-4dde-99d7-834518236c92"
PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET="EL5mXp3aF8ITjcwoOXRpbSK7zGrWhW4zTDpQXTkf"
# Auditing
AUDITING_ENABLED=true
# Telescope
TELESCOPE_ENABLED=false
# Services
GOTENBERG_URL=http://0.0.0.0:3000
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_HOST=
PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=mt1
VITE_APP_NAME="${APP_NAME}"
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
VITE_PUSHER_HOST="${PUSHER_HOST}"
VITE_PUSHER_PORT="${PUSHER_PORT}"
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"

View File

@@ -4,7 +4,7 @@ APP_ENV=local
APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
APP_DEBUG=true
APP_URL=https://solidtime.test
AUDITING_ENABLED=true
APP_FORCE_HTTPS=false
APP_ENABLE_REGISTRATION=true
SUPER_ADMINS=admin@example.com
PAGINATION_PER_PAGE_DEFAULT=500
@@ -49,7 +49,9 @@ MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
MAIL_FROM_NAME="${APP_NAME}"
MAIL_FROM_NAME="solidtime"
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
MAIL_REPLY_TO_NAME="solidtime"
# Filesystems
FILESYSTEM_DISK=s3
@@ -62,14 +64,24 @@ S3_URL=http://storage.solidtime.test/local
S3_ENDPOINT=http://storage.solidtime.test
S3_USE_PATH_STYLE_ENDPOINT=true
# Passport
PASSPORT_PERSONAL_ACCESS_CLIENT_ID="9e27f54d-5dfb-4dde-99d7-834518236c92"
PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET="EL5mXp3aF8ITjcwoOXRpbSK7zGrWhW4zTDpQXTkf"
# Auditing
AUDITING_ENABLED=true
# Telescope
TELESCOPE_ENABLED=false
# Services
GOTENBERG_URL=http://gotenberg:3000
VITE_HOST_NAME=vite.solidtime.test
VITE_APP_NAME="${APP_NAME}"
# Local setup
NGINX_HOST_NAME=solidtime.test
NETWORK_NAME=reverse-proxy-docker-traefik_routing
FORWARD_DB_PORT=5432
FORWARD_WEB_PORT=8083
VITE_HOST_NAME=vite.solidtime.test
VITE_APP_NAME="${APP_NAME}"
#SAIL_XDEBUG_MODE=develop,debug,coverage

47
.github/ISSUE_TEMPLATE/1_bug_report.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
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 Normal file
View File

@@ -0,0 +1,8 @@
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 Normal file
View File

@@ -0,0 +1,8 @@
<!--
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.
-->

View File

@@ -11,10 +11,21 @@ on:
- 'docker/prod/**'
workflow_dispatch:
env:
DOCKERHUB_REPO: solidtime/solidtime
GHCR_REPO: ghcr.io/solidtime-io/solidtime
name: Build - Public
jobs:
build:
runs-on: ubuntu-22.04
strategy:
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:
packages: write
contents: read
@@ -29,7 +40,7 @@ jobs:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
- name: "Get build"
id: build
id: release-build
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
- name: "Get Previous tag (normal push)"
@@ -40,7 +51,7 @@ jobs:
prefix: "v"
- name: "Get version"
id: version
id: release-version
run: |
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
@@ -61,21 +72,23 @@ jobs:
rm .env.production .env.ci .env.example
- name: "Add version to .env"
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.version.outputs.app_version }}/g' .env
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.release-version.outputs.app_version }}/g' .env
- name: "Add build to .env"
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.build.outputs.build }}/g' .env
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.release-build.outputs.build }}/g' .env
- name: "Output .env"
run: cat .env
- name: "Install dependencies"
uses: php-actions/composer@v6
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Setup PHP with PECL extension"
uses: shivammathur/setup-php@v2
with:
command: install
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
php_version: 8.3
php-version: '8.3'
extensions: mbstring, dom, fileinfo, pgsql
- name: "Install dependencies"
run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js"
uses: actions/setup-node@v4
@@ -88,29 +101,31 @@ jobs:
- name: "Build"
run: npm run build
- name: "Login to GitHub Container Registry"
- name: "Prepare"
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
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
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"
uses: docker/setup-qemu-action@v3
@@ -118,16 +133,90 @@ jobs:
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
- name: "Build and push"
- name: "Build and push by digest"
id: build
uses: docker/build-push-action@v6
with:
context: .
file: docker/prod/Dockerfile
build-args: |
DOCKER_FILES_BASE_PATH=docker/prod/
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
platforms: ${{ matrix.platform }}
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-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 }}

View File

@@ -8,7 +8,8 @@ jobs:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- name: "Checkout code"
uses: actions/checkout@v4
# Setup .npmrc file to publish to npm
- name: Install root project dependencies
run: npm ci

View File

@@ -8,7 +8,8 @@ jobs:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- name: "Checkout code"
uses: actions/checkout@v4
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@v4
with:

View File

@@ -63,7 +63,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v5.3.1
uses: codecov/codecov-action@v5.4.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime

View File

@@ -27,45 +27,47 @@ jobs:
- name: "Checkout code"
uses: actions/checkout@v4
- uses: actions/setup-node@v4
- name: "Setup node"
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: Setup PHP
- name: "Setup PHP"
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
coverage: none
- name: Run composer install
- name: "Run composer install"
run: composer install -n --prefer-dist
- name: Prepare Laravel Application
- name: "Prepare Laravel Application"
run: |
cp .env.ci .env
php artisan key:generate
php artisan migrate --seed
php artisan passport:keys
php artisan migrate --seed
- name: Install dependencies
- name: "Install dependencies"
run: npm ci
- name: Build Frontend
- name: "Build Frontend"
run: npm run build
- name: Run Laravel Server
- name: "Run Laravel Server"
run: php artisan serve > /dev/null 2>&1 &
- name: Install Playwright Browsers
- name: "Install Playwright Browsers"
run: npx playwright install --with-deps
- name: Run Playwright tests
- name: "Run Playwright tests"
run: npx playwright test
env:
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000'
- uses: actions/upload-artifact@v4
- name: "Upload test results"
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results

View File

@@ -0,0 +1,59 @@
<?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;
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException
{
public const string KEY = 'changing_role_of_placeholder_is_not_allowed';
}

View File

@@ -0,0 +1,10 @@
<?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';
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class PersonalAccessClientIsNotConfiguredException extends ApiException
{
public const string KEY = 'personal_access_client_is_not_configured';
}

View File

@@ -0,0 +1,10 @@
<?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';
}

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\TokenResource\Pages;
use App\Models\Passport\Client;
use App\Models\Passport\Token;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class TokenResource extends Resource
{
protected static ?string $model = Token::class;
protected static ?string $navigationIcon = 'heroicon-o-key';
protected static ?string $navigationGroup = 'Auth';
protected static ?int $navigationSort = 6;
public static function form(Form $form): Form
{
return $form
->columns(1)
->schema([
Forms\Components\TextInput::make('id')
->label('ID')
->disabled()
->visibleOn(['update', 'show'])
->readOnly()
->maxLength(255),
Forms\Components\TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
Forms\Components\Select::make('user_id')
->label('User')
->relationship(name: 'user', titleAttribute: 'name')
->searchable(['name'])
->disabled()
->required(),
Forms\Components\Select::make('client_id')
->label('Client')
->relationship(name: 'client', titleAttribute: 'name')
->searchable(['name'])
->required(),
Forms\Components\Toggle::make('revoked')
->label('Revoked')
->required(),
Forms\Components\DateTimePicker::make('expires_at')
->label('Expires At')
->disabled(),
Forms\Components\DateTimePicker::make('created_at')
->label('Created At')
->disabled(),
Forms\Components\DateTimePicker::make('updated_at')
->label('Updated At')
->disabled(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('user.name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('client.name')
->searchable()
->sortable(),
Tables\Columns\IconColumn::make('client.personal_access_client')
->boolean()
->label('API token?')
->sortable(),
Tables\Columns\IconColumn::make('revoked')
->boolean()
->label('Revoked?')
->sortable(),
Tables\Columns\TextColumn::make('expires_at')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('created_at', 'desc')
->filters([
TernaryFilter::make('is_personal_access_client')
->queries(
true: function (Builder $query) {
/** @var Builder<Token> $query */
return $query->whereHas('client', function (Builder $query) {
/** @var Builder<Client> $query */
return $query->where('personal_access_client', true);
});
},
false: function (Builder $query) {
/** @var Builder<Token> $query */
return $query->whereHas('client', function (Builder $query) {
/** @var Builder<Client> $query */
return $query->where('personal_access_client', false);
});
},
blank: function (Builder $query) {
/** @var Builder<Token> $query */
return $query;
},
)
->label('API token?'),
TernaryFilter::make('revoked')
->label('Revoked?'),
])
->actions([
Tables\Actions\ViewAction::make(),
])
->bulkActions([
]);
}
public static function getRelations(): array
{
return [
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListTokens::route('/'),
'view' => Pages\ViewToken::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\TokenResource\Pages;
use App\Filament\Resources\TokenResource;
use Filament\Resources\Pages\ListRecords;
class ListTokens extends ListRecords
{
protected static string $resource = TokenResource::class;
protected function getHeaderActions(): array
{
return [
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\TokenResource\Pages;
use App\Filament\Resources\TokenResource;
use Filament\Resources\Pages\ViewRecord;
class ViewToken extends ViewRecord
{
protected static string $resource = TokenResource::class;
protected function getHeaderActions(): array
{
return [
];
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
use App\Http\Requests\V1\ApiToken\ApiTokenStoreRequest;
use App\Http\Resources\V1\ApiToken\ApiTokenCollection;
use App\Http\Resources\V1\ApiToken\ApiTokenWithAccessTokenResource;
use App\Models\Passport\Token;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
class ApiTokenController extends Controller
{
/**
* List all api token of the currently authenticated user
*
* This endpoint is independent of organization.
*
* @operationId getApiTokens
*
* @throws AuthorizationException
*/
public function index(): ApiTokenCollection
{
$user = $this->user();
$tokens = $user->tokens()
->where('client_id', '=', config('passport.personal_access_client.id'))
->get();
return new ApiTokenCollection($tokens);
}
/**
* Create a new api token for the currently authenticated user
*
* The response will contain the access token that can be used to send authenticated API requests.
* Please note that the access token is only shown in this response and cannot be retrieved later.
*
* @operationId createApiToken
*
* @throws AuthorizationException|PersonalAccessClientIsNotConfiguredException
*/
public function store(ApiTokenStoreRequest $request): ApiTokenWithAccessTokenResource
{
$user = $this->user();
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
throw new PersonalAccessClientIsNotConfiguredException;
}
$token = $user->createToken($request->getName(), ['*']);
/** @var Token $tokenModel */
$tokenModel = $token->token;
return new ApiTokenWithAccessTokenResource($tokenModel, $token->accessToken);
}
/**
* Revoke an api token
*
* @operationId revokeApiToken
*
* @throws AuthorizationException
* @throws PersonalAccessClientIsNotConfiguredException
*/
public function revoke(Token $apiToken): JsonResponse
{
$user = $this->user();
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
throw new PersonalAccessClientIsNotConfiguredException;
}
if ($apiToken->user_id !== $user->getKey()) {
throw new AuthorizationException('API token does not belong to user');
}
if ($apiToken->client_id !== config('passport.personal_access_client.id')) {
throw new AuthorizationException('API token is not a personal access token');
}
$apiToken->revoke();
return response()->json(null, 204);
}
/**
* Delete an api token
*
* @operationId deleteApiToken
*
* @throws AuthorizationException|PersonalAccessClientIsNotConfiguredException
*/
public function destroy(Token $apiToken): JsonResponse
{
$user = $this->user();
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
throw new PersonalAccessClientIsNotConfiguredException;
}
if ($apiToken->user_id !== $user->getKey()) {
throw new AuthorizationException('API token does not belong to user');
}
if ($apiToken->client_id !== config('passport.personal_access_client.id')) {
throw new AuthorizationException('API token is not a personal access token');
}
$apiToken->delete();
return response()->json(null, 204);
}
}

View File

@@ -0,0 +1,172 @@
<?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);
}
}

View File

@@ -7,12 +7,17 @@ namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Events\MemberMadeToPlaceholder;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Exceptions\Api\UserNotPlaceholderApiException;
use App\Http\Requests\V1\Member\MemberIndexRequest;
use App\Http\Requests\V1\Member\MemberMergeIntoRequest;
use App\Http\Requests\V1\Member\MemberUpdateRequest;
use App\Http\Resources\V1\Member\MemberCollection;
use App\Http\Resources\V1\Member\MemberResource;
@@ -24,6 +29,8 @@ use App\Service\MemberService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class MemberController extends Controller
{
@@ -63,6 +70,7 @@ class MemberController extends Controller
* @throws OrganizationNeedsAtLeastOneOwner
* @throws OnlyOwnerCanChangeOwnership
* @throws ChangingRoleToPlaceholderIsNotAllowed
* @throws ChangingRoleOfPlaceholderIsNotAllowed
*
* @operationId updateMember
*/
@@ -105,7 +113,9 @@ class MemberController extends Controller
/**
* Make a member a placeholder member
*
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization|ChangingRoleOfPlaceholderIsNotAllowed
*
* @operationId makePlaceholder
*/
public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse
{
@@ -114,6 +124,9 @@ class MemberController extends Controller
if ($member->role === Role::Owner->value) {
throw new CanNotRemoveOwnerFromOrganization;
}
if ($member->role === Role::Placeholder->value) {
throw new ChangingRoleOfPlaceholderIsNotAllowed;
}
$memberService->makeMemberToPlaceholder($member);
@@ -122,10 +135,39 @@ class MemberController extends Controller
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
*
* @throws AuthorizationException|UserNotPlaceholderApiException
* @throws AuthorizationException
* @throws UserNotPlaceholderApiException
* @throws UserIsAlreadyMemberOfOrganizationApiException
* @throws ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException
*
* @operationId invitePlaceholder
*/
@@ -138,6 +180,10 @@ class MemberController extends Controller
throw new UserNotPlaceholderApiException;
}
if (Str::endsWith($user->email, '@solidtime-import.test')) {
throw new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
}
$invitationService->inviteUser($organization, $user->email, Role::Employee);
return response()->json(null, 204);

View File

@@ -73,6 +73,7 @@ class ReportController extends Controller
false,
$report->properties->start,
$report->properties->end,
true
);
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesQuery->clone(),
@@ -83,6 +84,7 @@ class ReportController extends Controller
true,
$report->properties->start,
$report->properties->end,
true
);
return new DetailedWithDataReportResource($report, $data, $historyData);

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat;
use App\Enums\Role;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
@@ -180,6 +181,7 @@ class TimeEntryController extends Controller
}
$user = $this->user();
$timezone = $user->timezone;
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
$timeEntriesQuery->with([
@@ -211,7 +213,8 @@ class TimeEntryController extends Controller
$user->week_start,
false,
null,
null
null,
$showBillableRate
);
$html = Blade::render($viewFile, [
'timeEntries' => $timeEntriesQuery->get(),
@@ -285,18 +288,18 @@ class TimeEntryController extends Controller
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* cost: int|null
* }
* }
*
@@ -312,6 +315,7 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:view:all');
}
$user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$group1Type = $request->getGroup();
$group2Type = $request->getSubGroup();
@@ -325,7 +329,8 @@ class TimeEntryController extends Controller
$user->week_start,
$request->getFillGapsInTimeGroups(),
$request->getStart(),
$request->getEnd()
$request->getEnd(),
$showBillableRate
);
return [
@@ -359,6 +364,7 @@ class TimeEntryController extends Controller
}
$debug = $request->getDebug();
$user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$group = $request->getGroup();
$subGroup = $request->getSubGroup();
@@ -372,7 +378,8 @@ class TimeEntryController extends Controller
$user->week_start,
false,
$request->getStart(),
$request->getEnd()
$request->getEnd(),
$showBillableRate
);
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery->clone(),
@@ -382,7 +389,8 @@ class TimeEntryController extends Controller
$user->week_start,
true,
$request->getStart(),
$request->getEnd()
$request->getEnd(),
$showBillableRate
);
$currency = $organization->currency;
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Enums\Role;
use App\Service\DashboardService;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
@@ -19,30 +20,14 @@ class DashboardController extends Controller
{
$user = $this->user();
$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;
if ($permissionStore->has($organization, 'time-entries:view:all')) {
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
}
return Inertia::render('Dashboard', [
'weeklyProjectOverview' => $weeklyProjectOverview,
'latestTasks' => $latestTasks,
'lastSevenDays' => $lastSevenDays,
'latestTeamActivity' => $latestTeamActivity,
'dailyTrackedHours' => $dailyTrackedHours,
'totalWeeklyTime' => $totalWeeklyTime,
'totalWeeklyBillableTime' => $totalWeeklyBillableTime,
'totalWeeklyBillableAmount' => $totalWeeklyBillableAmount,
'weeklyHistory' => $weeklyHistory,
]);
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
return Inertia::render('Dashboard');
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\ApiToken;
use Illuminate\Foundation\Http\FormRequest;
class ApiTokenStoreRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string>>
*/
public function rules(): array
{
return [
'name' => [
'required',
'string',
'min:1',
'max:255',
],
];
}
public function getName(): string
{
return $this->input('name');
}
}

View File

@@ -0,0 +1,42 @@
<?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');
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\ApiToken;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ApiTokenCollection extends ResourceCollection
{
/**
* The resource that this resource collects.
*
* @var string
*/
public $collects = ApiTokenResource::class;
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\ApiToken;
use App\Http\Resources\V1\BaseResource;
use App\Models\Passport\Token;
use Illuminate\Http\Request;
/**
* @property-read Token $resource
*/
class ApiTokenResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, string|bool|int|null|array<string>>
*/
public function toArray(Request $request): array
{
return [
/** @var string $id ID of the API token, this ID is NOT a UUID */
'id' => $this->resource->id,
/** @var string $name Name of the API token */
'name' => $this->resource->name,
/** @var bool $revoked Whether the API token is revoked */
'revoked' => $this->resource->revoked,
/** @var array<string> $scopes List of scopes that the API token has */
'scopes' => $this->resource->scopes,
/** @var string $created_at When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'expires_at' => $this->formatDateTime($this->resource->expires_at),
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\ApiToken;
use App\Http\Resources\V1\BaseResource;
use App\Models\Passport\Token;
use Illuminate\Http\Request;
/**
* @property-read Token $resource
*/
class ApiTokenWithAccessTokenResource extends BaseResource
{
private string $accessToken;
public function __construct(Token $resource, string $accessToken)
{
$this->accessToken = $accessToken;
parent::__construct($resource);
}
/**
* Transform the resource into an array.
*
* @return array<string, string|bool|int|null|array<string>>
*/
public function toArray(Request $request): array
{
return [
/** @var string $id ID of the API token, this ID is NOT a UUID */
'id' => $this->resource->id,
/** @var string $name Name of the API token */
'name' => $this->resource->name,
/** @var bool $revoked Whether the API token is revoked */
'revoked' => $this->resource->revoked,
/** @var array<string> $scopes List of scopes that the API token has */
'scopes' => $this->resource->scopes,
/** @var string $created_at When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'expires_at' => $this->formatDateTime($this->resource->expires_at),
// Additional fields
/** @var string $access_token Access token that can be used to authenticate requests */
'access_token' => $this->accessToken,
];
}
}

View File

@@ -18,20 +18,20 @@ use Illuminate\Http\Request;
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* cost: int|null
* }
*/
class DetailedWithDataReportResource extends BaseResource

View File

@@ -6,7 +6,7 @@ namespace App\Listeners;
use App\Models\Member;
use App\Models\User;
use App\Service\UserService;
use App\Service\MemberService;
use Illuminate\Database\Eloquent\Builder;
use Laravel\Jetstream\Events\TeamMemberAdded;
@@ -17,8 +17,11 @@ class RemovePlaceholder
*/
public function handle(TeamMemberAdded $event): void
{
/** @var UserService $userService */
$userService = app(UserService::class);
$memberService = app(MemberService::class);
$member = Member::query()
->whereBelongsTo($event->team, 'organization')
->whereBelongsTo($event->user, 'user')
->firstOrFail();
$placeholders = Member::query()
->whereHas('user', function (Builder $query) use ($event): void {
/** @var Builder<User> $query */
@@ -32,7 +35,7 @@ class RemovePlaceholder
foreach ($placeholders as $placeholder) {
/** @var Member $placeholder */
$placeholderUser = $placeholder->user;
$userService->assignOrganizationEntitiesToDifferentUser($event->team, $placeholderUser, $event->user);
$memberService->assignOrganizationEntitiesToDifferentMember($event->team, $placeholder, $member);
$placeholder->delete();
$placeholderUser->delete();
}

View File

@@ -7,6 +7,7 @@ namespace App\Models;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
use Database\Factories\MemberFactory;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -24,6 +25,8 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property Carbon|null $updated_at
* @property-read Organization $organization
* @property-read User $user
* @property-read Collection<ProjectMember> $projectMembers
* @property-read Collection<TimeEntry> $timeEntries
*
* @method static MemberFactory factory()
*/
@@ -59,6 +62,14 @@ class Member extends JetstreamMembership implements AuditableContract
return $this->belongsTo(Organization::class, 'organization_id');
}
/**
* @return HasMany<TimeEntry>
*/
public function timeEntries(): HasMany
{
return $this->hasMany(TimeEntry::class, 'member_id');
}
/**
* @return HasMany<ProjectMember>
*/

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Models\Passport;
use Laravel\Passport\AuthCode as PassportAuthCode;
class AuthCode extends PassportAuthCode {}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Models\Passport;
use Database\Factories\Passport\ClientFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Laravel\Passport\Client as PassportClient;
/**
* @property string $id
* @property string|null $user_id
* @property string $name
* @property string|null $secret
* @property string|null $provider
* @property string $redirect
* @property bool $personal_access_client
* @property bool $password_client
* @property bool $revoked
*/
class Client extends PassportClient
{
/** @use HasFactory<ClientFactory> */
use HasFactory;
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Models\Passport;
use Laravel\Passport\PersonalAccessClient as PassportPersonalAccessClient;
class PersonalAccessClient extends PassportPersonalAccessClient {}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Models\Passport;
use Laravel\Passport\RefreshToken as PassportRefreshToken;
class RefreshToken extends PassportRefreshToken {}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Models\Passport;
use Database\Factories\Passport\TokenFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use Laravel\Passport\Token as PassportToken;
/**
* @property string $id
* @property null|string $user_id
* @property string $client_id
* @property null|string $name
* @property array<string> $scopes
* @property bool $revoked
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property Carbon|null $expires_at
*/
class Token extends PassportToken
{
/** @use HasFactory<TokenFactory> */
use HasFactory;
/**
* Get the client that the token belongs to.
*
* @return BelongsTo<Client, Token>
*/
public function client(): BelongsTo
{
return $this->belongsTo(Client::class, 'client_id', 'id');
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Models;
use App\Enums\Weekday;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
use App\Models\Passport\Token;
use Database\Factories\UserFactory;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
@@ -27,7 +28,6 @@ use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Jetstream\HasTeams;
use Laravel\Passport\AuthCode;
use Laravel\Passport\HasApiTokens;
use Laravel\Passport\Token;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/**
@@ -44,6 +44,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property-read Organization|null $currentOrganization
* @property-read Organization|null $currentTeam
* @property-read string $profile_photo_url
* @property-read Collection<int, Token> $tokens
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property string|null $current_team_id
@@ -196,6 +197,17 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
return $this->hasMany(AuthCode::class);
}
/**
* Get the access tokens for the user.
*
* @return HasMany<Token>
*/
public function tokens(): HasMany
{
return $this->hasMany(Token::class, 'user_id')
->orderBy('created_at', 'desc');
}
/**
* @param Builder<User> $builder
*/

View File

@@ -9,6 +9,7 @@ use App\Models\FailedJob;
use App\Models\Member;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\Passport\Token;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\Tag;
@@ -100,5 +101,6 @@ class AppServiceProvider extends ServiceProvider
// Routing
Route::model('member', Member::class);
Route::model('invitation', OrganizationInvitation::class);
Route::model('apiToken', Token::class);
}
}

View File

@@ -5,6 +5,11 @@ declare(strict_types=1);
namespace App\Providers;
use App\Models\Organization;
use App\Models\Passport\AuthCode;
use App\Models\Passport\Client;
use App\Models\Passport\PersonalAccessClient;
use App\Models\Passport\RefreshToken;
use App\Models\Passport\Token;
use App\Policies\OrganizationPolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Laravel\Jetstream\Jetstream;
@@ -42,6 +47,16 @@ class AuthServiceProvider extends ServiceProvider
// 'delete',
]);
Passport::useTokenModel(Token::class);
Passport::useRefreshTokenModel(RefreshToken::class);
Passport::useAuthCodeModel(AuthCode::class);
Passport::useClientModel(Client::class);
Passport::usePersonalAccessClientModel(PersonalAccessClient::class);
// Passport::tokensExpireIn(now()->addDays(15));
// Passport::refreshTokensExpireIn(now()->addDays(30));
Passport::personalAccessTokensExpireIn(now()->addMonths(12));
// same as passport default above
Jetstream::defaultApiTokenPermissions(['read']);

View File

@@ -69,6 +69,9 @@ class AdminPanelProvider extends PanelProvider
NavigationGroup::make()
->label('System')
->collapsed(),
NavigationGroup::make()
->label('Auth')
->collapsed(),
])
->middleware([
EncryptCookies::class,

View File

@@ -80,6 +80,8 @@ class JetstreamServiceProvider extends ServiceProvider
Jetstream::defaultApiTokenPermissions([]);
Jetstream::role(Role::Owner->value, 'Owner', [
'charts:view:own',
'charts:view:all',
'projects:view',
'projects:view:all',
'projects:create',
@@ -123,6 +125,7 @@ class JetstreamServiceProvider extends ServiceProvider
'members:invite-placeholder',
'members:change-ownership',
'members:make-placeholder',
'members:merge-into',
'members:update',
'members:delete',
'billing',
@@ -133,6 +136,8 @@ class JetstreamServiceProvider extends ServiceProvider
])->description('Owner users can perform any action. There is only one owner per organization.');
Jetstream::role(Role::Admin->value, 'Administrator', [
'charts:view:own',
'charts:view:all',
'projects:view',
'projects:view:all',
'projects:create',
@@ -172,8 +177,10 @@ class JetstreamServiceProvider extends ServiceProvider
'invitations:resend',
'invitations:remove',
'members:view',
'members:update',
'members:invite-placeholder',
'members:make-placeholder',
'members:merge-into',
'members:update',
'reports:view',
'reports:create',
'reports:update',
@@ -181,6 +188,8 @@ class JetstreamServiceProvider extends ServiceProvider
])->description('Administrator users can perform any action, except accessing the billing dashboard.');
Jetstream::role(Role::Manager->value, 'Manager', [
'charts:view:own',
'charts:view:all',
'projects:view',
'projects:view:all',
'projects:create',
@@ -221,6 +230,7 @@ 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.).');
Jetstream::role(Role::Employee->value, 'Employee', [
'charts:view:own',
'projects:view',
'tags:view',
'tasks:view',

View File

@@ -33,6 +33,11 @@ class ColorService
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
{
if ($seed !== null) {

View File

@@ -11,6 +11,7 @@ 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;
@@ -23,7 +24,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
*/
private function getTags(string $tags): array
{
if (trim($tags) === '') {
if (Str::trim($tags) === '') {
return [];
}
$tagsParsed = explode(', ', $tags);

View File

@@ -0,0 +1,105 @@
<?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');
}
}

View File

@@ -0,0 +1,208 @@
<?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');
}
}

View File

@@ -0,0 +1,76 @@
<?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');
}
}

View File

@@ -0,0 +1,107 @@
<?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');
}
}

View File

@@ -0,0 +1,191 @@
<?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');
}
}

View File

@@ -15,6 +15,11 @@ class ImporterProvider
'clockify_time_entries' => ClockifyTimeEntriesImporter::class,
'clockify_projects' => ClockifyProjectsImporter::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,
];
/**

View File

@@ -328,7 +328,7 @@ class SolidtimeImporter extends DefaultImporter
*/
private function getTags(string $tags): array
{
if (trim($tags) === '') {
if (Str::trim($tags) === '') {
return [];
}
$tagsParsed = json_decode($tags);

View File

@@ -5,8 +5,11 @@ declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Enums\Role;
use App\Service\TimezoneService;
use Exception;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Override;
use Spatie\TemporaryDirectory\TemporaryDirectory;
use ValueError;
@@ -93,11 +96,22 @@ class TogglDataImporter extends DefaultImporter
}
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([
'email' => $workspaceUser->email,
], [
'name' => $workspaceUser->name,
'timezone' => $workspaceUser->timezone ?? 'UTC',
'timezone' => $timezone,
'is_placeholder' => true,
], (string) $workspaceUser->uid);
$this->memberImportHelper->getKey([

View File

@@ -11,6 +11,7 @@ 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;
@@ -23,7 +24,7 @@ class TogglTimeEntriesImporter extends DefaultImporter
*/
private function getTags(string $tags): array
{
if (trim($tags) === '') {
if (Str::trim($tags) === '') {
return [];
}
$tagsParsed = explode(', ', $tags);

View File

@@ -7,15 +7,18 @@ namespace App\Service;
use App\Enums\Role;
use App\Events\MemberRemoved;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use Laravel\Jetstream\Events\AddingTeamMember;
@@ -75,6 +78,7 @@ class MemberService
* @throws ChangingRoleToPlaceholderIsNotAllowed
* @throws OnlyOwnerCanChangeOwnership
* @throws OrganizationNeedsAtLeastOneOwner
* @throws ChangingRoleOfPlaceholderIsNotAllowed
*/
public function changeRole(Member $member, Organization $organization, Role $newRole, bool $allowOwnerChange): void
{
@@ -82,6 +86,9 @@ class MemberService
if ($oldRole === Role::Owner) {
throw new OrganizationNeedsAtLeastOneOwner;
}
if ($oldRole === Role::Placeholder) {
throw new ChangingRoleOfPlaceholderIsNotAllowed;
}
if ($newRole === Role::Placeholder) {
throw new ChangingRoleToPlaceholderIsNotAllowed;
}
@@ -96,6 +103,39 @@ 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.
* The previous owner will be demoted to an admin.
@@ -132,7 +172,7 @@ class MemberService
$member->role = Role::Placeholder->value;
$member->save();
$this->userService->assignOrganizationEntitiesToDifferentMember($member->organization, $user, $placeholderUser, $member);
$this->userService->assignOrganizationEntitiesToDifferentUser($member->organization, $user, $placeholderUser);
if ($makeSureUserHasAtLeastOneOrganization) {
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
}

View File

@@ -22,18 +22,18 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* cost: int|null
* }
*/
private array $data;
@@ -52,18 +52,18 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* cost: int|null
* } $data
*/
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup)

View File

@@ -27,21 +27,21 @@ class TimeEntryAggregationService
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* cost: int|null
* }
*/
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end): array
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
{
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
$group1Select = null;
@@ -96,7 +96,7 @@ class TimeEntryAggregationService
$group2Response[] = [
'key' => $group2 === '' ? null : (string) $group2,
'seconds' => (int) $aggregate->get(0)->aggregate,
'cost' => (int) $aggregate->get(0)->cost,
'cost' => $showBillableRate ? (int) $aggregate->get(0)->cost : null,
'grouped_type' => null,
'grouped_data' => null,
];
@@ -113,7 +113,7 @@ class TimeEntryAggregationService
$group1Response[] = [
'key' => $group1 === '' ? null : (string) $group1,
'seconds' => $group2ResponseSum,
'cost' => $group2ResponseCost,
'cost' => $showBillableRate ? $group2ResponseCost : null,
'grouped_type' => $group2Type?->value,
'grouped_data' => $group2Response,
];
@@ -133,7 +133,7 @@ class TimeEntryAggregationService
return [
'seconds' => $group1ResponseSum,
'cost' => $group1ResponseCost,
'cost' => $showBillableRate ? $group1ResponseCost : null,
'grouped_type' => $group1Type?->value,
'grouped_data' => $group1Response,
];
@@ -148,25 +148,25 @@ class TimeEntryAggregationService
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* cost: int|null
* }
*/
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end): array
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
{
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end);
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate);
$keysGroup1 = [];
$keysGroup2 = [];
@@ -289,12 +289,12 @@ class TimeEntryAggregationService
* @param array<array{
* key: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: null|mixed,
* grouped_data: null|mixed
* }>
@@ -302,12 +302,12 @@ class TimeEntryAggregationService
* @return array<array{
* key: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: null|mixed,
* grouped_data: null|mixed
* }>

View File

@@ -49,24 +49,10 @@ class UserService
}
/**
* Assign all organization entities (time entries, project members) from one user to another.
* This is useful when a placeholder user is replaced with a real user.
* This does NOT change the member id.
* This should only be used in if you want to change a member to a placeholder!
*/
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
TimeEntry::query()
@@ -74,7 +60,6 @@ class UserService
->whereBelongsTo($fromUser, 'user')
->update([
'user_id' => $toUser->getKey(),
'member_id' => $toMember->getKey(),
]);
// Project members
@@ -83,7 +68,6 @@ class UserService
->whereBelongsTo($fromUser, 'user')
->update([
'user_id' => $toUser->getKey(),
'member_id' => $toMember->getKey(),
]);
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Database\Factories\Passport;
use App\Models\Passport\Client;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Client>
*/
class ClientFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'id' => $this->faker->uuid,
'user_id' => null,
'name' => $this->faker->company(),
'secret' => $this->faker->regexify('[A-Za-z]{40}'),
'provider' => 'users',
'redirect' => $this->faker->url(),
'personal_access_client' => false,
'password_client' => false,
'revoked' => false,
'created_at' => $this->faker->dateTime(),
'updated_at' => $this->faker->dateTime(),
];
}
public function personalAccessClient(): self
{
return $this->state(function (array $attributes) {
return [
'personal_access_client' => true,
];
});
}
public function forUser(User $user): self
{
return $this->state(function (array $attributes) use ($user): array {
return [
'user_id' => $user->getKey(),
];
});
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Database\Factories\Passport;
use App\Models\Passport\Client;
use App\Models\Passport\Token;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Token>
*/
class TokenFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'id' => $this->faker->uuid,
'user_id' => null,
'client_id' => $this->faker->uuid,
'name' => null,
'scopes' => [],
'revoked' => false,
'created_at' => $this->faker->dateTime,
'updated_at' => $this->faker->dateTime,
'expires_at' => $this->faker->dateTime,
];
}
public function forUser(User $user): self
{
return $this->state(function (array $attributes) use ($user): array {
return [
'user_id' => $user->getKey(),
];
});
}
public function forClient(Client $client): self
{
return $this->state(function (array $attributes) use ($client): array {
return [
'client_id' => $client->getKey(),
];
});
}
}

View File

@@ -34,6 +34,29 @@ class DatabaseSeeder extends Seeder
public function run(): void
{
$this->deleteAll();
app(ClientRepository::class)->create(
null,
'desktop',
'solidtime://oauth/callback',
null,
false,
false,
false
);
$personalAccessClient = new PassportClient;
$personalAccessClient->id = config('passport.personal_access_client.id');
$personalAccessClient->secret = config('passport.personal_access_client.secret');
$personalAccessClient->name = 'API';
$personalAccessClient->redirect = 'http://localhost';
$personalAccessClient->user_id = null;
$personalAccessClient->revoked = false;
$personalAccessClient->provider = null;
$personalAccessClient->personal_access_client = true;
$personalAccessClient->password_client = false;
$personalAccessClient->save();
$userWithMultipleOrganizations = User::factory()->withPersonalOrganization()->create([
'name' => 'Mister Overemployed',
'email' => 'overemployed@acme.test',
@@ -55,6 +78,8 @@ class DatabaseSeeder extends Seeder
'name' => 'Acme Manager',
'email' => 'test@example.com',
]);
$userAcmeManager->createToken('Testing Token 1')->accessToken;
$userAcmeManager->createToken('Testing Token 2')->accessToken;
$userAcmeAdmin = User::factory()->withPersonalOrganization()->create([
'name' => 'Acme Admin',
'email' => 'admin@acme.test',
@@ -159,15 +184,6 @@ class DatabaseSeeder extends Seeder
'email' => 'admin@example.com',
]);
app(ClientRepository::class)->create(
null,
'desktop',
'solidtime://oauth/callback',
null,
false,
false,
false
);
}
private function deleteAll(): void

View File

@@ -182,7 +182,7 @@ COPY --chown=${USER}:${USER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini /lib/ph
# && composer clear-cache
RUN cat .env
RUN php artisan env
#RUN php artisan env
RUN php artisan storage:link
RUN chmod +x /usr/local/bin/start-container

View File

@@ -1,25 +1,69 @@
import { test, expect } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import {test, expect} from '../playwright/fixtures';
import {PLAYWRIGHT_BASE_URL} from '../playwright/config';
test('test that user name can be updated', async ({ page }) => {
test('test that user name can be updated', async ({page}) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await page.getByLabel('Name').fill('NEW NAME');
await page.getByLabel('Name', {exact: true} ).fill('NEW NAME');
await Promise.all([
page.getByRole('button', { name: 'Save' }).first().click(),
page.getByRole('button', {name: 'Save'}).first().click(),
page.waitForResponse('**/user/profile-information'),
]);
await page.reload();
await expect(page.getByLabel('Name')).toHaveValue('NEW NAME');
await expect(page.getByLabel('Name', {exact: true})).toHaveValue('NEW NAME');
});
test.skip('test that user email can be updated', async ({ page }) => {
test.skip('test that user email can be updated', async ({page}) => {
// this does not work because of email verification currently
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
const emailId = Math.round(Math.random() * 10000);
await page.getByLabel('Email').fill(`newemail+${emailId}@test.com`);
await page.getByRole('button', { name: 'Save' }).first().click();
await page.getByRole('button', {name: 'Save'}).first().click();
await page.reload();
await expect(page.getByLabel('Email')).toHaveValue(
`newemail+${emailId}@test.com`
);
});
async function createNewApiToken(page) {
await page.getByLabel('API Key Name').fill('NEW API KEY');
await Promise.all([
page.getByRole('button', {name: 'Create API Key'}).click(),
page.waitForResponse('**/users/me/api-tokens')
]);
await expect(page.locator('body')).toContainText('API Token created successfully');
await page.getByRole('dialog').getByText('Close').click();
await expect(page.locator('body')).toContainText('NEW API KEY');
}
test('test that user can create an API key', async ({page}) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await createNewApiToken(page);
});
test('test that user can delete an API key', async ({page}) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await createNewApiToken(page);
page.getByLabel('Delete API Token NEW API KEY').click();
await expect(page.getByRole('dialog')).toContainText('Are you sure you would like to delete this API token?');
await Promise.all([
page.getByRole('dialog').getByRole('button', {name: 'Delete'}).click(),
page.waitForResponse('**/users/me/api-tokens')
]);
await expect(page.locator('body')).not.toContainText('NEW API KEY');
});
test('test that user can revoke an API key', async ({page}) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await createNewApiToken(page);
page.getByLabel('Revoke API Token NEW API KEY').click();
await expect(page.getByRole('dialog')).toContainText('Are you sure you would like to revoke this API token?');
await Promise.all([
page.getByRole('dialog').getByRole('button', {name: 'Revoke'}).click(),
page.waitForResponse('**/users/me/api-tokens')
]);
await expect(page.getByRole('button', {name: 'Revoke'})).toBeHidden();
await expect(page.locator('body')).toContainText('NEW API KEY');
await expect(page.locator('body')).toContainText('Revoked');
});

View File

@@ -136,6 +136,7 @@ test('test that starting and updating the time while running works', async ({
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.url().includes('/time-entries') &&
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&

View File

@@ -18,6 +18,7 @@ export function newTimeEntryResponse(
) {
return page.waitForResponse(async (response) => {
return (
response.url().includes('/time-entries') &&
response.status() === status &&
(await response.headerValue('Content-Type')) ===
'application/json' &&

View File

@@ -4,14 +4,18 @@ declare(strict_types=1);
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
@@ -37,6 +41,10 @@ return [
OrganizationHasNoSubscriptionButMultipleMembersException::KEY => 'Organization has no subscription but multiple members',
PdfRendererIsNotConfiguredException::KEY => 'PDF renderer is not configured',
FeatureIsNotAvailableInFreePlanApiException::KEY => 'Feature is not available in free plan',
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.',
];

View File

@@ -13,6 +13,14 @@ 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><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' => [
'name' => 'Clockify Projects',
'description' => '1. Make sure to set the language of Clockify to English in "Preferences -> General".<br>'.
@@ -38,4 +46,22 @@ return [
'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.',
],
'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.',
],
];

View File

@@ -41,5 +41,7 @@
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="AUDITING_ENABLED" value="true"/>
<env name="NEWSLETTER_URL" value="null"/>
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_ID" value="null"/>
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET" value="null"/>
</php>
</phpunit>

View File

@@ -39,7 +39,7 @@ async function resendInvitation() {
await handleApiRequestNotifications(
() =>
api.resendInvitationEmail(
{},
undefined,
{
params: {
invitation: props.invitation.id,

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { computed, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useMembersStore } from '@/utils/useMembers';
import { UserIcon, ChevronDownIcon } from '@heroicons/vue/24/solid';
@@ -46,22 +46,6 @@ const filteredMembers = computed<Member[]>(() => {
});
});
watch(filteredMembers, () => {
resetHighlightedItem();
});
onMounted(() => {
resetHighlightedItem();
});
function resetHighlightedItem() {
if (filteredMembers.value.length > 0) {
highlightedItemId.value = filteredMembers.value[0].id;
}
}
const highlightedItemId = ref<string | null>(null);
const currentValue = computed(() => {
if (model.value) {
return members.value.find((member) => member.id === model.value)?.name;

View File

@@ -0,0 +1,80 @@
<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>

View File

@@ -0,0 +1,109 @@
<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>

View File

@@ -1,16 +1,19 @@
<script setup lang="ts">
import { TrashIcon, PencilSquareIcon } from '@heroicons/vue/20/solid';
import { TrashIcon, UserCircleIcon, PencilSquareIcon, ArrowDownOnSquareStackIcon } from '@heroicons/vue/20/solid';
import type { Member } from '@/packages/api/src';
import { canDeleteMembers, canUpdateMembers } from '@/utils/permissions';
import {canDeleteMembers, canMakeMembersPlaceholders, canMergeMembers, canUpdateMembers} from '@/utils/permissions';
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
const emit = defineEmits<{
delete: [];
edit: [];
merge: [];
makePlaceholder: [];
}>();
const props = defineProps<{
member: Member;
}>();
</script>
<template>
@@ -36,6 +39,23 @@ const props = defineProps<{
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</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>
</MoreOptionsDropdown>
</template>

View File

@@ -10,16 +10,20 @@ import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import { canInvitePlaceholderMembers } from '@/utils/permissions';
import { useMembersStore } from '@/utils/useMembers';
import { ref } from 'vue';
import {computed, ref} from 'vue';
import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
import { getOrganizationCurrencyString } from '@/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<{
member: Member;
}>();
const showEditMemberModal = ref(false);
const showMergeMemberModal = ref(false);
const showMakeMemberPlaceholderModal = ref(false);
function removeMember() {
useMembersStore().removeMember(props.member.id);
@@ -32,7 +36,7 @@ async function invitePlaceholder(id: string) {
await handleApiRequestNotifications(
() =>
api.invitePlaceholder(
{},
undefined,
{
params: {
organization: organizationId,
@@ -45,6 +49,11 @@ async function invitePlaceholder(id: string) {
);
}
}
const userHasValidMailAddress = computed(() => {
return !props.member.email.endsWith('@solidtime-import.test');
})
</script>
<template>
@@ -87,7 +96,8 @@ async function invitePlaceholder(id: string) {
<SecondaryButton
v-if="
member.is_placeholder === true &&
canInvitePlaceholderMembers()
canInvitePlaceholderMembers() &&
userHasValidMailAddress
"
size="small"
@click="invitePlaceholder(member.id)"
@@ -96,11 +106,16 @@ async function invitePlaceholder(id: string) {
<MemberMoreOptionsDropdown
:member="member"
@edit="showEditMemberModal = true"
@delete="removeMember"></MemberMoreOptionsDropdown>
@delete="removeMember"
@merge="showMergeMemberModal = true"
@make-placeholder="showMakeMemberPlaceholderModal = true"
></MemberMoreOptionsDropdown>
</div>
<MemberEditModal
v-model:show="showEditMemberModal"
:member="member"></MemberEditModal>
<MemberMergeModal v-model:show="showMergeMemberModal" :member="member"></MemberMergeModal>
<MemberMakePlaceholderModal v-model:show="showMakeMemberPlaceholderModal" :member="member"></MemberMakePlaceholderModal>
</TableRow>
</template>

View File

@@ -12,7 +12,7 @@ type AggregatedGroupedData = GroupedData & {
type GroupedData = {
seconds: number;
cost: number;
cost: number | null;
description: string | null | undefined;
};
@@ -48,7 +48,7 @@ const expanded = ref(false);
{{ formatHumanReadableDuration(entry.seconds) }}
</div>
<div class="justify-end pr-6 flex items-center">
{{ formatCents(entry.cost, getOrganizationCurrencyString()) }}
{{entry.cost ? formatCents(entry.cost, getOrganizationCurrencyString()) : '--' }}
</div>
</div>
<div

View File

@@ -2,9 +2,13 @@
import { router } from '@inertiajs/vue3';
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import {canViewReport} from "@/utils/permissions";
import {computed} from "vue";
defineProps<{
active: 'reporting' | 'detailed' | 'shared';
}>();
const showSharedReports = computed(() => canViewReport());
</script>
<template>
@@ -20,6 +24,7 @@ defineProps<{
>Detailed</TabBarItem
>
<TabBarItem
v-if="showSharedReports"
:active="active === 'shared'"
@click="router.visit(route('reporting.shared'))"
>Shared</TabBarItem

View File

@@ -1,29 +1,45 @@
<script lang="ts" setup>
import VChart, { THEME_KEY } from 'vue-echarts';
import { provide, ref } from 'vue';
import { use } from 'echarts/core';
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
import { BoltIcon } from '@heroicons/vue/20/solid';
import { HeatmapChart } from 'echarts/charts';
import VChart, { THEME_KEY } from "vue-echarts";
import { provide, computed } from "vue";
import { use } from "echarts/core";
import DashboardCard from "@/Components/Dashboard/DashboardCard.vue";
import { BoltIcon } from "@heroicons/vue/20/solid";
import { HeatmapChart } from "echarts/charts";
import {
CalendarComponent,
TitleComponent,
TooltipComponent,
VisualMapComponent,
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import dayjs from 'dayjs';
VisualMapComponent
} from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
import dayjs from "dayjs";
import {
firstDayIndex,
formatDate,
formatHumanReadableDuration,
getDayJsInstance,
} from '@/packages/ui/src/utils/time';
import { useCssVar } from '@vueuse/core';
getDayJsInstance
} from "@/packages/ui/src/utils/time";
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";
const props = defineProps<{
dailyHoursTracked: { duration: number; date: string }[];
}>();
// Get the organization ID using the utility function
const organizationId = computed(() => getCurrentOrganizationId());
const { data: dailyHoursTracked, isLoading } = useQuery({
queryKey: ["dailyTrackedHours", organizationId],
queryFn: () => {
return api.dailyTrackedHours({
params: {
organization: organizationId.value!
}
});
},
enabled: computed(() => !!organizationId.value)
});
use([
TitleComponent,
@@ -31,89 +47,113 @@ use([
VisualMapComponent,
CalendarComponent,
HeatmapChart,
CanvasRenderer,
CanvasRenderer
]);
provide(THEME_KEY, 'dark');
provide(THEME_KEY, "dark");
const max = Math.max(
Math.max(...props.dailyHoursTracked.map((el) => el.duration)),
1
const max = computed(() => {
if (!isLoading.value && dailyHoursTracked.value) {
return Math.max(
Math.max(...dailyHoursTracked.value.map((el) => el.duration)),
1
);
} else {
return 1;
}
}
);
const backgroundColor = useCssVar('--color-bg-secondary');
const itemBackgroundColor = useCssVar('--color-bg-tertiary');
const option = ref({
tooltip: {},
visualMap: {
min: 0,
max: max,
type: 'piecewise',
orient: 'horizontal',
left: 'center',
top: 'center',
inRange: {
color: [itemBackgroundColor.value, '#2DBE45'],
},
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)
);
const backgroundColor = useCssVar("--color-bg-secondary");
const itemBackgroundColor = useCssVar("--color-bg-tertiary");
const option = computed(() => {
return {
tooltip: {},
visualMap: {
min: 0,
max: max.value,
type: "piecewise",
orient: "horizontal",
left: "center",
top: "center",
inRange: {
color: [itemBackgroundColor.value, "#2DBE45"]
},
show: false
},
},
},
backgroundColor: 'transparent',
});
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: 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>
<template>
<DashboardCard title="Activity Graph" :icon="BoltIcon">
<div class="px-2">
<v-chart
class="chart"
:autoresize="true"
:option="option"
style="height: 260px; background-color: transparent" />
<div v-if="isLoading" class="flex justify-center items-center h-40">
<LoadingSpinner />
</div>
<div v-else-if="dailyHoursTracked">
<v-chart
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>
</DashboardCard>
</template>

View File

@@ -1,24 +1,53 @@
<script setup lang="ts">
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
import DayOverviewCardEntry from '@/Components/Dashboard/DayOverviewCardEntry.vue';
import { CalendarIcon } from '@heroicons/vue/20/solid';
defineProps<{
last7Days: {
date: string;
duration: number; // Total duration in seconds
history: number[]; // Array representing the duration in seconds of the 3h windows for the day
}[];
}>();
import { useQuery } from "@tanstack/vue-query";
import { computed } from "vue";
import DashboardCard from "@/Components/Dashboard/DashboardCard.vue";
import DayOverviewCardEntry from "@/Components/Dashboard/DayOverviewCardEntry.vue";
import { CalendarIcon } from "@heroicons/vue/20/solid";
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 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>
<template>
<DashboardCard title="Last 7 Days" :icon="CalendarIcon">
<DayOverviewCardEntry
v-for="day in last7Days"
:key="day.date"
:class="last7Days.length === 7 ? 'last:border-0 first:pt-3' : ''"
:date="day.date"
:history="day.history"
:duration="day.duration"></DayOverviewCardEntry>
<div v-if="isLoading" class="flex justify-center items-center h-40">
<LoadingSpinner />
</div>
<div v-else-if="last7Days">
<DayOverviewCardEntry
v-for="day in last7Days"
:key="day.date"
: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>
</template>

View File

@@ -1,32 +1,83 @@
<script setup lang="ts">
import RecentlyTrackedTasksCardEntry from '@/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue';
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { PlusCircleIcon } from '@heroicons/vue/24/solid';
import { router } from '@inertiajs/vue3';
import { useQuery } from "@tanstack/vue-query";
import { computed } from "vue";
import RecentlyTrackedTasksCardEntry from "@/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue";
import DashboardCard from "@/Components/Dashboard/DashboardCard.vue";
import { CheckCircleIcon } from "@heroicons/vue/20/solid";
import SecondaryButton from "@/packages/ui/src/Buttons/SecondaryButton.vue";
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";
const props = defineProps<{
latestTasks: {
id: string;
name: string;
project_name: string;
project_id: string;
}[];
}>();
// Get the organization ID using the utility function
const organizationId = computed(() => getCurrentOrganizationId());
// Function to fetch latest tasks using the API client
// Set up the query
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>
<template>
<DashboardCard title="Recently Tracked Tasks" :icon="CheckCircleIcon">
<RecentlyTrackedTasksCardEntry
v-for="lastTask in props.latestTasks"
:key="lastTask.id"
:class="props.latestTasks.length === 4 ? 'last:border-0' : ''"
:project_id="lastTask.project_id"
:task_id="lastTask.id"
:title="lastTask.name"></RecentlyTrackedTasksCardEntry>
<DashboardCard title="Recent Time Entries" :icon="CheckCircleIcon">
<div v-if="isLoading" class="flex justify-center items-center h-40">
<LoadingSpinner />
</div>
<div v-else-if="filteredLatestTasks && filteredLatestTasks.length > 0">
<RecentlyTrackedTasksCardEntry
v-for="lastTask in filteredLatestTasks"
:key="lastTask.id"
:time-entry="lastTask"
:class="filteredLatestTasks.length === 4 ? 'last:border-0' : ''"></RecentlyTrackedTasksCardEntry>
</div>
<div
v-if="props.latestTasks.length === 0"
v-else
class="text-center flex flex-1 justify-center items-center">
<div>
<PlusCircleIcon
@@ -36,12 +87,12 @@ const props = defineProps<{
</h3>
<p class="pb-5 text-sm">Create tasks inside of a project!</p>
<SecondaryButton @click="router.visit(route('projects'))"
>Go to Projects
>Go to Projects
</SecondaryButton>
</div>
</div>
<div
v-if="props.latestTasks.length === 1"
v-if="latestTasks && latestTasks.length === 1"
class="text-center flex flex-1 justify-center items-center text-sm">
<div>
<PlusCircleIcon
@@ -49,7 +100,7 @@ const props = defineProps<{
<h3 class="text-white font-semibold">Add more tasks</h3>
<p class="pb-5">Create tasks inside of a project!</p>
<SecondaryButton @click="router.visit(route('projects'))"
>Go to Projects
>Go to Projects
</SecondaryButton>
</div>
</div>

View File

@@ -6,17 +6,16 @@ import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
import type { TimeEntry } from "@/packages/api/src";
const props = defineProps<{
title: string;
project_id: string;
task_id: string;
timeEntry: TimeEntry
}>();
const { projects } = storeToRefs(useProjectsStore());
const project = computed(() => {
return projects.value.find((project) => project.id === props.project_id);
return projects.value.find((project) => project.id === props.timeEntry.project_id);
});
const { currentTimeEntry } = storeToRefs(useCurrentTimeEntryStore());
@@ -26,23 +25,28 @@ async function startTaskTimer() {
if (currentTimeEntry.value.id) {
await setActiveState(false);
}
currentTimeEntry.value.project_id = props.project_id;
currentTimeEntry.value.task_id = props.task_id;
currentTimeEntry.value.description = props.timeEntry.description;
currentTimeEntry.value.project_id = props.timeEntry.project_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();
await setActiveState(true);
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
}
</script>
<template>
<div
class="px-3.5 py-2 grid grid-cols-5 border-b border-b-card-background-separator">
<div class="col-span-4">
<p class="font-semibold text-white text-sm pb-1 overflow-ellipsis">
{{ title }}
<p class="font-medium text-white text-sm pb-1 truncate">
<span v-if="timeEntry.description"> {{ timeEntry.description }}</span>
<span v-else class="text-text-tertiary">No description</span>
</p>
<ProjectBadge
:name="project?.name"
:name="project?.name ?? 'No Project'"
:color="project?.color"></ProjectBadge>
</div>
<div class="flex items-center justify-center">

View File

@@ -1,33 +1,52 @@
<script lang="ts" setup>
import { useQuery } from '@tanstack/vue-query';
import { computed } from 'vue';
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
import TeamActivityCardEntry from '@/Components/Dashboard/TeamActivityCardEntry.vue';
import { UserGroupIcon } from '@heroicons/vue/20/solid';
import { router } from '@inertiajs/vue3';
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';
// Get the organization ID using the utility function
const organizationId = computed(() => getCurrentOrganizationId());
// Set up the query
const { data: latestTeamActivity, isLoading } = useQuery({
queryKey: ['latestTeamActivity', organizationId],
queryFn: () => {
return api.latestTeamActivity({
params: {
organization: organizationId.value!
}
})
},
enabled: computed(() => !!organizationId.value),
});
defineProps<{
latestTeamActivity: {
user_id: string;
name: string;
description: string;
time_entry_id: string;
task_id: string;
status: boolean;
}[];
}>();
</script>
<template>
<DashboardCard title="Team Activity" :icon="UserGroupIcon">
<TeamActivityCardEntry
v-for="activity in latestTeamActivity"
:key="activity.user_id"
:class="latestTeamActivity.length === 4 ? 'last:border-0' : ''"
:name="activity.name"
:description="activity.description"
:working="activity.status"></TeamActivityCardEntry>
<div v-if="isLoading" class="flex justify-center items-center h-40">
<LoadingSpinner />
</div>
<div v-else-if="latestTeamActivity">
<TeamActivityCardEntry
v-for="activity in latestTeamActivity"
:key="activity.time_entry_id"
: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
v-if="latestTeamActivity.length <= 1"
v-if="latestTeamActivity && latestTeamActivity.length <= 1"
class="text-center flex flex-1 justify-center items-center">
<div>
<UserGroupIcon

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
defineProps<{
name: string;
description: string;
description: string | null;
working?: boolean;
}>();
</script>

View File

@@ -1,25 +1,28 @@
<script setup lang="ts">
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { BarChart } from 'echarts/charts';
import { use } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { BarChart } from "echarts/charts";
import {
GridComponent,
LegendComponent,
TitleComponent,
TooltipComponent,
} from 'echarts/components';
import VChart, { THEME_KEY } from 'vue-echarts';
import { computed, provide, ref } from 'vue';
import StatCard from '@/Components/Common/StatCard.vue';
import { ClockIcon } from '@heroicons/vue/20/solid';
import CardTitle from '@/packages/ui/src/CardTitle.vue';
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { formatCents } from '@/packages/ui/src/utils/money';
import { getWeekStart } from '@/packages/ui/src/utils/settings';
import { useCssVar } from '@vueuse/core';
import { getOrganizationCurrencyString } from '@/utils/money';
TooltipComponent
} from "echarts/components";
import VChart, { THEME_KEY } from "vue-echarts";
import { computed, provide } from "vue";
import StatCard from "@/Components/Common/StatCard.vue";
import { ClockIcon } from "@heroicons/vue/20/solid";
import CardTitle from "@/packages/ui/src/CardTitle.vue";
import LinearGradient from "zrender/lib/graphic/LinearGradient";
import ProjectsChartCard from "@/Components/Dashboard/ProjectsChartCard.vue";
import { formatHumanReadableDuration } from "@/packages/ui/src/utils/time";
import { formatCents } from "@/packages/ui/src/utils/money";
import { getWeekStart } from "@/packages/ui/src/utils/settings";
import { useCssVar } from "@vueuse/core";
import { getOrganizationCurrencyString } from "@/utils/money";
import { useQuery } from "@tanstack/vue-query";
import { getCurrentOrganizationId } from "@/utils/useUser";
import { api } from "@/packages/api/src";
use([
CanvasRenderer,
@@ -27,85 +30,22 @@ use([
TitleComponent,
GridComponent,
TooltipComponent,
LegendComponent,
LegendComponent
]);
provide(THEME_KEY, 'dark');
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)',
},
]),
},
},
};
});
});
provide(THEME_KEY, "dark");
const accentColor = useCssVar("--color-accent-quaternary");
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> = {
monday: 'Mon',
tuesday: 'Tue',
wednesday: 'Wed',
thursday: 'Thu',
friday: 'Fri',
saturday: 'Sat',
sunday: 'Sun',
monday: "Mon",
tuesday: "Tue",
wednesday: "Wed",
thursday: "Thu",
friday: "Fri",
saturday: "Sat",
sunday: "Sun"
};
if (dayMapping[getWeekStart()]) {
@@ -122,59 +62,179 @@ const weekdays = computed(() => {
}
});
const markLineColor = useCssVar('--color-border-secondary');
const markLineColor = useCssVar("--color-border-secondary");
const option = ref({
tooltip: {
trigger: 'item',
// Get the organization ID using the utility function
const organizationId = computed(() => getCurrentOrganizationId());
// Set up the queries
const { data: weeklyProjectOverview } = useQuery({
queryKey: ["weeklyProjectOverview", organizationId],
queryFn: () => {
return api.weeklyProjectOverview({
params: {
organization: organizationId.value!
}
});
},
grid: {
top: 0,
right: 0,
bottom: 50,
left: 0,
enabled: computed(() => !!organizationId.value)
});
const { data: totalWeeklyTime } = useQuery({
queryKey: ["totalWeeklyTime", organizationId],
queryFn: () => {
return api.totalWeeklyTime({
params: {
organization: organizationId.value!
}
});
},
backgroundColor: 'transparent',
xAxis: {
type: 'category',
data: weekdays.value,
axisLine: {
enabled: computed(() => !!organizationId.value)
});
const { data: totalWeeklyBillableTime } = useQuery({
queryKey: ["totalWeeklyBillableTime", organizationId],
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: {
color: 'transparent', // Set desired color here
},
color: "transparent" // Set desired color here
}
},
axisLabel: {
fontSize: 16,
fontWeight: 600,
margin: 24,
fontFamily: 'Outfit, sans-serif',
fontWeight: 600,
margin: 24,
fontFamily: "Outfit, sans-serif"
},
axisTick: {
lineStyle: {
color: 'transparent', // Set desired color here
},
},
color: "transparent" // Set desired color here
}
}
},
yAxis: {
type: 'value',
splitLine: {
lineStyle: {
color: markLineColor.value,
},
yAxis: {
type: "value",
splitLine: {
lineStyle: {
color: markLineColor.value
}
}
},
},
series: [
{
data: seriesData,
type: 'bar',
tooltip: {
valueFormatter: (value: number) => {
return formatHumanReadableDuration(value);
},
},
},
],
series: [
{
data: seriesData.value,
type: "bar",
tooltip: {
valueFormatter: (value: number) => {
return formatHumanReadableDuration(value);
}
}
}
]
}
});
</script>
<template>
@@ -185,28 +245,35 @@ const option = ref({
title="This Week"
class="pb-8"
:icon="ClockIcon"></CardTitle>
<v-chart :autoresize="true" class="chart" :option="option" />
<v-chart
v-if="weeklyHistory"
:autoresize="true" class="chart" :option="option" />
</div>
<div class="space-y-6">
<StatCard
title="Spent Time"
:value="formatHumanReadableDuration(props.totalWeeklyTime)" />
:value="
totalWeeklyTime ?
formatHumanReadableDuration(totalWeeklyTime) : '--'" />
<StatCard
title="Billable Time"
:value="
formatHumanReadableDuration(props.totalWeeklyBillableTime)
totalWeeklyBillableTime ?
formatHumanReadableDuration(totalWeeklyBillableTime) : '--'
" />
<StatCard
title="Billable Amount"
:value="
totalWeeklyBillableAmount ?
formatCents(
props.totalWeeklyBillableAmount.value,
totalWeeklyBillableAmount.value,
getOrganizationCurrencyString()
)
) : '--'
" />
<ProjectsChartCard
v-if="weeklyProjectOverview"
:weekly-project-overview="
props.weeklyProjectOverview
weeklyProjectOverview
"></ProjectsChartCard>
</div>
</div>

View File

@@ -14,7 +14,7 @@ const props = defineProps<{
icon?: Component;
current?: boolean;
href: string;
subItems?: { title: string; route: string }[];
subItems?: { title: string; route: string, show: boolean }[];
}>();
const open = useSessionStorage('nav-collapse-state-' + props.title, true);
@@ -66,6 +66,7 @@ const open = useSessionStorage('nav-collapse-state-' + props.title, true);
:key="subItem.title"
class="w-full relative">
<NavigationSidebarLink
v-if="subItem.show"
:title="subItem.title"
:current="route().current(subItem.route)"
:href="

View File

@@ -27,7 +27,7 @@ import {
canUpdateOrganization,
canViewClients,
canViewMembers,
canViewProjects,
canViewProjects, canViewReport,
canViewTags,
} from '@/utils/permissions';
import { isBillingActivated } from '@/utils/billing';
@@ -118,14 +118,17 @@ const page = usePage<{
{
title: 'Overview',
route: 'reporting',
show: true
},
{
title: 'Detailed',
route: 'reporting.detailed',
show: true
},
{
title: 'Shared',
route: 'reporting.shared',
show: canViewReport()
},
]"
:current="

View File

@@ -1,100 +1,51 @@
<script setup lang="ts">
import AppLayout from '@/Layouts/AppLayout.vue';
import TimeTracker from '@/Components/TimeTracker.vue';
import RecentlyTrackedTasksCard from '@/Components/Dashboard/RecentlyTrackedTasksCard.vue';
import LastSevenDaysCard from '@/Components/Dashboard/LastSevenDaysCard.vue';
import TeamActivityCard from '@/Components/Dashboard/TeamActivityCard.vue';
import ThisWeekOverview from '@/Components/Dashboard/ThisWeekOverview.vue';
import ActivityGraphCard from '@/Components/Dashboard/ActivityGraphCard.vue';
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import { canViewMembers } from '@/utils/permissions';
import { router } from '@inertiajs/vue3';
import AppLayout from "@/Layouts/AppLayout.vue";
import TimeTracker from "@/Components/TimeTracker.vue";
import RecentlyTrackedTasksCard from "@/Components/Dashboard/RecentlyTrackedTasksCard.vue";
import LastSevenDaysCard from "@/Components/Dashboard/LastSevenDaysCard.vue";
import TeamActivityCard from "@/Components/Dashboard/TeamActivityCard.vue";
import ThisWeekOverview from "@/Components/Dashboard/ThisWeekOverview.vue";
import ActivityGraphCard from "@/Components/Dashboard/ActivityGraphCard.vue";
import MainContainer from "@/packages/ui/src/MainContainer.vue";
import { canViewMembers } from "@/utils/permissions";
import { useQueryClient } from "@tanstack/vue-query";
const props = defineProps<{
latestTasks: {
id: string;
name: string;
project_name: string;
project_id: string;
}[];
latestTeamActivity: {
user_id: string;
name: string;
description: string;
time_entry_id: string;
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;
}[];
}>();
const queryClient = useQueryClient();
const refreshDashboardData = () => {
// Invalidate all dashboard queries to trigger refetching
queryClient.invalidateQueries({ queryKey: ["latestTasks"] });
queryClient.invalidateQueries({ queryKey: ["lastSevenDays"] });
queryClient.invalidateQueries({ queryKey: ["dailyTrackedHours"] });
queryClient.invalidateQueries({ queryKey: ["latestTeamActivity"] });
queryClient.invalidateQueries({ queryKey: ["weeklyProjectOverview"] });
queryClient.invalidateQueries({ queryKey: ["totalWeeklyTime"] });
queryClient.invalidateQueries({ queryKey: ["totalWeeklyBillableTime"] });
queryClient.invalidateQueries({ queryKey: ["totalWeeklyBillableAmount"] });
queryClient.invalidateQueries({ queryKey: ["weeklyHistory"] });
};
function refreshDashboardData() {
router.reload({
only: [
'latestTasks',
'latestTeamActivity',
'lastSevenDays',
'dailyTrackedHours',
'weeklyProjectOverview',
'totalWeeklyTime',
'totalWeeklyBillableTime',
'totalWeeklyBillableAmount',
'weeklyHistory',
],
});
}
</script>
<template>
<AppLayout title="Dashboard" data-testid="dashboard_view">
<MainContainer
class="pt-5 sm:pt-8 pb-4 sm:pb-6 border-b border-default-background-separator">
<TimeTracker @change="refreshDashboardData"></TimeTracker>
</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">
<RecentlyTrackedTasksCard
:latest-tasks="props.latestTasks"></RecentlyTrackedTasksCard>
<LastSevenDaysCard
:last7-days="props.lastSevenDays"></LastSevenDaysCard>
<ActivityGraphCard
:daily-hours-tracked="
props.dailyTrackedHours
"></ActivityGraphCard>
<TeamActivityCard
v-if="canViewMembers()"
class="flex lg:hidden xl:flex"
: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>
<MainContainer
class="pt-5 sm:pt-8 pb-4 sm:pb-6 border-b border-default-background-separator">
<TimeTracker @change="refreshDashboardData"></TimeTracker>
</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">
<RecentlyTrackedTasksCard></RecentlyTrackedTasksCard>
<LastSevenDaysCard></LastSevenDaysCard>
<ActivityGraphCard></ActivityGraphCard>
<TeamActivityCard
v-if="canViewMembers()"
class="flex lg:hidden xl:flex">
</TeamActivityCard>
</MainContainer>
<MainContainer class="py-5">
<ThisWeekOverview></ThisWeekOverview>
</MainContainer>
</AppLayout>
</template>

View File

@@ -0,0 +1,331 @@
<script setup lang="ts">
import FormSection from '@/Components/FormSection.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import {computed, ref} from 'vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import {
api,
type ApiToken,
type CreateApiTokenBody
} from '@/packages/api/src';
import SectionBorder from "@/Components/SectionBorder.vue";
import DangerButton from "@/packages/ui/src/Buttons/DangerButton.vue";
import TextInput from "../../../packages/ui/src/Input/TextInput.vue";
import SecondaryButton from "../../../packages/ui/src/Buttons/SecondaryButton.vue";
import DialogModal from "@/packages/ui/src/DialogModal.vue";
import InputError from "@/packages/ui/src/Input/InputError.vue";
import ActionMessage from "@/Components/ActionMessage.vue";
import ConfirmationModal from "@/Components/ConfirmationModal.vue";
import ActionSection from "@/Components/ActionSection.vue";
import {useForm} from "@inertiajs/vue3";
import {useMutation, useQuery, useQueryClient} from "@tanstack/vue-query";
import {useNotificationsStore} from "@/utils/notification";
import {useClipboard} from "@vueuse/core";
import { formatDateTimeLocalized} from "../../../packages/ui/src/utils/time";
import {ClockIcon} from "@heroicons/vue/20/solid";
const queryClient = useQueryClient();
const apiTokenBeingDeleted = ref<ApiToken | null>(null);
const apiTokenBeingRevoked = ref<ApiToken | null>(null);
const { handleApiRequestNotifications } = useNotificationsStore();
const newToken = ref('');
const { copy, copied, isSupported } = useClipboard();
async function createApiToken(){
await handleApiRequestNotifications(
() =>
createApiTokenMutation.mutateAsync({
name: createApiTokenForm.name,
}),
'API Token successfully created',
'There was an error while creating the API Token',
(response) => {
createApiTokenForm.name = '';
displayingToken.value = true;
newToken.value = response.data.access_token;
}
);
}
const createApiTokenForm = useForm({
name: '',
});
function confirmApiTokenDeletion (token: ApiToken) {
apiTokenBeingDeleted.value = token;
}
function confirmApiTokenRevocation(token: ApiToken){
apiTokenBeingRevoked.value = token;
}
const displayingToken = ref(false);
async function deleteApiToken () {
if(apiTokenBeingDeleted.value){
await handleApiRequestNotifications(
() =>
deleteApiTokenMutation.mutateAsync(apiTokenBeingDeleted.value!.id),
'API Token successfully deleted',
'There was an error while deleting the API Token',
() => {
apiTokenBeingDeleted.value = null;
}
);
}
};
async function revokeApiToken () {
if(apiTokenBeingRevoked.value){
await handleApiRequestNotifications(
() =>
revokeApiTokenMutation.mutateAsync(apiTokenBeingRevoked.value!.id),
'API Token successfully revoked',
'There was an error while revoking the API Token',
() => {
apiTokenBeingRevoked.value = null;
}
);
}
};
const { data: sharedReportResponseData } = useQuery({
queryKey: ['api-tokens'],
queryFn: () =>
api.getApiTokens(),
});
const tokens = computed(() => {
return sharedReportResponseData.value?.data ?? [];
})
const createApiTokenMutation = useMutation({
mutationFn: async (apiToken: CreateApiTokenBody) => {
return await api.createApiToken(apiToken);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['api-tokens'] })
},
});
const deleteApiTokenMutation = useMutation({
mutationFn: async (apiTokenId: string) => {
return await api.deleteApiToken(undefined, {
params: {
apiToken: apiTokenId,
},
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['api-tokens'] })
},
});
const revokeApiTokenMutation = useMutation({
mutationFn: async (apiTokenId: string) => {
return await api.revokeApiToken(undefined, {
params: {
apiToken: apiTokenId,
},
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['api-tokens'] })
},
});
</script>
<template>
<div>
<!-- Generate API Token -->
<FormSection @submitted="createApiToken">
<template #title> Create API Token </template>
<template #description>
API tokens allow third-party services to authenticate with our
application on your behalf.
</template>
<template #form>
<!-- Token Name -->
<div class="col-span-6 sm:col-span-4">
<InputLabel for="api_key_name" value="API Key Name" />
<TextInput
id="api_key_name"
v-model="createApiTokenForm.name"
type="text"
class="mt-1 block w-full" />
<InputError
:message="createApiTokenForm.errors.name"
class="mt-2" />
<div class="text-text-tertiary text-sm pt-3 flex space-x-1.5 font-medium items-center">
<ClockIcon class="w-4"></ClockIcon>
<span>
API Tokens are valid for 1 year
</span>
</div>
</div>
</template>
<template #actions>
<ActionMessage
:on="createApiTokenForm.recentlySuccessful"
class="me-3">
Created.
</ActionMessage>
<PrimaryButton
:class="{ 'opacity-25': createApiTokenForm.processing }"
:disabled="createApiTokenForm.processing">
Create API Key
</PrimaryButton>
</template>
</FormSection>
<div v-if="tokens.length > 0">
<SectionBorder />
<!-- Manage API Tokens -->
<div class="mt-10 sm:mt-0">
<ActionSection>
<template #title> Manage API Tokens </template>
<template #description>
You may delete or revoke any of your existing tokens if they are
no longer needed.
</template>
<!-- API Token List -->
<template #content>
<div class="divide-border-secondary divide-y">
<div
v-for="token in tokens"
:key="token.id"
class="flex items-center py-2.5 justify-between">
<div class="break-all text-white">
<div>{{ token.name }}</div>
<div class="text-sm text-text-tertiary space-x-3">
<span v-if="token.created_at">
Created at {{ formatDateTimeLocalized(token.created_at) }}
</span>
<span v-if="token.expires_at">
Expires at {{ formatDateTimeLocalized(token.expires_at) }}
</span>
<span v-if="token.revoked">
Revoked
</span>
</div>
</div>
<div class="flex items-center ms-2">
<div
v-if="token.last_used_ago"
class="text-sm text-gray-400">
Last used {{ token.last_used_ago }}
</div>
<button
v-if="!token.revoked"
class="cursor-pointer ms-6 text-sm text-text-secondary"
:aria-label="'Revoke API Token ' + token.name"
@click="confirmApiTokenRevocation(token)">
Revoke
</button>
<button
class="cursor-pointer ms-6 text-sm text-red-500"
:aria-label="'Delete API Token ' + token.name"
@click="confirmApiTokenDeletion(token)">
Delete
</button>
</div>
</div>
</div>
</template>
</ActionSection>
</div>
</div>
<!-- Token Value Modal -->
<DialogModal :show="displayingToken" @close="displayingToken = false">
<template #title> API Token created successfully </template>
<template #content>
<div>
Please copy your new API token. For your security, it won't
be shown again.
<strong>This token is valid for one year</strong> unless you revoke it.
</div>
<div>
</div>
<div class="flex gap-2 pt-6 w-full">
<TextInput v-if="newToken" disabled :model-value="newToken" class="flex-1 text-gray-500"></TextInput>
<PrimaryButton v-if="isSupported" @click="copy(newToken)">{{ copied ? 'Copied!' : 'Copy Token' }}</PrimaryButton>
</div>
</template>
<template #footer>
<SecondaryButton @click="displayingToken = false">
Close
</SecondaryButton>
</template>
</DialogModal>
<!-- Delete Token Confirmation Modal -->
<ConfirmationModal
:show="apiTokenBeingDeleted != null"
@close="apiTokenBeingDeleted = null">
<template #title> Delete API Token </template>
<template #content>
Are you sure you would like to delete this API token?
</template>
<template #footer>
<SecondaryButton @click="apiTokenBeingDeleted = null">
Cancel
</SecondaryButton>
<DangerButton
class="ms-3"
:class="{ 'opacity-25': createApiTokenMutation.isPending.value }"
:disabled="createApiTokenMutation.isPending.value"
@click="deleteApiToken">
Delete
</DangerButton>
</template>
</ConfirmationModal>
<ConfirmationModal
:show="apiTokenBeingRevoked != null"
@close="apiTokenBeingRevoked = null">
<template #title> Revoke API Token </template>
<template #content>
Are you sure you would like to revoke this API token?
</template>
<template #footer>
<SecondaryButton @click="apiTokenBeingRevoked = null">
Cancel
</SecondaryButton>
<DangerButton
class="ms-3"
:class="{ 'opacity-25': revokeApiTokenMutation.isPending.value }"
:disabled="revokeApiTokenMutation.isPending.value"
@click="revokeApiToken">
Revoke
</DangerButton>
</template>
</ConfirmationModal>
</div>
</template>

View File

@@ -9,6 +9,7 @@ import UpdateProfileInformationForm from '@/Pages/Profile/Partials/UpdateProfile
import { usePage } from '@inertiajs/vue3';
import type { User } from '@/types/models';
import type { Session } from '@/types/jetstream';
import ApiTokensForm from "@/Pages/Profile/Partials/ApiTokensForm.vue";
defineProps<{
confirmsTwoFactorAuthentication: boolean;
@@ -65,6 +66,9 @@ const page = usePage<{
<LogoutOtherBrowserSessionsForm
:sessions="sessions"
class="mt-10 sm:mt-0" />
<SectionBorder />
<ApiTokensForm></ApiTokensForm>
<template
v-if="page.props.jetstream.hasAccountDeletionFeatures">

View File

@@ -53,6 +53,7 @@ onMounted(() => {
if (canViewProjectMembers()) {
useProjectMembersStore().fetchProjectMembers(projectId);
}
useTasksStore().fetchTasks();
});
const showEditProjectModal = ref(false);

View File

@@ -464,10 +464,11 @@ const tableData = computed(() => {
<div
class="justify-end pr-6 flex items-center font-medium">
{{
aggregatedTableTimeEntries.cost ?
formatCents(
aggregatedTableTimeEntries.cost,
getOrganizationCurrencyString()
)
) : '--'
}}
</div>
</div>

View File

@@ -58,7 +58,7 @@ import {
PaginationRoot,
} from 'radix-vue';
import { useQuery, useQueryClient } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.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 TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { canCreateProjects } from '@/utils/permissions';
import {canCreateProjects, canViewAllTimeEntries} from '@/utils/permissions';
import ReportingExportModal from '@/Components/Common/Reporting/ReportingExportModal.vue';
const startDate = useSessionStorage<string>(
@@ -98,6 +98,7 @@ function getFilterAttributes() {
};
const params = {
...defaultParams,
member_id: !canViewAllTimeEntries() ? getCurrentMembershipId() : undefined,
member_ids:
selectedMembers.value.length > 0
? selectedMembers.value

View File

@@ -29,7 +29,7 @@ async function exportData() {
const response = await handleApiRequestNotifications(
() =>
api.exportOrganization(
{},
undefined,
{
params: {
organization: organizationId,

View File

@@ -162,6 +162,15 @@ export type UpdateReportBody = ZodiosBodyByAlias<SolidTimeApi, 'updateReport'>;
export type CreateReportBodyProperties = CreateReportBody['properties'];
export type Report = ReportIndexResponse['data'][0];
export type ApiTokenIndexResponse = ZodiosResponseByAlias<
SolidTimeApi,
'getApiTokens'
>;
export type CreateApiTokenBody = ZodiosBodyByAlias<SolidTimeApi, 'createApiToken'>;
export type ApiToken = ApiTokenIndexResponse['data'][0];
const api = createApiClient('/api', { validate: 'none' });
export { createApiClient, api };

View File

@@ -1,6 +1,31 @@
import { makeApi, Zodios, type ZodiosOptions } from '@zodios/core';
import { z } from 'zod';
const ApiTokenResource = z
.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()]),
})
.passthrough();
const ApiTokenCollection = z.array(ApiTokenResource);
const ApiTokenStoreRequest = z
.object({ name: z.string().min(1).max(255) })
.passthrough();
const ApiTokenWithAccessTokenResource = z
.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
.object({
id: z.string(),
@@ -26,9 +51,11 @@ const ImportRequest = z
const InvitationResource = z
.object({ id: z.string(), email: z.string(), role: z.string() })
.passthrough();
const Role = z.enum(['owner', 'admin', 'manager', 'employee', 'placeholder']);
const InvitationStoreRequest = z
.object({ email: z.string().email(), role: Role })
.object({
email: z.string().email(),
role: z.enum(['admin', 'manager', 'employee']),
})
.passthrough();
const MemberResource = z
.object({
@@ -41,10 +68,15 @@ const MemberResource = z
billable_rate: z.union([z.number(), z.null()]),
})
.passthrough();
const Role = z.enum(['owner', 'admin', 'manager', 'employee', 'placeholder']);
const MemberUpdateRequest = z
.object({ role: Role, billable_rate: z.union([z.number(), z.null()]) })
.partial()
.passthrough();
const MemberMergeIntoRequest = z
.object({ member_id: z.string() })
.partial()
.passthrough();
const OrganizationResource = z
.object({
id: z.string(),
@@ -190,13 +222,6 @@ const ReportStoreRequest = z
timezone: z.union([z.string(), z.null()]).optional(),
})
.passthrough(),
'properties.member_ids': z.string().optional(),
'properties.client_ids': z.string().optional(),
'properties.project_ids': z.string().optional(),
'properties.tag_ids': z.string().optional(),
'properties.task_ids': z.string().optional(),
'properties.week_start': z.string().optional(),
'properties.timezone': z.string().optional(),
})
.passthrough();
const DetailedReportResource = z
@@ -459,19 +484,23 @@ const PersonalMembershipResource = z
role: z.string(),
})
.passthrough();
const PersonalMembershipCollection = z.array(PersonalMembershipResource);
export const schemas = {
ApiTokenResource,
ApiTokenCollection,
ApiTokenStoreRequest,
ApiTokenWithAccessTokenResource,
ClientResource,
ClientCollection,
ClientStoreRequest,
ClientUpdateRequest,
ImportRequest,
InvitationResource,
Role,
InvitationStoreRequest,
MemberResource,
Role,
MemberUpdateRequest,
MemberMergeIntoRequest,
OrganizationResource,
OrganizationUpdateRequest,
ProjectResource,
@@ -502,7 +531,6 @@ export const schemas = {
TimeEntryUpdateRequest,
UserResource,
PersonalMembershipResource,
PersonalMembershipCollection,
};
const endpoints = makeApi([
@@ -583,6 +611,332 @@ 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',
path: '/v1/organizations/:organization/clients',
@@ -786,11 +1140,6 @@ const endpoints = makeApi([
alias: 'exportOrganization',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({}).partial().passthrough(),
},
{
name: 'organization',
type: 'Path',
@@ -1122,11 +1471,6 @@ const endpoints = makeApi([
alias: 'resendInvitationEmail',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({}).partial().passthrough(),
},
{
name: 'organization',
type: 'Path',
@@ -1157,6 +1501,71 @@ 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',
path: '/v1/organizations/:organization/members',
@@ -1345,11 +1754,6 @@ const endpoints = makeApi([
alias: 'invitePlaceholder',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({}).partial().passthrough(),
},
{
name: 'organization',
type: 'Path',
@@ -1394,14 +1798,9 @@ const endpoints = makeApi([
{
method: 'post',
path: '/v1/organizations/:organization/members/:member/make-placeholder',
alias: 'v1.members.make-placeholder',
alias: 'makePlaceholder',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({}).partial().passthrough(),
},
{
name: 'organization',
type: 'Path',
@@ -2614,6 +3013,11 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'user_id',
type: 'Query',
schema: z.string().optional(),
},
{
name: 'member_ids',
type: 'Query',
@@ -2639,11 +3043,6 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'user_id',
type: 'Query',
schema: z.string().optional(),
},
],
response: z
.object({
@@ -3052,7 +3451,7 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
.object({
key: z.union([z.string(), z.null()]),
seconds: z.number().int(),
cost: z.number().int(),
cost: z.union([z.number(), z.null()]),
grouped_type: z.union([
z.string(),
z.null(),
@@ -3068,7 +3467,10 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
seconds: z
.number()
.int(),
cost: z.number().int(),
cost: z.union([
z.number(),
z.null(),
]),
grouped_type: z.null(),
grouped_data: z.null(),
})
@@ -3082,7 +3484,7 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
z.null(),
]),
seconds: z.number().int(),
cost: z.number().int(),
cost: z.union([z.number(), z.null()]),
})
.passthrough(),
})
@@ -3438,6 +3840,163 @@ The report is considered public if the &#x60;is_public&#x60; field is set to &#x
},
],
},
{
method: 'get',
path: '/v1/users/me/api-tokens',
alias: 'getApiTokens',
description: `This endpoint is independent of organization.`,
requestFormat: 'json',
response: z.object({ data: ApiTokenCollection }).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(),
},
],
},
{
method: 'post',
path: '/v1/users/me/api-tokens',
alias: 'createApiToken',
description: `The response will contain the access token that can be used to send authenticated API requests.
Please note that the access token is only shown in this response and cannot be retrieved later.`,
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z
.object({ name: z.string().min(1).max(255) })
.passthrough(),
},
],
response: z
.object({ data: ApiTokenWithAccessTokenResource })
.passthrough(),
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: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
},
{
method: 'delete',
path: '/v1/users/me/api-tokens/:apiToken',
alias: 'deleteApiToken',
requestFormat: 'json',
parameters: [
{
name: 'apiToken',
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(),
},
],
},
{
method: 'post',
path: '/v1/users/me/api-tokens/:apiToken/revoke',
alias: 'revokeApiToken',
requestFormat: 'json',
parameters: [
{
name: 'apiToken',
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(),
},
],
},
{
method: 'get',
path: '/v1/users/me/memberships',
@@ -3445,7 +4004,7 @@ The report is considered public if the &#x60;is_public&#x60; field is set to &#x
description: `This endpoint is independent of organization.`,
requestFormat: 'json',
response: z
.object({ data: PersonalMembershipCollection })
.object({ data: z.array(PersonalMembershipResource) })
.passthrough(),
errors: [
{

View File

@@ -8,6 +8,7 @@ import { twMerge } from 'tailwind-merge';
const props = defineProps<{
class?: string;
tabindex?: string;
}>();
// This has to be a localized timestamp, not UTC
@@ -50,6 +51,7 @@ const emit = defineEmits(['changed']);
<input
id="start"
ref="datePicker"
:tabindex="tabindex"
:class="
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',

View File

@@ -1,6 +1,6 @@
<script setup lang="ts" generic="T">
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { computed, nextTick, ref, watch } from 'vue';
import {computed, nextTick, onMounted, ref, watch} from 'vue';
import SelectDropdownItem from '@/packages/ui/src/Input/SelectDropdownItem.vue';
import { onKeyStroke } from '@vueuse/core';
import { type Placement } from '@floating-ui/vue';
@@ -43,10 +43,22 @@ const filteredItems = computed<T[]>(() => {
const highlightedItemId = ref<string | null>(model.value);
watch(model, () => {
highlightedItemId.value = model.value;
if(model.value){
highlightedItemId.value = model.value;
}
});
onMounted(() => {
if (!highlightedItemId.value) {
resetHightlightedItem();
}
});
watch(filteredItems, () => {
resetHightlightedItem();
});
function resetHightlightedItem(){
if (
filteredItems.value.length > 0 &&
filteredItems.value.find(
@@ -55,7 +67,7 @@ watch(filteredItems, () => {
) {
highlightedItemId.value = props.getKeyFromItem(filteredItems.value[0]);
}
});
}
watch(highlightedItemId, () => {
if (highlightedItemId.value) {

View File

@@ -6,6 +6,7 @@ import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
import MultiselectDropdownItem from '@/packages/ui/src/Input/MultiselectDropdownItem.vue';
import type { Tag } from '@/packages/api/src';
import type { Placement } from '@floating-ui/vue';
import {UseFocusTrap} from "@vueuse/integrations/useFocusTrap/component";
const props = withDefaults(
defineProps<{
@@ -177,46 +178,50 @@ const showCreateTagModal = ref(false);
<slot name="trigger"></slot>
</template>
<template #content>
<input
ref="searchInput"
:value="searchValue"
data-testid="tag_dropdown_search"
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"
placeholder="Search for a Tag..."
@input="updateSearchValue"
@keydown.enter="addTagIfNoneExists"
@keydown.up.prevent="moveHighlightUp"
@keydown.down.prevent="moveHighlightDown" />
<div ref="dropdownViewport" class="w-60 max-h-60 overflow-y-scroll">
<div
v-for="tag in filteredTags"
:key="tag.id"
role="option"
:value="tag.id"
:class="{
<UseFocusTrap
v-if="open"
:options="{ immediate: true, allowOutsideClick: true }">
<input
ref="searchInput"
:value="searchValue"
data-testid="tag_dropdown_search"
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"
placeholder="Search for a Tag..."
@input="updateSearchValue"
@keydown.enter="addTagIfNoneExists"
@keydown.up.prevent="moveHighlightUp"
@keydown.down.prevent="moveHighlightDown" />
<div ref="dropdownViewport" class="w-60 max-h-60 overflow-y-scroll">
<div
v-for="tag in filteredTags"
:key="tag.id"
role="option"
:value="tag.id"
:class="{
'bg-card-background-active':
tag.id === highlightedItemId,
}"
data-testid="tag_dropdown_entries"
:data-tag-id="tag.id">
<MultiselectDropdownItem
:selected="isTagSelected(tag.id)"
:name="tag.name"
@click="toggleTag(tag.id)"></MultiselectDropdownItem>
data-testid="tag_dropdown_entries"
:data-tag-id="tag.id">
<MultiselectDropdownItem
:selected="isTagSelected(tag.id)"
:name="tag.name"
@click="toggleTag(tag.id)"></MultiselectDropdownItem>
</div>
</div>
</div>
<div class="hover:bg-card-background-active rounded-b-lg">
<button
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="
<div class="hover:bg-card-background-active rounded-b-lg">
<button
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;
showCreateTagModal = true;
">
<PlusCircleIcon
class="w-5 flex-shrink-0 text-icon-default"></PlusCircleIcon>
<span>Create new Tag</span>
</button>
</div>
<PlusCircleIcon
class="w-5 flex-shrink-0 text-icon-default"></PlusCircleIcon>
<span>Create new Tag</span>
</button>
</div>
</UseFocusTrap>
</template>
</Dropdown>
</template>

View File

@@ -18,7 +18,6 @@ import type {
Client,
CreateTimeEntryBody,
} from '@/packages/api/src';
import TimePicker from '@/packages/ui/src/Input/TimePicker.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import { canCreateProjects } from '@/utils/permissions';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
@@ -30,6 +29,7 @@ import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
import { InformationCircleIcon } from '@heroicons/vue/20/solid';
import type { Tag, Task } from '@/packages/api/src';
import TimePickerSimple from "@/packages/ui/src/Input/TimePickerSimple.vue";
const show = defineModel('show', { default: false });
const saving = ref(false);
@@ -175,7 +175,6 @@ type BillableOption = {
size="xlarge">
<TagIcon
v-if="timeEntry.tags.length === 0"
tag="button"
class="w-4"></TagIcon>
<div
v-else
@@ -245,30 +244,34 @@ type BillableOption = {
<div class="">
<InputLabel>Start</InputLabel>
<div class="flex flex-col items-center space-y-2 mt-1">
<TimePicker
<TimePickerSimple
v-model="localStart"
size="large"></TimePicker>
size="large"></TimePickerSimple>
<DatePicker
v-model="localStart"
tabindex="1"
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
</div>
</div>
<div class="">
<InputLabel>End</InputLabel>
<div class="flex flex-col items-center space-y-2 mt-1">
<TimePicker
<TimePickerSimple
v-model="localEnd"
size="large"></TimePicker>
size="large"></TimePickerSimple>
<DatePicker
v-model="localEnd"
tabindex="1"
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
</div>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
<SecondaryButton tabindex="2" @click="show = false"> Cancel</SecondaryButton>
<PrimaryButton
tabindex="2"
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"

View File

@@ -99,6 +99,10 @@ export function formatDateLocalized(date: string): string {
return getLocalizedDayJs(date).format('DD.MM.YYYY');
}
export function formatDateTimeLocalized(date: string): string {
return getLocalizedDayJs(date).format('DD.MM.YYYY HH:mm');
}
export function formatWeek(date: string | null): string {
return 'Week ' + getDayJsInstance()(date).week();
}

View File

@@ -77,6 +77,14 @@ export function canDeleteMembers() {
return currentUserHasPermission('members:delete');
}
export function canMergeMembers() {
return currentUserHasPermission('members:merge-into');
}
export function canMakeMembersPlaceholders() {
return currentUserHasPermission('members:make-placeholder');
}
export function canInvitePlaceholderMembers() {
return currentUserHasPermission('members:invite-placeholder');
}
@@ -101,9 +109,16 @@ export function canManageBilling() {
return currentUserHasPermission('billing');
}
export function canViewReport() {
return currentUserHasPermission('reports:view');
}
export function canUpdateReport() {
return currentUserHasPermission('reports:update');
}
export function canDeleteReport() {
return currentUserHasPermission('reports:delete');
}
export function canViewAllTimeEntries() {
return currentUserHasPermission('time-entries:view:all');
}

View File

@@ -0,0 +1,4 @@
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 name color billable_rate is_public client billable_default estimated_time archived_at
2 Project for Big Company 10001 false Big Company true
3 Project without Client #ef5350 false false 1000
4 Project (Archived) #6a407f true Some client true 0 2024-08-25T10:00:00Z

Some files were not shown because too many files have changed in this diff Show More