Compare commits

..

2 Commits

Author SHA1 Message Date
Constantin Graf
ccc07c4235 Enhanced description for Clockify imports 2024-12-20 19:46:41 -05:00
Constantin Graf
453dbaac9e Fixed timezones in unit tests 2024-12-20 19:39:38 -05:00
219 changed files with 4575 additions and 6146 deletions

View File

@@ -4,7 +4,6 @@ APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_URL=http://localhost APP_URL=http://localhost
APP_FORCE_HTTPS=false APP_FORCE_HTTPS=false
APP_ENABLE_REGISTRATION=true
SESSION_SECURE_COOKIE=false SESSION_SECURE_COOKIE=false
# Logging # Logging

View File

@@ -1,13 +1,10 @@
# Application
APP_NAME=solidtime APP_NAME=solidtime
APP_ENV=local APP_ENV=local
APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk= APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
APP_DEBUG=true APP_DEBUG=true
APP_URL=https://solidtime.test APP_URL=https://solidtime.test
AUDITING_ENABLED=true AUDITING_ENABLED=true
APP_ENABLE_REGISTRATION=true
SUPER_ADMINS=admin@example.com SUPER_ADMINS=admin@example.com
PAGINATION_PER_PAGE_DEFAULT=500
# Logging # Logging
LOG_CHANNEL=single LOG_CHANNEL=single
@@ -28,16 +25,9 @@ DB_TEST_DATABASE=laravel
DB_TEST_USERNAME=root DB_TEST_USERNAME=root
DB_TEST_PASSWORD=root DB_TEST_PASSWORD=root
# Broadcasting BROADCAST_DRIVER=log
BROADCAST_DRIVER=null
# Cache
CACHE_DRIVER=file CACHE_DRIVER=file
# Queue
QUEUE_CONNECTION=sync QUEUE_CONNECTION=sync
# Session
SESSION_DRIVER=database SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
@@ -51,6 +41,14 @@ MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="no-reply@solidtime.test" MAIL_FROM_ADDRESS="no-reply@solidtime.test"
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_HOST=
PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=mt1
# Filesystems # Filesystems
FILESYSTEM_DISK=s3 FILESYSTEM_DISK=s3
PUBLIC_FILESYSTEM_DISK=s3 PUBLIC_FILESYSTEM_DISK=s3
@@ -67,9 +65,16 @@ GOTENBERG_URL=http://gotenberg:3000
VITE_HOST_NAME=vite.solidtime.test VITE_HOST_NAME=vite.solidtime.test
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
VITE_PUSHER_HOST="${PUSHER_HOST}"
VITE_PUSHER_PORT="${PUSHER_PORT}"
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
# Local setup
NGINX_HOST_NAME=solidtime.test NGINX_HOST_NAME=solidtime.test
NETWORK_NAME=reverse-proxy-docker-traefik_routing NETWORK_NAME=reverse-proxy-docker-traefik_routing
FORWARD_DB_PORT=5432 FORWARD_DB_PORT=5432
FORWARD_WEB_PORT=8083 FORWARD_WEB_PORT=8083
PAGINATION_PER_PAGE_DEFAULT=500

13
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,13 @@
/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution")
module.exports = {
extends: ['plugin:vue/vue3-essential', '@vue/eslint-config-typescript/recommended', '@vue/eslint-config-prettier'],
rules: {
'vue/multi-word-component-names': 'off',
"@typescript-eslint/no-unused-vars": "off",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": "error",
},
plugins: ['unused-imports'],
}

View File

@@ -14,7 +14,7 @@ on:
name: Build - Public name: Build - Public
jobs: jobs:
build: build:
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
permissions: permissions:
packages: write packages: write
contents: read contents: read

View File

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

View File

@@ -10,6 +10,6 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: "Check code style" - name: "Check code style"
uses: aglipanci/laravel-pint-action@2.5 uses: aglipanci/laravel-pint-action@2.4
with: with:
configPath: "pint.json" configPath: "pint.json"

View File

@@ -4,14 +4,16 @@ declare(strict_types=1);
namespace App\Actions\Fortify; namespace App\Actions\Fortify;
use App\Enums\Role;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Events\NewsletterRegistered; use App\Events\NewsletterRegistered;
use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Service\IpLookup\IpLookupServiceContract; use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use App\Service\UserService;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
@@ -32,12 +34,6 @@ class CreateNewUser implements CreatesNewUsers
*/ */
public function create(array $input): User public function create(array $input): User
{ {
if (! config('app.enable_registration')) {
throw ValidationException::withMessages([
'email' => [__('Registration is disabled.')],
]);
}
Validator::make($input, [ Validator::make($input, [
'name' => [ 'name' => [
'required', 'required',
@@ -85,16 +81,30 @@ class CreateNewUser implements CreatesNewUsers
$currency = $ipLookupResponse->currency; $currency = $ipLookupResponse->currency;
} }
$user = null; $user = null;
DB::transaction(function () use (&$user, $input, $timezone, $startOfWeek, $currency): void { $organization = null;
$userService = app(UserService::class); DB::transaction(function () use (&$user, &$organization, $input, $timezone, $startOfWeek, $currency): void {
$user = $userService->createUser( $user = User::create([
$input['name'], 'name' => $input['name'],
$input['email'], 'email' => $input['email'],
$input['password'], 'password' => Hash::make($input['password']),
$timezone ?? 'UTC', 'timezone' => $timezone ?? 'UTC',
$startOfWeek, 'week_start' => $startOfWeek,
$currency ?? 'EUR', ]);
$organization = new Organization;
$organization->name = explode(' ', $user->name, 2)[0]."'s Organization";
$organization->personal_team = true;
$organization->currency = $currency ?? 'EUR';
$organization->owner()->associate($user);
$organization->save();
$organization->users()->attach(
$user, [
'role' => Role::Owner->value,
]
); );
$user->ownedTeams()->save($organization);
}); });
$newsletterConsent = isset($input['newsletter_consent']) && (bool) $input['newsletter_consent']; $newsletterConsent = isset($input['newsletter_consent']) && (bool) $input['newsletter_consent'];

View File

@@ -7,16 +7,18 @@ namespace App\Actions\Jetstream;
use App\Enums\Role; use App\Enums\Role;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Service\MemberService;
use Closure; use Closure;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In; use Illuminate\Validation\Rules\In;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Laravel\Jetstream\Contracts\AddsTeamMembers; use Laravel\Jetstream\Contracts\AddsTeamMembers;
use Laravel\Jetstream\Events\AddingTeamMember;
use Laravel\Jetstream\Events\TeamMemberAdded;
class AddOrganizationMember implements AddsTeamMembers class AddOrganizationMember implements AddsTeamMembers
{ {
@@ -34,7 +36,15 @@ class AddOrganizationMember implements AddsTeamMembers
->where('is_placeholder', '=', false) ->where('is_placeholder', '=', false)
->firstOrFail(); ->firstOrFail();
app(MemberService::class)->addMember($newOrganizationMember, $organization, Role::from($role)); AddingTeamMember::dispatch($organization, $newOrganizationMember);
DB::transaction(function () use ($organization, $newOrganizationMember, $role): void {
$organization->users()->attach(
$newOrganizationMember, ['role' => $role]
);
});
TeamMemberAdded::dispatch($organization, $newOrganizationMember);
} }
/** /**

View File

@@ -1,92 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Admin;
use App\Enums\Weekday;
use App\Models\Organization;
use App\Models\User;
use App\Service\UserService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use LogicException;
class UserCreateCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'admin:user:create
{ name : The name of the user }
{ email : The email of the user }
{ --ask-for-password : Ask for the password, otherwise the command will generate a random one }
{ --verify-email : Verify the email address of the user }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Create a new user';
/**
* Execute the console command.
*/
public function handle(): int
{
$name = $this->argument('name');
$email = $this->argument('email');
$askForPassword = (bool) $this->option('ask-for-password');
$verifyEmail = (bool) $this->option('verify-email');
if (User::query()->where('email', $email)->where('is_placeholder', '=', false)->exists()) {
$this->error('User with email "'.$email.'" already exists.');
return self::FAILURE;
}
if ($askForPassword) {
$outputPassword = false;
$password = $this->secret('Enter the password');
} else {
$outputPassword = true;
$password = bin2hex(random_bytes(16));
}
$user = null;
DB::transaction(function () use (&$user, $name, $email, $password, $verifyEmail): void {
$user = app(UserService::class)->createUser(
$name,
$email,
$password,
'UTC',
Weekday::Monday,
'EUR',
$verifyEmail
);
});
/** @var Organization|null $organization */
$organization = $user->ownedTeams->first();
if ($organization === null) {
throw new LogicException('User does not have an organization');
}
$this->info('Created user "'.$name.'" ("'.$email.'")');
$this->line('ID: '.$user->getKey());
$this->line('Name: '.$name);
$this->line('Email: '.$email);
if ($outputPassword) {
$this->line('Password: '.$password);
}
$this->line('Timezone: '.$user->timezone);
$this->line('Week start: '.$user->week_start->value);
// Organization
$this->line('Currency: '.$organization->currency);
return self::SUCCESS;
}
}

View File

@@ -35,9 +35,7 @@ class UserVerifyCommand extends Command
$this->info('Start verifying user with email "'.$email.'"'); $this->info('Start verifying user with email "'.$email.'"');
/** @var User|null $user */ /** @var User|null $user */
$user = User::query()->where('email', $email) $user = User::where('email', $email)->first();
->where('is_placeholder', '=', false)
->first();
if ($user === null) { if ($user === null) {
$this->error('User with email "'.$email.'" not found.'); $this->error('User with email "'.$email.'" not found.');

View File

@@ -60,13 +60,8 @@ class ClientResource extends Resource
->defaultSort('created_at', 'desc') ->defaultSort('created_at', 'desc')
->filters([ ->filters([
SelectFilter::make('organization') SelectFilter::make('organization')
->label('Organization')
->relationship('organization', 'name') ->relationship('organization', 'name')
->searchable(), ->searchable(),
SelectFilter::make('organization_id')
->label('Organization ID')
->relationship('organization', 'id')
->searchable(),
]) ])
->actions([ ->actions([
Tables\Actions\EditAction::make(), Tables\Actions\EditAction::make(),

View File

@@ -15,8 +15,7 @@ class EditClient extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make() Actions\DeleteAction::make(),
->icon('heroicon-m-trash'),
]; ];
} }
} }

View File

@@ -15,8 +15,7 @@ class ListClients extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make() Actions\CreateAction::make(),
->icon('heroicon-s-plus'),
]; ];
} }
} }

View File

@@ -1,114 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Enums\Role;
use App\Filament\Resources\OrganizationInvitationResource\Pages;
use App\Models\OrganizationInvitation;
use App\Service\OrganizationInvitationService;
use Filament\Forms;
use Filament\Forms\Components\Select;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Collection;
class OrganizationInvitationResource extends Resource
{
protected static ?string $model = OrganizationInvitation::class;
protected static ?string $label = 'Invitations';
protected static ?string $navigationIcon = 'heroicon-o-user-plus';
protected static ?string $navigationGroup = 'Users';
protected static ?int $navigationSort = 9;
public static function form(Form $form): Form
{
return $form
->columns(1)
->schema([
Forms\Components\TextInput::make('email')
->label('Email')
->disabledOn(['edit'])
->required(),
Select::make('role')
->options(Role::class),
Forms\Components\Select::make('organization_id')
->label('Organization')
->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name'])
->disabledOn(['edit'])
->required(),
Forms\Components\DateTimePicker::make('created_at')
->label('Created At')
->hiddenOn(['create'])
->disabled(),
Forms\Components\DateTimePicker::make('updated_at')
->label('Updated At')
->hiddenOn(['create'])
->disabled(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('organization.name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('email')
->sortable(),
Tables\Columns\TextColumn::make('role'),
Tables\Columns\TextColumn::make('created_at')
->label('Created At')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
->label('Updated At')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('created_at', 'desc')
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\BulkAction::make('resend')
->label('Resend')
->action(function (Collection $records): void {
foreach ($records as $organizationInvite) {
app(OrganizationInvitationService::class)->resend($organizationInvite);
}
}),
]),
]);
}
public static function getRelations(): array
{
return [
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListOrganizationInvitations::route('/'),
'edit' => Pages\EditOrganizationInvitation::route('/{record}/edit'),
'view' => Pages\ViewOrganizationInvitation::route('/{record}'),
];
}
}

