Compare commits

...

44 Commits

Author SHA1 Message Date
Constantin Graf
8982bfac2b Fixed bug in user delete feature 2024-06-17 12:50:39 +02:00
Gregor Vostrak
9ac1d19722 change reporting default back 2024-06-13 21:12:41 +02:00
dependabot[bot]
843e16c4c0 Bump codecov/codecov-action from 4.4.1 to 4.5.0
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.4.1 to 4.5.0.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v4.4.1...v4.5.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-13 16:26:56 +02:00
Constantin Graf
9a920bd4e9 Fixed migration 2024-06-13 16:26:44 +02:00
Constantin Graf
bb8c944df5 Added telescope migration for local setup 2024-06-13 16:26:44 +02:00
Constantin Graf
e4c1363193 Moved local setup to octane with swoole 2024-06-13 16:26:44 +02:00
Constantin Graf
bd9cede081 Prevent and remove zero values for billable rates 2024-06-13 16:26:44 +02:00
Gregor Vostrak
92dde6a701 fix timezone issues related to time entries spanning over a day 2024-06-12 17:06:10 +02:00
Gregor Vostrak
91cb6ab087 add formatting to importer descriptions, change reporting group limit for months 2024-06-10 18:01:32 +02:00
Gregor Vostrak
fadcd042c0 bg card color improvements 2024-06-10 17:34:09 +02:00
Constantin Graf
0eef5ffcfa Performance optimization for import 2024-06-10 17:33:07 +02:00
Constantin Graf
90480f3bb8 Updated composer dependencies minor 2024-06-10 17:33:07 +02:00
Constantin Graf
86f5ea47bb Added user and organization deletion system; Added coverage annotations 2024-06-09 13:58:46 +02:00
Gregor Vostrak
8857befc6c add loading indicator to import (ST-149), change card divider color 2024-06-07 14:18:27 +02:00
Constantin Graf
f40ae91444 Fixed timezone in time entries importer (tests) 2024-06-06 18:46:27 +02:00
Constantin Graf
94940be02c Fixed timezone in time entries importer 2024-06-06 18:35:19 +02:00
Gregor Vostrak
f2f128e184 remove misplaced create tag button from import screen 2024-06-06 18:30:04 +02:00
Gregor Vostrak
ffea3c6b68 fix time picker messing up the date when number exceeds 24 hours 2024-06-06 17:41:00 +02:00
Constantin Graf
1fdbfe77f0 Build images for all tags 2024-06-06 17:30:45 +02:00
Gregor Vostrak
7fb58ea341 add is_billable support for create / update project and in timetracker, fixes ST-217 2024-06-06 17:19:41 +02:00
Constantin Graf
d9244d1ab4 Fixed bug in user profile update validation that did not ignore placeholder users 2024-06-06 17:19:41 +02:00
Constantin Graf
b0cdeb3e33 Fixed test case and travelTo function in test cases 2024-06-06 17:19:41 +02:00
Constantin Graf
86555664c5 Added billable flag to projects 2024-06-06 17:19:41 +02:00
Constantin Graf
20f9b344f6 Added is_imported flag to time entries 2024-06-06 17:19:41 +02:00
Constantin Graf
802d9558a3 Fixed endOfWeek bug 2024-06-06 17:19:41 +02:00
Gregor Vostrak
474c0de3ac add update member billable rate , fixes ST-241 2024-06-05 18:36:00 +02:00
Gregor Vostrak
b1795392ad add date selector to time entries, fixes ST-134 2024-06-05 18:36:00 +02:00
Gregor Vostrak
2692db2a86 fix timetracker timepicker when no timer is started yet 2024-06-05 18:36:00 +02:00
Gregor Vostrak
ded58f8bd6 add edit modal for tasks and clients, fixes ST-233 2024-06-05 18:36:00 +02:00
Gregor Vostrak
81e3ffd921 add project add button to dropdown and set fixed height, fixes ST-170 2024-06-05 18:36:00 +02:00
Gregor Vostrak
22363e1c89 move from variable font to static fonts to fix safari rendering problems, fixes ST-230 2024-06-05 18:36:00 +02:00
Gregor Vostrak
d28269ebb0 add project member edit modal, fixes ST-119 2024-06-05 18:36:00 +02:00
Gregor Vostrak
3fc9d8b381 fix feedback bubble overlays elements, fixes ST-172 2024-06-05 18:36:00 +02:00
Gregor Vostrak
5bfd9e7dce add time picker to timetracker, fixes ST-180 2024-06-05 18:36:00 +02:00
Gregor Vostrak
ee6999af90 fix billable rate input field, fixes ST-203 2024-06-05 18:36:00 +02:00
Gregor Vostrak
22420439d9 fix placeholder alignment in time view 2024-06-05 18:36:00 +02:00
Gregor Vostrak
a065744d40 indent design bug fixes on time view, fixes ST-208 2024-06-05 18:36:00 +02:00
Gregor Vostrak
4a7db27a05 reload stores after new team is created and data imported 2024-06-05 18:36:00 +02:00
Gregor Vostrak
bae4265f70 improve error handling for 401 requests and fetch 2024-06-05 18:36:00 +02:00
Gregor Vostrak
a64ee87d19 improve sidebar 2024-06-05 18:36:00 +02:00
Gregor Vostrak
c9311780ed restrict group and subgroup so they are always different 2024-06-05 18:36:00 +02:00
Gregor Vostrak
4943baa236 add reporting client aggregation 2024-06-05 18:36:00 +02:00
Gregor Vostrak
4c977b5bf8 add client_ids to reporting filters 2024-06-05 18:36:00 +02:00
Gregor Vostrak
4e439010d1 hide admin submenu title for non-admins 2024-06-05 18:36:00 +02:00
194 changed files with 6072 additions and 1211 deletions

View File

@@ -6,8 +6,8 @@ APP_URL=https://solidtime.test
SUPER_ADMINS=admin@example.com
LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null
LOG_CHANNEL=single
LOG_DEPRECATIONS_CHANNEL=deprecation
LOG_LEVEL=debug
DB_CONNECTION=pgsql
@@ -73,3 +73,5 @@ NETWORK_NAME=reverse-proxy-docker-traefik_routing
FORWARD_DB_PORT=5432
FORWARD_WEB_PORT=8083
PAGINATION_PER_PAGE_DEFAULT=500

View File

@@ -3,6 +3,8 @@ on:
branches:
- main
- develop
tags:
- '*'
pull_request:
paths:
- '.github/workflows/build-private.yml'

View File

@@ -3,6 +3,8 @@ on:
branches:
- main
- develop
tags:
- '*'
pull_request:
paths:
- '.github/workflows/build-public.yml'

View File

@@ -54,7 +54,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@v4.4.1
uses: codecov/codecov-action@v4.5.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime

1
.gitignore vendored
View File

@@ -33,3 +33,4 @@ yarn-error.log
/k8s
/_ide_helper.php
/.phpstorm.meta.php
/.rnd

View File

@@ -7,9 +7,11 @@ namespace App\Actions\Fortify;
use App\Enums\Weekday;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
@@ -24,11 +26,33 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
public function update(User $user, array $input): void
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'],
'timezone' => ['required', 'timezone:all'],
'week_start' => ['required', Rule::enum(Weekday::class)],
'name' => [
'required',
'string',
'max:255',
],
'email' => [
'required',
'email',
'max:255',
(new UniqueEloquent(User::class, 'email'))->ignore($user->id)->query(function (Builder $query) {
/** @var Builder<User> $query */
return $query->where('is_placeholder', '=', false);
}),
],
'photo' => [
'nullable',
'mimes:jpg,jpeg,png',
'max:1024',
],
'timezone' => [
'required',
'timezone:all',
],
'week_start' => [
'required',
Rule::enum(Weekday::class),
],
])->validateWithBag('updateProfileInformation');
if (isset($input['photo'])) {

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Models\Organization;
use App\Service\DeletionService;
use Laravel\Jetstream\Contracts\DeletesTeams;
class DeleteOrganization implements DeletesTeams
@@ -12,8 +13,8 @@ class DeleteOrganization implements DeletesTeams
/**
* Delete the given team.
*/
public function delete(Organization $team): void
public function delete(Organization $organization): void
{
$team->purge();
app(DeletionService::class)->deleteOrganization($organization);
}
}

View File

