mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8982bfac2b | ||
|
|
9ac1d19722 | ||
|
|
843e16c4c0 | ||
|
|
9a920bd4e9 | ||
|
|
bb8c944df5 | ||
|
|
e4c1363193 | ||
|
|
bd9cede081 | ||
|
|
92dde6a701 | ||
|
|
91cb6ab087 | ||
|
|
fadcd042c0 | ||
|
|
0eef5ffcfa | ||
|
|
90480f3bb8 | ||
|
|
86f5ea47bb | ||
|
|
8857befc6c | ||
|
|
f40ae91444 | ||
|
|
94940be02c | ||
|
|
f2f128e184 | ||
|
|
ffea3c6b68 | ||
|
|
1fdbfe77f0 | ||
|
|
7fb58ea341 | ||
|
|
d9244d1ab4 | ||
|
|
b0cdeb3e33 | ||
|
|
86555664c5 | ||
|
|
20f9b344f6 | ||
|
|
802d9558a3 | ||
|
|
474c0de3ac | ||
|
|
b1795392ad | ||
|
|
2692db2a86 | ||
|
|
ded58f8bd6 | ||
|
|
81e3ffd921 | ||
|
|
22363e1c89 | ||
|
|
d28269ebb0 | ||
|
|
3fc9d8b381 | ||
|
|
5bfd9e7dce | ||
|
|
ee6999af90 | ||
|
|
22420439d9 | ||
|
|
a065744d40 | ||
|
|
4a7db27a05 | ||
|
|
bae4265f70 | ||
|
|
a64ee87d19 | ||
|
|
c9311780ed | ||
|
|
4943baa236 | ||
|
|
4c977b5bf8 | ||
|
|
4e439010d1 |
@@ -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
|
||||
|
||||
2
.github/workflows/build-private.yml
vendored
2
.github/workflows/build-private.yml
vendored
@@ -3,6 +3,8 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
tags:
|
||||
- '*'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build-private.yml'
|
||||
|
||||
2
.github/workflows/build-public.yml
vendored
2
.github/workflows/build-public.yml
vendored
@@ -3,6 +3,8 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
tags:
|
||||
- '*'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build-public.yml'
|
||||
|
||||
2
.github/workflows/phpunit.yml
vendored
2
.github/workflows/phpunit.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -33,3 +33,4 @@ yarn-error.log
|
||||
/k8s
|
||||
/_ide_helper.php
|
||||
/.phpstorm.meta.php
|
||||
/.rnd
|
||||
|
||||
@@ -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'])) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
28
app/Actions/Jetstream/ValidateOrganizationDeletion.php
Normal file
28
app/Actions/Jetstream/ValidateOrganizationDeletion.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
59
app/Console/Commands/Admin/DeleteOrganizationCommand.php
Normal file
59
app/Console/Commands/Admin/DeleteOrganizationCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
20
app/Events/BeforeOrganizationDeletion.php
Normal file
20
app/Events/BeforeOrganizationDeletion.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
91
app/Filament/Resources/ProjectMemberResource.php
Normal file
91
app/Filament/Resources/ProjectMemberResource.php
Normal 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}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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([
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
46
app/Filament/Resources/UserResource/Actions/DeleteUser.php
Normal file
46
app/Filament/Resources/UserResource/Actions/DeleteUser.php
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
77
app/Filament/Widgets/TimeEntriesImported.php
Normal file
77
app/Filament/Widgets/TimeEntriesImported.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ class UserRegistrations extends ChartWidget
|
||||
|
||||
public ?string $filter = 'week';
|
||||
|
||||
protected static ?int $sort = 2;
|
||||
|
||||
protected function getData(): array
|
||||
{
|
||||
$filter = $this->filter;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
163
app/Service/DeletionService.php
Normal file
163
app/Service/DeletionService.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
492
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -144,6 +144,11 @@ return [
|
||||
'emergency' => [
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
],
|
||||
|
||||
'deprecation' => [
|
||||
'driver' => 'single',
|
||||
'path' => storage_path('logs/deprecation.log'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -185,7 +185,7 @@ return [
|
||||
'watch' => [
|
||||
'app',
|
||||
'bootstrap',
|
||||
'config',
|
||||
'config/**/*.php',
|
||||
'database/**/*.php',
|
||||
'public/**/*.php',
|
||||
'resources/**/*.php',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
1137
database/schema/pgsql-schema.sql
Normal file
1137
database/schema/pgsql-schema.sql
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
@@ -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}'
|
||||
|
||||
@@ -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
1
e2e/members.spec.ts
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: Edit Billable Rate
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
];
|
||||
|
||||
@@ -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.',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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 `null` 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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
BIN
public/fonts/Outfit-Bold.ttf
Normal file
BIN
public/fonts/Outfit-Bold.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Outfit-ExtraBold.ttf
Normal file
BIN
public/fonts/Outfit-ExtraBold.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Outfit-Medium.ttf
Normal file
BIN
public/fonts/Outfit-Medium.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Outfit-Regular.ttf
Normal file
BIN
public/fonts/Outfit-Regular.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Outfit-SemiBold.ttf
Normal file
BIN
public/fonts/Outfit-SemiBold.ttf
Normal file
Binary file not shown.
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
70
resources/js/Components/Common/Client/ClientEditModal.vue
Normal file
70
resources/js/Components/Common/Client/ClientEditModal.vue
Normal 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
Reference in New Issue
Block a user