Compare commits

...

25 Commits

Author SHA1 Message Date
Constantin Graf
45a60a926f Fix docker image 2025-02-07 13:50:56 -05:00
Constantin Graf
0a956fd9e7 Fixed user create in filament 2025-02-06 14:20:37 -05:00
Constantin Graf
09b168cddb Update composer dependencies - minor 2025-02-06 14:00:30 -05:00
Gregor Vostrak
31b9659f7e start time entry on click in recently tracked time entries dropdown 2025-02-06 18:36:16 +01:00
Gregor Vostrak
db7111da44 add recently tracked timeentries dropdown to timetracker 2025-02-06 18:36:16 +01:00
Gregor Vostrak
18ab1f714b update dependencies, update eslint config, update optional ts props types 2025-02-06 18:36:16 +01:00
Gregor Vostrak
00e2518196 fix TimeTrackerRangeSelector detection so it does not open the Dropdown again after pressing Escape 2025-02-06 18:36:16 +01:00
Gregor Vostrak
6f6e5fb4c3 fix time update test to respect new taborder logic 2025-02-06 18:36:16 +01:00
Gregor Vostrak
68228bccb2 fix enter submits in the time range dropdown 2025-02-06 18:36:16 +01:00
Gregor Vostrak
2dd80ba6cc fix focus state for dropdowns, fix taborder for timerange select in timetracker and timeentryrows 2025-02-06 18:36:16 +01:00
Gregor Vostrak
b783ea9ecd improve focus state styling 2025-02-06 18:36:16 +01:00
Constantin Graf
dce608e403 Add more tests; Add filter in filament resource; Added options for user create command 2025-02-06 12:22:19 -05:00
Constantin Graf
84c9cfe2f2 Fixed bugs causing incorrect computed attributes in imported data 2025-02-06 12:22:19 -05:00
Constantin Graf
f14bd6413a Add missing serve option to local filesystem disk 2025-02-06 12:22:19 -05:00
Constantin Graf
eb19199bc6 Updated composer dependencies 2025-02-06 12:22:19 -05:00
Constantin Graf
0252d984cb Added estimated time to clockify project import 2025-02-06 12:22:19 -05:00
Constantin Graf
18162b0ff5 Fixed timezones in unit tests 2025-02-06 12:22:19 -05:00
Constantin Graf
3dab7440dd Updated composer dependencies 2025-02-06 12:22:19 -05:00
Constantin Graf
713e12e54e Fixed reports in deletion service 2025-02-06 12:22:19 -05:00
Constantin Graf
fc0a840ded Deactivated registration 2025-02-06 12:22:19 -05:00
dependabot[bot]
28904b650e Bump aglipanci/laravel-pint-action from 2.4 to 2.5
Bumps [aglipanci/laravel-pint-action](https://github.com/aglipanci/laravel-pint-action) from 2.4 to 2.5.
- [Release notes](https://github.com/aglipanci/laravel-pint-action/releases)
- [Commits](https://github.com/aglipanci/laravel-pint-action/compare/2.4...2.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-06 12:02:07 -05:00
dependabot[bot]
1d34a77eb2 Bump codecov/codecov-action from 5.1.2 to 5.3.1
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.1.2 to 5.3.1.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5.1.2...v5.3.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-06 11:34:37 -05:00
Constantin Graf
49e045809b Enhanced description for Clockify imports 2024-12-20 19:57:50 -05:00
Constantin Graf
e90fa8307f Fixed timezones in unit tests 2024-12-20 19:57:50 -05:00
dependabot[bot]
895540d0a9 Bump codecov/codecov-action from 4.5.0 to 5.1.2
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.5.0 to 5.1.2.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v4.5.0...v5.1.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-20 19:29:52 -05:00
224 changed files with 6174 additions and 4599 deletions

View File

@@ -4,6 +4,7 @@ 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,10 +1,13 @@
# 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
@@ -25,9 +28,16 @@ DB_TEST_DATABASE=laravel
DB_TEST_USERNAME=root DB_TEST_USERNAME=root
DB_TEST_PASSWORD=root DB_TEST_PASSWORD=root
BROADCAST_DRIVER=log # Broadcasting
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
@@ -41,14 +51,6 @@ 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
@@ -65,16 +67,9 @@ 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

View File

@@ -1,13 +0,0 @@
/* 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-latest runs-on: ubuntu-22.04
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@v4.5.0 uses: codecov/codecov-action@v5.3.1
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.4 uses: aglipanci/laravel-pint-action@2.5
with: with:
configPath: "pint.json" configPath: "pint.json"

View File

@@ -4,16 +4,14 @@ 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;
@@ -34,6 +32,12 @@ 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',
@@ -81,30 +85,16 @@ class CreateNewUser implements CreatesNewUsers
$currency = $ipLookupResponse->currency; $currency = $ipLookupResponse->currency;
} }
$user = null; $user = null;
$organization = null; DB::transaction(function () use (&$user, $input, $timezone, $startOfWeek, $currency): void {
DB::transaction(function () use (&$user, &$organization, $input, $timezone, $startOfWeek, $currency): void { $userService = app(UserService::class);
$user = User::create([ $user = $userService->createUser(
'name' => $input['name'], $input['name'],
'email' => $input['email'], $input['email'],
'password' => Hash::make($input['password']), $input['password'],
'timezone' => $timezone ?? 'UTC', $timezone ?? 'UTC',
'week_start' => $startOfWeek, $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,18 +7,16 @@ 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
{ {
@@ -36,15 +34,7 @@ class AddOrganizationMember implements AddsTeamMembers
->where('is_placeholder', '=', false) ->where('is_placeholder', '=', false)
->firstOrFail(); ->firstOrFail();
AddingTeamMember::dispatch($organization, $newOrganizationMember); app(MemberService::class)->addMember($newOrganizationMember, $organization, Role::from($role));
DB::transaction(function () use ($organization, $newOrganizationMember, $role): void {
$organization->users()->attach(
$newOrganizationMember, ['role' => $role]
);
});
TeamMemberAdded::dispatch($organization, $newOrganizationMember);
} }
/** /**

View File

@@ -0,0 +1,92 @@
<?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,7 +35,9 @@ 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::where('email', $email)->first(); $user = User::query()->where('email', $email)
->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,8 +60,13 @@ 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,7 +15,8 @@ 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,7 +15,8 @@ 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

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

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

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

@@ -0,0 +1,22 @@
<?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,8 +5,10 @@ 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;
@@ -46,10 +48,13 @@ 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')
@@ -62,6 +67,7 @@ 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)')
@@ -75,9 +81,11 @@ 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(),
]); ]);
} }
@@ -97,7 +105,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 ?? 'EUR', divideBy: 100), ->money(fn (Organization $resource) => $resource->currency, divideBy: 100),
Tables\Columns\TextColumn::make('created_at') Tables\Columns\TextColumn::make('created_at')
->dateTime() ->dateTime()
->sortable(), ->sortable(),
@@ -112,6 +120,10 @@ 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) {
@@ -199,8 +211,6 @@ class OrganizationResource extends Resource
]), ]),
]) ])
->bulkActions([ ->bulkActions([
Tables\Actions\BulkActionGroup::make([
]),
]); ]);
} }
@@ -208,6 +218,7 @@ class OrganizationResource extends Resource
{ {
return [ return [
UsersRelationManager::class, UsersRelationManager::class,
InvitationsRelationManager::class,
]; ];
} }

View File

@@ -15,7 +15,6 @@ 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,10 +4,33 @@ 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,7 +15,8 @@ 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

@@ -0,0 +1,86 @@
<?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,17 +5,24 @@ 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
{ {
@@ -36,20 +43,40 @@ 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($this->getOwnerRecord()->currency ?? 'EUR', divideBy: 100), ->money($organization->currency, divideBy: 100),
]) ])
->headerActions([ ->headerActions([
Tables\Actions\AttachAction::make()->form(fn (AttachAction $action): array => [ Tables\Actions\AttachAction::make()
$action->getRecordSelect(), ->recordTitle(fn (User $record): string => "{$record->name} ({$record->email})")
Select::make('role') ->form(fn (AttachAction $action): array => [
->options(Role::class), $action->getRecordSelect(),
]), 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')
@@ -58,13 +85,55 @@ 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()
Tables\Actions\DetachAction::make(), ->using(function (User $record, array $data): User {
/** @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,7 +15,8 @@ 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,7 +15,8 @@ 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,8 +72,13 @@ 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,7 +15,8 @@ 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,7 +15,8 @@ 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

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

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

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

@@ -0,0 +1,22 @@
<?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,8 +60,13 @@ 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,7 +15,8 @@ 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,7 +15,8 @@ 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,8 +61,13 @@ 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,7 +15,8 @@ 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,7 +15,8 @@ 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,8 +92,13 @@ 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,7 +15,8 @@ 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,7 +15,8 @@ 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,21 +5,27 @@ 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
@@ -34,6 +40,8 @@ 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([
@@ -50,12 +58,25 @@ 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())
@@ -67,15 +88,39 @@ 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(),
]); ]);
} }
@@ -145,11 +190,22 @@ 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,24 +4,29 @@ 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 afterCreate(): void protected function handleRecordCreation(array $data): User
{ {
/** @var User $user */ $userService = app(UserService::class);
$user = $this->record; $user = $userService->createUser(
$data['name'],
$data['email'],
$data['password_create'],
$data['timezone'],
Weekday::from($data['week_start']),
$data['currency'],
(bool) $data['is_email_verified']
);
$user->ownedTeams()->save(Organization::forceCreate([ return $user;
'user_id' => $user->id,
'name' => explode(' ', $user->name, 2)[0]."'s Organization",
'personal_team' => true,
]));
} }
} }