@@ -4,51 +4,25 @@ declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Models\Organization;
use App\Exceptions\Api\ApiException;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Laravel\Jetstream\Contracts\DeletesTeams;
use App\Service\DeletionService;
use Illuminate\Validation\ValidationException;
use Laravel\Jetstream\Contracts\DeletesUsers;
class DeleteUser implements DeletesUsers
{
/**
* The team deleter implementation.
*
* @var \Laravel\Jetstream\Contracts\DeletesTeams
*/
protected $deletesTeams;
/**
* Create a new action instance.
*/
public function __construct(DeletesTeams $deletesTeams)
{
$this->deletesTeams = $deletesTeams;
}
/**
* Delete the given user.
*/
public function delete(User $user): void
{
DB::transaction(function () use ($user) {
$this->deleteTeams($user);
$user->deleteProfilePhoto();
$user->tokens->each->delete();
$user->delete();
});
}
/**
* Delete the teams and team associations attached to the user.
*/
protected function deleteTeams(User $user): void
{
$user->teams()->detach();
$user->ownedTeams->each(function (Organization $team) {
$this->deletesTeams->delete($team);
});
try {
app(DeletionService::class)->deleteUser($user);
} catch (ApiException $exception) {
throw ValidationException::withMessages([
'password' => $exception->getTranslatedMessage(),
]);
}
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Models\Organization;
use App\Models\User;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
class ValidateOrganizationDeletion
{
/**
* Validate that the team can be deleted by the given user.
*
* @param User $user Authenticated user
* @param Organization $organization Organization to be deleted
*
* @throws AuthorizationException
*/
public function validate(User $user, Organization $organization): void
{
if (! app(PermissionStore::class)->userHas($organization, $user, 'organizations:delete')) {
throw new AuthorizationException();
}
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Admin;
use App\Models\Organization;
use App\Service\DeletionService;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
class DeleteOrganizationCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'admin:delete-organization
{ organization : The ID of the organization to delete }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Delete a organization.';
/**
* Execute the console command.
*/
public function handle(DeletionService $deletionService): int
{
$organizationId = $this->argument('organization');
if (! Str::isUuid($organizationId)) {
$this->error('Organization ID must be a valid UUID.');
return self::FAILURE;
}
/** @var Organization|null $organization */
$organization = Organization::find($organizationId);
if ($organization === null) {
$this->error('Organization with ID '.$organizationId.' not found.');
return self::FAILURE;
}
$this->info('Deleting organization with ID '.$organization->getKey());
$deletionService->deleteOrganization($organization);
$this->info('Organization with ID '.$organization->getKey().' has been deleted.');
return self::SUCCESS;
}
}

View File

@@ -9,7 +9,7 @@ use Illuminate\Encryption\Encrypter;
use Illuminate\Support\Str;
use phpseclib3\Crypt\RSA;
class SelfHostGenerateKeys extends Command
class SelfHostGenerateKeysCommand extends Command
{
/**
* The name and signature of the console command.

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Organization;
use Illuminate\Foundation\Events\Dispatchable;
class BeforeOrganizationDeletion
{
use Dispatchable;
public Organization $organization;
public function __construct(Organization $organization)
{
$this->organization = $organization;
}
}

View File

@@ -13,6 +13,11 @@ abstract class ApiException extends Exception
{
public const string KEY = 'api_exception';
public function __construct()
{
parent::__construct(static::KEY);
}
/**
* Render the exception into an HTTP response.
*/

View File

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

View File

@@ -12,7 +12,7 @@ class EntityStillInUseApiException extends ApiException
public function __construct(string $modelToDelete, string $modelInUse)
{
parent::__construct('', 0, null);
parent::__construct();
$this->modelToDelete = $modelToDelete;
$this->modelInUse = $modelInUse;
}

View File

@@ -65,6 +65,11 @@ class OrganizationResource extends Resource
Forms\Components\TextInput::make('billable_rate')
->label('Billable rate (in Cents)')
->nullable()
->rules([
'nullable',
'integer',
'gt:0',
])
->numeric(),
Forms\Components\DateTimePicker::make('created_at')
->label('Created At')
@@ -169,7 +174,6 @@ class OrganizationResource extends Resource
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\Actions;
use App\Exceptions\Api\ApiException;
use App\Models\Organization;
use App\Service\DeletionService;
use Filament\Actions\DeleteAction;
use Throwable;
class DeleteOrganization extends DeleteAction
{
protected function setUp(): void
{
parent::setUp();
// TODO: check why setting the icon is necessary
$this->icon('heroicon-m-trash');
$this->action(function (): void {
$result = $this->process(function (Organization $record): bool {
try {
$deletionService = app(DeletionService::class);
$deletionService->deleteOrganization($record);
return true;
} catch (ApiException $exception) {
$this->failureNotificationTitle($exception->getTranslatedMessage());
report($exception);
} catch (Throwable $exception) {
$this->failureNotificationTitle(__('exceptions.unknown_error_in_admin_panel'));
report($exception);
}
return false;
});
if (! $result) {
$this->failure();
return;
}
$this->success();
});
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditOrganization extends EditRecord
@@ -15,7 +14,7 @@ class EditOrganization extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
OrganizationResource\Actions\DeleteOrganization::make(),
];
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
@@ -18,8 +17,6 @@ class ViewOrganization extends ViewRecord
return [
EditAction::make('edit')
->icon('heroicon-s-pencil'),
DeleteAction::make('delete')
->icon('heroicon-s-trash'),
];
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\ProjectMemberResource\Pages;
use App\Models\ProjectMember;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class ProjectMemberResource extends Resource
{
protected static ?string $model = ProjectMember::class;
protected static bool $shouldRegisterNavigation = false;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('billable_rate')
->label('Billable rate (in Cents)')
->nullable()
->rules([
'nullable',
'integer',
'gt:0',
])
->numeric(),
Forms\Components\Select::make('user_id')
->relationship('user', 'name')
->required(),
Forms\Components\Select::make('member_id')
->relationship('member', 'id')
->required(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')
->label('ID'),
Tables\Columns\TextColumn::make('billable_rate')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('project.name'),
Tables\Columns\TextColumn::make('user.name'),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListProjectMembers::route('/'),
'create' => Pages\CreateProjectMember::route('/create'),
'edit' => Pages\EditProjectMember::route('/{record}/edit'),
'view' => Pages\ViewProjectMembers::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ProjectMemberResource\Pages;
use App\Filament\Resources\ProjectMemberResource;
use Filament\Resources\Pages\CreateRecord;
class CreateProjectMember extends CreateRecord
{
protected static string $resource = ProjectMemberResource::class;
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ProjectMemberResource\Pages;
use App\Filament\Resources\ProjectMemberResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditProjectMember extends EditRecord
{
protected static string $resource = ProjectMemberResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

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

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ProjectMemberResource\Pages;
use App\Filament\Resources\ProjectMemberResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewProjectMembers extends ViewRecord
{
protected static string $resource = ProjectMemberResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make('edit')
->icon('heroicon-s-pencil'),
];
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\ProjectResource\Pages;
use App\Filament\Resources\ProjectResource\RelationManagers\ProjectMembersRelationManager;
use App\Models\Project;
use Filament\Forms;
use Filament\Forms\Components\ColorPicker;
@@ -37,6 +38,15 @@ class ProjectResource extends Resource
ColorPicker::make('color')
->label('Color')
->required(),
Forms\Components\TextInput::make('billable_rate')
->label('Billable rate (in Cents)')
->nullable()
->rules([
'nullable',
'integer',
'gt:0',
])
->numeric(),
Forms\Components\Select::make('organization_id')
->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name'])
@@ -78,7 +88,7 @@ class ProjectResource extends Resource
public static function getRelations(): array
{
return [
//
ProjectMembersRelationManager::make(),
];
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ProjectResource\RelationManagers;
use App\Filament\Resources\ProjectMemberResource;
use App\Models\ProjectMember;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Table;
class ProjectMembersRelationManager extends RelationManager
{
protected static ?string $title = 'Project Members';
protected static string $relationship = 'members';
public function form(Form $form): Form
{
return $form
->schema([
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('name')
->columns([
Tables\Columns\TextColumn::make('user.name'),
Tables\Columns\TextColumn::make('billable_rate')
->numeric()
->sortable(),
])
->filters([
//
])
->headerActions([
])
->actions([
Action::make('view')
->icon('heroicon-o-eye')
->color('gray')
->url(fn (ProjectMember $record): string => ProjectMemberResource::getUrl('view', [
'record' => $record->getKey(),
])),
Action::make('edit')
->icon('heroicon-o-pencil')
->url(fn (ProjectMember $record): string => ProjectMemberResource::getUrl('edit', [
'record' => $record->getKey(),
]))
->openUrlInNewTab(),
])
->bulkActions([
]);
}
}

View File

@@ -11,6 +11,7 @@ use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class TagResource extends Resource
@@ -58,7 +59,9 @@ class TagResource extends Resource
])
->defaultSort('created_at', 'desc')
->filters([
//
SelectFilter::make('organization')
->relationship('organization', 'name')
->searchable(),
])
->actions([
Tables\Actions\EditAction::make(),

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Actions;
use App\Exceptions\Api\ApiException;
use App\Models\User;
use App\Service\DeletionService;
use Filament\Actions\DeleteAction;
use Throwable;
class DeleteUser extends DeleteAction
{
protected function setUp(): void
{
parent::setUp();
$this->icon('heroicon-m-trash');
$this->action(function (): void {
$result = $this->process(function (User $record): bool {
try {
$deletionService = app(DeletionService::class);
$deletionService->deleteUser($record);
return true;
} catch (ApiException $exception) {
$this->failureNotificationTitle($exception->getTranslatedMessage());
report($exception);
} catch (Throwable $exception) {
$this->failureNotificationTitle(__('exceptions.unknown_error_in_admin_panel'));
report($exception);
}
return false;
});
if (! $result) {
$this->failure();
return;
}
$this->success();
});
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
use STS\FilamentImpersonate\Pages\Actions\Impersonate;
@@ -17,7 +16,7 @@ class EditUser extends EditRecord
{
return [
Impersonate::make()->record($this->getRecord()),
Actions\DeleteAction::make(),
UserResource\Actions\DeleteUser::make(),
];
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
@@ -18,8 +17,6 @@ class ViewUser extends ViewRecord
return [
EditAction::make('edit')
->icon('heroicon-s-pencil'),
DeleteAction::make('delete')
->icon('heroicon-s-trash'),
];
}
}

View File

@@ -11,6 +11,8 @@ use Illuminate\Database\Eloquent\Builder;
class ActiveUserOverview extends BaseWidget
{
protected static ?int $sort = 1;
protected static ?string $heading = 'A Registrations';
protected function getCards(): array

View File

@@ -15,6 +15,8 @@ class TimeEntriesCreated extends ChartWidget
public ?string $filter = 'week';
protected static ?int $sort = 3;
protected function getData(): array
{
$filter = $this->filter;
@@ -27,7 +29,9 @@ class TimeEntriesCreated extends ChartWidget
} else {
$start = now()->subWeek();
}
$trend = Trend::model(TimeEntry::class)
$trend = Trend::query(
TimeEntry::query()->where('is_imported', '=', false)
)
->between(
start: $start,
end: now(),

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets;
use App\Models\TimeEntry;
use Filament\Widgets\ChartWidget;
use Flowframe\Trend\Trend;
use Flowframe\Trend\TrendValue;
class TimeEntriesImported extends ChartWidget
{
protected static ?string $heading = 'Time Entries Imported';
public ?string $filter = 'week';
protected static ?int $sort = 4;
protected function getData(): array
{
$filter = $this->filter;
if ($filter === 'week') {
$start = now()->subWeek();
} elseif ($filter === 'month') {
$start = now()->subMonth();
} elseif ($filter === 'year') {
$start = now()->subYear();
} else {
$start = now()->subWeek();
}
$trend = Trend::query(
TimeEntry::query()->where('is_imported', '=', true)
)
->between(
start: $start,
end: now(),
)
->perDay();
if ($filter === 'week') {
$trend->perDay();
} elseif ($filter === 'month') {
$trend->perDay();
} elseif ($filter === 'year') {
$trend->perMonth();
} else {
$trend->perDay();
}
$data = $trend->count();
return [
'datasets' => [
[
'label' => self::$heading,
'data' => $data->map(fn (TrendValue $value) => $value->aggregate),
],
],
'labels' => $data->map(fn (TrendValue $value) => $value->date),
];
}
protected function getFilters(): ?array
{
return [
'week' => 'Last week',
'month' => 'Last month',
'year' => 'Last year',
];
}
protected function getType(): string
{
return 'line';
}
}

View File

@@ -15,6 +15,8 @@ class UserRegistrations extends ChartWidget
public ?string $filter = 'week';
protected static ?int $sort = 2;
protected function getData(): array
{
$filter = $this->filter;

View File

@@ -4,13 +4,9 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
class Controller extends \App\Http\Controllers\Controller
{
@@ -48,34 +44,4 @@ class Controller extends \App\Http\Controllers\Controller
{
return $this->permissionStore->has($organization, $permission);
}
/**
* @throws AuthorizationException
*/
protected function user(): User
{
/** @var User|null $user */
$user = Auth::user();
if ($user === null) {
Log::error('This function should only be called in authenticated context');
throw new AuthorizationException();
}
return $user;
}
/**
* @throws AuthorizationException
*/
protected function member(Organization $organization): Member
{
$user = $this->user();
$member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first();
if ($member === null) {
Log::error('This function should only be called in authenticated context after checking the user is a member of the organization');
throw new AuthorizationException();
}
return $member;
}
}

View File

@@ -14,6 +14,8 @@ class OrganizationController extends Controller
/**
* Get organization
*
* @operationId getOrganization
*
* @throws AuthorizationException
*/
public function show(Organization $organization): OrganizationResource
@@ -26,6 +28,8 @@ class OrganizationController extends Controller
/**
* Update organization
*
* @operationId updateOrganization
*
* @throws AuthorizationException
*/
public function update(Organization $organization, OrganizationUpdateRequest $request): OrganizationResource
@@ -33,7 +37,7 @@ class OrganizationController extends Controller
$this->checkPermission($organization, 'organizations:update');
$organization->name = $request->input('name');
$organization->billable_rate = $request->input('billable_rate');
$organization->billable_rate = $request->getBillableRate();
$organization->save();
return new OrganizationResource($organization);

View File

@@ -85,7 +85,8 @@ class ProjectController extends Controller
$project = new Project();
$project->name = $request->input('name');
$project->color = $request->input('color');
$project->billable_rate = $request->input('billable_rate');
$project->is_billable = (bool) $request->input('is_billable');
$project->billable_rate = $request->getBillableRate();
$project->client_id = $request->input('client_id');
$project->organization()->associate($organization);
$project->save();
@@ -105,7 +106,8 @@ class ProjectController extends Controller
$this->checkPermission($organization, 'projects:update', $project);
$project->name = $request->input('name');
$project->color = $request->input('color');
$project->billable_rate = $request->input('billable_rate');
$project->is_billable = (bool) $request->input('is_billable');
$project->billable_rate = $request->getBillableRate();
$project->client_id = $request->input('client_id');
$project->save();

View File

@@ -71,7 +71,7 @@ class ProjectMemberController extends Controller
}
$projectMember = new ProjectMember();
$projectMember->billable_rate = $request->input('billable_rate');
$projectMember->billable_rate = $request->getBillableRate();
$projectMember->member()->associate($member);
$projectMember->user()->associate($member->user);
$projectMember->project()->associate($project);
@@ -90,7 +90,7 @@ class ProjectMemberController extends Controller
public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request): JsonResource
{
$this->checkPermission($organization, 'project-members:update', projectMember: $projectMember);
$projectMember->billable_rate = $request->input('billable_rate');
$projectMember->billable_rate = $request->getBillableRate();
$projectMember->save();
return new ProjectMemberResource($projectMember);

View File

@@ -4,11 +4,63 @@ declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
class Controller extends BaseController
{
use AuthorizesRequests, ValidatesRequests;
use AuthorizesRequests;
use ValidatesRequests;
/**
* @throws AuthorizationException
*/
protected function user(): User
{
/** @var User|null $user */
$user = Auth::user();
if ($user === null) {
Log::error('This function should only be called in authenticated context');
throw new AuthorizationException();
}
return $user;
}
/**
* @throws AuthorizationException
*/
protected function member(Organization $organization): Member
{
$user = $this->user();
/** @var Member|null $member */
$member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first();
if ($member === null) {
Log::error('This function should only be called in authenticated context after checking the user is a member of the organization');
throw new AuthorizationException();
}
return $member;
}
/**
* @throws AuthorizationException
*/
protected function currentOrganization(): Organization
{
$user = $this->user();
$organization = $user->currentTeam;
if ($organization === null) {
$organization = $user->organizations()->first();
}
return $organization;
}
}

View File

@@ -4,21 +4,21 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Models\Organization;
use App\Models\User;
use App\Service\DashboardService;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
/**
* @throws AuthorizationException
*/
public function dashboard(DashboardService $dashboardService, PermissionStore $permissionStore): Response
{
/** @var User $user */
$user = auth()->user();
/** @var Organization $organization */
$organization = $user->currentTeam;
$user = $this->user();
$organization = $this->currentOrganization();
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
$weeklyHistory = $dashboardService->getWeeklyHistory($user, $organization);
$totalWeeklyTime = $dashboardService->totalWeeklyTime($user, $organization);

View File

@@ -36,4 +36,11 @@ class MemberUpdateRequest extends FormRequest
],
];
}
public function getBillableRate(): ?int
{
$input = $this->input('billable_rate');
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
}
}

View File

@@ -33,4 +33,11 @@ class OrganizationUpdateRequest extends FormRequest
],
];
}
public function getBillableRate(): ?int
{
$input = $this->input('billable_rate');
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
}
}

View File

@@ -38,6 +38,10 @@ class ProjectStoreRequest extends FormRequest
'max:255',
new ColorRule(),
],
'is_billable' => [
'required',
'boolean',
],
'billable_rate' => [
'nullable',
'integer',
@@ -52,4 +56,11 @@ class ProjectStoreRequest extends FormRequest
],
];
}
public function getBillableRate(): ?int
{
$input = $this->input('billable_rate');
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
}
}

View File

@@ -37,6 +37,10 @@ class ProjectUpdateRequest extends FormRequest
'max:255',
new ColorRule(),
],
'is_billable' => [
'required',
'boolean',
],
'billable_rate' => [
'nullable',
'integer',
@@ -51,4 +55,11 @@ class ProjectUpdateRequest extends FormRequest
],
];
}
public function getBillableRate(): ?int
{
$input = $this->input('billable_rate');
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
}
}