View File

@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\OrganizationInvitationResource\Pages;
use App\Filament\Resources\OrganizationInvitationResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditOrganizationInvitation extends EditRecord
{
protected static string $resource = OrganizationInvitationResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
];
}
}

View File

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

View File

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

View File

@@ -5,10 +5,8 @@ declare(strict_types=1);
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Resources\OrganizationResource\Pages; use App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource\RelationManagers\InvitationsRelationManager;
use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager; use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager;
use App\Models\Organization; use App\Models\Organization;
use App\Service\DeletionService;
use App\Service\Export\ExportService; use App\Service\Export\ExportService;
use App\Service\Import\Importers\ImporterProvider; use App\Service\Import\Importers\ImporterProvider;
use App\Service\Import\Importers\ImportException; use App\Service\Import\Importers\ImportException;
@@ -48,13 +46,10 @@ class OrganizationResource extends Resource
->maxLength(255), ->maxLength(255),
Forms\Components\Toggle::make('personal_team') Forms\Components\Toggle::make('personal_team')
->label('Is personal?') ->label('Is personal?')
->hiddenOn(['create'])
->required(), ->required(),
Forms\Components\Select::make('user_id') Forms\Components\Select::make('user_id')
->label('Owner')
->relationship(name: 'owner', titleAttribute: 'email') ->relationship(name: 'owner', titleAttribute: 'email')
->searchable(['name', 'email']) ->searchable(['name', 'email'])
->disabledOn(['edit'])
->required(), ->required(),
Forms\Components\Select::make('currency') Forms\Components\Select::make('currency')
->label('Currency') ->label('Currency')
@@ -67,7 +62,6 @@ class OrganizationResource extends Resource
return $select; return $select;
}) })
->required()
->searchable(), ->searchable(),
Forms\Components\TextInput::make('billable_rate') Forms\Components\TextInput::make('billable_rate')
->label('Billable rate (in Cents)') ->label('Billable rate (in Cents)')
@@ -81,11 +75,9 @@ class OrganizationResource extends Resource
->numeric(), ->numeric(),
Forms\Components\DateTimePicker::make('created_at') Forms\Components\DateTimePicker::make('created_at')
->label('Created At') ->label('Created At')
->hiddenOn(['create'])
->disabled(), ->disabled(),
Forms\Components\DateTimePicker::make('updated_at') Forms\Components\DateTimePicker::make('updated_at')
->label('Updated At') ->label('Updated At')
->hiddenOn(['create'])
->disabled(), ->disabled(),
]); ]);
} }
@@ -105,7 +97,7 @@ class OrganizationResource extends Resource
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('currency'), Tables\Columns\TextColumn::make('currency'),
TextColumn::make('billable_rate') TextColumn::make('billable_rate')
->money(fn (Organization $resource) => $resource->currency, divideBy: 100), ->money(fn (Organization $resource) => $resource->currency ?? 'EUR', divideBy: 100),
Tables\Columns\TextColumn::make('created_at') Tables\Columns\TextColumn::make('created_at')
->dateTime() ->dateTime()
->sortable(), ->sortable(),
@@ -120,10 +112,6 @@ class OrganizationResource extends Resource
]) ])
->actions([ ->actions([
Tables\Actions\EditAction::make(), Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make()
->using(function (Organization $record): void {
app(DeletionService::class)->deleteOrganization($record);
}),
Action::make('Export') Action::make('Export')
->icon('heroicon-o-arrow-down-tray') ->icon('heroicon-o-arrow-down-tray')
->action(function (Organization $record) { ->action(function (Organization $record) {
@@ -211,6 +199,8 @@ class OrganizationResource extends Resource
]), ]),
]) ])
->bulkActions([ ->bulkActions([
Tables\Actions\BulkActionGroup::make([
]),
]); ]);
} }
@@ -218,7 +208,6 @@ class OrganizationResource extends Resource
{ {
return [ return [
UsersRelationManager::class, UsersRelationManager::class,
InvitationsRelationManager::class,
]; ];
} }

View File

@@ -15,6 +15,7 @@ class DeleteOrganization extends DeleteAction
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
// TODO: check why setting the icon is necessary
$this->icon('heroicon-m-trash'); $this->icon('heroicon-m-trash');
$this->action(function (): void { $this->action(function (): void {
$result = $this->process(function (Organization $record): bool { $result = $this->process(function (Organization $record): bool {

View File

@@ -4,33 +4,10 @@ declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\Pages; namespace App\Filament\Resources\OrganizationResource\Pages;
use App\Enums\Role;
use App\Filament\Resources\OrganizationResource; use App\Filament\Resources\OrganizationResource;
use App\Models\Organization;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
class CreateOrganization extends CreateRecord class CreateOrganization extends CreateRecord
{ {
protected static string $resource = OrganizationResource::class; protected static string $resource = OrganizationResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['personal_team'] = false;
return $data;
}
protected function afterCreate(): void
{
/** @var Organization $organization */
$organization = $this->record;
$user = $organization->owner;
$organization->users()->attach(
$user, [
'role' => Role::Owner->value,
]
);
}
} }

View File

@@ -15,8 +15,7 @@ class ListOrganizations extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make() Actions\CreateAction::make(),
->icon('heroicon-s-plus'),
]; ];
} }
} }

View File

@@ -1,86 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\RelationManagers;
use App\Enums\Role;
use App\Filament\Resources\OrganizationInvitationResource;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Service\InvitationService;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
class InvitationsRelationManager extends RelationManager
{
protected static string $relationship = 'teamInvitations';
protected static ?string $title = 'Invitations';
public function form(Form $form): Form
{
return $form
->schema([
TextInput::make('email')
->label('Email')
->disabledOn(['edit'])
->required(),
Select::make('role')
->options(Role::class)
->label('Role')
->rules([
'required',
'string',
Rule::enum(Role::class)
->except([Role::Owner, Role::Placeholder]),
])
->required(),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('email')
->modelLabel('Invitation')
->pluralModelLabel('Invitations')
->columns([
Tables\Columns\TextColumn::make('email'),
Tables\Columns\TextColumn::make('role'),
])
->headerActions([
Tables\Actions\CreateAction::make()
->icon('heroicon-s-plus')
->using(function (array $data, string $model): Model {
/** @var Organization $ownerRecord */
$ownerRecord = $this->getOwnerRecord();
return app(InvitationService::class)
->inviteUser($ownerRecord, $data['email'], Role::from($data['role']));
}),
])
->actions([
Action::make('view')
->icon('heroicon-o-eye')
->color('gray')
->url(fn (OrganizationInvitation $record): string => OrganizationInvitationResource::getUrl('view', [
'record' => $record->getKey(),
])),
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DetachBulkAction::make(),
]),
]);
}
}

View File

@@ -5,24 +5,17 @@ declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\RelationManagers; namespace App\Filament\Resources\OrganizationResource\RelationManagers;
use App\Enums\Role; use App\Enums\Role;
use App\Exceptions\Api\ApiException;
use App\Filament\Resources\UserResource; use App\Filament\Resources\UserResource;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Service\BillableRateService;
use App\Service\MemberService;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Actions\Action; use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\AttachAction; use Filament\Tables\Actions\AttachAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Validation\Rule;
class UsersRelationManager extends RelationManager class UsersRelationManager extends RelationManager
{ {
@@ -43,40 +36,20 @@ class UsersRelationManager extends RelationManager
public function table(Table $table): Table public function table(Table $table): Table
{ {
/** @var Organization $organization */
$organization = $this->getOwnerRecord();
return $table return $table
->recordTitleAttribute('name') ->recordTitleAttribute('name')
->columns([ ->columns([
Tables\Columns\TextColumn::make('name'), Tables\Columns\TextColumn::make('name'),
Tables\Columns\TextColumn::make('role'), Tables\Columns\TextColumn::make('role'),
TextColumn::make('billable_rate') TextColumn::make('billable_rate')
->money($organization->currency, divideBy: 100), ->money($this->getOwnerRecord()->currency ?? 'EUR', divideBy: 100),
]) ])
->headerActions([ ->headerActions([
Tables\Actions\AttachAction::make() Tables\Actions\AttachAction::make()->form(fn (AttachAction $action): array => [
->recordTitle(fn (User $record): string => "{$record->name} ({$record->email})") $action->getRecordSelect(),
->form(fn (AttachAction $action): array => [ Select::make('role')
$action->getRecordSelect(), ->options(Role::class),
Select::make('role') ]),
->required()
->options(Role::class)
->rule([
'required',
'string',
Rule::enum(Role::class)
->except([Role::Owner, Role::Placeholder]),
]),
])
->label('Add user')
->modalHeading('Add user')
->icon('heroicon-s-plus')
->using(function (User $record, array $data): void {
/** @var Organization $organization */
$organization = $this->getOwnerRecord();
app(MemberService::class)->addMember($record, $organization, Role::from($data['role']), true);
}),
]) ])
->actions([ ->actions([
Action::make('view') Action::make('view')
@@ -85,55 +58,13 @@ class UsersRelationManager extends RelationManager
->url(fn (User $record): string => UserResource::getUrl('view', [ ->url(fn (User $record): string => UserResource::getUrl('view', [
'record' => $record->getKey(), 'record' => $record->getKey(),
])), ])),
Tables\Actions\EditAction::make() Tables\Actions\EditAction::make(),
->using(function (User $record, array $data): User { Tables\Actions\DetachAction::make(),
/** @var Organization $organization */
$organization = $this->getOwnerRecord();
/** @var Member $member */
$member = $record->getRelation('membership');
if ($data['billable_rate'] !== $member->billable_rate) {
$member->billable_rate = $data['billable_rate'];
app(BillableRateService::class)->updateTimeEntriesBillableRateForMember($member);
}
if ($data['role'] !== $member->role) {
try {
app(MemberService::class)->changeRole($member, $organization, Role::from($data['role']), true);
} catch (ApiException $exception) {
Notification::make()
->danger()
->title('Update failed')
->body($exception->getTranslatedMessage())
->persistent()
->send();
}
}
$member->save();
return $record;
}),
Tables\Actions\DetachAction::make()
->using(function (User $record): void {
/** @var Organization $organization */
$organization = $this->getOwnerRecord();
$member = Member::query()
->whereBelongsTo($record, 'user')
->whereBelongsTo($organization, 'organization')
->firstOrFail();
try {
app(MemberService::class)->removeMember($member, $organization);
} catch (ApiException $exception) {
Notification::make()
->danger()
->title('Delete failed')
->body($exception->getTranslatedMessage())
->persistent()
->send();
}
}),
]) ])
->bulkActions([ ->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DetachBulkAction::make(),
]),
]); ]);
} }
} }

View File

@@ -15,8 +15,7 @@ class EditProjectMember extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make() Actions\DeleteAction::make(),
->icon('heroicon-m-trash'),
]; ];
} }
} }

View File

@@ -15,8 +15,7 @@ class ListProjectMembers extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make() Actions\CreateAction::make(),
->icon('heroicon-s-plus'),
]; ];
} }
} }

View File

