Compare commits

..

35 Commits

Author SHA1 Message Date
Gregor Vostrak
1e7de6022a fix css variables not updating correctly when system theme changes 2025-06-08 15:47:25 +02:00
Constantin Graf
4de7868851 Add postgres version matrix to phpunit tests 2025-06-04 21:43:35 +02:00
dependabot[bot]
ffc016a1ec Bump codecov/codecov-action from 5.4.2 to 5.4.3
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.4.2 to 5.4.3.
- [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.4.2...v5.4.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-22 18:32:13 +02:00
Constantin Graf
be69626970 Add permissions to all GitHub actions 2025-05-22 11:04:37 +02:00
Gregor Vostrak
f1dce88dab fix time zone issue in daterangepicker 2025-05-21 12:34:02 -07:00
Constantin Graf
15411ec0c8 Add resend verification email to filament resource 2025-05-19 11:50:40 +02:00
Constantin Graf
48f09421d0 Fixed time entries exports for employees #2 2025-05-16 15:14:22 +02:00
Constantin Graf
36caadeb14 Fixed time entries exports for employees 2025-05-16 13:20:23 +02:00
Gregor Vostrak
b4edcaa2dc hide shared reports create for employees, fix export request for employees 2025-05-16 13:20:23 +02:00
Constantin Graf
a3dda8b03c Fixed text for clockify import 2025-05-16 13:03:47 +02:00
Constantin Graf
d64f0c52be Fixed bugs in current organization; Add database consistency checks; Add foreign key 2025-05-16 13:03:47 +02:00
Gregor Vostrak
c80d51c2e1 fix sub_group empty type placeholders showing parent type in shared reports view 2025-05-15 13:34:27 +02:00
Gregor Vostrak
46dea00b34 fix user name not displayed correctly for employee users in reporting 2025-05-15 12:54:30 +02:00
Constantin Graf
16fed4a2b7 Add base request class with generic rule sets 2025-05-14 21:07:54 +02:00
Gregor Vostrak
9a2af2e743 respect organization time format settings in api tokens section 2025-05-14 16:21:37 +02:00
Gregor Vostrak
2e3a517502 improve positioning and overflow behaviour of dialogs 2025-05-14 16:03:32 +02:00
Gregor Vostrak
a69fb9c551 make client deselectable for projects, fixes #333 2025-05-14 15:27:28 +02:00
Gregor Vostrak
62b5730fa8 fix contrast on select and dropdown foreground colors, add missing placeholder in billable input 2025-05-14 14:09:19 +02:00
Gregor Vostrak
098ead8da6 change billable rate input to use shadcn component 2025-05-13 18:51:36 +02:00
Constantin Graf
d49082d7f3 Fixed localization in PDF reports 2025-05-13 18:48:37 +02:00
Gregor Vostrak
cc88f034c7 fix sharedreport date_format provide 2025-05-13 17:45:02 +02:00
Gregor Vostrak
9620c89545 migrate daterange picker to shadcn component 2025-05-13 16:32:11 +02:00
Gregor Vostrak
f9c3f42289 improve time entry range design issue in 12-h format 2025-05-13 16:32:11 +02:00
Gregor Vostrak
fca4c26cfc add support for timeFormat in the frontend 2025-05-13 16:32:11 +02:00
Gregor Vostrak
d8f4ba1517 add format options for number field component 2025-05-13 16:32:11 +02:00
Constantin Graf
284d8cd786 Add unit test for currency endpoint 2025-05-13 16:32:11 +02:00
Gregor Vostrak
411fc6ea5e add e2e tests for organization format settings 2025-05-13 16:32:11 +02:00
Gregor Vostrak
02a8367d16 change e2e tests to use organization default values for money formatting 2025-05-13 16:32:11 +02:00
Gregor Vostrak
68f636c8ff fix shared report endpoint test to check new structure that includes organization format properties, format 2025-05-13 16:32:11 +02:00
Gregor Vostrak
9c44abf7aa update api client, add api types, fix activitygraphcard formatting 2025-05-13 16:32:11 +02:00
Gregor Vostrak
b1ff97a82f add frontend support for the date formatting option 2025-05-13 16:32:11 +02:00
Gregor Vostrak
ed32c6b217 add frontend format support for currencies, add currencies endpoint 2025-05-13 16:32:11 +02:00
Gregor Vostrak
8b950d99d6 add support for interval / duration format in frontend views 2025-05-13 16:32:11 +02:00
Constantin Graf
e374d8b3de Fixed typos in organization format settings 2025-05-13 16:32:11 +02:00
Gregor Vostrak
301d09e830 add formating options to organization settings 2025-05-13 16:32:11 +02:00
98 changed files with 1377 additions and 442 deletions

View File

@@ -10,6 +10,8 @@ on:
- '.github/workflows/build-private.yml'
- 'docker/prod/**'
workflow_dispatch:
permissions:
contents: read
name: Build - Private
jobs:
@@ -17,6 +19,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: "Check out code"
uses: actions/checkout@v4

View File

@@ -11,6 +11,12 @@ on:
- 'docker/prod/**'
workflow_dispatch:
permissions:
packages: write
contents: read
attestations: write
id-token: write
env:
DOCKERHUB_REPO: solidtime/solidtime
GHCR_REPO: ghcr.io/solidtime-io/solidtime
@@ -26,11 +32,6 @@ jobs:
- runs-on: "ubuntu-24.04"
platform: "linux/amd64"
runs-on: ${{ matrix.runs-on }}
permissions:
packages: write
contents: read
attestations: write
id-token: write
timeout-minutes: 90
steps:
@@ -163,11 +164,6 @@ jobs:
merge:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
attestations: write
id-token: write
timeout-minutes: 90
needs:
- build

View File

@@ -3,6 +3,9 @@ on:
push:
branches:
- main
permissions:
contents: read
jobs:
api_docs:
runs-on: ubuntu-latest

View File

@@ -1,6 +1,8 @@
name: NPM Build
on: [push]
permissions:
contents: read
jobs:
build:

View File

@@ -1,6 +1,8 @@
name: NPM Lint
on: [push]
permissions:
contents: read
jobs:
build:

View File

@@ -1,6 +1,8 @@
name: Publish API package to NPM
on:
workflow_dispatch
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest

View File

@@ -1,6 +1,8 @@
name: Publish UI package to NPM
on:
workflow_dispatch
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest

View File

@@ -1,7 +1,8 @@
name: NPM Typecheck
on: [push]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest

View File

@@ -1,5 +1,7 @@
name: Static code analysis (PHPStan)
on: push
permissions:
contents: read
jobs:
phpstan:
runs-on: ubuntu-latest

View File

@@ -1,13 +1,18 @@
name: PHPUnit Tests
on: push
permissions:
contents: read
jobs:
phpunit:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
matrix:
postgres_version: [ 15, 16, 17 ]
services:
pgsql_test:
image: postgres:15
image: postgres:${{ matrix.postgres_version }}
env:
PGPASSWORD: 'root'
POSTGRES_DB: 'laravel'
@@ -63,7 +68,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.4.2
uses: codecov/codecov-action@v5.4.3
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime

View File

@@ -1,5 +1,7 @@
name: PHP Linting
on: push
permissions:
contents: read
jobs:
pint:
runs-on: ubuntu-latest

View File

@@ -1,5 +1,7 @@
name: Playwright Tests
on: [push]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\SelfHost;
use Illuminate\Console\Command;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class SelfHostDatabaseConsistency extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'self-host:database-consistency';
/**
* The console command description.
*
* @var string
*/
protected $description = '';
/**
* Execute the console command.
*/
public function handle(): int
{
$hadAProblem = false;
// Task need to be part of project in time entries
$problems = DB::table('time_entries')
->select(['time_entries.id as id'])
->join('tasks', 'time_entries.task_id', '=', 'tasks.id')
->where('tasks.project_id', '!=', DB::raw('time_entries.project_id'))
->get();
$this->logProblems($problems, 'Time entries have a task that does not belong to the project of the time entry', $hadAProblem);
// Client id is the client id of the project
$problems = DB::table('time_entries')
->select(['time_entries.id as id'])
->join('projects', 'time_entries.project_id', '=', 'projects.id')
->where(DB::raw('coalesce(projects.client_id::varchar, \'\')'), '!=', DB::raw('coalesce(time_entries.client_id::varchar, \'\')'))
->get();
$this->logProblems($problems, 'Time entries have a client that does not match the client of the project', $hadAProblem);
// Client id can only be not null if the project id is not null
$problems = DB::table('time_entries')
->select(['time_entries.id as id'])
->whereNotNull('client_id')
->whereNull('project_id')
->get();
$this->logProblems($problems, 'Time entries have a client but no project', $hadAProblem);
// Every user needs to be a member of at least one organization
$problems = DB::table('users')
->select(['users.id as id'])
->leftJoin('members', 'users.id', '=', 'members.user_id')
->whereNull('members.id')
->get();
$this->logProblems($problems, 'Users are not member of any organization', $hadAProblem);
// Every organization needs at least an owner
$problems = DB::table('organizations')
->select(['organizations.id as id'])
->leftJoin('members', function (JoinClause $join): void {
$join->on('organizations.id', '=', 'members.organization_id')
->where('members.role', '=', 'owner');
})
->whereNull('members.id')
->get();
$this->logProblems($problems, 'Organizations without an owner', $hadAProblem);
// Every member can only have one running time entry
$problems = DB::table('time_entries')
->select(['user_id as id'])
->whereNull('end')
->groupBy('user_id')
->havingRaw('count(*) > 1')
->get(['user_id', DB::raw('count(*) as count')]);
$this->logProblems($problems, 'Users with more than one running time entry', $hadAProblem);
// Users have a current organization that they are not a member of
$problems = DB::table('users')
->select(['users.id as id'])
->whereNotNull('current_team_id')
->whereNotIn('current_team_id', function (Builder $query): void {
$query->select('organization_id')
->from('members')
->whereColumn('members.user_id', 'users.id');
})->get();
$this->logProblems($problems, 'Users have a current organization that they are not a member of', $hadAProblem);
return $hadAProblem ? self::FAILURE : self::SUCCESS;
}
/**
* @param Collection<int, \stdClass> $problems
*/
private function logProblems(Collection $problems, string $message, bool &$hadAProblem): void
{
$message = 'Consistency problem: '.$message;
if ($problems->isNotEmpty()) {
$ids = $problems->pluck('id');
$hadAProblem = true;
Log::error($message, [
'ids' => $ids,
]);
$error = $message;
foreach ($ids as $id) {
$error .= "\n - ".$id;
}
$this->error($error);
}
}
}