View File

@@ -39,4 +39,11 @@ class ProjectMemberStoreRequest extends FormRequest
],
];
}
public function getBillableRate(): ?int
{
$input = $this->input('billable_rate');
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
}
}

View File

@@ -28,4 +28,11 @@ class ProjectMemberUpdateRequest extends FormRequest
],
];
}
public function getBillableRate(): ?int
{
$input = $this->input('billable_rate');
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryAggregationType;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
@@ -86,6 +87,19 @@ class TimeEntryAggregateRequest extends FormRequest
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Filter by client IDs, client IDs are OR combined
'client_ids' => [
'array',
'min:1',
],
'client_ids.*' => [
'string',
'uuid',
new ExistsEloquent(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Filter by tag IDs, tag IDs are AND combined
'tag_ids' => [
'array',

View File

@@ -25,7 +25,7 @@ class OrganizationResource extends BaseResource
'id' => $this->resource->id,
/** @var string $name Name */
'name' => $this->resource->name,
/** @var string $color Personal organizations automatically created after registration */
/** @var bool $color Personal organizations automatically created after registration */
'is_personal' => $this->resource->personal_team,
/** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->resource->billable_rate,

View File

@@ -31,6 +31,8 @@ class ProjectResource extends BaseResource
'client_id' => $this->resource->client_id,
/** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->resource->billable_rate,
/** @var bool $is_billable Project time entries billable default */
'is_billable' => $this->resource->is_billable,
];
}
}

View File

@@ -20,6 +20,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property string $organization_id
* @property string $client_id
* @property int|null $billable_rate
* @property bool $is_billable
* @property-read Organization $organization
* @property-read Client|null $client
* @property-read Collection<int, Task> $tasks
@@ -43,6 +44,15 @@ class Project extends Model
'color' => 'string',
];
/**
* Set default values for attributes.
*
* @var array<string, mixed>
*/
protected $attributes = [
'is_billable' => false,
];
/**
* @return BelongsTo<Organization, Project>
*/