@@ -72,13 +72,8 @@ class ProjectResource extends Resource
]) ])
->filters([ ->filters([
SelectFilter::make('organization') SelectFilter::make('organization')
->label('Organization')
->relationship('organization', 'name') ->relationship('organization', 'name')
->searchable(), ->searchable(),
SelectFilter::make('organization_id')
->label('Organization ID')
->relationship('organization', 'id')
->searchable(),
]) ])
->defaultSort('created_at', 'desc') ->defaultSort('created_at', 'desc')
->actions([ ->actions([

View File

@@ -15,8 +15,7 @@ class EditProject extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make() Actions\DeleteAction::make(),
->icon('heroicon-m-trash'),
]; ];
} }
} }

View File

@@ -15,8 +15,7 @@ class ListProjects extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make() Actions\CreateAction::make(),
->icon('heroicon-s-plus'),
]; ];
} }
} }

View File

@@ -1,141 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\ReportResource\Pages;
use App\Models\Report;
use App\Service\Dto\ReportPropertiesDto;
use Filament\Forms;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Novadaemon\FilamentPrettyJson\PrettyJson;
class ReportResource extends Resource
{
protected static ?string $model = Report::class;
protected static ?string $navigationIcon = 'heroicon-o-document-chart-bar';
protected static ?string $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 7;
public static function form(Form $form): Form
{
return $form
->columns(1)
->schema([
Forms\Components\TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
Forms\Components\TextInput::make('description')
->label('Description')
->nullable()
->maxLength(255),
Toggle::make('is_public')
->label('Is public?')
->required(),
DateTimePicker::make('public_until')
->label('Public until')
->nullable(),
Forms\Components\Select::make('organization_id')
->label('Organization')
->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name'])
->disabled()
->required(),
Forms\Components\TextInput::make('share_secret')
->label('Share Secret')
->nullable(),
PrettyJson::make('properties')
->formatStateUsing(function (ReportPropertiesDto $state, Report $record): string {
return $record->getRawOriginal('properties');
})
->disabled(),
Forms\Components\DateTimePicker::make('created_at')
->label('Created At')
->hiddenOn(['create'])
->disabled(),
Forms\Components\DateTimePicker::make('updated_at')
->label('Updated At')
->hiddenOn(['create'])
->disabled(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('description')
->searchable()
->sortable(),
ToggleColumn::make('is_public')
->label('Is public?')
->sortable(),
TextColumn::make('organization.name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('created_at', 'desc')
->filters([
SelectFilter::make('organization')
->label('Organization')
->relationship('organization', 'name')
->searchable(),
SelectFilter::make('organization_id')
->label('Organization ID')
->relationship('organization', 'id')
->searchable(),
])
->actions([
Action::make('public-view')
->label('Public')
->icon('heroicon-o-eye')
->color('gray')
->hidden(fn (Report $record): bool => $record->getShareableLink() === null)
->url(fn (Report $record): string => $record->getShareableLink(), true),
Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
]);
}
public static function getRelations(): array
{
return [
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListReports::route('/'),
'edit' => Pages\EditReport::route('/{record}/edit'),
'view' => Pages\ViewReport::route('/{record}'),
];
}
}

View File

@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ReportResource\Pages;
use App\Filament\Resources\ReportResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditReport extends EditRecord
{
protected static string $resource = ReportResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
];
}
}

View File

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

View File

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

View File

@@ -60,13 +60,8 @@ class TagResource extends Resource
->defaultSort('created_at', 'desc') ->defaultSort('created_at', 'desc')
->filters([ ->filters([
SelectFilter::make('organization') SelectFilter::make('organization')
->label('Organization')
->relationship('organization', 'name') ->relationship('organization', 'name')
->searchable(), ->searchable(),
SelectFilter::make('organization_id')
->label('Organization ID')
->relationship('organization', 'id')
->searchable(),
]) ])
->actions([ ->actions([
Tables\Actions\EditAction::make(), Tables\Actions\EditAction::make(),

View File

@@ -15,8 +15,7 @@ class EditTag extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make() Actions\DeleteAction::make(),
->icon('heroicon-m-trash'),
]; ];
} }
} }

View File

@@ -15,8 +15,7 @@ class ListTags extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make() Actions\CreateAction::make(),
->icon('heroicon-s-plus'),
]; ];
} }
} }

View File

@@ -61,13 +61,8 @@ class TaskResource extends Resource
]) ])
->filters([ ->filters([
SelectFilter::make('organization') SelectFilter::make('organization')
->label('Organization')
->relationship('organization', 'name') ->relationship('organization', 'name')
->searchable(), ->searchable(),
SelectFilter::make('organization_id')
->label('Organization ID')
->relationship('organization', 'id')
->searchable(),
]) ])
->defaultSort('created_at', 'desc') ->defaultSort('created_at', 'desc')
->actions([ ->actions([

View File

@@ -15,8 +15,7 @@ class EditTask extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make() Actions\DeleteAction::make(),
->icon('heroicon-m-trash'),
]; ];
} }
} }

View File

@@ -15,8 +15,7 @@ class ListTasks extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make() Actions\CreateAction::make(),
->icon('heroicon-s-plus'),
]; ];
} }
} }

View File

@@ -92,13 +92,8 @@ class TimeEntryResource extends Resource
]) ])
->filters([ ->filters([
SelectFilter::make('organization') SelectFilter::make('organization')
->label('Organization')
->relationship('organization', 'name') ->relationship('organization', 'name')
->searchable(), ->searchable(),
SelectFilter::make('organization_id')
->label('Organization ID')
->relationship('organization', 'id')
->searchable(),
]) ])
->defaultSort('created_at', 'desc') ->defaultSort('created_at', 'desc')
->actions([ ->actions([

View File

@@ -15,8 +15,7 @@ class EditTimeEntry extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make() Actions\DeleteAction::make(),
->icon('heroicon-m-trash'),
]; ];
} }
} }

View File

@@ -15,8 +15,7 @@ class ListTimeEntries extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make() Actions\CreateAction::make(),
->icon('heroicon-s-plus'),
]; ];
} }
} }

View File

@@ -5,27 +5,21 @@ declare(strict_types=1);
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Exceptions\Api\ApiException;
use App\Filament\Resources\UserResource\Pages; use App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource\RelationManagers\OrganizationsRelationManager; use App\Filament\Resources\UserResource\RelationManagers\OrganizationsRelationManager;
use App\Filament\Resources\UserResource\RelationManagers\OwnedOrganizationsRelationManager; use App\Filament\Resources\UserResource\RelationManagers\OwnedOrganizationsRelationManager;
use App\Models\User; use App\Models\User;
use App\Service\DeletionService;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use Brick\Money\ISOCurrencyProvider;
use Exception; use Exception;
use Filament\Forms; use Filament\Forms;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
use STS\FilamentImpersonate\Tables\Actions\Impersonate; use STS\FilamentImpersonate\Tables\Actions\Impersonate;
class UserResource extends Resource class UserResource extends Resource
@@ -40,8 +34,6 @@ class UserResource extends Resource
public static function form(Form $form): Form public static function form(Form $form): Form
{ {
/** @var User|null $record */
$record = $form->getRecord();
return $form return $form
->columns(1) ->columns(1)
->schema([ ->schema([
@@ -58,25 +50,12 @@ class UserResource extends Resource
Forms\Components\TextInput::make('email') Forms\Components\TextInput::make('email')
->label('Email') ->label('Email')
->required() ->required()
->rules($record?->is_placeholder ? [] : [
UniqueEloquent::make(User::class, 'email')
->ignore($record?->getKey()),
])
->rule([
'email',
])
->maxLength(255), ->maxLength(255),
Forms\Components\Toggle::make('is_placeholder') Forms\Components\Toggle::make('is_placeholder')
->label('Is Placeholder?') ->label('Is Placeholder'),
->hiddenOn(['create'])
->disabledOn(['edit']),
Forms\Components\DateTimePicker::make('email_verified_at') Forms\Components\DateTimePicker::make('email_verified_at')
->label('Email Verified At') ->label('Email Verified At')
->hiddenOn(['create'])
->nullable(), ->nullable(),
Forms\Components\Toggle::make('is_email_verified')
->label('Email Verified?')
->visibleOn(['create']),
Forms\Components\Select::make('timezone') Forms\Components\Select::make('timezone')
->label('Timezone') ->label('Timezone')
->options(fn (): array => app(TimezoneService::class)->getSelectOptions()) ->options(fn (): array => app(TimezoneService::class)->getSelectOptions())
@@ -88,39 +67,15 @@ class UserResource extends Resource
->required(), ->required(),
TextInput::make('password') TextInput::make('password')
->password() ->password()
->label('Password')
->dehydrateStateUsing(fn ($state) => Hash::make($state)) ->dehydrateStateUsing(fn ($state) => Hash::make($state))
->dehydrated(fn ($state) => filled($state)) ->dehydrated(fn ($state) => filled($state))
->hiddenOn(['create'])
->required(fn (string $context): bool => $context === 'create') ->required(fn (string $context): bool => $context === 'create')
->maxLength(255), ->maxLength(255),
TextInput::make('password_create')
->password()
->label('Password')
->visibleOn(['create'])
->required(fn (string $context): bool => $context === 'create')
->maxLength(255),
Forms\Components\Select::make('currency')
->label('Currency (Personal Organization)')
->options(function (): array {
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
$select = [];
foreach ($currencies as $currency) {
$select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')';
}
return $select;
})
->required()
->visibleOn(['create'])
->searchable(),
Forms\Components\DateTimePicker::make('created_at') Forms\Components\DateTimePicker::make('created_at')
->label('Created At') ->label('Created At')
->hiddenOn(['create'])
->disabled(), ->disabled(),
Forms\Components\DateTimePicker::make('updated_at') Forms\Components\DateTimePicker::make('updated_at')
->label('Updated At') ->label('Updated At')
->hiddenOn(['create'])
->disabled(), ->disabled(),
]); ]);
} }
@@ -190,22 +145,11 @@ class UserResource extends Resource
} }
}), }),
Tables\Actions\EditAction::make(), Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make()
->hidden(fn (User $record) => $record->is(Auth::user()))
->using(function (User $record): void {
try {
app(DeletionService::class)->deleteUser($record);
} catch (ApiException $exception) {
Notification::make()
->danger()
->title('Delete failed')
->body($exception->getTranslatedMessage())
->persistent()
->send();
}
}),
]) ])
->bulkActions([ ->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]); ]);
} }

View File

@@ -4,29 +4,24 @@ declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Pages; namespace App\Filament\Resources\UserResource\Pages;
use App\Enums\Weekday;
use App\Filament\Resources\UserResource; use App\Filament\Resources\UserResource;
use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Service\UserService;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
class CreateUser extends CreateRecord class CreateUser extends CreateRecord
{ {
protected static string $resource = UserResource::class; protected static string $resource = UserResource::class;
protected function handleRecordCreation(array $data): User protected function afterCreate(): void
{ {
$userService = app(UserService::class); /** @var User $user */
$user = $userService->createUser( $user = $this->record;
$data['name'],
$data['email'],
$data['password_create'],
$data['timezone'],
Weekday::from($data['week_start']),
$data['currency'],
(bool) $data['is_email_verified']
);
return $user; $user->ownedTeams()->save(Organization::forceCreate([
'user_id' => $user->id,
'name' => explode(' ', $user->name, 2)[0]."'s Organization",
'personal_team' => true,
]));
} }
} }

View File

@@ -15,8 +15,7 @@ class ListUsers extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make() Actions\CreateAction::make(),
->icon('heroicon-s-plus'),
]; ];
} }
} }

View File