View File

@@ -25,6 +25,10 @@ class Kernel extends ConsoleKernel
$schedule->command('self-host:telemetry')
->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
->twiceDaily();
$schedule->command('self-host:database-consistency')
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
->twiceDaily();
}
/**

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
class DatabaseSeederAfterSeed
{
use Dispatchable;
public function __construct() {}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
class DatabaseSeederBeforeDelete
{
use Dispatchable;
public function __construct() {}
}

View File

@@ -23,6 +23,7 @@ use Filament\Tables;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
@@ -207,6 +208,14 @@ class UserResource extends Resource
}),
])
->bulkActions([
Tables\Actions\BulkAction::make('Resend verification email')
->icon('heroicon-o-paper-airplane')
->action(function (Collection $records): void {
foreach ($records as $user) {
/** @var User $user */
$user->sendEmailVerificationNotification();
}
}),
]);
}

View File

@@ -226,6 +226,7 @@ class TimeEntryController extends Controller
'start' => $request->getStart()->timezone($timezone),
'end' => $request->getEnd()->timezone($timezone),
'localization' => $localizationService,
'showBillableRate' => $showBillableRate,
]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf-footer.blade.php'));
if ($footerViewFile === false) {
@@ -428,6 +429,7 @@ class TimeEntryController extends Controller
'end' => $request->getEnd()->timezone($timezone),
'debug' => $debug,
'localization' => $localizationService,
'showBillableRate' => $showBillableRate,
]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf-footer.blade.php'));
if ($footerViewFile === false) {
@@ -456,7 +458,7 @@ class TimeEntryController extends Controller
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
} else {
Excel::store(
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup),
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate),
$path,
config('filesystems.private'),
$format->getExportPackageType(),

View File

@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\ApiToken;
use Illuminate\Foundation\Http\FormRequest;
use App\Http\Requests\V1\BaseFormRequest;
class ApiTokenStoreRequest extends FormRequest
class ApiTokenStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1;
use Illuminate\Foundation\Http\FormRequest;
class BaseFormRequest extends FormRequest
{
/**
* @return list<string>
*/
protected function moneyRules(bool $bigInt = false): array
{
$rules = [
'integer',
'min:0',
];
if ($bigInt) {
$rules[] = 'max:9223372036854775807';
} else {
$rules[] = 'max:2147483647';
}
return $rules;
}
}

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Client;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ClientIndexRequest extends FormRequest
class ClientIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,17 +4,17 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Client;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class ClientStoreRequest extends FormRequest
class ClientStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,18 +4,18 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Client;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
* @property Client|null $client Client from model binding
*/
class ClientUpdateRequest extends FormRequest
class ClientUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Import;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ImportRequest extends FormRequest
class ImportRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Invitation;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* @property Organization $organization
*/
class InvitationIndexRequest extends FormRequest
class InvitationIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -5,18 +5,18 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Invitation;
use App\Enums\Role;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization
*/
class InvitationStoreRequest extends FormRequest
class InvitationStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Member;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* @property Organization $organization
*/
class MemberIndexRequest extends FormRequest
class MemberIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,17 +4,17 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Member;
use App\Http\Requests\V1\BaseFormRequest;
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
class MemberMergeIntoRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -5,15 +5,15 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Member;
use App\Enums\Role;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* @property Organization $organization
*/
class MemberUpdateRequest extends FormRequest
class MemberUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -27,12 +27,12 @@ class MemberUpdateRequest extends FormRequest
'string',
Rule::enum(Role::class),
],
'billable_rate' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
],
'billable_rate' => array_merge(
[
'nullable',
],
$this->moneyRules()
),
];
}

View File

@@ -9,14 +9,14 @@ use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* @property Organization $organization Organization from model binding
*/
class OrganizationUpdateRequest extends FormRequest
class OrganizationUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -30,12 +30,12 @@ class OrganizationUpdateRequest extends FormRequest
'string',
'max:255',
],
'billable_rate' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
],
'billable_rate' => array_merge(
[
'nullable',
],
$this->moneyRules()
),
'employees_can_see_billable_rates' => [
'boolean',
],

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Project;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ProjectIndexRequest extends FormRequest
class ProjectIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,13 +4,13 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Project;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Organization;
use App\Models\Project;
use App\Rules\ColorRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
@@ -18,7 +18,7 @@ use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class ProjectStoreRequest extends FormRequest
class ProjectStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -55,12 +55,12 @@ class ProjectStoreRequest extends FormRequest
'required',
'boolean',
],
'billable_rate' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
],
'billable_rate' => array_merge(
[
'nullable',
],
$this->moneyRules()
),
// ID of the client
'client_id' => [
'present',

View File

@@ -4,13 +4,13 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Project;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Organization;
use App\Models\Project;
use App\Rules\ColorRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
@@ -19,7 +19,7 @@ use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
* @property Organization $organization Organization from model binding
* @property Project|null $project Project from model binding
*/
class ProjectUpdateRequest extends FormRequest
class ProjectUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -68,12 +68,11 @@ class ProjectUpdateRequest extends FormRequest
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
'billable_rate' => [
'billable_rate' => array_merge([
'nullable',
'integer',
'min:0',
'max:2147483647',
],
$this->moneyRules()
),
// Estimated time in seconds
'estimated_time' => [
'nullable',

View File

@@ -4,17 +4,17 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\ProjectMember;
use App\Http\Requests\V1\BaseFormRequest;
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 Organization from model binding
*/
class ProjectMemberStoreRequest extends FormRequest
class ProjectMemberStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -31,12 +31,12 @@ class ProjectMemberStoreRequest extends FormRequest
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
'billable_rate' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
],
'billable_rate' => array_merge(
[
'nullable',
],
$this->moneyRules()
),
];
}

View File

@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\ProjectMember;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* @property Organization $organization Organization from model binding
*/
class ProjectMemberUpdateRequest extends FormRequest
class ProjectMemberUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -21,12 +21,12 @@ class ProjectMemberUpdateRequest extends FormRequest
public function rules(): array
{
return [
'billable_rate' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
],
'billable_rate' => array_merge(
[
'nullable',
],
$this->moneyRules()
),
];
}

View File

@@ -7,17 +7,17 @@ namespace App\Http\Requests\V1\Report;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
/**
* @property Organization $organization Organization from model binding
*/
class ReportStoreRequest extends FormRequest
class ReportStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,15 +4,15 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Report;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
/**
* @property Organization $organization Organization from model binding
*/
class ReportUpdateRequest extends FormRequest
class ReportUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,17 +4,17 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Tag;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Models\Tag;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TagStoreRequest extends FormRequest
class TagStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,18 +4,18 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Tag;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Models\Tag;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
* @property Tag|null $tag Tag from model binding
*/
class TagUpdateRequest extends FormRequest
class TagUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,19 +4,19 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Task;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Models\Project;
use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TaskIndexRequest extends FormRequest
class TaskIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,19 +4,19 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Task;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TaskStoreRequest extends FormRequest
class TaskStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,18 +4,18 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Task;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
* @property Task|null $task Task from model binding
*/
class TaskUpdateRequest extends FormRequest
class TaskUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -7,6 +7,7 @@ namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
@@ -16,7 +17,6 @@ use App\Models\Task;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
@@ -24,7 +24,7 @@ use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class TimeEntryAggregateExportRequest extends FormRequest
class TimeEntryAggregateExportRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryAggregationType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
@@ -14,7 +15,6 @@ use App\Models\Task;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
@@ -22,7 +22,7 @@ use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class TimeEntryAggregateRequest extends FormRequest
class TimeEntryAggregateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* @property Organization $organization Organization from model binding
*/
class TimeEntryDestroyMultipleRequest extends FormRequest
class TimeEntryDestroyMultipleRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
@@ -12,13 +13,12 @@ use App\Models\Tag;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class TimeEntryIndexRequest extends FormRequest
class TimeEntryIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
@@ -11,13 +12,12 @@ use App\Models\Tag;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TimeEntryStoreRequest extends FormRequest
class TimeEntryStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
@@ -11,13 +12,12 @@ use App\Models\Tag;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TimeEntryUpdateMultipleRequest extends FormRequest
class TimeEntryUpdateMultipleRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
@@ -11,13 +12,12 @@ use App\Models\Tag;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TimeEntryUpdateRequest extends FormRequest
class TimeEntryUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -100,12 +100,18 @@ class DeletionService
// Make sure all users have at least one organization and delete placeholders
foreach ($users as $user) {
/** @var User $user */
if ($ignoreUser !== null && $user->is($ignoreUser)) {
continue;
}
if ($user->is_placeholder) {
$user->delete();
} else {
if ($user->current_team_id === $organization->getKey()) {
$user->currentOrganization()->disassociate();
$user->save();
}
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
$this->userService->makeSureUserHasCurrentOrganization($user);
}

View File

@@ -80,7 +80,7 @@ class LocalizationService
if ($this->intervalFormat === IntervalFormat::Decimal) {
$interval->cascade();
return $this->formatNumber($interval->totalHours);
return $this->formatNumber($interval->totalHours).' h';
} elseif ($this->intervalFormat === IntervalFormat::HoursMinutes) {
$interval->cascade();

View File

@@ -164,6 +164,11 @@ class MemberService
public function makeMemberToPlaceholder(Member $member, bool $makeSureUserHasAtLeastOneOrganization = true): void
{
$user = $member->user;
if ($user->current_team_id === $member->organization_id) {
$user->currentTeam()->disassociate();
$user->save();
}
$placeholderUser = $user->replicate();
$placeholderUser->is_placeholder = true;
$placeholderUser->save();
@@ -175,6 +180,7 @@ class MemberService
$this->userService->assignOrganizationEntitiesToDifferentUser($member->organization, $user, $placeholderUser);
if ($makeSureUserHasAtLeastOneOrganization) {
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
$this->userService->makeSureUserHasCurrentOrganization($user);
}
}
}

View File

@@ -46,6 +46,8 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
private TimeEntryAggregationType $subGroup;
private bool $showBillableRate;
/**
* @param array{
* grouped_type: string|null,
@@ -66,13 +68,14 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
* cost: int|null
* } $data
*/
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup)
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup, bool $showBillableRate)
{
$this->data = $data;
$this->exportFormat = $exportFormat;
$this->currency = $currency;
$this->group = $group;
$this->subGroup = $subGroup;
$this->showBillableRate = $showBillableRate;
}
public function view(): View
@@ -83,6 +86,7 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
'group' => $this->group,
'subGroup' => $this->subGroup,
'exportFormat' => $this->exportFormat,
'showBillableRate' => $this->showBillableRate,
]);
}