View File

@@ -25,6 +25,7 @@ use Korridor\LaravelComputedAttributes\ComputedAttributes;
* @property array $tags
* @property string $user_id
* @property string $member_id
* @property bool $is_imported
* @property-read User $user
* @property-read Member $member
* @property string $organization_id
@@ -57,6 +58,7 @@ class TimeEntry extends Model
'billable' => 'bool',
'tags' => 'array',
'billable_rate' => 'int',
'is_imported' => 'bool',
];
/**

View File

@@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
@@ -36,11 +37,12 @@ use Laravel\Passport\HasApiTokens;
* @property bool $is_placeholder
* @property Weekday $week_start
* @property string|null $profile_photo_path
* @property-read Organization $currentTeam
* @property-read Organization|null $currentOrganization
* @property-read Organization|null $currentTeam
* @property-read string $profile_photo_url
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property string $current_team_id
* @property string|null $current_team_id
* @property Collection<int, Organization> $organizations
* @property Collection<int, TimeEntry> $timeEntries
* @property Member $membership
@@ -154,6 +156,14 @@ class User extends Authenticatable implements FilamentUser, MustVerifyEmail
return $this->hasMany(TimeEntry::class);
}
/**
* @return BelongsTo<Organization, User>
*/
public function currentOrganization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'current_team_id');
}
/**
* @return HasMany<ProjectMember>
*/

View File