@@ -7,7 +7,6 @@ namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource; use App\Filament\Resources\UserResource;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use STS\FilamentImpersonate\Pages\Actions\Impersonate;
class ViewUser extends ViewRecord class ViewUser extends ViewRecord
{ {
@@ -16,7 +15,6 @@ class ViewUser extends ViewRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Impersonate::make()->record($this->getRecord()),
EditAction::make('edit') EditAction::make('edit')
->icon('heroicon-s-pencil'), ->icon('heroicon-s-pencil'),
]; ];

View File

@@ -5,18 +5,15 @@ declare(strict_types=1);
namespace App\Filament\Resources\UserResource\RelationManagers; namespace App\Filament\Resources\UserResource\RelationManagers;
use App\Enums\Role; use App\Enums\Role;
use App\Exceptions\Api\ApiException;
use App\Filament\Resources\OrganizationResource; use App\Filament\Resources\OrganizationResource;
use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User;
use App\Service\MemberService;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Actions\Action; use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\AttachAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
@@ -30,6 +27,10 @@ class OrganizationsRelationManager extends RelationManager
->schema([ ->schema([
Select::make('role') Select::make('role')
->options(Role::class), ->options(Role::class),
TextInput::make('billable_rate')
->label('Billable rate (in Cents)')
->nullable()
->numeric(),
]); ]);
} }
@@ -40,11 +41,15 @@ class OrganizationsRelationManager extends RelationManager
->columns([ ->columns([
TextColumn::make('name'), TextColumn::make('name'),
TextColumn::make('role'), TextColumn::make('role'),
TextColumn::make('membership.billable_rate') TextColumn::make('billable_rate')
->label('Billable rate') ->money(fn (Organization $resource) => $resource->currency ?? 'EUR', divideBy: 100),
->money(fn (Organization $resource) => $resource->currency, divideBy: 100),
]) ])
->headerActions([ ->headerActions([
Tables\Actions\AttachAction::make()->form(fn (AttachAction $action): array => [
$action->getRecordSelect(),
Select::make('role')
->options(Role::class),
]),
]) ])
->actions([ ->actions([
Action::make('view') Action::make('view')
@@ -53,48 +58,13 @@ class OrganizationsRelationManager extends RelationManager
->url(fn (Organization $record): string => OrganizationResource::getUrl('view', [ ->url(fn (Organization $record): string => OrganizationResource::getUrl('view', [
'record' => $record->getKey(), 'record' => $record->getKey(),
])), ])),
Tables\Actions\EditAction::make() Tables\Actions\EditAction::make(),
->using(function (Organization $record, array $data): Organization { Tables\Actions\DetachAction::make(),
/** @var Member $member */
$member = $record->getRelation('membership');
if ($data['role'] !== $member->role) {
try {
app(MemberService::class)->changeRole($member, $record, Role::from($data['role']), true);
} catch (ApiException $exception) {
Notification::make()
->danger()
->title('Update failed')
->body($exception->getTranslatedMessage())
->persistent()
->send();
}
}
$member->save();
return $record;
}),
Tables\Actions\DetachAction::make()
->using(function (Organization $record): void {
/** @var User $user */
$user = $this->getOwnerRecord();
$member = Member::query()
->whereBelongsTo($user, 'user')
->whereBelongsTo($record, 'organization')
->firstOrFail();
try {
app(MemberService::class)->removeMember($member, $record);
} catch (ApiException $exception) {
Notification::make()
->danger()
->title('Delete failed')
->body($exception->getTranslatedMessage())
->persistent()
->send();
}
}),
]) ])
->bulkActions([ ->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DetachBulkAction::make(),
]),
]); ]);
} }
} }

View File