View File

@@ -114,13 +114,15 @@ class UserService
public function makeSureUserHasCurrentOrganization(User $user): void
{
if ($user->currentOrganization !== null) {
if ($user->current_team_id !== null) {
return;
}
$organization = $user->organizations()->first();
$user->currentOrganization()->associate($organization);
$user->save();
if ($organization !== null) {
$user->currentOrganization()->associate($organization);
$user->save();
}
}
/**

View File

@@ -8,5 +8,6 @@ return [
'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true),
'self_hosting_check_for_update' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_CHECK_FOR_UPDATE', true),
'self_hosting_telemetry' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_TELEMETRY', true),
'self_hosting_database_consistency' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_DATABASE_CONSISTENCY', false),
],
];

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::statement('
update users
set current_team_id = null
where id in (
select users.id from users
left join organizations on users.current_team_id = organizations.id
where users.current_team_id is not null and organizations.id is null
)
');
Schema::table('users', function (Blueprint $table): void {
$table->foreign('current_team_id', 'organizations_current_organization_id_foreign')
->references('id')
->on('organizations')
->onDelete('restrict')
->onUpdate('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropForeign('organizations_current_organization_id_foreign');
});
}
};

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace Database\Seeders;
use App\Enums\Role;
use App\Events\DatabaseSeederAfterSeed;
use App\Events\DatabaseSeederBeforeDelete;
use App\Models\Audit;
use App\Models\Client;
use App\Models\Member;
@@ -184,10 +186,13 @@ class DatabaseSeeder extends Seeder
'email' => 'admin@example.com',
]);
DatabaseSeederAfterSeed::dispatch();
}
private function deleteAll(): void
{
DatabaseSeederBeforeDelete::dispatch();
// Laravel Passport tables
DB::table((new RefreshToken)->getTable())->delete();
DB::table((new Token)->getTable())->delete();
@@ -213,6 +218,9 @@ class DatabaseSeeder extends Seeder
DB::table((new Client)->getTable())->delete();
DB::table((new Member)->getTable())->delete();
DB::table((new OrganizationInvitation)->getTable())->delete();
DB::table((new User)->getTable())->update([
'current_team_id' => null,
]);
DB::table((new Organization)->getTable())->delete();
DB::table((new User)->getTable())->delete();
}

View File

@@ -52,7 +52,7 @@ test('test that organization billable rate can be updated with all existing time
await page
.locator('form')
.filter({ hasText: 'Organization Billable' })
.getByRole('button')
.getByRole('button', { name: 'Save' })
.click();
await Promise.all([
@@ -179,7 +179,9 @@ test('test that format settings are reflected in the dashboard', async ({
page,
}) => {
// check that 0h 00min is displayed
await expect(page.getByText('0h 00min', { exact: true }).nth(0)).toBeVisible();
await expect(
page.getByText('0h 00min', { exact: true }).nth(0)
).toBeVisible();
// First set the format settings
await goToOrganizationSettings(page);
@@ -230,11 +232,17 @@ test('test that format settings are reflected in the dashboard', async ({
// check that 00:00 is displayed
await expect(page.getByText('0:00', { exact: true }).nth(0)).toBeVisible();
// check that 0h 00min is not displayed
await expect(page.getByText('0h 00min', { exact: true }).nth(0)).not.toBeVisible();
await expect(
page.getByText('0h 00min', { exact: true }).nth(0)
).not.toBeVisible();
// check that the current date is displayed in the dd/mm/yyyy format on the time page
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await expect(page.getByText(new Date().toLocaleDateString('en-GB'), { exact: true }).nth(0)).toBeVisible();
await expect(
page
.getByText(new Date().toLocaleDateString('en-GB'), { exact: true })
.nth(0)
).toBeVisible();
});
// TODO: Test 12-hour clock format
// TODO: Test 12-hour clock format

View File

@@ -9,7 +9,8 @@ return [
'2. In the same preferences page change the language of Clockfiy to English.<br>'.
'3. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'.
'4. Now select the date range that you want to export in the right top. '.
'It is currently not possible to select more than one year. You can export each year separately and import them one after another .'.
'In the free Clockify plan it\'s currently not possible to select more than one year. '.
'You can export each year separately and import them one after another.'.
'<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.',
],

View File

@@ -43,6 +43,9 @@
--theme-color-input-select-active: rgb(var(--color-accent-300));
--theme-color-input-select-active-hover: rgb(var(--color-accent-200));
--color-accent-default: rgba(var(--color-accent-300), 0.2);
--color-accent-foreground: rgb(var(--color-accent-100));
}
:root.light {
@@ -86,6 +89,9 @@
--theme-color-input-select-active: rgb(var(--color-accent-400));
--theme-color-input-select-active-hover: rgb(var(--color-accent-500));
--color-accent-default: rgb(var(--color-accent-100));
--color-accent-foreground: rgb(var(--color-accent-800));
}
:root {

View File

@@ -26,10 +26,12 @@ const createClient = ref(false);
<ClientTableHeading></ClientTableHeading>
<div
v-if="clients.length === 0"
class="col-span-2 py-24 text-center">
class="col-span-3 py-24 text-center">
<UserCircleIcon
class="w-8 text-icon-default inline pb-2"></UserCircleIcon>
<h3 class="text-text-primary font-semibold">No clients found</h3>
<h3 class="text-text-primary font-semibold">
No clients found
</h3>
<p v-if="canCreateClients()" class="pb-5">
Create your first client now!
</p>

View File

@@ -154,7 +154,6 @@ const roleDescription = computed(() => {
class="flex-1">
<InputLabel
for="memberBillableRate"
class="mb-2"
value="Billable Rate" />
<BillableRateInput
v-model="

View File

@@ -48,7 +48,7 @@ const project = ref<CreateProjectBody>({
async function submit() {
if (props.originalProject.billable_rate !== project.value.billable_rate) {
//
// make sure that the alert modal is not immediately submitted when user presses enter
setTimeout(() => {
showBillableRateModal.value = true;
}, 0);
@@ -133,7 +133,7 @@ async function submitBillableRate() {
</ClientDropdown>
</div>
</div>
<div class="lg:grid grid-cols-2 gap-12">
<div>
<div>
<ProjectEditBillableSection
v-model:is-billable="project.is_billable"

View File

@@ -5,6 +5,7 @@ import { h, ref } from 'vue';
import type { CreateReportBodyProperties } from '@/packages/api/src';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
import { canCreateReports } from '@/utils/permissions';
defineProps<{
reportProperties: CreateReportBodyProperties;
}>();
@@ -33,7 +34,10 @@ function onSaveReportClick() {
<strong>Sharable Reports</strong> is only available in solidtime
Professional.
</UpgradeModal>
<SecondaryButton :icon="SaveIcon" @click="onSaveReportClick"
<SecondaryButton
v-if="canCreateReports()"
:icon="SaveIcon"
@click="onSaveReportClick"
>Save Report</SecondaryButton
>
</template>

View File

@@ -17,7 +17,7 @@ import {
TooltipComponent,
} from 'echarts/components';
import type { AggregatedTimeEntries, Organization } from '@/packages/api/src';
import { useCssVar } from '@vueuse/core';
import { useCssVariable } from '@/utils/useCssVariable';
use([
CanvasRenderer,
@@ -43,10 +43,14 @@ const xAxisLabels = computed(() => {
if (props.groupedType === 'week') {
return props?.groupedData?.map((el) => formatWeek(el.key));
}
return props?.groupedData?.map((el) => formatDate(el.key ?? '', organization?.value?.date_format));
return props?.groupedData?.map((el) =>
formatDate(el.key ?? '', organization?.value?.date_format)
);
});
const accentColor = useCssVar('--theme-color-chart', null, { observe: true });
const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
const accentColor = useCssVariable('--theme-color-chart');
const labelColor = useCssVariable('--color-text-secondary');
const markLineColor = useCssVariable('--color-border-secondary');
const splitLineColor = useCssVariable('--color-border-tertiary');
const seriesData = computed(() => {
return props?.groupedData?.map((el) => {
@@ -109,7 +113,7 @@ const option = computed(() => ({
data: xAxisLabels.value,
markLine: {
lineStyle: {
color: 'rgba(125,156,188,0.1)',
color: markLineColor.value,
type: 'dashed',
},
},
@@ -133,9 +137,13 @@ const option = computed(() => ({
},
yAxis: {
type: 'value',
axisLabel: {
color: labelColor.value,
fontFamily: 'Outfit, sans-serif',
},
splitLine: {
lineStyle: {
color: 'rgba(125,156,188,0.2)', // Set desired color here
color: splitLineColor.value,
},
},
},

View File

@@ -28,8 +28,10 @@ const activeClass = computed(() => {
activeClass
)
">
<component :is="icon" class="-ml-0.5 h-4 w-4 text-text-quaternary"></component>
<span> {{ title }} </span>
<component
:is="icon"
class="-ml-0.5 h-4 w-4 text-text-quaternary"></component>
<span class="text-nowrap"> {{ title }} </span>
<div
v-if="count"
class="bg-accent-300/20 w-5 h-5 font-medium rounded flex items-center transition justify-center">

View File

@@ -107,6 +107,10 @@ function getFilterAttributes(): AggregatedTimeEntriesQueryParams {
: undefined,
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
billable: billable.value !== null ? billable.value : undefined,
member_id:
getCurrentRole() === 'employee'
? getCurrentMembershipId()
: undefined,
};
return params;
}

View File

@@ -11,7 +11,7 @@ import {
TooltipComponent,
} from 'echarts/components';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { useCssVar } from '@vueuse/core';
import { useCssVariable } from '@/utils/useCssVariable';
import type { Organization } from '@/packages/api/src';
use([
@@ -36,7 +36,7 @@ type ReportingChartDataEntry = {
const props = defineProps<{
data: ReportingChartDataEntry | null;
}>();
const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
const labelColor = useCssVariable('--color-text-secondary');
const seriesData = computed(() => {
return props.data?.map((el) => {

View File

@@ -19,7 +19,7 @@ import {
formatHumanReadableDuration,
getDayJsInstance,
} from '@/packages/ui/src/utils/time';
import { useCssVar } from '@vueuse/core';
import { useCssVariable } from '@/utils/useCssVariable';
import { useQuery } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { api, type Organization } from '@/packages/api/src';
@@ -64,12 +64,9 @@ const max = computed(() => {
}
});
const backgroundColor = useCssVar('--color-card-background', null, {
observe: true,
});
const itemBackgroundColor = useCssVar('--color-bg-tertiary', null, {
observe: true,
});
const backgroundColor = useCssVariable('--theme-color-card-background');
const itemBackgroundColor = useCssVariable('--color-bg-tertiary');
const borderColor = useCssVariable('--color-border');
const option = computed(() => {
return {
@@ -120,7 +117,7 @@ const option = computed(() => {
[],
itemStyle: {
borderRadius: 5,
borderColor: 'rgba(255,255,255,0.05)',
borderColor: borderColor.value,
borderWidth: 1,
},
tooltip: {

View File

@@ -1,13 +1,14 @@
<script setup lang="ts">
import VChart from 'vue-echarts';
import { computed, ref } from 'vue';
import { useCssVar } from '@vueuse/core';
import { computed } from 'vue';
import { useCssVariable } from '@/utils/useCssVariable';
const props = defineProps<{
history: number[];
}>();
const accentColor = useCssVar('--theme-color-chart', null, { observe: true });
const accentColor = useCssVariable('--theme-color-chart');
const markLineColor = useCssVariable('--color-border-secondary');
const seriesData = computed(() => props.history.map((el) => {
return {
@@ -22,7 +23,7 @@ const seriesData = computed(() => props.history.map((el) => {
},
};
}));
const option = ref({
const option = computed(() => ({
grid: {
top: 0,
right: 0,
@@ -35,7 +36,7 @@ const option = ref({
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
markLine: {
lineStyle: {
color: 'rgba(125,156,188,0.1)',
color: markLineColor.value,
type: 'dashed',
},
},
@@ -66,11 +67,11 @@ const option = ref({
},
series: [
{
data: seriesData,
data: seriesData.value,
type: 'bar',
},
],
});
}));
</script>
<template>

View File

@@ -11,7 +11,7 @@ import {
TooltipComponent,
} from 'echarts/components';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { useCssVar } from "@vueuse/core";
import { useCssVariable } from '@/utils/useCssVariable';
import type { Organization } from "@/packages/api/src";
use([
@@ -24,7 +24,7 @@ use([
]);
provide(THEME_KEY, 'dark');
const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
const labelColor = useCssVariable('--color-text-secondary');
const props = defineProps<{
weeklyProjectOverview: {

View File

@@ -18,7 +18,7 @@ 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 { useCssVariable } from '@/utils/useCssVariable';
import { getOrganizationCurrencyString } from '@/utils/money';
import { useQuery } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser';
@@ -60,7 +60,7 @@ const weekdays = computed(() => {
}
});
const accentColor = useCssVar('--theme-color-chart', null, { observe: true });
const accentColor = useCssVariable('--theme-color-chart');
// Get the organization ID using the utility function
const organizationId = computed(() => getCurrentOrganizationId());
@@ -176,10 +176,8 @@ const seriesData = computed(() => {
});
});
const markLineColor = useCssVar('--color-border-secondary', null, {
observe: true,
});
const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
const markLineColor = useCssVariable('--color-border-secondary');
const labelColor = useCssVariable('--color-text-secondary');
const option = computed(() => {
return {
tooltip: {
@@ -215,6 +213,10 @@ const option = computed(() => {
},
yAxis: {
type: 'value',
axisLabel: {
color: labelColor.value,
fontFamily: 'Outfit, sans-serif',
},
splitLine: {
lineStyle: {
color: markLineColor.value,

View File

@@ -20,7 +20,7 @@ const forwardedProps = useForwardProps(delegatedProps)
:class="cn(
buttonVariants({ variant: 'ghost' }),
'h-8 w-8 p-0 font-normal',
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
'[&[data-today]:not([data-selected])]:border-accent [&[data-today]:not([data-selected])]:border [&[data-today]:not([data-selected])]:text-accent-foreground',
// Selected
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
// Disabled

View File

@@ -34,11 +34,18 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
v-bind="forwarded"
:class="
cn(
'fixed left-1/2 top-1/3 bg-default-background z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 border shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
props.class,
'fixed top-0 left-0 z-50 w-screen h-screen flex items-start pt-6 md:pt-20 xl:pt-32 justify-center overflow-auto data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
)"
>
<slot />
<div
:class="cn(
'bg-default-background grid w-full max-w-lg border shadow-lg duration-200 sm:rounded-lg',
props.class,
)"
>
<slot />
</div>
</DialogContent>
</DialogPortal>
</template>

View File

@@ -20,7 +20,7 @@ const forwardedProps = useForwardProps(delegatedProps)
:class="cn(
buttonVariants({ variant: 'ghost' }),
'h-8 w-8 p-0 font-normal data-[selected]:opacity-100',
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
'[&[data-today]:not([data-selected])]:border-accent [&[data-today]:not([data-selected])]:border [&[data-today]:not([data-selected])]:text-accent-foreground',
// Selection Start
'data-[selection-start]:bg-primary data-[selection-start]:text-primary-foreground data-[selection-start]:hover:bg-primary data-[selection-start]:hover:text-primary-foreground data-[selection-start]:focus:bg-primary data-[selection-start]:focus:text-primary-foreground',
// Selection End

View File

@@ -1,7 +1,7 @@
<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 {computed, ref, inject, type ComputedRef} from 'vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import {
api,
@@ -23,6 +23,7 @@ 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";
import type { Organization } from '@/packages/api/src';
const queryClient = useQueryClient();
@@ -34,6 +35,8 @@ const newToken = ref('');
const { copy, copied, isSupported } = useClipboard();
const organization = inject<ComputedRef<Organization>>('organization');
async function createApiToken(){
await handleApiRequestNotifications(
() =>
@@ -213,10 +216,10 @@ const revokeApiTokenMutation = useMutation({
<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) }}
Created at {{ formatDateTimeLocalized(token.created_at, organization?.date_format, organization?.time_format) }}
</span>
<span v-if="token.expires_at">
Expires at {{ formatDateTimeLocalized(token.expires_at) }}
Expires at {{ formatDateTimeLocalized(token.expires_at, organization?.date_format, organization?.time_format) }}
</span>
<span v-if="token.revoked">
Revoked

View File

@@ -57,7 +57,12 @@ const reportNumberFormat = computed(() => {
});
const reportCurrencyFormat = computed(() => {
return (sharedReportResponseData.value?.currency_format ?? 'symbol-before') as CurrencyFormat;
return (sharedReportResponseData.value?.currency_format ??
'symbol-before') as CurrencyFormat;
});
const reportDateFormat = computed(() => {
return sharedReportResponseData.value?.date_format;
});
const reportCurrencySymbol = computed(() => {
@@ -71,6 +76,7 @@ provide(
'interval_format': reportIntervalFormat.value,
'currency_format': reportCurrencyFormat.value,
'currency_symbol': reportCurrencySymbol.value,
'date_format': reportDateFormat.value,
}))
);
@@ -154,10 +160,7 @@ const tableData = computed(() => {
cost: el.cost,
description:
el.description ??
emptyPlaceholder[
aggregatedTableTimeEntries.value
?.grouped_type ?? 'project'
],
emptyPlaceholder[entry.grouped_type ?? 'project'],
};
}) ?? [],
};
@@ -252,7 +255,7 @@ onMounted(async () => {
aggregatedTableTimeEntries.cost,
reportCurrency,
reportCurrencyFormat,
reportCurrencySymbol,
reportCurrencySymbol
)
}}
</div>

View File

@@ -1,9 +1,18 @@
<script setup lang="ts">
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { type Component, computed, nextTick, ref, watch } from 'vue';
import { computed, nextTick, ref, watch } from 'vue';
import ClientDropdownItem from '@/packages/ui/src/Client/ClientDropdownItem.vue';
import type { CreateClientBody, Client } from '@/packages/api/src';
import {
ComboboxAnchor,
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxRoot,
ComboboxViewport,
} from 'radix-vue';
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
const model = defineModel<string | null>({
default: null,
@@ -14,10 +23,8 @@ const props = defineProps<{
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
}>();
const searchInput = ref<HTMLInputElement | null>(null);
const searchInput = ref<HTMLElement | null>(null);
const open = ref(false);
const dropdownViewport = ref<Component | null>(null);
const searchValue = ref('');
function isClientSelected(id: string) {
@@ -27,7 +34,8 @@ function isClientSelected(id: string) {
watch(open, (isOpen) => {
if (isOpen) {
nextTick(() => {
searchInput.value?.focus();
// @ts-expect-error We need to access the actual HTML Element to focus as radix-vue does not support any other way right now
searchInput.value?.$el?.focus();
});
}
});
@@ -48,132 +56,89 @@ async function addClientIfNoneExists() {
if (newClient) {
model.value = newClient.id;
searchValue.value = '';
}
} else {
if (highlightedItemId.value) {
model.value = highlightedItemId.value;
open.value = false;
}
}
}
watch(filteredClients, () => {
if (filteredClients.value.length > 0) {
highlightedItemId.value = filteredClients.value[0].id;
}
const currentClient = computed(() => {
return (
props.clients.find((client) => client.id === model.value) ?? {
id: null,
name: 'No Client',
}
);
});
function updateSearchValue(event: Event) {
const newInput = (event.target as HTMLInputElement).value;
if (newInput === ' ') {
searchValue.value = '';
const highlightedClientId = highlightedItemId.value;
if (highlightedClientId) {
const highlightedClient = props.clients.find(
(client) => client.id === highlightedClientId
);
if (highlightedClient) {
model.value = highlightedClient.id;
}
}
} else {
searchValue.value = newInput;
}
}
const emit = defineEmits(['update:modelValue', 'changed']);
function updateClient(newValue: string) {
model.value = newValue;
nextTick(() => {
emit('changed');
});
function updateValue(client: { id: string | null; name: string }) {
model.value = client.id;
emit('changed');
}
function moveHighlightUp() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredClients.value.indexOf(
highlightedItem.value
);
if (currentHightlightedIndex === 0) {
highlightedItemId.value =
filteredClients.value[filteredClients.value.length - 1].id;
} else {
highlightedItemId.value =
filteredClients.value[currentHightlightedIndex - 1].id;
}
}
}
function moveHighlightDown() {
if (highlightedItem.value) {
const currentHightlightedIndex = filteredClients.value.indexOf(
highlightedItem.value
);
if (currentHightlightedIndex === filteredClients.value.length - 1) {
highlightedItemId.value = filteredClients.value[0].id;
} else {
highlightedItemId.value =
filteredClients.value[currentHightlightedIndex + 1].id;
}
}
}
const highlightedItemId = ref<string | null>(null);
const highlightedItem = computed(() => {
return props.clients.find(
(client) => client.id === highlightedItemId.value
);
});
</script>
<template>
<Dropdown v-model="open" width="120" :close-on-content-click="true">
<Dropdown v-model="open" align="start" width="60">
<template #trigger>
<slot name="trigger"></slot>
</template>
<template #content>
<input
ref="searchInput"
:value="searchValue"
data-testid="client_dropdown_search"
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a client..."
@input="updateSearchValue"
@keydown.enter="addClientIfNoneExists"
@keydown.up.prevent="moveHighlightUp"
@keydown.down.prevent="moveHighlightDown" />
<div ref="dropdownViewport" class="w-60 max-h-60 overflow-y-scroll">
<div
v-if="
searchValue.length > 0 && filteredClients.length === 0
"
class="bg-card-background-active"
@click="addClientIfNoneExists">
<div
class="flex space-x-3 items-center px-4 py-3 text-xs text-text-primary font-medium border-t rounded-b-lg border-card-background-separator">
<PlusCircleIcon
class="w-5 flex-shrink-0"></PlusCircleIcon>
<span>Add "{{ searchValue }}" as a new Client</span>
</div>
</div>
<div v-else></div>
<div
v-for="client in filteredClients"
:key="client.id"
role="option"
:value="client.id"
:class="{
'bg-card-background-active':
client.id === highlightedItemId,
}"
data-testid="client_dropdown_entries"
:data-client-id="client.id">
<ClientDropdownItem
:selected="isClientSelected(client.id)"
:name="client.name"
@click="updateClient(client.id)"></ClientDropdownItem>
</div>
</div>
<UseFocusTrap
v-if="open"
:options="{ immediate: true, allowOutsideClick: true }">
<ComboboxRoot
v-model:search-term="searchValue"
:open="open"
:model-value="currentClient"
class="relative"
@update:model-value="updateValue">
<ComboboxAnchor>
<ComboboxInput
ref="searchInput"
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a client..." />
</ComboboxAnchor>
<ComboboxContent>
<ComboboxViewport
class="w-60 max-h-60 overflow-y-scroll">
<ComboboxItem
:value="{ id: null, name: 'No Client' }"
class="data-[highlighted]:bg-card-background-active">
<ClientDropdownItem
:selected="model === null"
name="No Client" />
</ComboboxItem>
<ComboboxItem
v-for="client in filteredClients"
:key="client.id"
:value="client"
class="data-[highlighted]:bg-card-background-active"
:data-client-id="client.id">
<ClientDropdownItem
:selected="isClientSelected(client.id)"
:name="client.name" />
</ComboboxItem>
<div
v-if="
searchValue.length > 0 &&
filteredClients.length === 0
"
class="bg-card-background-active">
<div
class="flex space-x-3 items-center px-4 py-3 text-xs text-text-primary font-medium border-t rounded-b-lg border-card-background-separator"
@click="addClientIfNoneExists">
<PlusCircleIcon class="w-5 flex-shrink-0" />
<span
>Add "{{ searchValue }}" as a new
Client</span
>
</div>
</div>
</ComboboxViewport>
</ComboboxContent>
</ComboboxRoot>
</UseFocusTrap>
</template>
</Dropdown>
</template>

View File

@@ -1,9 +1,13 @@
<script setup lang="ts">
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import { formatCents } from '@/packages/ui/src/utils/money';
import { ref, watch, inject, type ComputedRef } from 'vue';
import { ref } from 'vue';
import { useFocus } from '@vueuse/core';
import type { Organization } from '@/packages/api/src';
import {
NumberField,
NumberFieldContent,
NumberFieldDecrement,
NumberFieldIncrement,
NumberFieldInput,
} from '@/Components/ui/number-field';
const props = defineProps<{
name: string;
@@ -11,8 +15,6 @@ const props = defineProps<{
currency: string;
}>();
const organization = inject<ComputedRef<Organization>>('organization');
const model = defineModel<number | null>({
default: null,
});
@@ -20,82 +22,32 @@ const model = defineModel<number | null>({
const billableRateInput = ref<HTMLInputElement | null>(null);
useFocus(billableRateInput, { initialValue: props.focus });
function cleanUpDecimalValue(value: string) {
value = value.replace(/,/g, '');
value = value.replace(props.currency, '');
return value.replace(/\./g, '');
}
function updateRate(value: string) {
value = value.trim();
if (value.includes(',')) {
const parts = value.split(',');
const lastPart = (parts[parts.length - 1] = parts[parts.length - 1]);
if (lastPart.length === 2) {
// we detected a decimal number with 2 digits after the comma
value = cleanUpDecimalValue(value);
model.value = parseInt(value);
}
} else if (value.includes('.')) {
const parts = value.split('.');
const lastPart = (parts[parts.length - 1] = parts[parts.length - 1]);
if (lastPart.length === 2) {
value = cleanUpDecimalValue(value);
model.value = parseInt(value);
}
} else if (value === '') {
model.value = 0;
} else {
// if it doesn't contain a comma or a dot, it's probably a whole number so let's convert it to cents
const parsedValue = parseInt(cleanUpDecimalValue(value)) * 100;
if (parsedValue) {
model.value = parsedValue;
} else {
model.value = 0;
}
}
inputValue.value = formatValue(model.value);
}
function formatValue(modelValue: number | null) {
const formattedValue = formatCents(
modelValue ?? 0,
props.currency,
organization?.value?.currency_format,
organization?.value?.currency_symbol,
organization?.value?.number_format
);
return formattedValue
?.replace(organization?.value?.currency_symbol ?? '', '')
.trim();
return modelValue ? modelValue / 100 : 0;
}
watch(model, (newValue) => {
inputValue.value = formatValue(newValue);
});
const inputValue = ref(formatValue(model.value));
</script>
<template>
<div class="relative">
<TextInput
<NumberField
:id="name"
ref="billableRateInput"
v-model="inputValue"
type="text"
:name="name"
placeholder="Billable Rate"
:model-value="formatValue(model)"
:step-snapping="false"
class="block w-full"
autocomplete="teamMemberRate"
@blur="updateRate($event.target.value)"
@keydown.enter="updateRate($event.target.value)" />
<div
class="absolute top-0 right-0 h-full flex items-center px-4 font-medium pointer-events-none">
<span>
{{ currency }}
</span>
</div>
:format-options="{
style: 'currency',
currency: currency,
currencyDisplay: 'code',
currencySign: 'accounting',
}"
@update:model-value="(value) => (model = value * 100)">
<NumberFieldContent>
<NumberFieldDecrement />
<NumberFieldInput placeholder="Billable Rate" />
<NumberFieldIncrement />
</NumberFieldContent>
</NumberField>
</div>
</template>

View File

@@ -5,10 +5,7 @@ import {
PopoverTrigger,
} from '@/Components/ui/popover';
import { RangeCalendar } from '@/Components/ui/range-calendar';
import {
CalendarDate,
getLocalTimeZone,
} from '@internationalized/date';
import { CalendarDate } from '@internationalized/date';
import { CalendarIcon } from 'lucide-vue-next';
import { computed, ref, inject, type ComputedRef, watch } from 'vue';
import { twMerge } from 'tailwind-merge';
@@ -16,8 +13,9 @@ import {
getDayJsInstance,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import { formatDateLocalized } from '@/packages/ui/src/utils/time';
import { type Organization } from '@/packages/api/src';
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
import { formatDate } from '@/packages/ui/src/utils/time';
const props = defineProps<{
start: string;
@@ -59,12 +57,13 @@ const modelValue = computed<CalendarDateRange>({
}),
set: (newValue) => {
if (newValue.start) {
const date = newValue.start.toDate(getLocalTimeZone());
emit('update:start', getDayJsInstance()(date).format('YYYY-MM-DD'));
console.log(newValue.start);
const date = newValue.start.toDate(getUserTimezone());
emit('update:start', getLocalizedDayJs(date.toString()).format());
}
if (newValue.end) {
const date = newValue.end.toDate(getLocalTimeZone());
emit('update:end', getDayJsInstance()(date).format('YYYY-MM-DD'));
const date = newValue.end.toDate(getUserTimezone());
emit('update:end', getLocalizedDayJs(date.toString()).format());
}
},
});
@@ -219,12 +218,27 @@ watch(open, (value) => {
<CalendarIcon class="mr-2 h-4 w-4" />
<template v-if="modelValue.start">
<template v-if="modelValue.end">
{{ formatDateLocalized(modelValue.start.toString(), organization?.date_format) }}
{{
formatDate(
modelValue.start.toString(),
organization?.date_format
)
}}
-
{{ formatDateLocalized(modelValue.end.toString(), organization?.date_format) }}
{{
formatDate(
modelValue.end.toString(),
organization?.date_format
)
}}
</template>
<template v-else>
{{ formatDateLocalized(modelValue.start.toString(), organization?.date_format) }}
{{
formatDate(
modelValue.start.toString(),
organization?.date_format
)
}}
</template>
</template>
<template v-else> Pick a date </template>

View File

@@ -128,7 +128,7 @@ const currentClientName = computed(() => {
</ClientDropdown>
</div>
</div>
<div class="lg:grid grid-cols-2 gap-12">
<div>
<div>
<ProjectEditBillableSection
v-model:is-billable="project.is_billable"

View File

@@ -66,9 +66,7 @@ const emit = defineEmits(['submit']);
v-model="billableRateSelect"
class="mt-2"></ProjectBillableSelect>
</div>
<div
v-if="billableRateSelect === 'custom-rate'"
class="sm:max-w-[120px]">
<div v-if="billableRateSelect === 'custom-rate'">
<InputLabel for="billableRate" value="Billable Rate" class="mb-2" />
<BillableRateInput
v-model="billableRate"

View File

@@ -146,8 +146,9 @@ export function formatDateLocalized(date: string, format: DateFormat = 'point-se
return getLocalizedDayJs(date).format(dateFormatMap[format]);
}
export function formatDateTimeLocalized(date: string): string {
return getLocalizedDayJs(date).format('DD.MM.YYYY HH:mm');
export function formatDateTimeLocalized(date: string, dateFormat?: DateFormat, timeFormat?: TimeFormat): string {
const format = `${dateFormatMap[dateFormat ?? 'point-separated-d-m-yyyy']} ${timeFormat === '12-hours' ? 'hh:mm A' : 'HH:mm'}`;
return getLocalizedDayJs(date).format(format);
}
export function formatWeek(date: string | null): string {

View File

@@ -125,4 +125,6 @@ export function canViewAllTimeEntries() {
export function canViewInvoices() {
return currentUserHasPermission('invoices:view');
}
export function canCreateReports() {
return currentUserHasPermission('reports:create');
}

View File

@@ -3,13 +3,6 @@ import { computed, watch } from "vue";
type themeOption = "system" | "light" | "dark";
const themeSetting = useStorage<themeOption>("theme", "system");
// reload page when themeSettingChanges
watch(
themeSetting,
() => {
location.reload();
}
)
const preferredColor = usePreferredColorScheme();
const theme = computed(() => {
if(themeSetting.value === "system"){

View File

@@ -0,0 +1,49 @@
import { ref, onMounted, onUnmounted } from 'vue'
export function useCssVariable(variableName: string) {
const value = ref('')
let observer: MutationObserver | null = null
let mediaQuery: MediaQueryList | null = null
const updateValue = () => {
const computedStyle = getComputedStyle(document.documentElement)
const cssValue = computedStyle.getPropertyValue(variableName).trim()
value.value = cssValue
}
onMounted(() => {
// Initialize with current value
updateValue()
// Watch for class changes on document.documentElement (where theme classes are applied)
observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
updateValue()
}
})
})
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
})
// Also watch for system color scheme changes
if (window.matchMedia) {
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', updateValue)
}
})
onUnmounted(() => {
if (observer) {
observer.disconnect()
}
if (mediaQuery) {
mediaQuery.removeEventListener('change', updateValue)
}
})
return value
}

View File

@@ -6,7 +6,11 @@ import type {
AggregatedTimeEntriesQueryParams,
ReportingResponse,
} from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import {
getCurrentOrganizationId,
getCurrentRole,
getCurrentUser,
} from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import { useProjectsStore } from '@/utils/useProjects';
import { useMembersStore } from '@/utils/useMembers';
@@ -106,6 +110,9 @@ export const useReportingStore = defineStore('reporting', () => {
return projects.value.find((project) => project.id === key)?.name;
}
if (type === 'user') {
if (getCurrentRole() === 'employee') {
return getCurrentUser().name;
}
const memberStore = useMembersStore();
const { members } = storeToRefs(memberStore);
return members.value.find((member) => member.user_id === key)?.name;

View File

@@ -10,6 +10,10 @@ function getCurrentUserId() {
return page.props.auth.user.id;
}
function getCurrentUser() {
return page.props.auth.user;
}
function getCurrentOrganizationId() {
return page.props.auth.user.current_team_id;
}
@@ -31,4 +35,5 @@ export {
getCurrentUserId,
getCurrentMembershipId,
getCurrentRole,
getCurrentUser,
};

View File

@@ -1,12 +1,12 @@
@use('Brick\Math\BigDecimal')
@use('Brick\Money\Money')
@use('PhpOffice\PhpSpreadsheet\Cell\DataType')
@use('Illuminate\Support\Carbon')
@use('Carbon\CarbonInterval')
@inject('colorService', 'App\Service\ColorService')
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta charset="utf-8"/>
<title>Report</title>
<style>
html, body, div, span, applet, object, iframe,
@@ -130,7 +130,7 @@
@if($debug)
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=outfit:200,300,400,500,600,700,800" rel="stylesheet" />
<link href="https://fonts.bunny.net/css?family=outfit:200,300,400,500,600,700,800" rel="stylesheet"/>
@endif
</head>
@@ -152,12 +152,13 @@
<div
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div>
</div>
@if($showBillableRate)
<div style="padding: 8px 12px; border-radius: 8px;">
<div style="color: #71717a; font-weight: 600;">Total cost</div>
<div
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)) }} </div>
</div>
@endif
</div>
<div id="main-chart" style="width: 700px; height: 300px; margin: 20px auto;"></div>
@@ -177,7 +178,9 @@
{{ $group->description() }}
</th>
<th>Duration</th>
@if($showBillableRate)
<th style="text-align: right;">Cost</th>
@endif
</tr>
</thead>
@foreach($aggregatedData['grouped_data'] as $group1Entry)
@@ -188,23 +191,21 @@
}};">
</div>
<span style="padding-left: 8px;">
@if($group->is(\App\Enums\TimeEntryAggregationType::Billable))
{{ $group1Entry['key'] === '1' ? 'Billable' : 'Non-billable' }}
@else
{{ $group1Entry['description'] ?? $group1Entry['key'] ?? 'No '.Str::lower($group->description()) }}
@endif
</span>
</span>
</td>
<td style="text-align: left;">
{{ $localization->formatInterval(CarbonInterval::seconds($group1Entry['seconds'])) }}
</td>
@if($showBillableRate)
<td style="text-align: right;">
{{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($group1Entry['cost'], 2)->__toString(), $currency)) }}
</td>
@endif
</tr>
@endforeach
<tfoot>
@@ -215,9 +216,11 @@
<td style="font-weight: 500;color: #18181b;">
{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }}
</td>
@if($showBillableRate)
<td style="text-align: right; font-weight: 500;color: #18181b;">
{{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)) }}
</td>
@endif
</tr>
</tfoot>
</table>
@@ -253,9 +256,11 @@
<th>
Duration (h)
</th>
@if($showBillableRate)
<th>
Cost
</th>
@endif
</tr>
</thead>
<tbody>
@@ -282,13 +287,17 @@
<td>
{{ $localization->formatNumber($duration->totalHours) }}
</td>
@if($showBillableRate)
<td>
{{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->__toString(), $currency)) }}
</td>
@endif
</tr>
@php
$totalDuration += $group2Entry['seconds'];
$totalCost += $group2Entry['cost'];
if ($showBillableRate) {
$totalCost += $group2Entry['cost'];
}
@endphp
@endforeach
</tbody>
@@ -356,7 +365,12 @@
animation: false,
tooltip: {},
xAxis: {
data: ['{!! collect($dataHistoryChart['grouped_data'])->pluck('key')->implode("', '") !!}'],
data: {!!
json_encode(collect($dataHistoryChart['grouped_data'])
->pluck('key')
->map(fn($value) => $localization->formatDate(Carbon::parse($value)))
->toArray())
!!},
axisLabel: {
fontSize: 10,
fontWeight: 400,
@@ -381,26 +395,16 @@
axisLabel: {
show: false,
inside: true,
formatter: function(value, index) {
let totalSeconds = value;
let hours = Math.floor(totalSeconds / 3600);
if (hours < 10) {
hours = "0" + hours;
}
totalSeconds %= 3600;
let minutes = Math.floor(totalSeconds / 60);
if (minutes < 10) {
minutes = "0" + minutes;
}
return hours + ":" + minutes;
}
}
},
series: [
{
name: "time",
type: "bar",
data: [{!! collect($dataHistoryChart['grouped_data'])->pluck('seconds')->implode(', ') !!}],
data: {!! json_encode(collect($dataHistoryChart['grouped_data'])->map(fn($value) => (object) [
'value' => $value['seconds'],
'name' => ((int) $value['seconds']) === 0 ? '' : $localization->formatInterval(CarbonInterval::seconds((int) $value['seconds']))
])->toArray()) !!},
itemStyle: {
borderColor: "#7dd3fc",
color: "#7dd3fc"
@@ -413,22 +417,8 @@
@endif
fontSize: 10,
position: "top",
formatter: function(params) {
let value = params.value;
if (value === 0) {
return "";
}
let totalSeconds = value;
let hours = Math.floor(totalSeconds / 3600);
if (hours < 10) {
hours = "0" + hours;
}
totalSeconds %= 3600;
let minutes = Math.floor(totalSeconds / 60);
if (minutes < 10) {
minutes = "0" + minutes;
}
return hours + ":" + minutes;
formatter: function (params) {
return params.name;
}
}
}

View File

@@ -62,9 +62,11 @@
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ round($duration->totalHours, 2) }}
</td>
@if($showBillableRate)
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ round(BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->toFloat(), 2) }}
</td>
@endif
@else
@if ($group === TimeEntryAggregationType::Billable)
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
@@ -92,16 +94,20 @@
data-format="{{ NumberFormat::FORMAT_NUMBER_00 }}">
{{ $duration->totalHours }}
</td>
@if($showBillableRate)
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_NUMERIC }}"
data-format="{{ NumberFormat::FORMAT_NUMBER_COMMA_SEPARATED1 }}">
{{ BigDecimal::ofUnscaledValue($group2Entry['cost'], 2)->__toString() }}
</td>
@endif
@endif
</tr>
@php
++$counter;
$totalDuration += $group2Entry['seconds'];
$totalCost += $group2Entry['cost'];
if ($showBillableRate) {
$totalCost += $group2Entry['cost'];
}
@endphp
@endforeach
@endforeach
@@ -120,9 +126,11 @@
<td style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_STRING }}">
{{ round($totalDurationInterval->totalHours, 2) }}
</td>
@if($showBillableRate)
<td style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_STRING }}">
{{ round(BigDecimal::ofUnscaledValue($totalCost, 2)->toFloat(), 2) }}
</td>
@endif
@else
<td style="border: 1px solid black; font-weight: bold;" data-type="{{ DataType::TYPE_FORMULA }}"
data-format="[hh]:mm:ss">

View File

@@ -1,6 +1,5 @@
@use('Brick\Math\BigDecimal')
@use('Brick\Money\Money')
@use('PhpOffice\PhpSpreadsheet\Cell\DataType')
@use('Carbon\CarbonInterval')
@inject('interval', 'App\Service\IntervalService')
<!DOCTYPE html>
@@ -141,12 +140,14 @@
<div
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div>
</div>
@if($showBillableRate)
<div style="padding: 8px 12px; border-radius: 8px;">
<div style="color: #71717a; font-weight: 600;">Total cost</div>
<div
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)) }} </div>
<div style="font-size: 24px; font-weight: 500; margin-top: 2px;">
{{ $localization->formatCurrency(Money::of(BigDecimal::ofUnscaledValue($aggregatedData['cost'], 2)->__toString(), $currency)) }}
</div>
</div>
@endif
</div>
<div>
<table style="width: 100%;">

View File

@@ -87,8 +87,8 @@ export default {
"800": "rgba(var(--color-accent-800), <alpha-value>)",
"900": "rgba(var(--color-accent-900), <alpha-value>)",
"950": "rgba(var(--color-accent-950), <alpha-value>)",
DEFAULT: "var(--color-bg-tertiary)",
foreground: "rgb(var(--color-accent-200))"
DEFAULT: "var(--color-accent-default)",
foreground: "var(--color-accent-foreground)",
},
"button-primary-background": "var(--theme-color-button-primary-background)",
"button-primary-background-hover": "var(--theme-color-button-primary-background-hover)",

View File

@@ -56,10 +56,12 @@ abstract class TestCaseWithDatabase extends TestCase
/**
* @return object{user: User, organization: Organization, member: Member, owner: User, ownerMember: Member}
*/
public function createUserWithRole(Role $role): object
public function createUserWithRole(Role $role, bool $employeesCanSeeBillableRates = false): object
{
$owner = User::factory()->create();
$organization = Organization::factory()->withOwner($owner)->create();
$organization = Organization::factory()->withOwner($owner)->create([
'employees_can_see_billable_rates' => $employeesCanSeeBillableRates,
]);
$ownerMember = Member::factory()->forUser($owner)->forOrganization($organization)->role(Role::Owner)->create();
$owner->currentOrganization()->associate($organization);
$owner->save();

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Console\Commands\SelfHost;
use App\Console\Commands\SelfHost\SelfHostDatabaseConsistency;
use App\Enums\Role;
use App\Models\Client;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
use Tests\TestCaseWithDatabase;
#[CoversClass(SelfHostDatabaseConsistency::class)]
#[UsesClass(SelfHostDatabaseConsistency::class)]
class SelfHostDatabaseConsistencyCommandTest extends TestCaseWithDatabase
{
public function test_checks_that_task_need_to_be_part_of_project_in_time_entries(): void
{
// Arrange
$user = $this->createUserWithRole(Role::Owner);
$project1 = Project::factory()->forOrganization($user->organization)->create();
$project2 = Project::factory()->forOrganization($user->organization)->create();
$task = Task::factory()->forOrganization($user->organization)->forProject($project1)->create();
$timeEntry = TimeEntry::factory()->forMember($user->member)->forTask($task)->forProject($project2)->create();
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:database-consistency');
// Assert
$this->assertSame(Command::FAILURE, $exitCode);
$output = Artisan::output();
$this->assertSame("Consistency problem: Time entries have a task that does not belong to the project of the time entry\n - ".$timeEntry->getKey()."\n", $output);
}
public function test_checks_that_client_id_is_the_client_id_of_the_project(): void
{
// Arrange
$user = $this->createUserWithRole(Role::Owner);
$client1 = Client::factory()->forOrganization($user->organization)->create();
$client2 = Client::factory()->forOrganization($user->organization)->create();
$project = Project::factory()->forOrganization($user->organization)->forClient($client1)->create();
$timeEntry = TimeEntry::factory()->forMember($user->member)->forProject($project)->create([
'client_id' => $client2->id,
]);
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:database-consistency');
// Assert
$this->assertSame(Command::FAILURE, $exitCode);
$output = Artisan::output();
$this->assertSame("Consistency problem: Time entries have a client that does not match the client of the project\n - ".$timeEntry->getKey()."\n", $output);
}
public function test_checks_that_client_id_is_the_client_id_of_the_project_with_no_client_in_time_entry(): void
{
// Arrange
$user = $this->createUserWithRole(Role::Owner);
$client1 = Client::factory()->forOrganization($user->organization)->create();
$client2 = Client::factory()->forOrganization($user->organization)->create();
$project = Project::factory()->forOrganization($user->organization)->forClient($client1)->create();
$timeEntry = TimeEntry::factory()->forMember($user->member)->forProject($project)->create([
'client_id' => null,
]);
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:database-consistency');
// Assert
$this->assertSame(Command::FAILURE, $exitCode);
$output = Artisan::output();
$this->assertSame("Consistency problem: Time entries have a client that does not match the client of the project\n - ".$timeEntry->getKey()."\n", $output);
}
public function test_checks_that_client_id_is_only_null_if_project_is_also_null(): void
{
// Arrange
$user = $this->createUserWithRole(Role::Owner);
$client1 = Client::factory()->forOrganization($user->organization)->create();
$project = Project::factory()->forOrganization($user->organization)->forClient($client1)->create();
$timeEntry = TimeEntry::factory()->forMember($user->member)->create([
'client_id' => $client1->getKey(),
]);
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:database-consistency');
// Assert
$this->assertSame(Command::FAILURE, $exitCode);
$output = Artisan::output();
$this->assertSame("Consistency problem: Time entries have a client but no project\n - ".$timeEntry->getKey()."\n", $output);
}
public function test_checks_that_every_user_needs_to_be_a_member_of_at_least_one_organization(): void
{
// Arrange
$user = User::factory()->create();
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:database-consistency');
// Assert
$this->assertSame(Command::FAILURE, $exitCode);
$output = Artisan::output();
$this->assertSame("Consistency problem: Users are not member of any organization\n - ".$user->getKey()."\n", $output);
}
public function test_checks_that_every_organization_needs_at_least_an_owner(): void
{
// Arrange
$user = $this->createUserWithRole(Role::Owner);
$organization = Organization::factory()->withOwner($user->user)->create();
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:database-consistency');
// Assert
$this->assertSame(Command::FAILURE, $exitCode);
$output = Artisan::output();
$this->assertSame("Consistency problem: Organizations without an owner\n - ".$organization->getKey()."\n", $output);
}
public function test_checks_that_every_member_can_only_have_one_running_time_entry(): void
{
// Arrange
$user = $this->createUserWithRole(Role::Owner);
$timeEntry1 = TimeEntry::factory()->forMember($user->member)->active()->create();
$timeEntry2 = TimeEntry::factory()->forMember($user->member)->active()->create();
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:database-consistency');
// Assert
$this->assertSame(Command::FAILURE, $exitCode);
$output = Artisan::output();
$this->assertSame("Consistency problem: Users with more than one running time entry\n - ".$user->user->getKey()."\n", $output);
}
public function test_checks_that_users_have_a_current_organization_that_they_are_not_a_member_of(): void
{
// Arrange
$user1 = $this->createUserWithRole(Role::Owner);
$user2 = $this->createUserWithRole(Role::Owner);
$user1->user->currentOrganization()->associate($user2->organization);
$user1->user->save();
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:database-consistency');
// Assert
$this->assertSame(Command::FAILURE, $exitCode);
$output = Artisan::output();
$this->assertSame("Consistency problem: Users have a current organization that they are not a member of\n - ".$user1->user->getKey()."\n", $output);
}
}

View File

@@ -686,6 +686,10 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
'time-entries:view:all',
]);
Passport::actingAs($data->user);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$this->actAsOrganizationWithSubscription();
// Act
@@ -700,6 +704,192 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$this->assertResponseCode($response, 200);
}
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_csv_as_employee_role_with_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, true);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::CSV,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->id,
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_ods_as_employee_role_with_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, true);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::ODS,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->id,
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_xlxs_as_employee_role_with_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, true);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::XLSX,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->id,
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_pdf_as_employee_role_with_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, true);
Passport::actingAs($data->user);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$this->actAsOrganizationWithSubscription();
// Act
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::PDF,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->id,
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_csv_as_employee_role_without_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, false);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::CSV,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->id,
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_ods_as_employee_role_without_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, false);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::ODS,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->id,
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_xlxs_as_employee_role_without_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, false);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::XLSX,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->id,
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_pdf_as_employee_role_without_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, false);
Passport::actingAs($data->user);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$this->actAsOrganizationWithSubscription();
// Act
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::PDF,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->id,
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_endpoints_fails_if_user_no_permission_to_view_time_entries(): void
{
// Arrange
@@ -815,6 +1005,58 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_endpoints_can_create_a_csv_report_as_employee_role_with_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, true);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$data->organization->getKey(),
'format' => ExportFormat::CSV,
'group' => TimeEntryAggregationType::Client,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Month,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->getKey(),
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_endpoints_can_create_a_csv_report_as_employee_role_without_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, false);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$data->organization->getKey(),
'format' => ExportFormat::CSV,
'group' => TimeEntryAggregationType::Client,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Month,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->getKey(),
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_endpoints_can_create_a_xlsx_report(): void
{
// Arrange
@@ -842,6 +1084,58 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_endpoints_can_create_a_xlsx_report_as_employee_role_with_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, true);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$data->organization->getKey(),
'format' => ExportFormat::XLSX,
'group' => TimeEntryAggregationType::Client,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Month,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->getKey(),
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_endpoints_can_create_a_xlsx_report_as_employee_role_without_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, false);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$data->organization->getKey(),
'format' => ExportFormat::XLSX,
'group' => TimeEntryAggregationType::Client,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Month,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->getKey(),
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_endpoints_can_create_a_ods_report(): void
{
// Arrange
@@ -869,6 +1163,58 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_endpoints_can_create_a_ods_report_as_employee_role_with_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, true);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$data->organization->getKey(),
'format' => ExportFormat::ODS,
'group' => TimeEntryAggregationType::User,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Month,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->getKey(),
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_endpoints_can_create_a_ods_report_as_employee_role_without_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, false);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$data->organization->getKey(),
'format' => ExportFormat::ODS,
'group' => TimeEntryAggregationType::User,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Month,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->getKey(),
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_endpoint_fails_if_pdf_renderer_is_not_configured_but_a_user_want_a_pdf_report(): void
{
// Arrange
@@ -927,6 +1273,60 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_endpoints_can_create_a_pdf_report_as_employee_role_with_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, true);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
$this->actAsOrganizationWithSubscription();
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$data->organization->getKey(),
'format' => ExportFormat::PDF,
'group' => TimeEntryAggregationType::User,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Month,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->getKey(),
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_endpoints_can_create_a_pdf_report_as_employee_role_without_show_billable_rate(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee, false);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
$this->actAsOrganizationWithSubscription();
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$data->organization->getKey(),
'format' => ExportFormat::PDF,
'group' => TimeEntryAggregationType::User,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Month,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
'member_id' => $data->member->getKey(),
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_aggregate_endpoint_fails_if_user_has_only_access_to_own_time_entries_but_does_not_filter_for_this(): void
{
// Arrange

View File

@@ -150,6 +150,24 @@ class DeletionServiceTest extends TestCaseWithDatabase
$this->assertSame($specialCase ? 7 : 6, TimeEntry::query()->whereBelongsTo($organization, 'organization')->count());
}
public function test_delete_organization_resets_the_current_organization_of_users_that_had_the_deleted_organization_as_current_organization(): void
{
// Arrange
$userOwner = User::factory()->create();
$organization = Organization::factory()->withOwner($userOwner)->create();
$userOwner->currentOrganization()->associate($organization);
$userOwner->save();
// Act
$this->deletionService->deleteOrganization($organization);
// Assert
$this->assertOrganizationDeleted($organization);
$userOwner->refresh();
$this->assertNull($userOwner->current_team_id);
$this->assertNotSame($organization->id, $userOwner->current_team_id);
}
public function test_delete_organization_deletes_all_resources_of_the_organization_but_does_not_delete_other_resources(): void
{
// Arrange

View File

@@ -47,7 +47,7 @@ class LocalizationServiceTest extends TestCaseWithDatabase
$formatted = $this->localizationService->formatInterval($interval);
// Assert
$this->assertSame('30,001.05', $formatted);
$this->assertSame('30,001.05 h', $formatted);
}
public function test_format_interval_with_type_decimal_and_number_format_thousands_space_decimal_point(): void
@@ -61,7 +61,7 @@ class LocalizationServiceTest extends TestCaseWithDatabase
$formatted = $this->localizationService->formatInterval($interval);
// Assert
$this->assertSame('30 001.05', $formatted);
$this->assertSame('30 001.05 h', $formatted);
}
public function test_format_interval_with_type_decimal_and_number_format_thousands_point_decimal_comma(): void
@@ -75,7 +75,7 @@ class LocalizationServiceTest extends TestCaseWithDatabase
$formatted = $this->localizationService->formatInterval($interval);
// Assert
$this->assertSame('30.001,05', $formatted);
$this->assertSame('30.001,05 h', $formatted);
}
public function test_format_interval_with_type_decimal_and_number_format_thousands_apostrophe_decimal_point(): void
@@ -89,7 +89,7 @@ class LocalizationServiceTest extends TestCaseWithDatabase
$formatted = $this->localizationService->formatInterval($interval);
// Assert
$this->assertSame('30\'001.05', $formatted);
$this->assertSame('30\'001.05 h', $formatted);
}
public function test_format_interval_with_type_hours_minutes(): void