@@ -6,6 +6,7 @@ namespace App\Providers\Filament;
use App\Filament\Widgets\ActiveUserOverview;
use App\Filament\Widgets\TimeEntriesCreated;
use App\Filament\Widgets\TimeEntriesImported;
use App\Filament\Widgets\UserRegistrations;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\DisableBladeIconComponents;
@@ -46,6 +47,7 @@ class AdminPanelProvider extends PanelProvider
ActiveUserOverview::class,
UserRegistrations::class,
TimeEntriesCreated::class,
TimeEntriesImported::class,
])
->plugins([
EnvironmentIndicatorPlugin::make()

View File

@@ -12,6 +12,7 @@ use App\Actions\Jetstream\InviteOrganizationMember;
use App\Actions\Jetstream\RemoveOrganizationMember;
use App\Actions\Jetstream\UpdateMemberRole;
use App\Actions\Jetstream\UpdateOrganization;
use App\Actions\Jetstream\ValidateOrganizationDeletion;
use App\Enums\Role;
use App\Enums\Weekday;
use App\Models\Member;
@@ -26,6 +27,7 @@ use Illuminate\Support\ServiceProvider;
use Inertia\Inertia;
use Laravel\Fortify\Fortify;
use Laravel\Jetstream\Actions\UpdateTeamMemberRole;
use Laravel\Jetstream\Actions\ValidateTeamDeletion;
use Laravel\Jetstream\Jetstream;
class JetstreamServiceProvider extends ServiceProvider
@@ -56,6 +58,7 @@ class JetstreamServiceProvider extends ServiceProvider
Jetstream::useMembershipModel(Member::class);
Jetstream::useTeamInvitationModel(OrganizationInvitation::class);
app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class);
app()->singleton(ValidateTeamDeletion::class, ValidateOrganizationDeletion::class);
Fortify::registerView(function () {
return Inertia::render('Auth/Register', [
'terms_url' => config('auth.terms_url'),
@@ -105,6 +108,7 @@ class JetstreamServiceProvider extends ServiceProvider
'clients:delete',
'organizations:view',
'organizations:update',
'organizations:delete',
'import',
'invitations:view',
'invitations:create',

View File

@@ -12,6 +12,27 @@ use App\Models\TimeEntry;
class BillableRateService
{
public function getBillableRateForTimeEntryWithGivenRelations(TimeEntry $timeEntry, ?ProjectMember $projectMember, ?Project $project, ?Member $member, ?Organization $organization): ?int
{
if (! $timeEntry->billable) {
return null;
}
if ($projectMember !== null && $projectMember->billable_rate !== null) {
return $projectMember->billable_rate;
}
if ($project !== null && $project->billable_rate !== null) {
return $project->billable_rate;
}
if ($member !== null && $member->billable_rate !== null) {
return $member->billable_rate;
}
if ($organization !== null && $organization->billable_rate !== null) {
return $organization->billable_rate;
}
return null;
}
public function getBillableRateForTimeEntry(TimeEntry $timeEntry): ?int
{
if (! $timeEntry->billable) {

View File

@@ -122,7 +122,7 @@ class DashboardService
{
return $builder->whereBetween('start', [
Carbon::now($timeZone)->startOfWeek($startOfWeek->carbonWeekDay())->utc(),
Carbon::now($timeZone)->endOfWeek($startOfWeek->carbonWeekDay())->utc(),
Carbon::now($timeZone)->endOfWeek($startOfWeek->toEndOfWeek()->carbonWeekDay())->utc(),
]);
}

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enums\Role;
use App\Events\BeforeOrganizationDeletion;
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class DeletionService
{
private UserService $userService;
public function __construct(UserService $userService)
{
$this->userService = $userService;
}
public function deleteOrganization(Organization $organization, bool $inTransaction = true, ?User $ignoreUser = null): void
{
if ($inTransaction) {
DB::transaction(function () use ($organization) {
$this->deleteOrganization($organization, false);
});
return;
}
Log::debug('Start deleting organization', [
'organization_id' => $organization->getKey(),
'name' => $organization->name,
'owner_id' => $organization->user_id,
]);
BeforeOrganizationDeletion::dispatch($organization);
// Delete all organization invitations
OrganizationInvitation::query()->whereBelongsTo($organization, 'organization')->delete();
// Delete all time entries
TimeEntry::query()->whereBelongsTo($organization, 'organization')->delete();
// Delete all tags
Tag::query()->whereBelongsTo($organization, 'organization')->delete();
// Delete all tasks
Task::query()->whereBelongsTo($organization, 'organization')->delete();
// Delete all project members
ProjectMember::query()->whereBelongsToOrganization($organization)->delete();
// Delete all projects
Project::query()->whereBelongsTo($organization, 'organization')->delete();
// Delete all clients
Client::query()->whereBelongsTo($organization, 'organization')->delete();
// Reset the current organization
$organization->owner()
->where('current_team_id', $organization->getKey())
->update(['current_team_id' => null]);
$organization->users()
->where('current_team_id', $organization->getKey())
->update(['current_team_id' => null]);
// Delete all members
$users = $organization->users()
->with([
'currentOrganization',
])
->get();
$organization->users()->sync([]);
// Make sure all users have at least one organization and delete placeholders
foreach ($users as $user) {
if ($ignoreUser !== null && $user->is($ignoreUser)) {
continue;
}
if ($user->is_placeholder) {
$user->delete();
} else {
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
$this->userService->makeSureUserHasCurrentOrganization($user);
}
}
// Delete organization
$organization->delete();
Log::debug('Finished deleting organization', [
'organization_id' => $organization->getKey(),
'name' => $organization->name,
'owner_id' => $organization->user_id,
]);
}
/**
* @throws CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers
*/
public function deleteUser(User $user, bool $inTransaction = true): void
{
if ($inTransaction) {
DB::transaction(function () use ($user) {
$this->deleteUser($user, false);
});
return;
}
Log::debug('Start deleting user', [
'id' => $user->getKey(),
'name' => $user->name,
'email' => $user->email,
]);
$members = Member::query()->whereBelongsTo($user, 'user')
->with([
'organization',
'user',
])
->get();
foreach ($members as $member) {
if ($member->role === Role::Owner->value && $member->organization->users()->count() > 1) {
throw new CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers();
}
}
/** @var Member $member */
foreach ($members as $member) {
if ($member->role === Role::Owner->value) {
$this->deleteOrganization($member->organization, false, $user);
} else {
$this->userService->makeMemberToPlaceholder($member);
}
}
// Note: Since the deletion of the profile photo is not reversible via a database rollback this needs to be done last
$user->deleteProfilePhoto();
$user->delete();
Log::debug('Finished deleting user', [
'id' => $user->getKey(),
'name' => $user->name,
'email' => $user->email,
]);
}
}

View File

@@ -30,6 +30,16 @@ class ImportDatabaseHelper
*/
private ?array $mapIdentifierToKey = null;
/**
* @var array<string, TModel|null>|null
*/
private ?array $mapKeyToModel = null;
/**
* @var array<string, TModel|null>|null
*/
private ?array $mapIdentifierToModel = null;
/**
* @var array<string, string>
*/
@@ -48,12 +58,14 @@ class ImportDatabaseHelper
*/
private array $validate;
private ?Closure $beforeSave;
/**
* @param class-string<TModel> $model
* @param array<string> $identifiers
* @param array<string, array<int, string>> $validate
*/
public function __construct(string $model, array $identifiers, bool $attachToExisting = false, ?Closure $queryModifier = null, ?Closure $afterCreate = null, array $validate = [])
public function __construct(string $model, array $identifiers, bool $attachToExisting = false, ?Closure $queryModifier = null, ?Closure $afterCreate = null, array $validate = [], ?Closure $beforeSave = null)
{
$this->model = $model;
$this->identifiers = $identifiers;
@@ -62,6 +74,7 @@ class ImportDatabaseHelper
$this->afterCreate = $afterCreate;
$this->createdCount = 0;
$this->validate = $validate;
$this->beforeSave = $beforeSave;
}
/**
@@ -89,6 +102,9 @@ class ImportDatabaseHelper
foreach ($data as $key => $value) {
$model->{$key} = $value;
}
if ($this->beforeSave !== null) {
($this->beforeSave)($model);
}
$model->save();
if ($this->afterCreate !== null) {
@@ -148,6 +164,47 @@ class ImportDatabaseHelper
}
}
/**
* @return TModel
*/
public function getModelById(string $id): ?Model
{
if ($this->mapKeyToModel === null) {
$this->mapKeyToModel = [];
}
if (isset($this->mapKeyToModel[$id])) {
return $this->mapKeyToModel[$id];
}
/** @var TModel|null $model */
$model = $this->getModelInstance()->find($id);
if ($model !== null) {
$this->mapKeyToModel[$id] = $model;
}
return $model;
}
/**
* @param array<string, mixed> $identifierData
* @return TModel|null
*/
public function getModel(array $identifierData): ?Model
{
if ($this->mapIdentifierToModel === null) {
$this->mapIdentifierToModel = [];
}
$hash = $this->getHash($identifierData);
if (isset($this->mapIdentifierToModel[$hash])) {
return $this->mapIdentifierToModel[$hash];
}
$model = $this->getModelInstance()->where($identifierData)->first();
if ($model !== null) {
$this->mapIdentifierToModel[$hash] = $model;
}
return $model;
}
/**
* @param array<string, mixed> $identifierData
*

View File

@@ -9,7 +9,7 @@ use App\Service\Import\Importers\ImporterContract;
use App\Service\Import\Importers\ImporterProvider;
use App\Service\Import\Importers\ImportException;
use App\Service\Import\Importers\ReportDto;
use Carbon\Carbon;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

View File

@@ -41,6 +41,7 @@ class ClockifyProjectsImporter extends DefaultImporter
], [
'client_id' => $clientId,
'color' => $this->colorService->getRandomColor(),
'is_billable' => $record['Billability'] === 'Yes',
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
]);
}

View File

@@ -64,6 +64,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
], [
'role' => Role::Placeholder->value,
]);
$member = $this->memberImportHelper->getModelById($memberId);
$clientId = null;
if ($record['Client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
@@ -72,6 +73,8 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
]);
}
$projectId = null;
$project = null;
$projectMember = null;
if ($record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'],
@@ -79,6 +82,12 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
], [
'client_id' => $clientId,
'color' => $this->colorService->getRandomColor(),
'is_billable' => false,
]);
$project = $this->projectImportHelper->getModelById($projectId);
$projectMember = $this->projectMemberImportHelper->getModel([
'project_id' => $projectId,
'member_id' => $memberId,
]);
}
$taskId = null;
@@ -105,6 +114,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
}
$timeEntry->billable = $record['Billable'] === 'Yes';
$timeEntry->tags = $this->getTags($record['Tags']);
$timeEntry->is_imported = true;
// Start
try {
@@ -119,7 +129,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
if ($start === null) {
throw new ImportException('Start date ("'.$record['Start Date'].'") or time ("'.$record['Start Time'].'") are invalid');
}
$timeEntry->start = $start;
$timeEntry->start = $start->utc();
// End
try {
@@ -134,8 +144,14 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
if ($end === null) {
throw new ImportException('End date ("'.$record['End Date'].'") or time ("'.$record['End Time'].'") are invalid');
}
$timeEntry->end = $end;
$timeEntry->setComputedAttributeValue('billable_rate');
$timeEntry->end = $end->utc();
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
$timeEntry,
$projectMember,
$project,
$member,
$this->organization
);
$timeEntry->save();
$this->timeEntriesCreated++;
}

View File

@@ -12,6 +12,7 @@ use App\Models\ProjectMember;
use App\Models\Tag;
use App\Models\Task;
use App\Models\User;
use App\Service\BillableRateService;
use App\Service\ColorService;
use App\Service\Import\ImportDatabaseHelper;
use App\Service\TimezoneService;
@@ -62,6 +63,8 @@ abstract class DefaultImporter implements ImporterContract
*/
protected ImportDatabaseHelper $projectMemberImportHelper;
protected BillableRateService $billableRateService;
public function init(Organization $organization): void
{
$this->organization = $organization;
@@ -96,11 +99,19 @@ abstract class DefaultImporter implements ImporterContract
'required',
'max:255',
],
'is_billable' => [
'required',
'boolean',
],
'billable_rate' => [
'nullable',
'integer',
],
]);
], beforeSave: function (Project $project) {
if ($project->billable_rate === 0) {
$project->billable_rate = null;
}
});
$this->projectMemberImportHelper = new ImportDatabaseHelper(ProjectMember::class, ['project_id', 'member_id'], true, function (Builder $builder) {
/** @var Builder<ProjectMember> $builder */
return $builder->whereBelongsToOrganization($this->organization);
@@ -109,7 +120,11 @@ abstract class DefaultImporter implements ImporterContract
'nullable',
'integer',
],
]);
], beforeSave: function (ProjectMember $projectMember) {
if ($projectMember->billable_rate === 0) {
$projectMember->billable_rate = null;
}
});
$this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder) {
return $builder->where('organization_id', $this->organization->id);
}, validate: [
@@ -137,6 +152,7 @@ abstract class DefaultImporter implements ImporterContract
$this->timeEntriesCreated = 0;
$this->colorService = app(ColorService::class);
$this->timezoneService = app(TimezoneService::class);
$this->billableRateService = app(BillableRateService::class);
}
#[\Override]

View File

@@ -121,6 +121,7 @@ class TogglDataImporter extends DefaultImporter
], [
'client_id' => $clientId,
'color' => $project->color,
'is_billable' => $project->rate !== null,
'billable_rate' => $project->rate !== null ? (int) ($project->rate * 100) : null,
], (string) $project->id);

View File

@@ -64,6 +64,7 @@ class TogglTimeEntriesImporter extends DefaultImporter
], [
'role' => Role::Placeholder->value,
]);
$member = $this->memberImportHelper->getModelById($memberId);
$clientId = null;
if ($record['Client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
@@ -72,14 +73,22 @@ class TogglTimeEntriesImporter extends DefaultImporter
]);
}
$projectId = null;
$project = null;
$projectMember = null;
if ($record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'],
'organization_id' => $this->organization->id,
], [
'client_id' => $clientId,
'is_billable' => false,
'color' => $this->colorService->getRandomColor(),
]);
$project = $this->projectImportHelper->getModelById($projectId);
$projectMember = $this->projectMemberImportHelper->getModel([
'project_id' => $projectId,
'member_id' => $memberId,
]);
}
$taskId = null;
if ($record['Task'] !== '') {
@@ -102,6 +111,7 @@ class TogglTimeEntriesImporter extends DefaultImporter
}
$timeEntry->billable = $record['Billable'] === 'Yes';
$timeEntry->tags = $this->getTags($record['Tags']);
$timeEntry->is_imported = true;
try {
$start = Carbon::createFromFormat('Y-m-d H:i:s', $record['Start date'].' '.$record['Start time'], $timezone);
} catch (InvalidFormatException) {
@@ -110,7 +120,7 @@ class TogglTimeEntriesImporter extends DefaultImporter
if ($start === null) {
throw new ImportException('Start date ("'.$record['Start date'].'") or time ("'.$record['Start time'].'") are invalid');
}
$timeEntry->start = $start;
$timeEntry->start = $start->utc();
try {
$end = Carbon::createFromFormat('Y-m-d H:i:s', $record['End date'].' '.$record['End time'], $timezone);
@@ -120,8 +130,14 @@ class TogglTimeEntriesImporter extends DefaultImporter
if ($end === null) {
throw new ImportException('End date ("'.$record['End date'].'") or time ("'.$record['End time'].'") are invalid');
}
$timeEntry->end = $end;
$timeEntry->setComputedAttributeValue('billable_rate');
$timeEntry->end = $end->utc();
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
$timeEntry,
$projectMember,
$project,
$member,
$this->organization
);
$timeEntry->save();
$this->timeEntriesCreated++;
}