@@ -9,12 +9,13 @@ use App\Http\Requests\V1\Invitation\InvitationIndexRequest;
use App\Http\Requests\V1\Invitation\InvitationStoreRequest; use App\Http\Requests\V1\Invitation\InvitationStoreRequest;
use App\Http\Resources\V1\Invitation\InvitationCollection; use App\Http\Resources\V1\Invitation\InvitationCollection;
use App\Http\Resources\V1\Invitation\InvitationResource; use App\Http\Resources\V1\Invitation\InvitationResource;
use App\Mail\OrganizationInvitationMail;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation; use App\Models\OrganizationInvitation;
use App\Service\InvitationService; use App\Service\InvitationService;
use App\Service\OrganizationInvitationService;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Mail;
class InvitationController extends Controller class InvitationController extends Controller
{ {
@@ -72,11 +73,12 @@ class InvitationController extends Controller
* *
* @operationId resendInvitationEmail * @operationId resendInvitationEmail
*/ */
public function resend(Organization $organization, OrganizationInvitation $invitation, OrganizationInvitationService $organizationInvitationService): JsonResponse public function resend(Organization $organization, OrganizationInvitation $invitation): JsonResponse
{ {
$this->checkPermission($organization, 'invitations:resend', $invitation); $this->checkPermission($organization, 'invitations:resend', $invitation);
$organizationInvitationService->resend($invitation); Mail::to($invitation->email)
->queue(new OrganizationInvitationMail($invitation));
return response()->json(null, 204); return response()->json(null, 204);
} }

View File

@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Enums\Role; use App\Enums\Role;
use App\Events\MemberMadeToPlaceholder; use App\Events\MemberMadeToPlaceholder;
use App\Events\MemberRemoved;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization; use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed; use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\EntityStillInUseApiException;
@@ -18,6 +19,8 @@ use App\Http\Resources\V1\Member\MemberCollection;
use App\Http\Resources\V1\Member\MemberResource; use App\Http\Resources\V1\Member\MemberResource;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Service\BillableRateService; use App\Service\BillableRateService;
use App\Service\InvitationService; use App\Service\InvitationService;
use App\Service\MemberService; use App\Service\MemberService;
@@ -77,8 +80,22 @@ class MemberController extends Controller
} }
if ($request->has('role') && $member->role !== $request->getRole()->value) { if ($request->has('role') && $member->role !== $request->getRole()->value) {
$newRole = $request->getRole(); $newRole = $request->getRole();
$allowOwnerChange = $this->hasPermission($organization, 'members:change-ownership'); $oldRole = Role::from($member->role);
$memberService->changeRole($member, $organization, $newRole, $allowOwnerChange); if ($oldRole === Role::Owner) {
throw new OrganizationNeedsAtLeastOneOwner;
}
if ($newRole === Role::Placeholder) {
throw new ChangingRoleToPlaceholderIsNotAllowed;
}
if ($newRole === Role::Owner) {
if ($this->hasPermission($organization, 'members:change-ownership')) {
$memberService->changeOwnership($organization, $member);
} else {
throw new OnlyOwnerCanChangeOwnership;
}
} else {
$member->role = $request->getRole()->value;
}
} }
$member->save(); $member->save();
@@ -92,11 +109,22 @@ class MemberController extends Controller
* *
* @operationId removeMember * @operationId removeMember
*/ */
public function destroy(Organization $organization, Member $member, MemberService $memberService): JsonResponse public function destroy(Organization $organization, Member $member): JsonResponse
{ {
$this->checkPermission($organization, 'members:delete', $member); $this->checkPermission($organization, 'members:delete', $member);
$memberService->removeMember($member, $organization); if (TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->exists()) {
throw new EntityStillInUseApiException('member', 'time_entry');
}
if (ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->exists()) {
throw new EntityStillInUseApiException('member', 'project_member');
}
if ($member->role === Role::Owner->value) {
throw new CanNotRemoveOwnerFromOrganization;
}
$member->delete();
MemberRemoved::dispatch($member, $organization);
return response() return response()
->json(null, 204); ->json(null, 204);

View File

@@ -7,13 +7,12 @@ namespace App\Jobs;
use App\Models\Project; use App\Models\Project;
use Exception; use Exception;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class RecalculateSpentTimeForProject implements ShouldDispatchAfterCommit, ShouldQueue class RecalculateSpentTimeForProject implements ShouldQueue
{ {
use Dispatchable; use Dispatchable;
use InteractsWithQueue; use InteractsWithQueue;

View File

@@ -7,13 +7,12 @@ namespace App\Jobs;
use App\Models\Task; use App\Models\Task;
use Exception; use Exception;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class RecalculateSpentTimeForTask implements ShouldDispatchAfterCommit, ShouldQueue class RecalculateSpentTimeForTask implements ShouldQueue
{ {
use Dispatchable; use Dispatchable;
use InteractsWithQueue; use InteractsWithQueue;

View File

@@ -34,7 +34,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string $id * @property string $id
* @property string $name * @property string $name
* @property string $email * @property string $email
* @property Carbon|null $email_verified_at * @property string|null $email_verified_at
* @property string|null $password * @property string|null $password
* @property string|null $two_factor_secret * @property string|null $two_factor_secret
* @property string $timezone * @property string $timezone

View File

@@ -13,7 +13,6 @@ use App\Models\Organization;
use App\Models\OrganizationInvitation; use App\Models\OrganizationInvitation;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectMember; use App\Models\ProjectMember;
use App\Models\Report;
use App\Models\Tag; use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use App\Models\TimeEntry; use App\Models\TimeEntry;
@@ -72,9 +71,6 @@ class DeletionService
// Delete all clients // Delete all clients
Client::query()->whereBelongsTo($organization, 'organization')->delete(); Client::query()->whereBelongsTo($organization, 'organization')->delete();
// Delete all reports
Report::query()->whereBelongsTo($organization, 'organization')->delete();
// Reset the current organization // Reset the current organization
$organization->owner() $organization->owner()
->where('current_team_id', $organization->getKey()) ->where('current_team_id', $organization->getKey())

View File

@@ -188,18 +188,6 @@ class ImportDatabaseHelper
return $model; return $model;
} }
/**
* @return array<TModel>
*/
public function getCachedModels(): array
{
if ($this->mapKeyToModel === null) {
return [];
}
return array_values($this->mapKeyToModel);
}
/** /**
* @param array<string, mixed> $identifierData * @param array<string, mixed> $identifierData
* @return TModel|null * @return TModel|null

View File

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

View File

@@ -5,8 +5,6 @@ declare(strict_types=1);
namespace App\Service\Import\Importers; namespace App\Service\Import\Importers;
use App\Enums\Role; use App\Enums\Role;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use Carbon\Exceptions\InvalidFormatException; use Carbon\Exceptions\InvalidFormatException;
use Exception; use Exception;
@@ -101,7 +99,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
'project_id' => $projectId, 'project_id' => $projectId,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
]); ]);
$this->taskImportHelper->getModelById($taskId);
} }
$timeEntry = new TimeEntry; $timeEntry = new TimeEntry;
$timeEntry->disableAuditing(); $timeEntry->disableAuditing();
@@ -161,12 +158,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$timeEntry->save(); $timeEntry->save();
$this->timeEntriesCreated++; $this->timeEntriesCreated++;
} }
foreach ($this->projectImportHelper->getCachedModels() as $usedProject) {
RecalculateSpentTimeForProject::dispatch($usedProject);
}
foreach ($this->taskImportHelper->getCachedModels() as $usedTask) {
RecalculateSpentTimeForTask::dispatch($usedTask);
}
} catch (ImportException $exception) { } catch (ImportException $exception) {
throw $exception; throw $exception;
} catch (CsvException $exception) { } catch (CsvException $exception) {

View File

@@ -5,8 +5,6 @@ declare(strict_types=1);
namespace App\Service\Import\Importers; namespace App\Service\Import\Importers;
use App\Enums\Role; use App\Enums\Role;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use Carbon\Exceptions\InvalidFormatException; use Carbon\Exceptions\InvalidFormatException;
use Exception; use Exception;
@@ -237,7 +235,6 @@ class SolidtimeImporter extends DefaultImporter
$taskId = null; $taskId = null;
if ($timeEntryRow['task_id'] !== '') { if ($timeEntryRow['task_id'] !== '') {
$taskId = $this->taskImportHelper->getKeyByExternalIdentifier($timeEntryRow['task_id']); $taskId = $this->taskImportHelper->getKeyByExternalIdentifier($timeEntryRow['task_id']);
$this->taskImportHelper->getModelById($taskId);
} }
$timeEntry = new TimeEntry; $timeEntry = new TimeEntry;
$timeEntry->disableAuditing(); $timeEntry->disableAuditing();
@@ -306,12 +303,6 @@ class SolidtimeImporter extends DefaultImporter
$timeEntry->save(); $timeEntry->save();
$this->timeEntriesCreated++; $this->timeEntriesCreated++;
} }
foreach ($this->projectImportHelper->getCachedModels() as $usedProject) {
RecalculateSpentTimeForProject::dispatch($usedProject);
}
foreach ($this->taskImportHelper->getCachedModels() as $usedTask) {
RecalculateSpentTimeForTask::dispatch($usedTask);
}
} catch (ImportException $exception) { } catch (ImportException $exception) {
throw $exception; throw $exception;
} catch (Exception $exception) { } catch (Exception $exception) {

View File

@@ -5,8 +5,6 @@ declare(strict_types=1);
namespace App\Service\Import\Importers; namespace App\Service\Import\Importers;
use App\Enums\Role; use App\Enums\Role;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use Carbon\Exceptions\InvalidFormatException; use Carbon\Exceptions\InvalidFormatException;
use Exception; use Exception;
@@ -101,7 +99,6 @@ class TogglTimeEntriesImporter extends DefaultImporter
'project_id' => $projectId, 'project_id' => $projectId,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
]); ]);
$this->taskImportHelper->getModelById($taskId);
} }
$timeEntry = new TimeEntry; $timeEntry = new TimeEntry;
$timeEntry->disableAuditing(); $timeEntry->disableAuditing();
@@ -147,12 +144,6 @@ class TogglTimeEntriesImporter extends DefaultImporter
$timeEntry->save(); $timeEntry->save();
$this->timeEntriesCreated++; $this->timeEntriesCreated++;
} }
foreach ($this->projectImportHelper->getCachedModels() as $usedProject) {
RecalculateSpentTimeForProject::dispatch($usedProject);
}
foreach ($this->taskImportHelper->getCachedModels() as $usedTask) {
RecalculateSpentTimeForTask::dispatch($usedTask);
}
} catch (ImportException $exception) { } catch (ImportException $exception) {
throw $exception; throw $exception;
} catch (CsvException $exception) { } catch (CsvException $exception) {

View File

@@ -5,21 +5,10 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Enums\Role; use App\Enums\Role;
use App\Events\MemberRemoved;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException; use InvalidArgumentException;
use Laravel\Jetstream\Events\AddingTeamMember;
use Laravel\Jetstream\Events\TeamMemberAdded;
class MemberService class MemberService
{ {
@@ -30,72 +19,6 @@ class MemberService
$this->userService = $userService; $this->userService = $userService;
} }
public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member
{
if (! $asSuperAdmin) {
AddingTeamMember::dispatch($organization, $user);
}
$member = new Member;
DB::transaction(function () use ($organization, $user, $role, &$member): void {
$member->user()->associate($user);
$member->organization()->associate($organization);
$member->role = $role->value;
$member->save();
});
if (! $asSuperAdmin) {
TeamMemberAdded::dispatch($organization, $user);
}
return $member;
}
/**
* @throws CanNotRemoveOwnerFromOrganization
* @throws EntityStillInUseApiException
*/
public function removeMember(Member $member, Organization $organization): void
{
if (TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->exists()) {
throw new EntityStillInUseApiException('member', 'time_entry');
}
if (ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->exists()) {
throw new EntityStillInUseApiException('member', 'project_member');
}
if ($member->role === Role::Owner->value) {
throw new CanNotRemoveOwnerFromOrganization;
}
$member->delete();
MemberRemoved::dispatch($member, $organization);
}
/**
* @throws ChangingRoleToPlaceholderIsNotAllowed
* @throws OnlyOwnerCanChangeOwnership
* @throws OrganizationNeedsAtLeastOneOwner
*/
public function changeRole(Member $member, Organization $organization, Role $newRole, bool $allowOwnerChange): void
{
$oldRole = Role::from($member->role);
if ($oldRole === Role::Owner) {
throw new OrganizationNeedsAtLeastOneOwner;
}
if ($newRole === Role::Placeholder) {
throw new ChangingRoleToPlaceholderIsNotAllowed;
}
if ($newRole === Role::Owner) {
if ($allowOwnerChange) {
$this->changeOwnership($organization, $member);
} else {
throw new OnlyOwnerCanChangeOwnership;
}
} else {
$member->role = $newRole->value;
}
}
/** /**
* Change the ownership of an organization to a new user. * Change the ownership of an organization to a new user.
* The previous owner will be demoted to an admin. * The previous owner will be demoted to an admin.

View File

@@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Mail\OrganizationInvitationMail;
use App\Models\OrganizationInvitation;
use Illuminate\Support\Facades\Mail;
class OrganizationInvitationService
{
public function resend(OrganizationInvitation $invitation): void
{
Mail::to($invitation->email)
->queue(new OrganizationInvitationMail($invitation));
}
}

View File

@@ -5,49 +5,15 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Enums\Role; use App\Enums\Role;
use App\Enums\Weekday;
use App\Events\AfterCreateOrganization; use App\Events\AfterCreateOrganization;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\ProjectMember; use App\Models\ProjectMember;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
class UserService class UserService
{ {
public function createUser(string $name, string $email, string $password, string $timezone, Weekday $weekStart, string $currency, bool $verifyEmail = false): User
{
$user = new User;
$user->name = $name;
$user->email = $email;
$user->password = Hash::make($password);
$user->timezone = $timezone;
$user->week_start = $weekStart;
if ($verifyEmail) {
$user->email_verified_at = Carbon::now();
}
$user->save();
$organization = new Organization;
$organization->name = explode(' ', $user->name, 2)[0]."'s Organization";
$organization->personal_team = true;
$organization->currency = $currency;
$organization->owner()->associate($user);
$organization->save();
$organization->users()->attach(
$user, [
'role' => Role::Owner->value,
]
);
$user->ownedTeams()->save($organization);
return $user;
}
/** /**
* Assign all organization entities (time entries, project members) from one user to another. * Assign all organization entities (time entries, project members) from one user to another.
* This is useful when a placeholder user is replaced with a real user. * This is useful when a placeholder user is replaced with a real user.

View File

@@ -9,7 +9,7 @@
"ext-zip": "*", "ext-zip": "*",
"brick/money": "^0.10.0", "brick/money": "^0.10.0",
"datomatic/laravel-enum-helper": "^2.0.0", "datomatic/laravel-enum-helper": "^2.0.0",
"dedoc/scramble": "^0.12.2", "dedoc/scramble": "^0.11.28",
"filament/filament": "^3.2", "filament/filament": "^3.2",
"flowframe/laravel-trend": "^0.3.0", "flowframe/laravel-trend": "^0.3.0",
"gotenberg/gotenberg-php": "^2.8", "gotenberg/gotenberg-php": "^2.8",
@@ -40,7 +40,7 @@
"barryvdh/laravel-ide-helper": "^3.0", "barryvdh/laravel-ide-helper": "^3.0",
"brianium/paratest": "^7.3", "brianium/paratest": "^7.3",
"fakerphp/faker": "^1.9.1", "fakerphp/faker": "^1.9.1",
"fumeapp/modeltyper": "^3.0", "fumeapp/modeltyper": "^2.2",
"phpstan/phpstan": "1.12.0", "phpstan/phpstan": "1.12.0",
"larastan/larastan": "^2.0", "larastan/larastan": "^2.0",
"laravel/pint": "^1.0", "laravel/pint": "^1.0",

2162
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -67,8 +67,6 @@ return [
'force_https' => (bool) env('APP_FORCE_HTTPS', false), 'force_https' => (bool) env('APP_FORCE_HTTPS', false),
'enable_registration' => (bool) env('APP_ENABLE_REGISTRATION', false),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Application Timezone | Application Timezone

View File

@@ -163,8 +163,8 @@ return [
*/ */
'cells' => [ 'cells' => [
'middleware' => [ 'middleware' => [
// \Maatwebsite\Excel\Middleware\TrimCellValue::class, //\Maatwebsite\Excel\Middleware\TrimCellValue::class,
// \Maatwebsite\Excel\Middleware\ConvertEmptyCellValuesToNull::class, //\Maatwebsite\Excel\Middleware\ConvertEmptyCellValuesToNull::class,
], ],
], ],

View File

@@ -39,7 +39,6 @@ return [
'local' => [ 'local' => [
'driver' => 'local', 'driver' => 'local',
'root' => storage_path('app'), 'root' => storage_path('app'),
'serve' => true,
'throw' => true, 'throw' => true,
], ],

View File

@@ -12,7 +12,6 @@ use App\Models\Organization;
use App\Models\OrganizationInvitation; use App\Models\OrganizationInvitation;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectMember; use App\Models\ProjectMember;
use App\Models\Report;
use App\Models\Tag; use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use App\Models\TimeEntry; use App\Models\TimeEntry;
@@ -135,7 +134,6 @@ class DatabaseSeeder extends Seeder
'personal_team' => true, 'personal_team' => true,
'currency' => 'USD', 'currency' => 'USD',
]); ]);
Member::factory()->forUser($rivalOwner)->forOrganization($organizationRival)->role(Role::Owner)->create();
$userRivalManager = User::factory()->withPersonalOrganization()->create([ $userRivalManager = User::factory()->withPersonalOrganization()->create([
'name' => 'Other User', 'name' => 'Other User',
'email' => 'test@rival-company.test', 'email' => 'test@rival-company.test',
@@ -188,7 +186,6 @@ class DatabaseSeeder extends Seeder
// Application tables // Application tables
DB::table((new Audit)->getTable())->delete(); DB::table((new Audit)->getTable())->delete();
DB::table((new Report)->getTable())->delete();
DB::table((new TimeEntry)->getTable())->delete(); DB::table((new TimeEntry)->getTable())->delete();
DB::table((new Task)->getTable())->delete(); DB::table((new Task)->getTable())->delete();
DB::table((new Tag)->getTable())->delete(); DB::table((new Tag)->getTable())->delete();

View File

@@ -109,7 +109,7 @@ services:
- sail - sail
- reverse-proxy - reverse-proxy
playwright: playwright:
image: mcr.microsoft.com/playwright:v1.50.0-jammy image: mcr.microsoft.com/playwright:v1.46.1-jammy
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0'] command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
working_dir: /src working_dir: /src
extra_hosts: extra_hosts:

View File

@@ -191,7 +191,7 @@ test('test that updating a the start of an existing time entry in the overview w
'time_entry_range_selector' 'time_entry_range_selector'
); );
await timeEntryRangeElement.click(); await timeEntryRangeElement.click();
await page.getByTestId('time_entry_range_start').first().fill('1'); await page.getByTestId('time_picker_input').first().fill('1');
await Promise.all([ await Promise.all([
page.waitForResponse(async (response) => { page.waitForResponse(async (response) => {
return ( return (
@@ -204,7 +204,10 @@ test('test that updating a the start of an existing time entry in the overview w
(await response.json()).data.end !== null (await response.json()).data.end !== null
); );
}), }),
page.getByTestId('time_entry_range_end').press('Enter'), page
.getByTestId('time_entry_range_end')
.getByTestId('time_picker_input')
.press('Enter'),
]); ]);
}); });

View File

@@ -152,7 +152,7 @@ test('test that starting and updating the time while running works', async ({
JSON.stringify([]) JSON.stringify([])
); );
}), }),
page.getByTestId('time_entry_time').press('Enter'), page.getByTestId('time_entry_time').press('Tab'),
]); ]);
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20/); await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20/);