View File

@@ -114,6 +114,41 @@ class MemberServiceTest extends TestCaseWithDatabase
$this->assertSame(1, $otherUser->organizations()->count());
}
public function test_make_member_to_placeholder_resets_current_organization_of_user_if_user_is_no_longer_member_to_newly_created_organization(): void
{
// Arrange
$organization = Organization::factory()->create();
$user = User::factory()->forCurrentOrganization($organization)->create();
$member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create();
// Act
$this->memberService->makeMemberToPlaceholder($member);
// Assert
$user->refresh();
$this->assertNotNull($user->current_team_id);
$this->assertNotSame($organization->id, $user->current_team_id);
}
public function test_make_member_to_placeholder_resets_current_organization_of_user_if_user_is_no_longer_member_to_already_existing_other_organization(): void
{
// Arrange
$organization = Organization::factory()->create();
$user = User::factory()->forCurrentOrganization($organization)->create();
$member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create();
$otherOrganization = Organization::factory()->create();
$otherMember = Member::factory()->forOrganization($otherOrganization)->forUser($user)->role(Role::Employee)->create();
// Act
$this->memberService->makeMemberToPlaceholder($member);
// Assert
$user->refresh();
$this->assertNotNull($user->current_team_id);
$this->assertSame($otherOrganization->id, $user->current_team_id);
}
public function test_assign_organization_entities_to_different_member_without_any_entries(): void
{
// Arrange