View File

@@ -30,6 +30,11 @@ class PermissionStore
return false;
}
return $this->userHas($organization, $user, $permission);
}
public function userHas(Organization $organization, User $user, string $permission): bool
{
if (! isset($this->permissionCache[$user->getKey().'|'.$organization->getKey()])) {
if (! $user->belongsToTeam($organization)) {
return false;

View File

@@ -28,6 +28,11 @@ class UserService
throw new \InvalidArgumentException('User is not a member of the organization');
}
$this->assignOrganizationEntitiesToDifferentMember($organization, $fromUser, $toUser, $toMember);
}
private function assignOrganizationEntitiesToDifferentMember(Organization $organization, User $fromUser, User $toUser, Member $toMember): void
{
// Time entries
TimeEntry::query()
->whereBelongsTo($organization, 'organization')
@@ -47,6 +52,53 @@ class UserService
]);
}
public function makeMemberToPlaceholder(Member $member): void
{
$user = $member->user;
$placeholderUser = $user->replicate();
$placeholderUser->is_placeholder = true;
$placeholderUser->save();
$member->user()->associate($placeholderUser);
$member->role = Role::Placeholder->value;
$member->save();
$this->assignOrganizationEntitiesToDifferentMember($member->organization, $user, $placeholderUser, $member);
$this->makeSureUserHasAtLeastOneOrganization($user);
}
public function makeSureUserHasAtLeastOneOrganization(User $user): void
{
if ($user->organizations()->count() > 0) {
return;
}
// Create a new organization
$organization = new Organization();
$organization->name = $user->name."'s Organization";
$organization->personal_team = true;
$organization->user_id = $user->id;
$organization->save();
// Attach the user to the organization
$organization->users()->attach($user, ['role' => Role::Owner->value]);
// Set the organization as the user's current organization
$user->currentOrganization()->associate($organization);
$user->save();
}
public function makeSureUserHasCurrentOrganization(User $user): void
{
if ($user->currentOrganization !== null) {
return;
}
$organization = $user->organizations()->first();
$user->currentOrganization()->associate($organization);
$user->save();
}
/**
* Change the ownership of an organization to a new user.
* The previous owner will be demoted to an admin.

View File

@@ -93,6 +93,9 @@
"test:coverage:report": [
"@php vendor/bin/phpunit --coverage-html=coverage"
],
"coverage-report": [
"@test:coverage:report"
],
"fix": [
"@php pint"
],

492
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -144,6 +144,11 @@ return [
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
'deprecation' => [
'driver' => 'single',
'path' => storage_path('logs/deprecation.log'),
],
],
];

View File

@@ -185,7 +185,7 @@ return [
'watch' => [
'app',
'bootstrap',
'config',
'config/**/*.php',
'database/**/*.php',
'public/**/*.php',
'resources/**/*.php',

View File