View File

@@ -1,36 +0,0 @@
import eslint from '@eslint/js';
import eslintConfigPrettier from 'eslint-config-prettier';
import eslintPluginVue from 'eslint-plugin-vue';
import globals from 'globals';
import typescriptEslint from 'typescript-eslint';
import unusedImports from "eslint-plugin-unused-imports";
export default typescriptEslint.config(
{ ignores: ['*.d.ts', '**/coverage', '**/dist'] },
{
extends: [
eslint.configs.recommended,
...typescriptEslint.configs.recommended,
...eslintPluginVue.configs['flat/recommended'],
],
files: ['**/*.{ts,vue,js}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: globals.browser,
parserOptions: {
parser: typescriptEslint.parser,
},
},
plugins: {
"unused-imports": unusedImports,
},
rules: {
"vue/multi-word-component-names": "off",
"@typescript-eslint/no-unused-vars": "off",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": "error",
},
},
eslintConfigPrettier
);

3414
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,60 +4,53 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"lint": "eslint resources/js", "lint": "eslint --ext .js,.vue,.ts --ignore-path .gitignore resources/js",
"lint:fix": "eslint --fix resources/js", "lint:fix": "eslint --fix --ext .js,.vue,.ts --ignore-path .gitignore resources/js",
"type-check": "vue-tsc --noEmit", "type-check": "vue-tsc --noEmit",
"test:e2e": "rm -rf test-results/.auth && npx playwright test", "test:e2e": "rm -rf test-results/.auth && npx playwright test",
"zod:generate": "npx openapi-zod-client http://localhost:80/docs/api.json --output resources/js/packages/api/src/openapi.json.client.ts --base-url /api" "zod:generate": "npx openapi-zod-client http://localhost:80/docs/api.json --output resources/js/packages/api/src/openapi.json.client.ts --base-url /api"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@inertiajs/vue3": "^1.0.0", "@inertiajs/vue3": "^1.0.0",
"@playwright/test": "^1.41.1", "@playwright/test": "^1.41.1",
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@types/node": "^22.10.10", "@types/node": "^20.11.5",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^4.5.0",
"@vue/tsconfig": "^0.5.1", "@vue/tsconfig": "^0.5.1",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"axios": "^1.6.4", "axios": "^1.6.4",
"eslint-plugin-unused-imports": "^4.1.4", "eslint-plugin-unused-imports": "^3.1.0",
"laravel-vite-plugin": "^1.0.0", "laravel-vite-plugin": "^1.0.0",
"openapi-zod-client": "^1.16.2", "openapi-zod-client": "^1.16.2",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"postcss-nesting": "^12.1.5", "postcss-nesting": "^12.1.5",
"tailwindcss": "^3.4.13", "tailwindcss": "^3.4.13",
"typescript": "^5.7.3", "typescript": "^5.3.3",
"vite": "^6.0.11", "vite": "^5.0.0",
"vite-plugin-checker": "^0.8.0", "vite-plugin-checker": "^0.7.2",
"vue": "^3.5.0", "vue": "^3.4.0",
"vue-tsc": "^2.2.0" "vue-tsc": "^2.0.28"
}, },
"dependencies": { "dependencies": {
"@floating-ui/core": "^1.6.0", "@floating-ui/core": "^1.6.0",
"@floating-ui/vue": "^1.0.6", "@floating-ui/vue": "^1.0.6",
"@heroicons/vue": "^2.1.1", "@heroicons/vue": "^2.1.1",
"@rushstack/eslint-patch": "^1.10.5", "@rushstack/eslint-patch": "^1.7.0",
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
"@tanstack/vue-query": "^5.56.2", "@tanstack/vue-query": "^5.56.2",
"@tanstack/vue-query-devtools": "^5.58.0", "@tanstack/vue-query-devtools": "^5.58.0",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^14.3.0", "@vue/eslint-config-typescript": "^13.0.0",
"@vueuse/core": "^12.5.0", "@vueuse/core": "^10.11.0",
"@vueuse/integrations": "^12.5.0", "@vueuse/integrations": "^11.1.0",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"echarts": "^5.5.0", "echarts": "^5.5.0",
"focus-trap": "^7.6.0", "focus-trap": "^7.6.0",
"parse-duration": "^2.0.1", "parse-duration": "^1.1.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"radix-vue": "^1.9.6", "radix-vue": "^1.9.6",
"tailwind-merge": "^2.2.1", "tailwind-merge": "^2.2.1",
"vue-echarts": "^7.0.3" "vue-echarts": "^6.7.2"
},
"overrides": {
"vite-plugin-checker": {
"vue-tsc": "$vue-tsc"
}
} }
} }

View File