View File

@@ -15,7 +15,8 @@ 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,6 +7,7 @@ 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
{ {
@@ -15,6 +16,7 @@ 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,15 +5,18 @@ 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;
@@ -27,10 +30,6 @@ 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(),
]); ]);
} }
@@ -41,15 +40,11 @@ class OrganizationsRelationManager extends RelationManager
->columns([ ->columns([
TextColumn::make('name'), TextColumn::make('name'),
TextColumn::make('role'), TextColumn::make('role'),
TextColumn::make('billable_rate') TextColumn::make('membership.billable_rate')
->money(fn (Organization $resource) => $resource->currency ?? 'EUR', divideBy: 100), ->label('Billable rate')
->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')
@@ -58,13 +53,48 @@ 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()
Tables\Actions\DetachAction::make(), ->using(function (Organization $record, array $data): Organization {
/** @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,13 +9,12 @@ 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
{ {
@@ -73,12 +72,11 @@ class InvitationController extends Controller
* *
* @operationId resendInvitationEmail * @operationId resendInvitationEmail
*/ */
public function resend(Organization $organization, OrganizationInvitation $invitation): JsonResponse public function resend(Organization $organization, OrganizationInvitation $invitation, OrganizationInvitationService $organizationInvitationService): JsonResponse
{ {
$this->checkPermission($organization, 'invitations:resend', $invitation); $this->checkPermission($organization, 'invitations:resend', $invitation);
Mail::to($invitation->email) $organizationInvitationService->resend($invitation);
->queue(new OrganizationInvitationMail($invitation));
return response()->json(null, 204); return response()->json(null, 204);
} }

View File

@@ -6,7 +6,6 @@ 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;
@@ -19,8 +18,6 @@ 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;
@@ -80,22 +77,8 @@ 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();
$oldRole = Role::from($member->role); $allowOwnerChange = $this->hasPermission($organization, 'members:change-ownership');
if ($oldRole === Role::Owner) { $memberService->changeRole($member, $organization, $newRole, $allowOwnerChange);
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();
@@ -109,22 +92,11 @@ class MemberController extends Controller
* *
* @operationId removeMember * @operationId removeMember
*/ */
public function destroy(Organization $organization, Member $member): JsonResponse public function destroy(Organization $organization, Member $member, MemberService $memberService): JsonResponse
{ {
$this->checkPermission($organization, 'members:delete', $member); $this->checkPermission($organization, 'members:delete', $member);
if (TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->exists()) { $memberService->removeMember($member, $organization);
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,12 +7,13 @@ 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 ShouldQueue class RecalculateSpentTimeForProject implements ShouldDispatchAfterCommit, ShouldQueue
{ {
use Dispatchable; use Dispatchable;
use InteractsWithQueue; use InteractsWithQueue;

View File

@@ -7,12 +7,13 @@ 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 ShouldQueue class RecalculateSpentTimeForTask implements ShouldDispatchAfterCommit, 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 string|null $email_verified_at * @property Carbon|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,6 +13,7 @@ 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;
@@ -71,6 +72,9 @@ 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,6 +188,18 @@ 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,6 +43,7 @@ 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,6 +5,8 @@ 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;
@@ -99,6 +101,7 @@ 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();
@@ -158,6 +161,12 @@ 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,6 +5,8 @@ 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;
@@ -235,6 +237,7 @@ 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();
@@ -303,6 +306,12 @@ 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,6 +5,8 @@ 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;
@@ -99,6 +101,7 @@ 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();
@@ -144,6 +147,12 @@ 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,10 +5,21 @@ 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
{ {
@@ -19,6 +30,72 @@ 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

@@ -0,0 +1,18 @@
<?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,15 +5,49 @@ 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.11.28", "dedoc/scramble": "^0.12.2",
"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": "^2.2", "fumeapp/modeltyper": "^3.0",
"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",

2160
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -67,6 +67,8 @@ 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,6 +39,7 @@ return [
'local' => [ 'local' => [
'driver' => 'local', 'driver' => 'local',
'root' => storage_path('app'), 'root' => storage_path('app'),
'serve' => true,
'throw' => true, 'throw' => true,
], ],

View File

@@ -12,6 +12,7 @@ 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;
@@ -134,6 +135,7 @@ 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',
@@ -186,6 +188,7 @@ 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.46.1-jammy image: mcr.microsoft.com/playwright:v1.50.0-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_picker_input').first().fill('1'); await page.getByTestId('time_entry_range_start').first().fill('1');
await Promise.all([ await Promise.all([
page.waitForResponse(async (response) => { page.waitForResponse(async (response) => {
return ( return (
@@ -204,10 +204,7 @@ 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 page.getByTestId('time_entry_range_end').press('Enter'),
.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('Tab'), page.getByTestId('time_entry_time').press('Enter'),
]); ]);
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20/); await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20/);

36
eslint.config.mjs Normal file
View File

@@ -0,0 +1,36 @@
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
);

View File

@@ -5,18 +5,20 @@ declare(strict_types=1);
return [ return [
'clockify_time_entries' => [ 'clockify_time_entries' => [
'name' => 'Clockify Time Entries', 'name' => 'Clockify Time Entries',
'description' => '1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br> '. 'description' => '1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br>'.
'2. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'. '2. In the same preferences page change the language of Clockfiy to English.<br>'.
'3. Now select the date range that you want to export in the right top. '. '3. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'.
'4. Now select the date range that you want to export in the right top. '.
'It is currently not possible to select more than one year. You can export each year separately and import them one after another .'. 'It is currently not possible to select more than one year. You can export each year separately and import them one after another .'.
'<br> 4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table left of the printer symbol. '. '<br> 4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table left of the printer symbol. '.
'<br><br>Before you import make sure that the Timezone settings in Clockify are the same as in solidtime.', '<br><br>Before you import make sure that the Timezone settings in Clockify are the same as in solidtime.',
], ],
'clockify_projects' => [ 'clockify_projects' => [
'name' => 'Clockify Projects', 'name' => 'Clockify Projects',
'description' => '1. Go to PROJECTS in the navigation on the left.<br> '. 'description' => '1. Make sure to set the language of Clockify to English in "Preferences -> General".<br>'.
'2. Now click on the three dots on the right of the project that you want to export and select Export.<br> '. '2. Go to PROJECTS in the navigation on the left.<br> '.
'3. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table in the top right corner.', '3. Now click on the three dots on the right of the project that you want to export and select Export.<br> '.
'4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table in the top right corner.',
], ],
'toggl_data_importer' => [ 'toggl_data_importer' => [
'name' => 'Toggl Data Importer', 'name' => 'Toggl Data Importer',

3424
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,53 +4,60 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"lint": "eslint --ext .js,.vue,.ts --ignore-path .gitignore resources/js", "lint": "eslint resources/js",
"lint:fix": "eslint --fix --ext .js,.vue,.ts --ignore-path .gitignore resources/js", "lint:fix": "eslint --fix 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": "^20.11.5", "@types/node": "^22.10.10",
"@vitejs/plugin-vue": "^4.5.0", "@vitejs/plugin-vue": "^5.2.1",
"@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": "^3.1.0", "eslint-plugin-unused-imports": "^4.1.4",
"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.3.3", "typescript": "^5.7.3",
"vite": "^5.0.0", "vite": "^6.0.11",
"vite-plugin-checker": "^0.7.2", "vite-plugin-checker": "^0.8.0",
"vue": "^3.4.0", "vue": "^3.5.0",
"vue-tsc": "^2.0.28" "vue-tsc": "^2.2.0"
}, },
"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.7.0", "@rushstack/eslint-patch": "^1.10.5",
"@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": "^9.0.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^13.0.0", "@vue/eslint-config-typescript": "^14.3.0",
"@vueuse/core": "^10.11.0", "@vueuse/core": "^12.5.0",
"@vueuse/integrations": "^11.1.0", "@vueuse/integrations": "^12.5.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": "^1.1.0", "parse-duration": "^2.0.1",
"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": "^6.7.2" "vue-echarts": "^7.0.3"
},
"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 @click="hideBlackFridayBanner = true" class="p-1"> <button class="p-1" @click="hideBlackFridayBanner = true">
<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 @click="hideTrialBanner = true" class="p-1"> <button class="p-1" @click="hideTrialBanner = true">
<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 @click="hideBlockedBanner = true" class="p-1"> <button class="p-1" @click="hideBlockedBanner = true">
<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 @click="hideFreeUpgradeBanner = true" class="p-1"> <button class="p-1" @click="hideFreeUpgradeBanner = true">
<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
searchPlaceholder="Search for a Client..." search-placeholder="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
class="col-span-2 py-24 text-center" v-if="clients.length === 0"
v-if="clients.length === 0"> class="col-span-2 py-24 text-center">
<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 class="pb-5" v-if="canCreateClients()"> <p v-if="canCreateClients()" class="pb-5">
Create your first client now! Create your first client now!
</p> </p>
<SecondaryButton <SecondaryButton
v-if="canCreateClients()" v-if="canCreateClients()"
@click="createClient = true"
:icon="PlusIcon as Component" :icon="PlusIcon as Component"
@click="createClient = true"
>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
:client="client" v-model:show="showEditModal"
v-model:show="showEditModal"></ClientEditModal> :client="client"></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 v-slot:trigger> <template #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 class="flex-1" v-else>Select a member...</div> <div v-else class="flex-1">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"
:newBillableRate="memberBody.billable_rate" :new-billable-rate="memberBody.billable_rate"
@submit="submitBillableRate"></MemberBillableRateModal> @submit="submitBillableRate"></MemberBillableRateModal>
<MemberOwnershipTransferConfirmModal <MemberOwnershipTransferConfirmModal
:member-name="member.name"
v-model:show="showOwnershipTransferConfirmModal" v-model:show="showOwnershipTransferConfirmModal"
:member-name="member.name"
@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" name="role"></MemberRoleSelect>
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
class="flex-1" v-if="billableRateSelect === 'custom-rate'"
v-if="billableRateSelect === 'custom-rate'"> class="flex-1">
<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"
v-model=" @keydown.enter="saveWithChecks()"></BillableRateInput>
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
searchPlaceholder="Search for a Member..." search-placeholder="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()
" "
@click="invitePlaceholder(member.id)"
size="small" size="small"
@click="invitePlaceholder(member.id)"
>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
:member="member" v-model:show="showEditMemberModal"
v-model:show="showEditMemberModal"></MemberEditModal> :member="member"></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"
@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"
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">
<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"
:modelValue="currentProject" :model-value="currentProject"
@update:modelValue="updateValue" :search-term="searchValue"
@update:searchTerm="(e) => console.log(e)" class="relative"
:searchTerm="searchValue" @update:model-value="updateValue"
class="relative"> @update:search-term="(e) => console.log(e)">
<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
class="mt-1" v-model="project.color"
v-model="project.color"></ProjectColorSelector> class="mt-1"></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
:createClient v-model="project.client_id"
: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
@submit="submit" v-model:is-billable="project.is_billable"
:currency="getOrganizationCurrencyString()" v-model:billable-rate="
v-model:isBillable="project.is_billable"
v-model:billableRate="
project.billable_rate project.billable_rate
"></ProjectEditBillableSection> "
:currency="getOrganizationCurrencyString()"
@submit="submit"></ProjectEditBillableSection>
</div> </div>
<div> <div>
<EstimatedTimeSection <EstimatedTimeSection
v-if="isAllowedToPerformPremiumAction()" v-if="isAllowedToPerformPremiumAction()"
@submit="submit()" v-model="project.estimated_time"
v-model="project.estimated_time"></EstimatedTimeSection> @submit="submit()"></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"></ProjectBillableRateModal> :project-name="project.name"
@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
@click.prevent="emit('delete')" v-if="canDeleteProjects()"
:aria-label="'Delete Project ' + props.project.name" :aria-label="'Delete Project ' + props.project.name"
data-testid="project_delete" data-testid="project_delete"
v-if="canDeleteProjects()" 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"
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"> @click.prevent="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: Project) {
<template> <template>
<MultiselectDropdown <MultiselectDropdown
searchPlaceholder="Search for a Project..." search-placeholder="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
:createProject v-model:show="showCreateProjectModal"
:createClient :create-project
:create-client
:currency="getOrganizationCurrencyString()" :currency="getOrganizationCurrencyString()"
:clients="clients" :clients="clients"
:enableEstimatedTime="isAllowedToPerformPremiumAction" :enable-estimated-time="isAllowedToPerformPremiumAction"></ProjectCreateModal>
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
:showBillableRate=" :show-billable-rate="
props.showBillableRate props.showBillableRate
"></ProjectTableHeading> "></ProjectTableHeading>
<div <div
class="col-span-5 py-24 text-center" v-if="projects.length === 0"
v-if="projects.length === 0"> class="col-span-5 py-24 text-center">
<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()"
@click="showCreateProjectModal = true"
:icon="PlusIcon" :icon="PlusIcon"
@click="showCreateProjectModal = true"
>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
:showBillableRate="props.showBillableRate" :show-billable-rate="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
class="px-3 py-1.5 text-left font-semibold text-white" v-if="showBillableRate"
v-if="showBillableRate"> class="px-3 py-1.5 text-left font-semibold text-white">
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
class="overflow-ellipsis overflow-hidden" v-if="project.client_id"
v-if="project.client_id"> class="overflow-ellipsis overflow-hidden">
{{ 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
class="whitespace-nowrap px-3 py-4 text-sm text-muted" v-if="showBillableRate"
v-if="showBillableRate"> class="whitespace-nowrap px-3 py-4 text-sm text-muted">
{{ 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
:hidden-members="props.existingMembers" v-model="projectMember.member_id"
v-model="projectMember.member_id"></MemberCombobox> :hidden-members="props.existingMembers"></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>

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