@@ -22,7 +22,7 @@ class OrganizationFactory extends Factory
{
return [
'name' => $this->faker->unique()->company(),
'currency' => $this->faker->currencyCode,
'currency' => $this->faker->currencyCode(),
'billable_rate' => $this->faker->numberBetween(50, 1000) * 100,
'user_id' => User::factory(),
'personal_team' => true,

View File

@@ -27,13 +27,24 @@ class ProjectFactory extends Factory
return [
'name' => $this->faker->company(),
'color' => app(ColorService::class)->getRandomColor(),
'billable_rate' => $this->faker->numberBetween(50, 1000) * 100,
'is_billable' => false,
'billable_rate' => null,
'is_public' => false,
'client_id' => null,
'organization_id' => Organization::factory(),
];
}
public function billable(): self
{
return $this->state(function (array $attributes): array {
return [
'is_billable' => true,
'billable_rate' => $this->faker->numberBetween(50, 1000) * 100,
];
});
}
public function forOrganization(Organization $organization): self
{
return $this->state(function (array $attributes) use ($organization): array {

View File

@@ -33,6 +33,7 @@ class TimeEntryFactory extends Factory
'start' => $start,
'end' => $this->faker->dateTimeBetween($start, 'now'),
'billable' => $this->faker->boolean(),
'is_imported' => false,
'tags' => [],
'user_id' => User::factory(),
'member_id' => Member::factory(),

View File

@@ -9,6 +9,7 @@ use App\Enums\Weekday;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
@@ -86,6 +87,20 @@ class UserFactory extends Factory
});
}
public function withProfilePicture(): static
{
$profilePhoto = $this->faker->image(null, 500, 500);
/** @see \Illuminate\Http\FileHelpers::hashName */
$path = 'profile-photos/'.Str::random(40).'.png';
Storage::disk(config('jetstream.profile_photo_disk', 'public'))->put($path, $profilePhoto);
return $this->state(function (array $attributes) use ($path): array {
return [
'profile_photo_path' => $path,
];
});
}
/**
* Indicate that the user should have a personal team.
*/

View File

@@ -0,0 +1,78 @@
<?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
{
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return config('telescope.storage.database.connection');
}
/**
* Run the migrations.
*/
public function up(): void
{
if (! App::isLocal()) {
return;
}
$schema = Schema::connection($this->getConnection());
$schema->create('telescope_entries', function (Blueprint $table) {
$table->bigIncrements('sequence');
$table->uuid('uuid');
$table->uuid('batch_id');
$table->string('family_hash')->nullable();
$table->boolean('should_display_on_index')->default(true);
$table->string('type', 20);
$table->longText('content');
$table->dateTime('created_at')->nullable();
$table->unique('uuid');
$table->index('batch_id');
$table->index('family_hash');
$table->index('created_at');
$table->index(['type', 'should_display_on_index']);
});
$schema->create('telescope_entries_tags', function (Blueprint $table) {
$table->uuid('entry_uuid');
$table->string('tag');
$table->primary(['entry_uuid', 'tag']);
$table->index('tag');
$table->foreign('entry_uuid')
->references('uuid')
->on('telescope_entries')
->onDelete('cascade');
});
$schema->create('telescope_monitoring', function (Blueprint $table) {
$table->string('tag')->primary();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (! App::isLocal()) {
return;
}
$schema = Schema::connection($this->getConnection());
$schema->dropIfExists('telescope_entries_tags');
$schema->dropIfExists('telescope_entries');
$schema->dropIfExists('telescope_monitoring');
}
};

View File

@@ -20,14 +20,14 @@ return new class extends Migration
$table->foreign('project_id')
->references('id')
->on('projects')
->onDelete('restrict')
->onUpdate('cascade');
->restrictOnDelete()
->cascadeOnUpdate();
$table->uuid('user_id');
$table->foreign('user_id')
->references('id')
->on('users')
->onDelete('restrict')
->onUpdate('cascade');
->restrictOnDelete()
->cascadeOnUpdate();
$table->timestamps();
$table->unique(['project_id', 'user_id']);
});

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('projects', function (Blueprint $table) {
$table->boolean('is_billable')->default(false);
});
DB::statement('
update projects
set is_billable = true
where projects.billable_rate is not null and projects.billable_rate > 0
');
Schema::table('projects', function (Blueprint $table) {
$table->boolean('is_billable')->default(null)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('projects', function (Blueprint $table) {
$table->dropColumn('is_billable');
});
}
};

View File

@@ -0,0 +1,30 @@
<?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
{
Schema::table('time_entries', function (Blueprint $table) {
$table->boolean('is_imported')->default(false);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('time_entries', function (Blueprint $table) {
$table->dropColumn('is_imported');
});
}
};

View File

@@ -0,0 +1,84 @@
<?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
{
Schema::table('time_entries', function (Blueprint $table) {
$table->dropForeign(['member_id']);
$table->foreign('member_id')
->references('id')
->on('members')
->restrictOnDelete()
->cascadeOnUpdate();
$table->dropForeign(['client_id']);
$table->foreign('client_id')
->references('id')
->on('clients')
->restrictOnDelete()
->cascadeOnUpdate();
});
Schema::table('project_members', function (Blueprint $table) {
$table->dropForeign(['member_id']);
$table->foreign('member_id')
->references('id')
->on('members')
->restrictOnDelete()
->cascadeOnUpdate();
});
Schema::table('organization_invitations', function (Blueprint $table) {
$table->dropForeign(['organization_id']);
$table->foreign('organization_id')
->references('id')
->on('organizations')
->restrictOnDelete()
->cascadeOnUpdate();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('time_entries', function (Blueprint $table) {
$table->dropForeign(['member_id']);
$table->foreign('member_id')
->references('id')
->on('members')
->cascadeOnDelete()
->cascadeOnUpdate();
$table->dropForeign(['client_id']);
$table->foreign('client_id')
->references('id')
->on('clients')
->cascadeOnDelete()
->cascadeOnUpdate();
});
Schema::table('project_members', function (Blueprint $table) {
$table->dropForeign(['member_id']);
$table->foreign('member_id')
->references('id')
->on('members')
->cascadeOnDelete()
->cascadeOnUpdate();
});
Schema::table('organization_invitations', function (Blueprint $table) {
$table->dropForeign(['organization_id']);
$table->foreign('organization_id')
->references('id')
->on('organizations')
->cascadeOnDelete()
->cascadeOnUpdate();
});
}
};

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::table('organizations')
->where('billable_rate', '=', 0)
->update(['billable_rate' => null]);
DB::table('project_members')
->where('billable_rate', '=', 0)
->update(['billable_rate' => null]);
DB::table('projects')
->where('billable_rate', '=', 0)
->update(['billable_rate' => null]);
DB::table('members')
->where('billable_rate', '=', 0)
->update(['billable_rate' => null]);
DB::table('time_entries')
->where('billable_rate', '=', 0)
->update(['billable_rate' => null]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -39,7 +39,7 @@ class DatabaseSeeder extends Seeder
'personal_team' => false,
'currency' => 'EUR',
]);
$userRivalManager = User::factory()->withPersonalOrganization()->create([
$userAcmeManager = User::factory()->withPersonalOrganization()->create([
'name' => 'Acme Manager',
'email' => 'test@example.com',
]);
@@ -57,7 +57,7 @@ class DatabaseSeeder extends Seeder
'password' => null,
]);
$userAcmeOwnerMember = Member::factory()->forUser($userAcmeOwner)->forOrganization($organizationAcme)->role(Role::Owner)->create();
$userAcmeManagerMember = Member::factory()->forUser($userRivalManager)->forOrganization($organizationAcme)->role(Role::Manager)->create();
$userAcmeManagerMember = Member::factory()->forUser($userAcmeManager)->forOrganization($organizationAcme)->role(Role::Manager)->create();
$userAcmeAdminMember = Member::factory()->forUser($userAcmeAdmin)->forOrganization($organizationAcme)->role(Role::Admin)->create();
$userAcmeEmployeeMember = Member::factory()->forUser($userAcmeEmployee)->forOrganization($organizationAcme)->role(Role::Employee)->create();
$userAcmePlaceholderMember = Member::factory()->forUser($userAcmePlaceholder)->forOrganization($organizationAcme)->role(Role::Placeholder)->create();
@@ -67,6 +67,10 @@ class DatabaseSeeder extends Seeder
->count(10)
->forMember($userAcmeAdminMember)
->create();
TimeEntry::factory()
->count(10)
->forMember($userAcmeManagerMember)
->create();
TimeEntry::factory()
->count(10)
->forMember($userAcmePlaceholderMember)
@@ -79,10 +83,10 @@ class DatabaseSeeder extends Seeder
->count(5)
->forMember($userWithMultipleOrganizationsAcmeMember)
->create();
$client = Client::factory()->forOrganization($organizationAcme)->create([
$acmeClient = Client::factory()->forOrganization($organizationAcme)->create([
'name' => 'Big Company',
]);
$bigCompanyProject = Project::factory()->forOrganization($organizationAcme)->forClient($client)->create([
$bigCompanyProject = Project::factory()->forOrganization($organizationAcme)->forClient($acmeClient)->create([
'name' => 'Big Company Project',
]);
ProjectMember::factory()->forProject($bigCompanyProject)->forMember($userAcmeEmployeeMember)->create();
@@ -101,11 +105,11 @@ class DatabaseSeeder extends Seeder
'name' => 'Internal Project',
]);
$organization2Owner = User::factory()->create([
$rivalOwner = User::factory()->create([
'name' => 'Other Owner',
'email' => 'owner@rival-company.test',
]);
$organizationRival = Organization::factory()->withOwner($organization2Owner)->create([
$organizationRival = Organization::factory()->withOwner($rivalOwner)->create([
'name' => 'Rival Corp',
'personal_team' => true,
'currency' => 'USD',
@@ -116,9 +120,12 @@ class DatabaseSeeder extends Seeder
]);
$userRivalManagerMember = Member::factory()->forUser($userRivalManager)->forOrganization($organizationRival)->role(Role::Admin)->create();
$userWithMultipleOrganizationsRivalMember = Member::factory()->forUser($userWithMultipleOrganizations)->forOrganization($organizationRival)->role(Role::Employee)->create();
$otherCompanyProject = Project::factory()->forOrganization($organizationRival)->forClient($client)->create([
$rivalClient = Client::factory()->forOrganization($organizationRival)->create([
'name' => 'Scale Company',
]);
$otherCompanyProject = Project::factory()->forOrganization($organizationRival)->forClient($rivalClient)->create([
'name' => 'Scale Company - Project ABC',
]);
ProjectMember::factory()->forProject($otherCompanyProject)->forMember($userRivalManagerMember)->create();
ProjectMember::factory()->forProject($otherCompanyProject)->forMember($userWithMultipleOrganizationsRivalMember)->create();
TimeEntry::factory()

View File

@@ -28,6 +28,7 @@ services:
extra_hosts:
- 'host.docker.internal:host-gateway'
environment:
SUPERVISOR_PHP_COMMAND: "/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan octane:start --server=swoole --watch --host=0.0.0.0 --port=80"
WWWUSER: '${WWWUSER}'
LARAVEL_SAIL: 1
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'

View File

@@ -49,3 +49,5 @@ test('test that creating and deleting a new client via the modal works', async (
newClientName
);
});
// TODO: Add Name Update Test

1
e2e/members.spec.ts Normal file
View File

@@ -0,0 +1 @@
// TODO: Edit Billable Rate

View File

@@ -69,3 +69,7 @@ test('test that creating and deleting a new project via the modal works', async
// Add Project with billable rate
// Edit Project with billable rate
// Edit Project Member Billable Rate
// Edit Task Name

View File

@@ -107,3 +107,5 @@ test('test that creating and deleting a new tag in a new project works', async (
// Test that project task count is displayed correctly
// Test that active / archive / all filter works (once implemented)
// Test update task name

View File

@@ -183,7 +183,7 @@ test('test that adding a new tag to an existing time entry works', async ({
});
// Test that Start / End Time Update Works
test('test that updating a the start of an existing time entry in the overview works on blur', async ({
test('test that updating a the start of an existing time entry in the overview works on enter', async ({
page,
}) => {
await goToTimeOverview(page);
@@ -220,7 +220,7 @@ test('test that updating a the start of an existing time entry in the overview w
page
.getByTestId('time_entry_range_end')
.getByTestId('time_picker_minute')
.press('Tab'),
.press('Enter'),
]);
});
@@ -466,3 +466,5 @@ test.skip('test that load more works when the end of page is reached', async ({
// TODO: Test manual time entries
// TODO: Test Grouped time entries by description/project
// TODO: Add Test for Date Update

View File

@@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
@@ -19,5 +20,7 @@ return [
UserIsAlreadyMemberOfProjectApiException::KEY => 'User is already a member of the project',
EntityStillInUseApiException::KEY => 'The :modelToDelete is still used by a :modelInUse and can not be deleted.',
CanNotRemoveOwnerFromOrganization::KEY => 'Can not remove owner from organization',
CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers::KEY => 'Can not delete user who is owner of organization with multiple members. Please delete the organization first.',
],
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
];

View File

@@ -5,31 +5,31 @@ declare(strict_types=1);
return [
'clockify_time_entries' => [
'name' => 'Clockify Time Entries',
'description' => 'First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings. '.
'Go to REPORTS -> TIME -> Detailed in the navigation on the left. '.
'Now select the date range that you want to export in the right top. '.
'description' => '1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br> '.
'2. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'.
'3. 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 .'.
'Now click Export -> Save as CSV. The Export dropdown is in the header of the export table left of the printer symbol. '.
'Before you import make sure that the Timezone settings in Clockify are the same as in solidtime.',
'<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.',
],
'clockify_projects' => [
'name' => 'Clockify Projects',
'description' => 'Go to PROJECTS in the navigation on the left. '.
'Now click on the three dots on the right of the project that you want to export and select Export. '.
'Now click Export -> Save as CSV. The Export dropdown is in the header of the export table in the top right corner.',
'description' => '1. Go to PROJECTS in the navigation on the left.<br> '.
'2. Now click on the three dots on the right of the project that you want to export and select Export.<br> '.
'3. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table in the top right corner.',
],
'toggl_data_importer' => [
'name' => 'Toggl Data Importer',
'description' => 'Go to Admin -> Settings -> Data export. '.
'Under "Data Export" select all items for export and click on "Export to email". '.
'You will receive an email with a download link. Download the ZIP and upload it here. '.
'The "Data Export" exports everything except time entries. '.
'description' => '1. Go to Admin -> Settings -> Data export. <br>'.
'2. Under "Data Export" select all items for export and click on "Export to email". <br> '.
'3. You will receive an email with a download link. Download the ZIP and upload it here. '.
'<br><br>The "Data Export" exports everything except time entries. '.
'If you want to also import time entries use the "Toggl Time Entries" importer afterwards.',
],
'toggl_time_entries' => [
'name' => 'Toggl Time Entries',
'description' => 'Important: If you want to import a Toggl organization use the "Toggl Data Importer" before using this importer, since this export contains more details. '.
'Go to Admin -> Settings -> Data export. Under "Time entries" select the year you want to export and click on "Export time entries". You can export all years one after another and import them one after another. '.
'Before you import make sure that the Timezone settings in Toggl are the same as in solidtime.',
'description' => '<strong>Important:</strong> If you want to import a Toggl organization use the "Toggl Data Importer" before using this importer, since this export contains more details. '.
'<br><br>1. Go to Admin -> Settings -> Data export. <br>2. Under "Time entries" select the year you want to export and click on "Export time entries". <br><br>You can export all years one after another and import them one after another. '.
' <br>Before you import make sure that the Timezone settings in Toggl are the same as in solidtime.',
],
];

View File

@@ -52,11 +52,11 @@ const OrganizationResource = z
.object({
id: z.string(),
name: z.string(),
is_personal: z.string(),
is_personal: z.boolean(),
billable_rate: z.union([z.number(), z.null()]),
})
.passthrough();
const v1_organizations_update_Body = z
const updateOrganization_Body = z
.object({
name: z.string(),
billable_rate: z.union([z.number(), z.null()]).optional(),
@@ -69,12 +69,14 @@ const ProjectResource = z
color: z.string(),
client_id: z.union([z.string(), z.null()]),
billable_rate: z.union([z.number(), z.null()]),
is_billable: z.boolean(),
})
.passthrough();
const createProject_Body = z
.object({
name: z.string(),
color: z.string(),
is_billable: z.boolean(),
billable_rate: z.union([z.number(), z.null()]).optional(),
client_id: z.union([z.string(), z.null()]).optional(),
})
@@ -165,15 +167,16 @@ const v1_time_entries_update_multiple_Body = z
.passthrough();
const updateTimeEntry_Body = z
.object({
member_id: z.string().uuid().optional(),
project_id: z.union([z.string(), z.null()]).optional(),
task_id: z.union([z.string(), z.null()]).optional(),
member_id: z.string().uuid(),
project_id: z.union([z.string(), z.null()]),
task_id: z.union([z.string(), z.null()]),
start: z.string(),
end: z.union([z.string(), z.null()]).optional(),
billable: z.boolean().optional(),
description: z.union([z.string(), z.null()]).optional(),
tags: z.union([z.array(z.string()), z.null()]).optional(),
end: z.union([z.string(), z.null()]),
billable: z.boolean(),
description: z.union([z.string(), z.null()]),
tags: z.union([z.array(z.string()), z.null()]),
})
.partial()
.passthrough();
export const schemas = {
@@ -187,7 +190,7 @@ export const schemas = {
updateMember_Body,
MemberResource,
OrganizationResource,
v1_organizations_update_Body,
updateOrganization_Body,
ProjectResource,
createProject_Body,
ProjectMemberResource,
@@ -209,7 +212,7 @@ const endpoints = makeApi([
{
method: 'get',
path: '/v1/organizations/:organization',
alias: 'v1.organizations.show',
alias: 'getOrganization',
requestFormat: 'json',
parameters: [
{
@@ -235,13 +238,13 @@ const endpoints = makeApi([
{
method: 'put',
path: '/v1/organizations/:organization',
alias: 'v1.organizations.update',
alias: 'updateOrganization',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: v1_organizations_update_Body,
schema: updateOrganization_Body,
},
{
name: 'organization',
@@ -2114,6 +2117,11 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'client_ids',
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'tag_ids',
type: 'Query',

View File

@@ -10,7 +10,7 @@ export const test = baseTest.extend<object, { workerStorageState: string }>({
await page.getByLabel('Name').fill('John Doe');
await page
.getByLabel('Email')
.fill(`john+${Math.round(Math.random() * 10000)}@doe.com`);
.fill(`john+${Math.round(Math.random() * 1000000)}@doe.com`);
await page
.getByLabel('Password', { exact: true })
.fill('amazingpassword123');

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -4,9 +4,10 @@
:root {
--color-bg-primary: #0f1011;
--color-bg-secondary: #1b1c20;
--color-bg-secondary: #17181a;
--color-bg-tertiary: #2A2C32;
--color-bg-quaternary: #141518;
--color-bg-background: #080808;
--color-text-primary: #ffffff;
--color-text-secondary: #e3e4e6;
--color-text-tertiary: #969799;
@@ -27,7 +28,7 @@
--theme-color-icon-active: rgb(var(--color-text-tertiary));
--theme-color-card-background: var(--color-bg-secondary);
--theme-color-card-background-active: var(--color-bg-tertiary);
--theme-color-card-background-separator: var(--color-border-quaternary);
--theme-color-card-background-separator: var(--color-border-tertiary);
--theme-color-card-border: var(--color-border-secondary);
--theme-color-card-border-active: var(--color-border-tertiary);
--theme-color-default-background-separator: var(--color-border-primary);
@@ -50,6 +51,27 @@
-moz-osx-font-smoothing: grayscale;
}
/* width */
::-webkit-scrollbar {
width: 5px;
}
/* Track */
::-webkit-scrollbar-track {
background: transparent;
}
/* Handle */
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 2px;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: #555;
}
[x-cloak] {
display: none;
}
@@ -60,5 +82,26 @@ body {
@font-face {
font-family: 'Outfit';
src: url('/fonts/Outfit-VariableFont_wght.ttf');
src: url('/fonts/Outfit-Regular.ttf');
font-weight: 400;
}
@font-face {
font-family: 'Outfit';
src: url('/fonts/Outfit-Medium.ttf');
font-weight: 500;
}
@font-face {
font-family: 'Outfit';
src: url('/fonts/Outfit-SemiBold.ttf');
font-weight: 600;
}
@font-face {
font-family: 'Outfit';
src: url('/fonts/Outfit-Bold.ttf');
font-weight: 700;
}
@font-face {
font-family: 'Outfit';
src: url('/fonts/Outfit-ExtraBold.ttf');
font-weight: 800;
}

View File

@@ -5,16 +5,21 @@ import {
getOrganizationCurrencyString,
getOrganizationCurrencySymbol,
} from '../../utils/money';
import { ref, watch } from 'vue';
import { useFocus } from '@vueuse/core';
defineProps<{
const props = defineProps<{
name: string;
focus?: boolean;
}>();
const model = defineModel({
const model = defineModel<number | null>({
default: null,
type: Number,
});
const billableRateInput = ref<HTMLInputElement | null>(null);
useFocus(billableRateInput, { initialValue: props.focus });
function cleanUpDecimalValue(value: string) {
value = value.replace(/,/g, '');
value = value.replace(getOrganizationCurrencySymbol(), '');
@@ -38,24 +43,39 @@ function updateRate(value: string) {
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
model.value = parseInt(cleanUpDecimalValue(value)) * 100;
const parsedValue = parseInt(cleanUpDecimalValue(value)) * 100;
if (parsedValue) {
model.value = parsedValue;
} else {
model.value = 0;
}
}
inputValue.value = formatValue(model.value);
}
function formatValue(modelValue: number) {
const formattedValue = formatCents(modelValue);
function formatValue(modelValue: number | null) {
const formattedValue = formatCents(modelValue ?? 0);
return formattedValue.replace(getOrganizationCurrencySymbol(), '').trim();
}
watch(model, (newValue) => {
inputValue.value = formatValue(newValue);
});
const inputValue = ref(formatValue(model.value));
</script>
<template>
<div class="relative">
<TextInput
:id="name"
ref="projectMemberRateInput"
:modelValue="formatValue(model)"
ref="billableRateInput"
v-model="inputValue"
@blur="updateRate($event.target.value)"
@keydown.enter="updateRate($event.target.value)"
type="text"
:name="name"
placeholder="Billable Rate"

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import TextInput from '@/Components/TextInput.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DialogModal from '@/Components/DialogModal.vue';
import { ref } from 'vue';
import type { Client, UpdateClientBody } from '@/utils/api';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import { useFocus } from '@vueuse/core';
import { useClientsStore } from '@/utils/useClients';
const { updateClient } = useClientsStore();
const show = defineModel('show', { default: false });
const saving = ref(false);
const props = defineProps<{
client: Client;
}>();
const clientBody = ref<UpdateClientBody>({
name: props.client.name,
});
async function submit() {
await updateClient(props.client.id, clientBody.value);
show.value = false;
}
const clientNameInput = ref<HTMLInputElement | null>(null);
useFocus(clientNameInput, { initialValue: true });
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Update Client </span>
</div>
</template>
<template #content>
<div class="flex items-center space-x-4">
<div class="col-span-6 sm:col-span-4 flex-1">
<TextInput
id="clientName"
ref="clientNameInput"
v-model="clientBody.name"
type="text"
placeholder="Client Name"
@keydown.enter="submit"
class="mt-1 block w-full"
required
autocomplete="clientName" />
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel </SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="submit">
Update Client
</PrimaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

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