mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 08:12:17 +01:00
Compare commits
25 Commits
feature/di
...
feature/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45a60a926f | ||
|
|
0a956fd9e7 | ||
|
|
09b168cddb | ||
|
|
31b9659f7e | ||
|
|
db7111da44 | ||
|
|
18ab1f714b | ||
|
|
00e2518196 | ||
|
|
6f6e5fb4c3 | ||
|
|
68228bccb2 | ||
|
|
2dd80ba6cc | ||
|
|
b783ea9ecd | ||
|
|
dce608e403 | ||
|
|
84c9cfe2f2 | ||
|
|
f14bd6413a | ||
|
|
eb19199bc6 | ||
|
|
0252d984cb | ||
|
|
18162b0ff5 | ||
|
|
3dab7440dd | ||
|
|
713e12e54e | ||
|
|
fc0a840ded | ||
|
|
28904b650e | ||
|
|
1d34a77eb2 | ||
|
|
49e045809b | ||
|
|
e90fa8307f | ||
|
|
895540d0a9 |
1
.env.ci
1
.env.ci
@@ -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
|
||||||
|
|||||||
29
.env.example
29
.env.example
@@ -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
|
|
||||||
|
|||||||
@@ -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'],
|
|
||||||
}
|
|
||||||
2
.github/workflows/build-public.yml
vendored
2
.github/workflows/build-public.yml
vendored
@@ -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
|
||||||
|
|||||||
2
.github/workflows/phpunit.yml
vendored
2
.github/workflows/phpunit.yml
vendored
@@ -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
|
||||||
|
|||||||
2
.github/workflows/pint.yml
vendored
2
.github/workflows/pint.yml
vendored
@@ -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"
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
92
app/Console/Commands/Admin/UserCreateCommand.php
Normal file
92
app/Console/Commands/Admin/UserCreateCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
114
app/Filament/Resources/OrganizationInvitationResource.php
Normal file
114
app/Filament/Resources/OrganizationInvitationResource.php
Normal 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}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 [
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
141
app/Filament/Resources/ReportResource.php
Normal file
141
app/Filament/Resources/ReportResource.php
Normal 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}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Filament/Resources/ReportResource/Pages/EditReport.php
Normal file
22
app/Filament/Resources/ReportResource/Pages/EditReport.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Filament/Resources/ReportResource/Pages/ListReports.php
Normal file
19
app/Filament/Resources/ReportResource/Pages/ListReports.php
Normal 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 [
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Filament/Resources/ReportResource/Pages/ViewReport.php
Normal file
22
app/Filament/Resources/ReportResource/Pages/ViewReport.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
18
app/Service/OrganizationInvitationService.php
Normal file
18
app/Service/OrganizationInvitationService.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
2160
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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'),
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
36
eslint.config.mjs
Normal 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
|
||||||
|
);
|
||||||
@@ -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
3424
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
41
package.json
41
package.json
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>{{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>{{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>{{
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user