@@ -112,7 +112,7 @@ const showBlackFridayBanner = computed(() => {
<span>Upgrade now</span> <span>Upgrade now</span>
</div> </div>
</Link> </Link>
<button class="p-1" @click="hideBlackFridayBanner = true"> <button @click="hideBlackFridayBanner = true" class="p-1">
<XMarkIcon <XMarkIcon
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon> class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button> </button>
@@ -142,7 +142,7 @@ const showBlackFridayBanner = computed(() => {
<span>Upgrade now</span> <span>Upgrade now</span>
</div> </div>
</Link> </Link>
<button class="p-1" @click="hideTrialBanner = true"> <button @click="hideTrialBanner = true" class="p-1">
<XMarkIcon <XMarkIcon
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon> class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button> </button>
@@ -174,7 +174,7 @@ const showBlackFridayBanner = computed(() => {
<span>Upgrade now</span> <span>Upgrade now</span>
</div> </div>
</Link> </Link>
<button class="p-1" @click="hideBlockedBanner = true"> <button @click="hideBlockedBanner = true" class="p-1">
<XMarkIcon <XMarkIcon
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon> class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button> </button>
@@ -206,7 +206,7 @@ const showBlackFridayBanner = computed(() => {
<span>Upgrade now</span> <span>Upgrade now</span>
</div> </div>
</Link> </Link>
<button class="p-1" @click="hideFreeUpgradeBanner = true"> <button @click="hideFreeUpgradeBanner = true" class="p-1">
<XMarkIcon <XMarkIcon
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon> class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button> </button>

View File

@@ -45,10 +45,10 @@ useFocus(clientNameInput, { initialValue: true });
v-model="client.name" v-model="client.name"
type="text" type="text"
placeholder="Client Name" placeholder="Client Name"
@keydown.enter="submit"
class="mt-1 block w-full" class="mt-1 block w-full"
required required
autocomplete="clientName" autocomplete="clientName" />
@keydown.enter="submit" />
</div> </div>
</div> </div>
</template> </template>

View File

@@ -46,10 +46,10 @@ useFocus(clientNameInput, { initialValue: true });
v-model="clientBody.name" v-model="clientBody.name"
type="text" type="text"
placeholder="Client Name" placeholder="Client Name"
@keydown.enter="submit"
class="mt-1 block w-full" class="mt-1 block w-full"
required required
autocomplete="clientName" autocomplete="clientName" />
@keydown.enter="submit" />
</div> </div>
</div> </div>
</template> </template>

View File

@@ -23,28 +23,28 @@ const props = defineProps<{
<div class="min-w-[150px]"> <div class="min-w-[150px]">
<button <button
v-if="canUpdateClients()" v-if="canUpdateClients()"
@click="emit('edit')"
:aria-label="'Edit Client ' + props.client.name" :aria-label="'Edit Client ' + props.client.name"
data-testid="client_edit" data-testid="client_edit"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out" class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
@click="emit('edit')">
<PencilSquareIcon <PencilSquareIcon
class="w-5 text-icon-active"></PencilSquareIcon> class="w-5 text-icon-active"></PencilSquareIcon>
<span>Edit</span> <span>Edit</span>
</button> </button>
<button <button
@click.prevent="emit('archive')"
v-if="canUpdateClients()" v-if="canUpdateClients()"
:aria-label="'Archive Client ' + props.client.name" :aria-label="'Archive Client ' + props.client.name"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out" class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
@click.prevent="emit('archive')">
<ArchiveBoxIcon class="w-5 text-icon-active"></ArchiveBoxIcon> <ArchiveBoxIcon class="w-5 text-icon-active"></ArchiveBoxIcon>
<span>{{ client.is_archived ? 'Unarchive' : 'Archive' }}</span> <span>{{ client.is_archived ? 'Unarchive' : 'Archive' }}</span>
</button> </button>
<button <button
v-if="canDeleteClients()" v-if="canDeleteClients()"
@click="emit('delete')"
:aria-label="'Delete Client ' + props.client.name" :aria-label="'Delete Client ' + props.client.name"
data-testid="client_delete" data-testid="client_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out" class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
@click="emit('delete')">
<TrashIcon class="w-5 text-icon-active"></TrashIcon> <TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span> <span>Delete</span>
</button> </button>

View File

@@ -18,7 +18,7 @@ function getNameForItem(item: Client) {
<template> <template>
<MultiselectDropdown <MultiselectDropdown
search-placeholder="Search for a Client..." searchPlaceholder="Search for a Client..."
:items="clients" :items="clients"
:get-key-from-item="getKeyFromItem" :get-key-from-item="getKeyFromItem"
:get-name-for-item="getNameForItem"> :get-name-for-item="getNameForItem">

View File

@@ -25,18 +25,18 @@ const createClient = ref(false);
style="grid-template-columns: 1fr 150px 200px 80px"> style="grid-template-columns: 1fr 150px 200px 80px">
<ClientTableHeading></ClientTableHeading> <ClientTableHeading></ClientTableHeading>
<div <div
v-if="clients.length === 0" class="col-span-2 py-24 text-center"
class="col-span-2 py-24 text-center"> v-if="clients.length === 0">
<UserCircleIcon <UserCircleIcon
class="w-8 text-icon-default inline pb-2"></UserCircleIcon> class="w-8 text-icon-default inline pb-2"></UserCircleIcon>
<h3 class="text-white font-semibold">No clients found</h3> <h3 class="text-white font-semibold">No clients found</h3>
<p v-if="canCreateClients()" class="pb-5"> <p class="pb-5" v-if="canCreateClients()">
Create your first client now! Create your first client now!
</p> </p>
<SecondaryButton <SecondaryButton
v-if="canCreateClients()" v-if="canCreateClients()"
:icon="PlusIcon as Component"
@click="createClient = true" @click="createClient = true"
:icon="PlusIcon as Component"
>Create your First Client >Create your First Client
</SecondaryButton> </SecondaryButton>
</div> </div>

View File

@@ -38,8 +38,8 @@ const showEditModal = ref(false);
<template> <template>
<TableRow> <TableRow>
<ClientEditModal <ClientEditModal
v-model:show="showEditModal" :client="client"
:client="client"></ClientEditModal> v-model:show="showEditModal"></ClientEditModal>
<div <div
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"> class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span> <span>

View File

@@ -10,16 +10,16 @@ const emit = defineEmits<{
<template> <template>
<MoreOptionsDropdown label="Actions for the invitation"> <MoreOptionsDropdown label="Actions for the invitation">
<button <button
@click="emit('resend')"
data-testid="invitation_delete" data-testid="invitation_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out" class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
@click="emit('resend')">
<ArrowPathIcon class="w-5 text-icon-active"></ArrowPathIcon> <ArrowPathIcon class="w-5 text-icon-active"></ArrowPathIcon>
<span>Resend Invitation</span> <span>Resend Invitation</span>
</button> </button>
<button <button
@click="emit('delete')"
data-testid="invitation_delete" data-testid="invitation_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out" class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
@click="emit('delete')">
<TrashIcon class="w-5 text-icon-active"></TrashIcon> <TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span> <span>Delete</span>
</button> </button>

View File

@@ -18,10 +18,10 @@ defineEmits<{
<template> <template>
<BillableRateModal <BillableRateModal
@submit="$emit('submit')"
v-model:show="show" v-model:show="show"
v-model:saving="saving" v-model:saving="saving"
title="Update Member Billable Rate" title="Update Member Billable Rate">
@submit="$emit('submit')">
<p class="py-1 text-center"> <p class="py-1 text-center">
The billable rate of {{ memberName }} will be updated to The billable rate of {{ memberName }} will be updated to
<strong>{{ <strong>{{

View File

@@ -17,8 +17,8 @@ const model = defineModel<string>({
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
hiddenMembers?: ProjectMember[]; hiddenMembers: ProjectMember[];
disabled?: boolean; disabled: boolean;
}>(), }>(),
{ {
hiddenMembers: () => [] as ProjectMember[], hiddenMembers: () => [] as ProjectMember[],
@@ -76,7 +76,7 @@ const currentValue = computed(() => {
:items="filteredMembers" :items="filteredMembers"
:get-key-from-item="(member) => member.id" :get-key-from-item="(member) => member.id"
:get-name-for-item="(member) => member.name"> :get-name-for-item="(member) => member.name">
<template #trigger> <template v-slot:trigger>
<Badge <Badge
tag="button" tag="button"
class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary font-normal cursor py-1.5"> class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary font-normal cursor py-1.5">
@@ -84,7 +84,7 @@ const currentValue = computed(() => {
<div v-if="currentValue" class="flex-1 truncate"> <div v-if="currentValue" class="flex-1 truncate">
{{ currentValue }} {{ currentValue }}
</div> </div>
<div v-else class="flex-1">Select a member...</div> <div class="flex-1" v-else>Select a member...</div>
<ChevronDownIcon class="w-4 text-muted"></ChevronDownIcon> <ChevronDownIcon class="w-4 text-muted"></ChevronDownIcon>
</Badge> </Badge>
</template> </template>

View File

@@ -108,11 +108,11 @@ const roleDescription = computed(() => {
v-model:saving="saving" v-model:saving="saving"
v-model:show="showBillableRateModal" v-model:show="showBillableRateModal"
:member-name="member.name" :member-name="member.name"
:new-billable-rate="memberBody.billable_rate" :newBillableRate="memberBody.billable_rate"
@submit="submitBillableRate"></MemberBillableRateModal> @submit="submitBillableRate"></MemberBillableRateModal>
<MemberOwnershipTransferConfirmModal <MemberOwnershipTransferConfirmModal
v-model:show="showOwnershipTransferConfirmModal"
:member-name="member.name" :member-name="member.name"
v-model:show="showOwnershipTransferConfirmModal"
@submit="submit"></MemberOwnershipTransferConfirmModal> @submit="submit"></MemberOwnershipTransferConfirmModal>
<DialogModal closeable :show="show" @close="show = false"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
@@ -127,9 +127,9 @@ const roleDescription = computed(() => {
<div> <div>
<InputLabel for="role" value="Role" /> <InputLabel for="role" value="Role" />
<MemberRoleSelect <MemberRoleSelect
v-model="memberBody.role"
class="mt-2" class="mt-2"
name="role"></MemberRoleSelect> name="role"
v-model="memberBody.role"></MemberRoleSelect>
</div> </div>
<div class="flex-1 text-xs flex items-center pt-6"> <div class="flex-1 text-xs flex items-center pt-6">
<p>{{ roleDescription }}</p> <p>{{ roleDescription }}</p>
@@ -140,28 +140,28 @@ const roleDescription = computed(() => {
<div> <div>
<InputLabel for="billableType" value="Billable" /> <InputLabel for="billableType" value="Billable" />
<MemberBillableSelect <MemberBillableSelect
class="mt-2"
name="billableType"
v-model=" v-model="
billableRateSelect billableRateSelect
" "></MemberBillableSelect>
class="mt-2"
name="billableType"></MemberBillableSelect>
</div> </div>
<div <div
v-if="billableRateSelect === 'custom-rate'" class="flex-1"
class="flex-1"> v-if="billableRateSelect === 'custom-rate'">
<InputLabel <InputLabel
for="memberBillableRate" for="memberBillableRate"
class="mb-2" class="mb-2"
value="Billable Rate" /> value="Billable Rate" />
<BillableRateInput <BillableRateInput
v-model="
memberBody.billable_rate
"
focus focus
class="w-full" class="w-full"
:currency="getOrganizationCurrencyString()" :currency="getOrganizationCurrencyString()"
@keydown.enter="saveWithChecks()"
name="memberBillableRate" name="memberBillableRate"
@keydown.enter="saveWithChecks()"></BillableRateInput> v-model="
memberBody.billable_rate
"></BillableRateInput>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -112,11 +112,11 @@ useFocus(clientNameInput, { initialValue: true });
v-if="isBillingActivated() && canManageBilling()" v-if="isBillingActivated() && canManageBilling()"
href="/billing"> href="/billing">
<PrimaryButton <PrimaryButton
type="button"
class="mt-6"
v-if=" v-if="
isBillingActivated() && canUpdateOrganization() isBillingActivated() && canUpdateOrganization()
" ">
type="button"
class="mt-6">
<CreditCardIcon class="w-5 h-5 me-2" /> <CreditCardIcon class="w-5 h-5 me-2" />
Go to Billing Go to Billing
</PrimaryButton> </PrimaryButton>
@@ -128,15 +128,15 @@ useFocus(clientNameInput, { initialValue: true });
<InputLabel for="email" value="Email" /> <InputLabel for="email" value="Email" />
<TextInput <TextInput
id="email" id="email"
name="email"
ref="memberEmailInput" ref="memberEmailInput"
v-model="addTeamMemberForm.email" v-model="addTeamMemberForm.email"
name="email"
type="text" type="text"
placeholder="Member Email" placeholder="Member Email"
@keydown.enter="submit"
class="mt-1 block w-full" class="mt-1 block w-full"
required required
autocomplete="memberName" autocomplete="memberName" />
@keydown.enter="submit" />
<InputError :message="errors.email" class="mt-2" /> <InputError :message="errors.email" class="mt-2" />
</div> </div>

View File

@@ -20,19 +20,19 @@ const props = defineProps<{
<div class="min-w-[150px]"> <div class="min-w-[150px]">
<button <button
v-if="canUpdateMembers()" v-if="canUpdateMembers()"
@click="emit('edit')"
:aria-label="'Edit Member ' + props.member.name" :aria-label="'Edit Member ' + props.member.name"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out" class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
@click="emit('edit')">
<PencilSquareIcon <PencilSquareIcon
class="w-5 text-icon-active"></PencilSquareIcon> class="w-5 text-icon-active"></PencilSquareIcon>
<span>Edit</span> <span>Edit</span>
</button> </button>
<button <button
v-if="canDeleteMembers()" v-if="canDeleteMembers()"
@click="emit('delete')"
:aria-label="'Delete Member ' + props.member.name" :aria-label="'Delete Member ' + props.member.name"
data-testid="member_delete" data-testid="member_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out" class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
@click="emit('delete')">
<TrashIcon class="w-5 text-icon-active"></TrashIcon> <TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span> <span>Delete</span>
</button> </button>

View File

@@ -18,7 +18,7 @@ function getNameForItem(item: Member) {
<template> <template>
<MultiselectDropdown <MultiselectDropdown
search-placeholder="Search for a Member..." searchPlaceholder="Search for a Member..."
:items="members" :items="members"
:get-key-from-item="getKeyFromItem" :get-key-from-item="getKeyFromItem"
:get-name-for-item="getNameForItem"> :get-name-for-item="getNameForItem">

View File

@@ -36,9 +36,9 @@ const emit = defineEmits<{
<SecondaryButton @click="show = false"> Cancel</SecondaryButton> <SecondaryButton @click="show = false"> Cancel</SecondaryButton>
<PrimaryButton <PrimaryButton
class="ms-3" class="ms-3"
@click="emit('submit')"
:class="{ 'opacity-25': saving }" :class="{ 'opacity-25': saving }"
:disabled="saving" :disabled="saving">
@click="emit('submit')">
Confirm Transfer Confirm Transfer
</PrimaryButton> </PrimaryButton>
</template> </template>

View File

@@ -89,8 +89,8 @@ async function invitePlaceholder(id: string) {
member.is_placeholder === true && member.is_placeholder === true &&
canInvitePlaceholderMembers() canInvitePlaceholderMembers()
" "
size="small"
@click="invitePlaceholder(member.id)" @click="invitePlaceholder(member.id)"
size="small"
>Invite</SecondaryButton >Invite</SecondaryButton
> >
<MemberMoreOptionsDropdown <MemberMoreOptionsDropdown
@@ -99,8 +99,8 @@ async function invitePlaceholder(id: string) {
@delete="removeMember"></MemberMoreOptionsDropdown> @delete="removeMember"></MemberMoreOptionsDropdown>
</div> </div>
<MemberEditModal <MemberEditModal
v-model:show="showEditMemberModal" :member="member"
:member="member"></MemberEditModal> v-model:show="showEditMemberModal"></MemberEditModal>
</TableRow> </TableRow>
</template> </template>

View File

@@ -34,8 +34,8 @@
<div class="ml-4 flex flex-shrink-0"> <div class="ml-4 flex flex-shrink-0">
<button <button
type="button" type="button"
class="inline-flex rounded-md bg-card-background text-muted hover:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" @click="show = false"
@click="show = false"> class="inline-flex rounded-md bg-card-background text-muted hover:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
<span class="sr-only">Close</span> <span class="sr-only">Close</span>
<XMarkIcon class="h-5 w-5" aria-hidden="true" /> <XMarkIcon class="h-5 w-5" aria-hidden="true" />
</button> </button>

View File

@@ -17,10 +17,10 @@ defineEmits<{
<template> <template>
<BillableRateModal <BillableRateModal
@submit="$emit('submit')"
v-model:show="show" v-model:show="show"
v-model:saving="saving" v-model:saving="saving"
title="Update Organization Billable Rate" title="Update Organization Billable Rate">
@submit="$emit('submit')">
<p class="py-0.5 text-center"> <p class="py-0.5 text-center">
The organization billable rate will be updated to The organization billable rate will be updated to
<strong>{{ <strong>{{

View File

@@ -40,7 +40,7 @@ const shownProjects = computed(() => {
withDefaults( withDefaults(
defineProps<{ defineProps<{
border?: boolean; border: boolean;
}>(), }>(),
{ {
border: true, border: true,
@@ -123,17 +123,17 @@ function updateValue(project: Project) {
<template #content> <template #content>
<ComboboxRoot <ComboboxRoot
:open="open" :open="open"
:model-value="currentProject" :modelValue="currentProject"
:search-term="searchValue" @update:modelValue="updateValue"
class="relative" @update:searchTerm="(e) => console.log(e)"
@update:model-value="updateValue" :searchTerm="searchValue"
@update:search-term="(e) => console.log(e)"> class="relative">
<ComboboxAnchor> <ComboboxAnchor>
<ComboboxInput <ComboboxInput
@keydown.enter="addProjectIfNoneExists"
ref="searchInput" ref="searchInput"
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full" class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a project..." placeholder="Search for a project..." />
@keydown.enter="addProjectIfNoneExists" />
</ComboboxAnchor> </ComboboxAnchor>
<ComboboxContent> <ComboboxContent>
<ComboboxViewport <ComboboxViewport

View File

@@ -90,8 +90,8 @@ async function submitBillableRate() {
<div class="text-center"> <div class="text-center">
<InputLabel for="color" value="Color" /> <InputLabel for="color" value="Color" />
<ProjectColorSelector <ProjectColorSelector
v-model="project.color" class="mt-1"
class="mt-1"></ProjectColorSelector> v-model="project.color"></ProjectColorSelector>
</div> </div>
</div> </div>
<div class="w-full"> <div class="w-full">
@@ -102,18 +102,18 @@ async function submitBillableRate() {
v-model="project.name" v-model="project.name"
type="text" type="text"
placeholder="Project Name" placeholder="Project Name"
@keydown.enter="submit()"
class="mt-1 block w-full" class="mt-1 block w-full"
required required
autocomplete="projectName" autocomplete="projectName" />
@keydown.enter="submit()" />
</div> </div>
<div class=""> <div class="">
<InputLabel for="client" value="Client" /> <InputLabel for="client" value="Client" />
<ClientDropdown <ClientDropdown
v-model="project.client_id" :createClient
:create-client
:clients="clients" :clients="clients"
class="mt-1"> class="mt-1"
v-model="project.client_id">
<template #trigger> <template #trigger>
<Badge <Badge
class="bg-input-background cursor-pointer hover:bg-tertiary" class="bg-input-background cursor-pointer hover:bg-tertiary"
@@ -133,18 +133,18 @@ async function submitBillableRate() {
<div class="lg:grid grid-cols-2 gap-12"> <div class="lg:grid grid-cols-2 gap-12">
<div> <div>
<ProjectEditBillableSection <ProjectEditBillableSection
v-model:is-billable="project.is_billable" @submit="submit"
v-model:billable-rate="
project.billable_rate
"
:currency="getOrganizationCurrencyString()" :currency="getOrganizationCurrencyString()"
@submit="submit"></ProjectEditBillableSection> v-model:isBillable="project.is_billable"
v-model:billableRate="
project.billable_rate
"></ProjectEditBillableSection>
</div> </div>
<div> <div>
<EstimatedTimeSection <EstimatedTimeSection
v-if="isAllowedToPerformPremiumAction()" v-if="isAllowedToPerformPremiumAction()"
v-model="project.estimated_time" @submit="submit()"
@submit="submit()"></EstimatedTimeSection> v-model="project.estimated_time"></EstimatedTimeSection>
</div> </div>
</div> </div>
</template> </template>
@@ -163,9 +163,9 @@ async function submitBillableRate() {
<ProjectBillableRateModal <ProjectBillableRateModal
v-model:show="showBillableRateModal" v-model:show="showBillableRateModal"
:currency="getOrganizationCurrencyString()" :currency="getOrganizationCurrencyString()"
@submit="submitBillableRate"
:new-billable-rate="project.billable_rate" :new-billable-rate="project.billable_rate"
:project-name="project.name" :project-name="project.name"></ProjectBillableRateModal>
@submit="submitBillableRate"></ProjectBillableRateModal>
</template> </template>
<style scoped></style> <style scoped></style>

View File

@@ -21,29 +21,29 @@ const props = defineProps<{
<MoreOptionsDropdown :label="'Actions for Project ' + props.project.name"> <MoreOptionsDropdown :label="'Actions for Project ' + props.project.name">
<div class="min-w-[150px]"> <div class="min-w-[150px]">
<button <button
@click.prevent="emit('edit')"
v-if="canUpdateProjects()" v-if="canUpdateProjects()"
:aria-label="'Edit Project ' + props.project.name" :aria-label="'Edit Project ' + props.project.name"
data-testid="project_edit" data-testid="project_edit"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out" class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
@click.prevent="emit('edit')">
<PencilSquareIcon <PencilSquareIcon
class="w-5 text-icon-active"></PencilSquareIcon> class="w-5 text-icon-active"></PencilSquareIcon>
<span>Edit</span> <span>Edit</span>
</button> </button>
<button <button
@click.prevent="emit('archive')"
v-if="canUpdateProjects()" v-if="canUpdateProjects()"
:aria-label="'Archive Project ' + props.project.name" :aria-label="'Archive Project ' + props.project.name"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out" class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
@click.prevent="emit('archive')">
<ArchiveBoxIcon class="w-5 text-icon-active"></ArchiveBoxIcon> <ArchiveBoxIcon class="w-5 text-icon-active"></ArchiveBoxIcon>
<span>{{ project.is_archived ? 'Unarchive' : 'Archive' }}</span> <span>{{ project.is_archived ? 'Unarchive' : 'Archive' }}</span>
</button> </button>
<button <button
v-if="canDeleteProjects()" @click.prevent="emit('delete')"
:aria-label="'Delete Project ' + props.project.name" :aria-label="'Delete Project ' + props.project.name"
data-testid="project_delete" data-testid="project_delete"
class="border-b border-card-background-separator flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out" v-if="canDeleteProjects()"
@click.prevent="emit('delete')"> class="border-b border-card-background-separator flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<TrashIcon class="w-5 text-icon-active"></TrashIcon> <TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span> <span>Delete</span>
</button> </button>

View File

@@ -18,7 +18,7 @@ function getNameForItem(item: Project) {
<template> <template>
<MultiselectDropdown <MultiselectDropdown
search-placeholder="Search for a Project..." searchPlaceholder="Search for a Project..."
:items="projects" :items="projects"
:get-key-from-item="getKeyFromItem" :get-key-from-item="getKeyFromItem"
:get-name-for-item="getNameForItem"> :get-name-for-item="getNameForItem">

View File

@@ -44,12 +44,12 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
<template> <template>
<ProjectCreateModal <ProjectCreateModal
v-model:show="showCreateProjectModal" :createProject
:create-project :createClient
:create-client
:currency="getOrganizationCurrencyString()" :currency="getOrganizationCurrencyString()"
:clients="clients" :clients="clients"
:enable-estimated-time="isAllowedToPerformPremiumAction"></ProjectCreateModal> :enableEstimatedTime="isAllowedToPerformPremiumAction"
v-model:show="showCreateProjectModal"></ProjectCreateModal>
<div class="flow-root max-w-[100vw] overflow-x-auto"> <div class="flow-root max-w-[100vw] overflow-x-auto">
<div class="inline-block min-w-full align-middle"> <div class="inline-block min-w-full align-middle">
<div <div
@@ -57,12 +57,12 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
class="grid min-w-full" class="grid min-w-full"
:style="gridTemplate"> :style="gridTemplate">
<ProjectTableHeading <ProjectTableHeading
:show-billable-rate=" :showBillableRate="
props.showBillableRate props.showBillableRate
"></ProjectTableHeading> "></ProjectTableHeading>
<div <div
v-if="projects.length === 0" class="col-span-5 py-24 text-center"
class="col-span-5 py-24 text-center"> v-if="projects.length === 0">
<FolderPlusIcon <FolderPlusIcon
class="w-8 text-icon-default inline pb-2"></FolderPlusIcon> class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-white font-semibold"> <h3 class="text-white font-semibold">
@@ -81,14 +81,14 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
</p> </p>
<SecondaryButton <SecondaryButton
v-if="canCreateProjects()" v-if="canCreateProjects()"
:icon="PlusIcon"
@click="showCreateProjectModal = true" @click="showCreateProjectModal = true"
:icon="PlusIcon"
>Create your First Project >Create your First Project
</SecondaryButton> </SecondaryButton>
</div> </div>
<template v-for="project in projects" :key="project.id"> <template v-for="project in projects" :key="project.id">
<ProjectTableRow <ProjectTableRow
:show-billable-rate="props.showBillableRate" :showBillableRate="props.showBillableRate"
:project="project"></ProjectTableRow> :project="project"></ProjectTableRow>
</template> </template>
</div> </div>

View File

@@ -19,8 +19,8 @@ defineProps<{
Progress Progress
</div> </div>
<div <div
v-if="showBillableRate" class="px-3 py-1.5 text-left font-semibold text-white"
class="px-3 py-1.5 text-left font-semibold text-white"> v-if="showBillableRate">
Billable Rate Billable Rate
</div> </div>
<div class="px-3 py-1.5 text-left font-semibold text-white">Status</div> <div class="px-3 py-1.5 text-left font-semibold text-white">Status</div>

View File

@@ -83,8 +83,8 @@ const showEditProjectModal = ref(false);
</div> </div>
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-muted"> <div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-muted">
<div <div
v-if="project.client_id" class="overflow-ellipsis overflow-hidden"
class="overflow-ellipsis overflow-hidden"> v-if="project.client_id">
{{ client?.name }} {{ client?.name }}
</div> </div>
<div v-else>No client</div> <div v-else>No client</div>
@@ -106,8 +106,8 @@ const showEditProjectModal = ref(false);
<span v-else> -- </span> <span v-else> -- </span>
</div> </div>
<div <div
v-if="showBillableRate" class="whitespace-nowrap px-3 py-4 text-sm text-muted"
class="whitespace-nowrap px-3 py-4 text-sm text-muted"> v-if="showBillableRate">
{{ billableRateInfo }} {{ billableRateInfo }}
</div> </div>
<div <div

View File

@@ -18,10 +18,10 @@ defineEmits<{
<template> <template>
<BillableRateModal <BillableRateModal
@submit="$emit('submit')"
v-model:show="show" v-model:show="show"
v-model:saving="saving" v-model:saving="saving"
title="Update Project Member Billable Rate" title="Update Project Member Billable Rate">
@submit="$emit('submit')">
<p class="py-1 text-center"> <p class="py-1 text-center">
The billable rate of {{ memberName }} will be updated to The billable rate of {{ memberName }} will be updated to
<strong>{{ <strong>{{

View File

@@ -52,16 +52,16 @@ useFocus(projectNameInput, { initialValue: true });
<div class="grid grid-cols-3 items-center space-x-4"> <div class="grid grid-cols-3 items-center space-x-4">
<div class="col-span-3 sm:col-span-2"> <div class="col-span-3 sm:col-span-2">
<MemberCombobox <MemberCombobox
v-model="projectMember.member_id" :hidden-members="props.existingMembers"
:hidden-members="props.existingMembers"></MemberCombobox> v-model="projectMember.member_id"></MemberCombobox>
</div> </div>
<div class="col-span-3 sm:col-span-1 flex-1"> <div class="col-span-3 sm:col-span-1 flex-1">
<BillableRateInput <BillableRateInput
name="billable_rate"
:currency="getOrganizationCurrencyString()"
v-model=" v-model="
projectMember.billable_rate projectMember.billable_rate
" "></BillableRateInput>
name="billable_rate"
:currency="getOrganizationCurrencyString()"></BillableRateInput>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -75,8 +75,8 @@ useFocus(projectNameInput, { initialValue: true });
<template #content> <template #content>
<ProjectMemberBillableRateModal <ProjectMemberBillableRateModal
v-model:show="showBillableRateModal"
:member-name="props.name" :member-name="props.name"
v-model:show="showBillableRateModal"
:new-billable-rate="projectMemberBody.billable_rate" :new-billable-rate="projectMemberBody.billable_rate"
@close="showBillableRateModal = false" @close="showBillableRateModal = false"
@submit="submitBillableRate"></ProjectMemberBillableRateModal> @submit="submitBillableRate"></ProjectMemberBillableRateModal>
@@ -92,12 +92,12 @@ useFocus(projectNameInput, { initialValue: true });
class="mb-2" class="mb-2"
value="Billable Rate"></InputLabel> value="Billable Rate"></InputLabel>
<BillableRateInput <BillableRateInput
v-model=" @keydown.enter="submit"
projectMemberBody.billable_rate
"
:currency="getOrganizationCurrencyString()" :currency="getOrganizationCurrencyString()"
name="billable_rate" name="billable_rate"
@keydown.enter="submit"></BillableRateInput> v-model="
projectMemberBody.billable_rate
"></BillableRateInput>
</div> </div>
</div> </div>
</template> </template>

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