mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d924fa74ec | ||
|
|
adf0d35c11 | ||
|
|
4ed8f16ae3 | ||
|
|
0a956fd9e7 | ||
|
|
09b168cddb | ||
|
|
31b9659f7e | ||
|
|
db7111da44 | ||
|
|
18ab1f714b | ||
|
|
00e2518196 | ||
|
|
6f6e5fb4c3 | ||
|
|
68228bccb2 | ||
|
|
2dd80ba6cc | ||
|
|
b783ea9ecd | ||
|
|
dce608e403 | ||
|
|
84c9cfe2f2 | ||
|
|
f14bd6413a | ||
|
|
eb19199bc6 | ||
|
|
0252d984cb | ||
|
|
18162b0ff5 | ||
|
|
3dab7440dd | ||
|
|
713e12e54e | ||
|
|
fc0a840ded | ||
|
|
28904b650e | ||
|
|
1d34a77eb2 | ||
|
|
49e045809b | ||
|
|
e90fa8307f | ||
|
|
895540d0a9 | ||
|
|
62270382dc | ||
|
|
29929467f6 | ||
|
|
02fe89dfdf | ||
|
|
03550a0ca6 | ||
|
|
2f1056dddb | ||
|
|
6e226cd743 | ||
|
|
19ed966504 | ||
|
|
33818f10b3 | ||
|
|
ee9d818d75 | ||
|
|
e3d8457523 | ||
|
|
67e42a0a54 | ||
|
|
fdbf88a9a6 | ||
|
|
c4daca32c5 | ||
|
|
4e10f9538f | ||
|
|
959cad8f74 | ||
|
|
e308ca78b1 | ||
|
|
4281736a6d | ||
|
|
9b0cf37bc7 | ||
|
|
a4f3e014d9 | ||
|
|
32bce2f749 | ||
|
|
ae7f5a98e7 | ||
|
|
e3f981aac2 | ||
|
|
bcb298bd6d | ||
|
|
620c4c97dc | ||
|
|
05da595470 | ||
|
|
a4d8a02b80 | ||
|
|
0860aa9d24 | ||
|
|
9c82efdf07 | ||
|
|
2560619c15 | ||
|
|
c03aad1abd | ||
|
|
0ee0175f04 | ||
|
|
0c1f06face | ||
|
|
86d625b18a |
2
.env.ci
2
.env.ci
@@ -4,7 +4,7 @@ APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
APP_FORCE_HTTPS=false
|
||||
SESSION_SECURE_COOKIE=false
|
||||
APP_ENABLE_REGISTRATION=true
|
||||
|
||||
# Logging
|
||||
LOG_CHANNEL=stack
|
||||
|
||||
29
.env.example
29
.env.example
@@ -1,10 +1,13 @@
|
||||
# Application
|
||||
APP_NAME=solidtime
|
||||
APP_ENV=local
|
||||
APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
|
||||
APP_DEBUG=true
|
||||
APP_URL=https://solidtime.test
|
||||
AUDITING_ENABLED=true
|
||||
APP_ENABLE_REGISTRATION=true
|
||||
SUPER_ADMINS=admin@example.com
|
||||
PAGINATION_PER_PAGE_DEFAULT=500
|
||||
|
||||
# Logging
|
||||
LOG_CHANNEL=single
|
||||
@@ -25,9 +28,16 @@ DB_TEST_DATABASE=laravel
|
||||
DB_TEST_USERNAME=root
|
||||
DB_TEST_PASSWORD=root
|
||||
|
||||
BROADCAST_DRIVER=log
|
||||
# Broadcasting
|
||||
BROADCAST_DRIVER=null
|
||||
|
||||
# Cache
|
||||
CACHE_DRIVER=file
|
||||
|
||||
# Queue
|
||||
QUEUE_CONNECTION=sync
|
||||
|
||||
# Session
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
|
||||
@@ -41,14 +51,6 @@ MAIL_ENCRYPTION=null
|
||||
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
|
||||
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
|
||||
FILESYSTEM_DISK=s3
|
||||
PUBLIC_FILESYSTEM_DISK=s3
|
||||
@@ -65,16 +67,9 @@ GOTENBERG_URL=http://gotenberg:3000
|
||||
|
||||
VITE_HOST_NAME=vite.solidtime.test
|
||||
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
|
||||
NETWORK_NAME=reverse-proxy-docker-traefik_routing
|
||||
|
||||
FORWARD_DB_PORT=5432
|
||||
FORWARD_WEB_PORT=8083
|
||||
|
||||
PAGINATION_PER_PAGE_DEFAULT=500
|
||||
|
||||
@@ -5,7 +5,6 @@ VITE_APP_NAME=solidtime
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
APP_FORCE_HTTPS=true
|
||||
SESSION_SECURE_COOKIE=true
|
||||
OCTANE_SERVER=frankenphp
|
||||
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
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
packages: write
|
||||
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
|
||||
|
||||
- name: "Upload coverage reports to Codecov"
|
||||
uses: codecov/codecov-action@v4.5.0
|
||||
uses: codecov/codecov-action@v5.3.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
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
|
||||
|
||||
- name: "Check code style"
|
||||
uses: aglipanci/laravel-pint-action@2.4
|
||||
uses: aglipanci/laravel-pint-action@2.5
|
||||
with:
|
||||
configPath: "pint.json"
|
||||
|
||||
@@ -13,7 +13,7 @@ solidtime is a modern open-source time tracking application for Freelancers and
|
||||
|
||||
- Time tracking: Track your time with a modern and easy-to-use interface
|
||||
- Projects: Create and manage projects and assign project members
|
||||
- Tasks: Create and manage tasks and assign tasks to project members
|
||||
- Tasks: Create and manage tasks and assign tasks to projects
|
||||
- Clients: Create and manage clients and assign clients to projects
|
||||
- Billable rates: Set billable rates for projects, project members, organization members and organizations
|
||||
- Multiple organizations: Create and manage multiple organizations with one account
|
||||
|
||||
@@ -4,16 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Enums\Weekday;
|
||||
use App\Events\NewsletterRegistered;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\IpLookup\IpLookupServiceContract;
|
||||
use App\Service\TimezoneService;
|
||||
use App\Service\UserService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
@@ -34,6 +32,12 @@ class CreateNewUser implements CreatesNewUsers
|
||||
*/
|
||||
public function create(array $input): User
|
||||
{
|
||||
if (! config('app.enable_registration')) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => [__('Registration is disabled.')],
|
||||
]);
|
||||
}
|
||||
|
||||
Validator::make($input, [
|
||||
'name' => [
|
||||
'required',
|
||||
@@ -81,30 +85,16 @@ class CreateNewUser implements CreatesNewUsers
|
||||
$currency = $ipLookupResponse->currency;
|
||||
}
|
||||
$user = null;
|
||||
$organization = null;
|
||||
DB::transaction(function () use (&$user, &$organization, $input, $timezone, $startOfWeek, $currency): void {
|
||||
$user = User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make($input['password']),
|
||||
'timezone' => $timezone ?? 'UTC',
|
||||
'week_start' => $startOfWeek,
|
||||
]);
|
||||
|
||||
$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,
|
||||
]
|
||||
DB::transaction(function () use (&$user, $input, $timezone, $startOfWeek, $currency): void {
|
||||
$userService = app(UserService::class);
|
||||
$user = $userService->createUser(
|
||||
$input['name'],
|
||||
$input['email'],
|
||||
$input['password'],
|
||||
$timezone ?? 'UTC',
|
||||
$startOfWeek,
|
||||
$currency ?? 'EUR',
|
||||
);
|
||||
|
||||
$user->ownedTeams()->save($organization);
|
||||
});
|
||||
|
||||
$newsletterConsent = isset($input['newsletter_consent']) && (bool) $input['newsletter_consent'];
|
||||
|
||||
@@ -7,18 +7,16 @@ namespace App\Actions\Jetstream;
|
||||
use App\Enums\Role;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\MemberService;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\In;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
use Laravel\Jetstream\Contracts\AddsTeamMembers;
|
||||
use Laravel\Jetstream\Events\AddingTeamMember;
|
||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
||||
|
||||
class AddOrganizationMember implements AddsTeamMembers
|
||||
{
|
||||
@@ -36,15 +34,7 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
->where('is_placeholder', '=', false)
|
||||
->firstOrFail();
|
||||
|
||||
AddingTeamMember::dispatch($organization, $newOrganizationMember);
|
||||
|
||||
DB::transaction(function () use ($organization, $newOrganizationMember, $role): void {
|
||||
$organization->users()->attach(
|
||||
$newOrganizationMember, ['role' => $role]
|
||||
);
|
||||
});
|
||||
|
||||
TeamMemberAdded::dispatch($organization, $newOrganizationMember);
|
||||
app(MemberService::class)->addMember($newOrganizationMember, $organization, Role::from($role));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
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.'"');
|
||||
|
||||
/** @var User|null $user */
|
||||
$user = User::where('email', $email)->first();
|
||||
$user = User::query()->where('email', $email)
|
||||
->where('is_placeholder', '=', false)
|
||||
->first();
|
||||
|
||||
if ($user === null) {
|
||||
$this->error('User with email "'.$email.'" not found.');
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Report;
|
||||
|
||||
use App\Models\Report;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Carbon;
|
||||
use LogicException;
|
||||
|
||||
class ReportSetExpiredToPrivateCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'report:set-expired-to-private '.
|
||||
' { --dry-run : Do not actually save anything to the database, just output what would happen }';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Makes public reports private if the public_until date has passed.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$this->comment('Makes public reports private if the public_until date has passed...');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
if ($dryRun) {
|
||||
$this->comment('Running in dry-run mode. Nothing will be saved to the database.');
|
||||
}
|
||||
|
||||
$resetReports = 0;
|
||||
Report::query()
|
||||
->where('public_until', '<', Carbon::now())
|
||||
->orderBy('created_at', 'asc')
|
||||
->chunk(500, function (Collection $reports) use ($dryRun, &$resetReports): void {
|
||||
/** @var Collection<int, Report> $reports */
|
||||
foreach ($reports as $report) {
|
||||
$publicUntil = $report->public_until;
|
||||
if ($publicUntil === null) {
|
||||
throw new LogicException('public_until should not be null');
|
||||
}
|
||||
$this->info('Make report "'.$report->name.'" ('.$report->getKey().') private, expired: '.
|
||||
$publicUntil->toIso8601ZuluString().' ('.$publicUntil->diffForHumans().')');
|
||||
$resetReports++;
|
||||
if (! $dryRun) {
|
||||
$report->is_public = false;
|
||||
$report->share_secret = null;
|
||||
$report->save();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->comment('Finished setting '.$resetReports.' expired reports to private...');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ class SelfHostGenerateKeysCommand extends Command
|
||||
*/
|
||||
protected $signature = 'self-host:generate-keys
|
||||
{ --length=4096 : The length of the passport private key }
|
||||
{ --multi-line : Whether to output the keys in multiple lines }
|
||||
{ --format=env : The format of the output (env, yaml) }';
|
||||
|
||||
/**
|
||||
@@ -34,6 +35,7 @@ class SelfHostGenerateKeysCommand extends Command
|
||||
{
|
||||
$format = $this->option('format');
|
||||
$key = RSA::createKey((int) $this->option('length'));
|
||||
$multiLine = (bool) $this->option('multi-line');
|
||||
|
||||
$publicKey = (string) $key->getPublicKey();
|
||||
$privateKey = (string) $key;
|
||||
@@ -41,12 +43,17 @@ class SelfHostGenerateKeysCommand extends Command
|
||||
|
||||
if ($format === 'env') {
|
||||
$this->line('APP_KEY="'.$appKey.'"');
|
||||
$this->line('PASSPORT_PRIVATE_KEY="'.$privateKey.'"');
|
||||
$this->line('PASSPORT_PUBLIC_KEY="'.$publicKey.'"');
|
||||
if ($multiLine) {
|
||||
$this->line('PASSPORT_PRIVATE_KEY="'.Str::replace("\r\n", "\n", $privateKey).'"');
|
||||
$this->line('PASSPORT_PUBLIC_KEY="'.Str::replace("\r\n", "\n", $publicKey).'"');
|
||||
} else {
|
||||
$this->line('PASSPORT_PRIVATE_KEY="'.Str::replace("\r\n", '\n', $privateKey).'"');
|
||||
$this->line('PASSPORT_PUBLIC_KEY="'.Str::replace("\r\n", '\n', $publicKey).'"');
|
||||
}
|
||||
} elseif ($format === 'yaml') {
|
||||
$this->line('APP_KEY: "'.$appKey.'"');
|
||||
$this->line("PASSPORT_PRIVATE_KEY: |\n ".Str::replace("\n", "\n ", $privateKey));
|
||||
$this->line("PASSPORT_PUBLIC_KEY: |\n ".Str::replace("\n", "\n ", $publicKey));
|
||||
$this->line("PASSPORT_PRIVATE_KEY: |\n ".Str::replace("\r\n", "\n ", $privateKey));
|
||||
$this->line("PASSPORT_PUBLIC_KEY: |\n ".Str::replace("\r\n", "\n ", $publicKey));
|
||||
} else {
|
||||
$this->error('Invalid format');
|
||||
|
||||
|
||||
@@ -4,10 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
enum Weekday: string
|
||||
{
|
||||
use LaravelEnumHelper;
|
||||
|
||||
case Monday = 'monday';
|
||||
case Tuesday = 'tuesday';
|
||||
case Wednesday = 'wednesday';
|
||||
|
||||
@@ -60,8 +60,13 @@ class ClientResource extends Resource
|
||||
->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([
|
||||
Tables\Actions\EditAction::make(),
|
||||
|
||||
@@ -15,7 +15,8 @@ class EditClient extends EditRecord
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make(),
|
||||
Actions\DeleteAction::make()
|
||||
->icon('heroicon-m-trash'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ class ListClients extends ListRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
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;
|
||||
|
||||
use App\Filament\Resources\OrganizationResource\Pages;
|
||||
use App\Filament\Resources\OrganizationResource\RelationManagers\InvitationsRelationManager;
|
||||
use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager;
|
||||
use App\Models\Organization;
|
||||
use App\Service\DeletionService;
|
||||
use App\Service\Export\ExportService;
|
||||
use App\Service\Import\Importers\ImporterProvider;
|
||||
use App\Service\Import\Importers\ImportException;
|
||||
@@ -46,10 +48,13 @@ class OrganizationResource extends Resource
|
||||
->maxLength(255),
|
||||
Forms\Components\Toggle::make('personal_team')
|
||||
->label('Is personal?')
|
||||
->hiddenOn(['create'])
|
||||
->required(),
|
||||
Forms\Components\Select::make('user_id')
|
||||
->label('Owner')
|
||||
->relationship(name: 'owner', titleAttribute: 'email')
|
||||
->searchable(['name', 'email'])
|
||||
->disabledOn(['edit'])
|
||||
->required(),
|
||||
Forms\Components\Select::make('currency')
|
||||
->label('Currency')
|
||||
@@ -62,6 +67,7 @@ class OrganizationResource extends Resource
|
||||
|
||||
return $select;
|
||||
})
|
||||
->required()
|
||||
->searchable(),
|
||||
Forms\Components\TextInput::make('billable_rate')
|
||||
->label('Billable rate (in Cents)')
|
||||
@@ -75,9 +81,11 @@ class OrganizationResource extends Resource
|
||||
->numeric(),
|
||||
Forms\Components\DateTimePicker::make('created_at')
|
||||
->label('Created At')
|
||||
->hiddenOn(['create'])
|
||||
->disabled(),
|
||||
Forms\Components\DateTimePicker::make('updated_at')
|
||||
->label('Updated At')
|
||||
->hiddenOn(['create'])
|
||||
->disabled(),
|
||||
]);
|
||||
}
|
||||
@@ -97,7 +105,7 @@ class OrganizationResource extends Resource
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('currency'),
|
||||
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')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
@@ -112,6 +120,10 @@ class OrganizationResource extends Resource
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
Tables\Actions\DeleteAction::make()
|
||||
->using(function (Organization $record): void {
|
||||
app(DeletionService::class)->deleteOrganization($record);
|
||||
}),
|
||||
Action::make('Export')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->action(function (Organization $record) {
|
||||
@@ -199,8 +211,6 @@ class OrganizationResource extends Resource
|
||||
]),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -208,6 +218,7 @@ class OrganizationResource extends Resource
|
||||
{
|
||||
return [
|
||||
UsersRelationManager::class,
|
||||
InvitationsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ class DeleteOrganization extends DeleteAction
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
// TODO: check why setting the icon is necessary
|
||||
$this->icon('heroicon-m-trash');
|
||||
$this->action(function (): void {
|
||||
$result = $this->process(function (Organization $record): bool {
|
||||
|
||||
@@ -4,10 +4,33 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\OrganizationResource\Pages;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Filament\Resources\OrganizationResource;
|
||||
use App\Models\Organization;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateOrganization extends CreateRecord
|
||||
{
|
||||
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
|
||||
{
|
||||
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;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\Api\ApiException;
|
||||
use App\Filament\Resources\UserResource;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\BillableRateService;
|
||||
use App\Service\MemberService;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Actions\AttachAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UsersRelationManager extends RelationManager
|
||||
{
|
||||
@@ -36,20 +43,40 @@ class UsersRelationManager extends RelationManager
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
/** @var Organization $organization */
|
||||
$organization = $this->getOwnerRecord();
|
||||
|
||||
return $table
|
||||
->recordTitleAttribute('name')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name'),
|
||||
Tables\Columns\TextColumn::make('role'),
|
||||
TextColumn::make('billable_rate')
|
||||
->money($this->getOwnerRecord()->currency ?? 'EUR', divideBy: 100),
|
||||
->money($organization->currency, divideBy: 100),
|
||||
])
|
||||
->headerActions([
|
||||
Tables\Actions\AttachAction::make()->form(fn (AttachAction $action): array => [
|
||||
$action->getRecordSelect(),
|
||||
Select::make('role')
|
||||
->options(Role::class),
|
||||
]),
|
||||
Tables\Actions\AttachAction::make()
|
||||
->recordTitle(fn (User $record): string => "{$record->name} ({$record->email})")
|
||||
->form(fn (AttachAction $action): array => [
|
||||
$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([
|
||||
Action::make('view')
|
||||
@@ -58,13 +85,55 @@ class UsersRelationManager extends RelationManager
|
||||
->url(fn (User $record): string => UserResource::getUrl('view', [
|
||||
'record' => $record->getKey(),
|
||||
])),
|
||||
Tables\Actions\EditAction::make(),
|
||||
Tables\Actions\DetachAction::make(),
|
||||
Tables\Actions\EditAction::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([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DetachBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ class EditProjectMember extends EditRecord
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make(),
|
||||
Actions\DeleteAction::make()
|
||||
->icon('heroicon-m-trash'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ class ListProjectMembers extends ListRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
Actions\CreateAction::make()
|
||||
->icon('heroicon-s-plus'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,8 +72,13 @@ class ProjectResource extends Resource
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('organization')
|
||||
->label('Organization')
|
||||
->relationship('organization', 'name')
|
||||
->searchable(),
|
||||
SelectFilter::make('organization_id')
|
||||
->label('Organization ID')
|
||||
->relationship('organization', 'id')
|
||||
->searchable(),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->actions([
|
||||
|
||||
@@ -15,7 +15,8 @@ class EditProject extends EditRecord
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make(),
|
||||
Actions\DeleteAction::make()
|
||||
->icon('heroicon-m-trash'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ class ListProjects extends ListRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
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')
|
||||
->filters([
|
||||
SelectFilter::make('organization')
|
||||
->label('Organization')
|
||||
->relationship('organization', 'name')
|
||||
->searchable(),
|
||||
SelectFilter::make('organization_id')
|
||||
->label('Organization ID')
|
||||
->relationship('organization', 'id')
|
||||
->searchable(),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
|
||||
@@ -15,7 +15,8 @@ class EditTag extends EditRecord
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make(),
|
||||
Actions\DeleteAction::make()
|
||||
->icon('heroicon-m-trash'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ class ListTags extends ListRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
Actions\CreateAction::make()
|
||||
->icon('heroicon-s-plus'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,8 +61,13 @@ class TaskResource extends Resource
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('organization')
|
||||
->label('Organization')
|
||||
->relationship('organization', 'name')
|
||||
->searchable(),
|
||||
SelectFilter::make('organization_id')
|
||||
->label('Organization ID')
|
||||
->relationship('organization', 'id')
|
||||
->searchable(),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->actions([
|
||||
|
||||
@@ -15,7 +15,8 @@ class EditTask extends EditRecord
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make(),
|
||||
Actions\DeleteAction::make()
|
||||
->icon('heroicon-m-trash'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ class ListTasks extends ListRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
Actions\CreateAction::make()
|
||||
->icon('heroicon-s-plus'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,8 +92,13 @@ class TimeEntryResource extends Resource
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('organization')
|
||||
->label('Organization')
|
||||
->relationship('organization', 'name')
|
||||
->searchable(),
|
||||
SelectFilter::make('organization_id')
|
||||
->label('Organization ID')
|
||||
->relationship('organization', 'id')
|
||||
->searchable(),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->actions([
|
||||
|
||||
@@ -15,7 +15,8 @@ class EditTimeEntry extends EditRecord
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make(),
|
||||
Actions\DeleteAction::make()
|
||||
->icon('heroicon-m-trash'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ class ListTimeEntries extends ListRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
Actions\CreateAction::make()
|
||||
->icon('heroicon-s-plus'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,21 +5,27 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Exceptions\Api\ApiException;
|
||||
use App\Filament\Resources\UserResource\Pages;
|
||||
use App\Filament\Resources\UserResource\RelationManagers\OrganizationsRelationManager;
|
||||
use App\Filament\Resources\UserResource\RelationManagers\OwnedOrganizationsRelationManager;
|
||||
use App\Models\User;
|
||||
use App\Service\DeletionService;
|
||||
use App\Service\TimezoneService;
|
||||
use Brick\Money\ISOCurrencyProvider;
|
||||
use Exception;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
use STS\FilamentImpersonate\Tables\Actions\Impersonate;
|
||||
|
||||
class UserResource extends Resource
|
||||
@@ -34,6 +40,9 @@ class UserResource extends Resource
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
/** @var User|null $record */
|
||||
$record = $form->getRecord();
|
||||
|
||||
return $form
|
||||
->columns(1)
|
||||
->schema([
|
||||
@@ -50,12 +59,25 @@ class UserResource extends Resource
|
||||
Forms\Components\TextInput::make('email')
|
||||
->label('Email')
|
||||
->required()
|
||||
->rules($record?->is_placeholder ? [] : [
|
||||
UniqueEloquent::make(User::class, 'email')
|
||||
->ignore($record?->getKey()),
|
||||
])
|
||||
->rule([
|
||||
'email',
|
||||
])
|
||||
->maxLength(255),
|
||||
Forms\Components\Toggle::make('is_placeholder')
|
||||
->label('Is Placeholder'),
|
||||
->label('Is Placeholder?')
|
||||
->hiddenOn(['create'])
|
||||
->disabledOn(['edit']),
|
||||
Forms\Components\DateTimePicker::make('email_verified_at')
|
||||
->label('Email Verified At')
|
||||
->hiddenOn(['create'])
|
||||
->nullable(),
|
||||
Forms\Components\Toggle::make('is_email_verified')
|
||||
->label('Email Verified?')
|
||||
->visibleOn(['create']),
|
||||
Forms\Components\Select::make('timezone')
|
||||
->label('Timezone')
|
||||
->options(fn (): array => app(TimezoneService::class)->getSelectOptions())
|
||||
@@ -67,15 +89,39 @@ class UserResource extends Resource
|
||||
->required(),
|
||||
TextInput::make('password')
|
||||
->password()
|
||||
->label('Password')
|
||||
->dehydrateStateUsing(fn ($state) => Hash::make($state))
|
||||
->dehydrated(fn ($state) => filled($state))
|
||||
->hiddenOn(['create'])
|
||||
->required(fn (string $context): bool => $context === 'create')
|
||||
->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')
|
||||
->label('Created At')
|
||||
->hiddenOn(['create'])
|
||||
->disabled(),
|
||||
Forms\Components\DateTimePicker::make('updated_at')
|
||||
->label('Updated At')
|
||||
->hiddenOn(['create'])
|
||||
->disabled(),
|
||||
]);
|
||||
}
|
||||
@@ -145,11 +191,22 @@ class UserResource extends Resource
|
||||
}
|
||||
}),
|
||||
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([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,24 +4,29 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\UserResource\Pages;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Filament\Resources\UserResource;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\UserService;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateUser extends CreateRecord
|
||||
{
|
||||
protected static string $resource = UserResource::class;
|
||||
|
||||
protected function afterCreate(): void
|
||||
protected function handleRecordCreation(array $data): User
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->record;
|
||||
$userService = app(UserService::class);
|
||||
$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([
|
||||
'user_id' => $user->id,
|
||||
'name' => explode(' ', $user->name, 2)[0]."'s Organization",
|
||||
'personal_team' => true,
|
||||
]));
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ class ListUsers extends ListRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
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 Filament\Actions\EditAction;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use STS\FilamentImpersonate\Pages\Actions\Impersonate;
|
||||
|
||||
class ViewUser extends ViewRecord
|
||||
{
|
||||
@@ -15,6 +16,7 @@ class ViewUser extends ViewRecord
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Impersonate::make()->record($this->getRecord()),
|
||||
EditAction::make('edit')
|
||||
->icon('heroicon-s-pencil'),
|
||||
];
|
||||
|
||||
@@ -5,15 +5,18 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Resources\UserResource\RelationManagers;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\Api\ApiException;
|
||||
use App\Filament\Resources\OrganizationResource;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\MemberService;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Actions\AttachAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
@@ -27,10 +30,6 @@ class OrganizationsRelationManager extends RelationManager
|
||||
->schema([
|
||||
Select::make('role')
|
||||
->options(Role::class),
|
||||
TextInput::make('billable_rate')
|
||||
->label('Billable rate (in Cents)')
|
||||
->nullable()
|
||||
->numeric(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -41,15 +40,11 @@ class OrganizationsRelationManager extends RelationManager
|
||||
->columns([
|
||||
TextColumn::make('name'),
|
||||
TextColumn::make('role'),
|
||||
TextColumn::make('billable_rate')
|
||||
->money(fn (Organization $resource) => $resource->currency ?? 'EUR', divideBy: 100),
|
||||
TextColumn::make('membership.billable_rate')
|
||||
->label('Billable rate')
|
||||
->money(fn (Organization $resource) => $resource->currency, divideBy: 100),
|
||||
])
|
||||
->headerActions([
|
||||
Tables\Actions\AttachAction::make()->form(fn (AttachAction $action): array => [
|
||||
$action->getRecordSelect(),
|
||||
Select::make('role')
|
||||
->options(Role::class),
|
||||
]),
|
||||
])
|
||||
->actions([
|
||||
Action::make('view')
|
||||
@@ -58,13 +53,48 @@ class OrganizationsRelationManager extends RelationManager
|
||||
->url(fn (Organization $record): string => OrganizationResource::getUrl('view', [
|
||||
'record' => $record->getKey(),
|
||||
])),
|
||||
Tables\Actions\EditAction::make(),
|
||||
Tables\Actions\DetachAction::make(),
|
||||
Tables\Actions\EditAction::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([
|
||||
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\Resources\V1\Invitation\InvitationCollection;
|
||||
use App\Http\Resources\V1\Invitation\InvitationResource;
|
||||
use App\Mail\OrganizationInvitationMail;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Service\InvitationService;
|
||||
use App\Service\OrganizationInvitationService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class InvitationController extends Controller
|
||||
{
|
||||
@@ -73,12 +72,11 @@ class InvitationController extends Controller
|
||||
*
|
||||
* @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);
|
||||
|
||||
Mail::to($invitation->email)
|
||||
->queue(new OrganizationInvitationMail($invitation));
|
||||
$organizationInvitationService->resend($invitation);
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Events\MemberMadeToPlaceholder;
|
||||
use App\Events\MemberRemoved;
|
||||
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
||||
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\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Service\BillableRateService;
|
||||
use App\Service\InvitationService;
|
||||
use App\Service\MemberService;
|
||||
@@ -80,22 +77,8 @@ class MemberController extends Controller
|
||||
}
|
||||
if ($request->has('role') && $member->role !== $request->getRole()->value) {
|
||||
$newRole = $request->getRole();
|
||||
$oldRole = Role::from($member->role);
|
||||
if ($oldRole === Role::Owner) {
|
||||
throw new OrganizationNeedsAtLeastOneOwner;
|
||||
}
|
||||
if ($newRole === Role::Placeholder) {
|
||||
throw new ChangingRoleToPlaceholderIsNotAllowed;
|
||||
}
|
||||
if ($newRole === Role::Owner) {
|
||||
if ($this->hasPermission($organization, 'members:change-ownership')) {
|
||||
$memberService->changeOwnership($organization, $member);
|
||||
} else {
|
||||
throw new OnlyOwnerCanChangeOwnership;
|
||||
}
|
||||
} else {
|
||||
$member->role = $request->getRole()->value;
|
||||
}
|
||||
$allowOwnerChange = $this->hasPermission($organization, 'members:change-ownership');
|
||||
$memberService->changeRole($member, $organization, $newRole, $allowOwnerChange);
|
||||
}
|
||||
$member->save();
|
||||
|
||||
@@ -109,28 +92,19 @@ class MemberController extends Controller
|
||||
*
|
||||
* @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);
|
||||
|
||||
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);
|
||||
$memberService->removeMember($member, $organization);
|
||||
|
||||
return response()
|
||||
->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a member a placeholder member
|
||||
*
|
||||
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization
|
||||
*/
|
||||
public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse
|
||||
|
||||
@@ -102,6 +102,7 @@ class ProjectController extends Controller
|
||||
$project->is_billable = (bool) $request->input('is_billable');
|
||||
$project->billable_rate = $request->getBillableRate();
|
||||
$project->client_id = $request->input('client_id');
|
||||
$project->is_public = $request->getIsPublic();
|
||||
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
||||
$project->estimated_time = $request->getEstimatedTime();
|
||||
}
|
||||
@@ -127,6 +128,9 @@ class ProjectController extends Controller
|
||||
if ($request->has('is_archived')) {
|
||||
$project->archived_at = $request->getIsArchived() ? Carbon::now() : null;
|
||||
}
|
||||
if ($request->has('is_public')) {
|
||||
$project->is_public = $request->boolean('is_public');
|
||||
}
|
||||
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
||||
$project->estimated_time = $request->getEstimatedTime();
|
||||
}
|
||||
|
||||
90
app/Http/Controllers/Api/V1/Public/ReportController.php
Normal file
90
app/Http/Controllers/Api/V1/Public/ReportController.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Public;
|
||||
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Http\Controllers\Api\V1\Controller;
|
||||
use App\Http\Resources\V1\Report\DetailedWithDataReportResource;
|
||||
use App\Models\Report;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Service\Dto\ReportPropertiesDto;
|
||||
use App\Service\TimeEntryAggregationService;
|
||||
use App\Service\TimeEntryFilter;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ReportController extends Controller
|
||||
{
|
||||
/**
|
||||
* Get report by a share secret
|
||||
*
|
||||
* This endpoint is public and does not require authentication. The report must be public and not expired.
|
||||
* The report is considered expired if the `public_until` field is set and the date is in the past.
|
||||
* The report is considered public if the `is_public` field is set to `true`.
|
||||
*
|
||||
* @operationId getPublicReport
|
||||
*/
|
||||
public function show(Request $request, TimeEntryAggregationService $timeEntryAggregationService): DetailedWithDataReportResource
|
||||
{
|
||||
$shareSecret = $request->header('X-Api-Key');
|
||||
if (! is_string($shareSecret)) {
|
||||
throw new ModelNotFoundException;
|
||||
}
|
||||
|
||||
$report = Report::query()
|
||||
->with([
|
||||
'organization',
|
||||
])
|
||||
->where('share_secret', '=', $shareSecret)
|
||||
->where('is_public', '=', true)
|
||||
->where(function (Builder $builder): void {
|
||||
/** @var Builder<Report> $builder */
|
||||
$builder->whereNull('public_until')
|
||||
->orWhere('public_until', '>', now());
|
||||
})
|
||||
->firstOrFail();
|
||||
/** @var ReportPropertiesDto $properties */
|
||||
$properties = $report->properties;
|
||||
|
||||
$timeEntriesQuery = TimeEntry::query()
|
||||
->whereBelongsTo($report->organization, 'organization');
|
||||
|
||||
$filter = new TimeEntryFilter($timeEntriesQuery);
|
||||
$filter->addStart($properties->start);
|
||||
$filter->addEnd($properties->end);
|
||||
$filter->addActive($properties->active);
|
||||
$filter->addBillable($properties->billable);
|
||||
$filter->addMemberIdsFilter($properties->memberIds?->toArray());
|
||||
$filter->addProjectIdsFilter($properties->projectIds?->toArray());
|
||||
$filter->addTagIdsFilter($properties->tagIds?->toArray());
|
||||
$filter->addTaskIdsFilter($properties->taskIds?->toArray());
|
||||
$filter->addClientIdsFilter($properties->clientIds?->toArray());
|
||||
$timeEntriesQuery = $filter->get();
|
||||
|
||||
$data = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
||||
$timeEntriesQuery->clone(),
|
||||
$report->properties->group,
|
||||
$report->properties->subGroup,
|
||||
$report->properties->timezone,
|
||||
$report->properties->weekStart,
|
||||
false,
|
||||
$report->properties->start,
|
||||
$report->properties->end,
|
||||
);
|
||||
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
||||
$timeEntriesQuery->clone(),
|
||||
TimeEntryAggregationType::fromInterval($report->properties->historyGroup),
|
||||
null,
|
||||
$report->properties->timezone,
|
||||
$report->properties->weekStart,
|
||||
true,
|
||||
$report->properties->start,
|
||||
$report->properties->end,
|
||||
);
|
||||
|
||||
return new DetailedWithDataReportResource($report, $data, $historyData);
|
||||
}
|
||||
}
|
||||
172
app/Http/Controllers/Api/V1/ReportController.php
Normal file
172
app/Http/Controllers/Api/V1/ReportController.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Http\Requests\V1\Report\ReportStoreRequest;
|
||||
use App\Http\Requests\V1\Report\ReportUpdateRequest;
|
||||
use App\Http\Resources\V1\Report\DetailedReportResource;
|
||||
use App\Http\Resources\V1\Report\ReportCollection;
|
||||
use App\Http\Resources\V1\Report\ReportResource;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Report;
|
||||
use App\Service\Dto\ReportPropertiesDto;
|
||||
use App\Service\ReportService;
|
||||
use App\Service\TimezoneService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class ReportController extends Controller
|
||||
{
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
protected function checkPermission(Organization $organization, string $permission, ?Report $report = null): void
|
||||
{
|
||||
parent::checkPermission($organization, $permission);
|
||||
if ($report !== null && $report->organization_id !== $organization->id) {
|
||||
throw new AuthorizationException('Report does not belong to organization');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reports
|
||||
*
|
||||
* @return ReportCollection<ReportResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getReports
|
||||
*/
|
||||
public function index(Organization $organization): ReportCollection
|
||||
{
|
||||
$this->checkPermission($organization, 'reports:view');
|
||||
|
||||
$reports = Report::query()
|
||||
->orderBy('created_at', 'desc')
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->paginate(config('app.pagination_per_page_default'));
|
||||
|
||||
return new ReportCollection($reports);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get report
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getReport
|
||||
*/
|
||||
public function show(Organization $organization, Report $report): DetailedReportResource
|
||||
{
|
||||
$this->checkPermission($organization, 'reports:view', $report);
|
||||
|
||||
return new DetailedReportResource($report);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create report
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId createReport
|
||||
*/
|
||||
public function store(Organization $organization, ReportStoreRequest $request, TimezoneService $timezoneService, ReportService $reportService): DetailedReportResource
|
||||
{
|
||||
$this->checkPermission($organization, 'reports:create');
|
||||
$user = $this->user();
|
||||
|
||||
$report = new Report;
|
||||
$report->name = $request->getName();
|
||||
$report->description = $request->getDescription();
|
||||
$isPublic = $request->getIsPublic();
|
||||
$report->is_public = $isPublic;
|
||||
$properties = new ReportPropertiesDto;
|
||||
$properties->group = $request->getPropertyGroup();
|
||||
$properties->subGroup = $request->getPropertySubGroup();
|
||||
$properties->historyGroup = $request->getPropertyHistoryGroup();
|
||||
$properties->start = $request->getPropertyStart();
|
||||
$properties->end = $request->getPropertyEnd();
|
||||
$properties->active = $request->getPropertyActive();
|
||||
$properties->setMemberIds($request->input('properties.member_ids', null));
|
||||
$properties->billable = $request->getPropertyBillable();
|
||||
$properties->setClientIds($request->input('properties.client_ids', null));
|
||||
$properties->setProjectIds($request->input('properties.project_ids', null));
|
||||
$properties->setTagIds($request->input('properties.tag_ids', null));
|
||||
$properties->setTaskIds($request->input('properties.task_ids', null));
|
||||
$properties->weekStart = $request->has('properties.week_start') ? Weekday::from($request->input('properties.week_start')) : $user->week_start;
|
||||
$timezone = $user->timezone;
|
||||
if ($request->has('properties.timezone')) {
|
||||
if ($timezoneService->isValid($request->input('properties.timezone'))) {
|
||||
$timezone = $request->input('properties.timezone');
|
||||
}
|
||||
if ($timezoneService->mapLegacyTimezone($request->input('properties.timezone')) !== null) {
|
||||
$timezone = $timezoneService->mapLegacyTimezone($request->input('properties.timezone'));
|
||||
}
|
||||
}
|
||||
$properties->timezone = $timezone;
|
||||
$report->properties = $properties;
|
||||
if ($isPublic) {
|
||||
$report->share_secret = $reportService->generateSecret();
|
||||
$report->public_until = $request->getPublicUntil();
|
||||
} else {
|
||||
$report->share_secret = null;
|
||||
$report->public_until = null;
|
||||
}
|
||||
$report->organization()->associate($organization);
|
||||
$report->save();
|
||||
|
||||
return new DetailedReportResource($report);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update report
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId updateReport
|
||||
*/
|
||||
public function update(Organization $organization, Report $report, ReportUpdateRequest $request, ReportService $reportService): DetailedReportResource
|
||||
{
|
||||
$this->checkPermission($organization, 'reports:update', $report);
|
||||
|
||||
if ($request->has('name')) {
|
||||
$report->name = $request->getName();
|
||||
}
|
||||
if ($request->has('description')) {
|
||||
$report->description = $request->getDescription();
|
||||
}
|
||||
if ($request->has('is_public') && $request->getIsPublic() !== $report->is_public) {
|
||||
$isPublic = $request->getIsPublic();
|
||||
$report->is_public = $isPublic;
|
||||
if ($isPublic) {
|
||||
$report->share_secret = $reportService->generateSecret();
|
||||
$report->public_until = $request->getPublicUntil();
|
||||
} else {
|
||||
$report->share_secret = null;
|
||||
$report->public_until = null;
|
||||
}
|
||||
}
|
||||
$report->save();
|
||||
|
||||
return new DetailedReportResource($report);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete report
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId deleteReport
|
||||
*/
|
||||
public function destroy(Organization $organization, Report $report): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'reports:delete', $report);
|
||||
|
||||
$report->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
||||
@@ -164,7 +164,7 @@ class TimeEntryController extends Controller
|
||||
*
|
||||
* @operationId exportTimeEntries
|
||||
*/
|
||||
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request): JsonResponse
|
||||
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
||||
{
|
||||
/** @var Member|null $member */
|
||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
@@ -173,6 +173,7 @@ class TimeEntryController extends Controller
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:view:all');
|
||||
}
|
||||
$debug = $request->getDebug();
|
||||
$format = $request->getFormatValue();
|
||||
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
|
||||
throw new FeatureIsNotAvailableInFreePlanApiException;
|
||||
@@ -195,19 +196,43 @@ class TimeEntryController extends Controller
|
||||
$export = new TimeEntriesDetailedCsvExport(config('filesystems.private'), $folderPath, $filename, $timeEntriesQuery, 1000, $timezone);
|
||||
$export->export();
|
||||
} elseif ($format === ExportFormat::PDF) {
|
||||
if (config('services.gotenberg.url') === null) {
|
||||
if (config('services.gotenberg.url') === null && ! $debug) {
|
||||
throw new PdfRendererIsNotConfiguredException;
|
||||
}
|
||||
$viewFile = file_get_contents(resource_path('views/reports/time-entry-index.blade.php'));
|
||||
$viewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf.blade.php'));
|
||||
if ($viewFile === false) {
|
||||
throw new \LogicException('View file not found');
|
||||
}
|
||||
$html = Blade::render($viewFile, ['timeEntries' => $timeEntriesQuery->get()]);
|
||||
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index-footer.blade.php'));
|
||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||
$timeEntriesQuery->clone()->reorder()->withOnly([]),
|
||||
null,
|
||||
null,
|
||||
$user->timezone,
|
||||
$user->week_start,
|
||||
false,
|
||||
null,
|
||||
null
|
||||
);
|
||||
$html = Blade::render($viewFile, [
|
||||
'timeEntries' => $timeEntriesQuery->get(),
|
||||
'aggregatedData' => $aggregatedData,
|
||||
'timezone' => $timezone,
|
||||
'currency' => $organization->currency,
|
||||
'start' => $request->getStart()->timezone($timezone),
|
||||
'end' => $request->getEnd()->timezone($timezone),
|
||||
]);
|
||||
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf-footer.blade.php'));
|
||||
if ($footerViewFile === false) {
|
||||
throw new \LogicException('View file not found');
|
||||
}
|
||||
$footerHtml = Blade::render($footerViewFile);
|
||||
if ($debug) {
|
||||
return response()->json([
|
||||
'html' => $html,
|
||||
'footer_html' => $footerHtml,
|
||||
]);
|
||||
}
|
||||
|
||||
$client = new Client([
|
||||
'auth' => config('services.gotenberg.basic_auth_username') !== null && config('services.gotenberg.basic_auth_password') !== null ? [
|
||||
config('services.gotenberg.basic_auth_username'),
|
||||
@@ -216,7 +241,10 @@ class TimeEntryController extends Controller
|
||||
]);
|
||||
$request = Gotenberg::chromium(config('services.gotenberg.url'))
|
||||
->pdf()
|
||||
->pdfa('PDF/A-3b')
|
||||
->assets(
|
||||
Stream::path(resource_path('pdf/Outfit-VariableFont_wght.ttf'), 'outfit.ttf'),
|
||||
)
|
||||
->margins(0.39, 0.78, 0.39, 0.39)
|
||||
->paperSize('8.27', '11.7') // A4
|
||||
->footer(Stream::string('footer', $footerHtml))
|
||||
->html(Stream::string('body', $html));
|
||||
@@ -329,6 +357,7 @@ class TimeEntryController extends Controller
|
||||
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
|
||||
throw new FeatureIsNotAvailableInFreePlanApiException;
|
||||
}
|
||||
$debug = $request->getDebug();
|
||||
$user = $this->user();
|
||||
|
||||
$group = $request->getGroup();
|
||||
@@ -363,7 +392,7 @@ class TimeEntryController extends Controller
|
||||
$path = $folderPath.'/'.$filename;
|
||||
|
||||
if ($format === ExportFormat::PDF) {
|
||||
if (config('services.gotenberg.url') === null) {
|
||||
if (config('services.gotenberg.url') === null && ! $debug) {
|
||||
throw new PdfRendererIsNotConfiguredException;
|
||||
}
|
||||
$client = new Client([
|
||||
@@ -372,7 +401,7 @@ class TimeEntryController extends Controller
|
||||
config('services.gotenberg.basic_auth_password'),
|
||||
] : null,
|
||||
]);
|
||||
$viewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate-index.blade.php'));
|
||||
$viewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf.blade.php'));
|
||||
if ($viewFile === false) {
|
||||
throw new \LogicException('View file not found');
|
||||
}
|
||||
@@ -384,17 +413,28 @@ class TimeEntryController extends Controller
|
||||
'subGroup' => $subGroup,
|
||||
'start' => $request->getStart()->timezone($timezone),
|
||||
'end' => $request->getEnd()->timezone($timezone),
|
||||
'debug' => $debug,
|
||||
]);
|
||||
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index-footer.blade.php'));
|
||||
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf-footer.blade.php'));
|
||||
if ($footerViewFile === false) {
|
||||
throw new \LogicException('View file not found');
|
||||
}
|
||||
$footerHtml = Blade::render($footerViewFile);
|
||||
if ($debug) {
|
||||
return response()->json([
|
||||
'html' => $html,
|
||||
'footer_html' => $footerHtml,
|
||||
]);
|
||||
}
|
||||
$request = Gotenberg::chromium(config('services.gotenberg.url'))
|
||||
->pdf()
|
||||
->pdfa('PDF/A-3b')
|
||||
->waitForExpression("window.status === 'ready'")
|
||||
->margins(0.39, 0.78, 0.39, 0.39)
|
||||
->paperSize('8.27', '11.7') // A4
|
||||
->footer(Stream::string('footer', $footerHtml))
|
||||
->assets(Stream::path(resource_path('pdf/echarts.min.js'), 'echarts.min.js'),
|
||||
Stream::path(resource_path('pdf/Outfit-VariableFont_wght.ttf'), 'outfit.ttf'),
|
||||
)
|
||||
->html(Stream::string('body', $html));
|
||||
$tempFolder = TemporaryDirectory::make();
|
||||
$filenameTemp = Gotenberg::save($request, $tempFolder->path(), $client);
|
||||
|
||||
@@ -64,6 +64,7 @@ class HealthCheckController extends Controller
|
||||
$response['app_env'] = app()->environment();
|
||||
$response['app_timezone'] = config('app.timezone');
|
||||
$response['app_force_https'] = config('app.force_https');
|
||||
$response['session_secure'] = config('session.secure');
|
||||
$response['trusted_proxies'] = config('trustedproxy.proxies');
|
||||
$headers = $request->headers->all();
|
||||
if (isset($headers['cookie'])) {
|
||||
|
||||
@@ -18,7 +18,7 @@ class Kernel extends HttpKernel
|
||||
* @var array<int, class-string|string>
|
||||
*/
|
||||
protected $middleware = [
|
||||
// \App\Http\Middleware\TrustHosts::class,
|
||||
\App\Http\Middleware\ForceHttps::class,
|
||||
\App\Http\Middleware\TrustProxies::class,
|
||||
\Illuminate\Http\Middleware\HandleCors::class,
|
||||
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
|
||||
|
||||
29
app/Http/Middleware/ForceHttps.php
Normal file
29
app/Http/Middleware/ForceHttps.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ForceHttps
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, string ...$guards): Response
|
||||
{
|
||||
if (config('app.force_https', false)) {
|
||||
URL::forceScheme('https');
|
||||
$request->server->set('HTTPS', 'on');
|
||||
$request->headers->set('X-Forwarded-Proto', 'https');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Http\Middleware\TrustHosts as Middleware;
|
||||
|
||||
class TrustHosts extends Middleware
|
||||
{
|
||||
/**
|
||||
* Get the host patterns that should be trusted.
|
||||
*
|
||||
* @return array<int, string|null>
|
||||
*/
|
||||
public function hosts(): array
|
||||
{
|
||||
return [
|
||||
$this->allSubdomainsOfApplicationUrl(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -68,9 +68,18 @@ class ProjectStoreRequest extends FormRequest
|
||||
'min:0',
|
||||
'max:2147483647',
|
||||
],
|
||||
// Whether the project is public
|
||||
'is_public' => [
|
||||
'boolean',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getIsPublic(): bool
|
||||
{
|
||||
return $this->has('is_public') && $this->boolean('is_public');
|
||||
}
|
||||
|
||||
public function getBillableRate(): ?int
|
||||
{
|
||||
$input = $this->input('billable_rate');
|
||||
|
||||
@@ -50,6 +50,9 @@ class ProjectUpdateRequest extends FormRequest
|
||||
'is_archived' => [
|
||||
'boolean',
|
||||
],
|
||||
'is_public' => [
|
||||
'boolean',
|
||||
],
|
||||
'client_id' => [
|
||||
'nullable',
|
||||
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
||||
|
||||
208
app/Http/Requests/V1/Report/ReportStoreRequest.php
Normal file
208
app/Http/Requests/V1/Report/ReportStoreRequest.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Report;
|
||||
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
*/
|
||||
class ReportStoreRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule|LegacyValidationRule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'description' => [
|
||||
'nullable',
|
||||
'string',
|
||||
],
|
||||
'is_public' => [
|
||||
'required',
|
||||
'boolean',
|
||||
],
|
||||
// After this date the report will be automatically set to private (is_public=false) (ISO 8601 format, UTC timezone)
|
||||
'public_until' => [
|
||||
'nullable',
|
||||
'date_format:Y-m-d\TH:i:s\Z',
|
||||
'after:now',
|
||||
],
|
||||
'properties' => [
|
||||
'required',
|
||||
'array',
|
||||
],
|
||||
'properties.start' => [
|
||||
'required',
|
||||
'date_format:Y-m-d\TH:i:s\Z',
|
||||
],
|
||||
'properties.end' => [
|
||||
'required',
|
||||
'date_format:Y-m-d\TH:i:s\Z',
|
||||
],
|
||||
'properties.active' => [
|
||||
'nullable',
|
||||
'boolean',
|
||||
],
|
||||
'properties.member_ids' => [
|
||||
'nullable',
|
||||
'array',
|
||||
],
|
||||
'properties.member_ids.*' => [
|
||||
'string',
|
||||
'uuid',
|
||||
],
|
||||
'properties.billable' => [
|
||||
'nullable',
|
||||
'boolean',
|
||||
],
|
||||
'properties.client_ids' => [
|
||||
'nullable',
|
||||
'array',
|
||||
],
|
||||
'properties.client_ids.*' => [
|
||||
'string',
|
||||
'uuid',
|
||||
],
|
||||
// Filter by project IDs, project IDs are OR combined
|
||||
'properties.project_ids' => [
|
||||
'nullable',
|
||||
'array',
|
||||
],
|
||||
'properties.project_ids.*' => [
|
||||
'string',
|
||||
'uuid',
|
||||
],
|
||||
// Filter by tag IDs, tag IDs are OR combined
|
||||
'properties.tag_ids' => [
|
||||
'nullable',
|
||||
'array',
|
||||
],
|
||||
'properties.tag_ids.*' => [
|
||||
'string',
|
||||
'uuid',
|
||||
],
|
||||
'properties.task_ids' => [
|
||||
'nullable',
|
||||
'array',
|
||||
],
|
||||
'properties.task_ids.*' => [
|
||||
'string',
|
||||
'uuid',
|
||||
],
|
||||
'properties.group' => [
|
||||
'required',
|
||||
Rule::enum(TimeEntryAggregationType::class),
|
||||
],
|
||||
'properties.sub_group' => [
|
||||
'required',
|
||||
Rule::enum(TimeEntryAggregationType::class),
|
||||
],
|
||||
'properties.history_group' => [
|
||||
'required',
|
||||
Rule::enum(TimeEntryAggregationTypeInterval::class),
|
||||
],
|
||||
'properties.week_start' => [
|
||||
'nullable',
|
||||
Rule::enum(Weekday::class),
|
||||
],
|
||||
'properties.timezone' => [
|
||||
'nullable',
|
||||
'timezone:all',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return (string) $this->input('name');
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->input('description');
|
||||
}
|
||||
|
||||
public function getIsPublic(): bool
|
||||
{
|
||||
return (bool) $this->input('is_public');
|
||||
}
|
||||
|
||||
public function getPublicUntil(): ?Carbon
|
||||
{
|
||||
$publicUntil = $this->input('public_until');
|
||||
|
||||
return $publicUntil === null ? null : Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $publicUntil);
|
||||
}
|
||||
|
||||
public function getPropertyStart(): Carbon
|
||||
{
|
||||
$start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('properties.start'));
|
||||
if ($start === null) {
|
||||
throw new \LogicException('Start date validation is not working');
|
||||
}
|
||||
|
||||
return $start;
|
||||
}
|
||||
|
||||
public function getPropertyEnd(): Carbon
|
||||
{
|
||||
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('properties.end'));
|
||||
if ($end === null) {
|
||||
throw new \LogicException('End date validation is not working');
|
||||
}
|
||||
|
||||
return $end;
|
||||
}
|
||||
|
||||
public function getPropertyActive(): ?bool
|
||||
{
|
||||
if ($this->has('properties.active') && $this->input('properties.active') !== null) {
|
||||
return (bool) $this->input('properties.active');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getPropertyBillable(): ?bool
|
||||
{
|
||||
if ($this->has('properties.billable') && $this->input('properties.billable') !== null) {
|
||||
return (bool) $this->input('properties.billable');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getPropertyGroup(): TimeEntryAggregationType
|
||||
{
|
||||
return TimeEntryAggregationType::from($this->input('properties.group'));
|
||||
}
|
||||
|
||||
public function getPropertySubGroup(): TimeEntryAggregationType
|
||||
{
|
||||
return TimeEntryAggregationType::from($this->input('properties.sub_group'));
|
||||
}
|
||||
|
||||
public function getPropertyHistoryGroup(): TimeEntryAggregationTypeInterval
|
||||
{
|
||||
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
|
||||
}
|
||||
}
|
||||
65
app/Http/Requests/V1/Report/ReportUpdateRequest.php
Normal file
65
app/Http/Requests/V1/Report/ReportUpdateRequest.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Report;
|
||||
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
*/
|
||||
class ReportUpdateRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'description' => [
|
||||
'nullable',
|
||||
'string',
|
||||
],
|
||||
'is_public' => [
|
||||
'boolean',
|
||||
],
|
||||
'public_until' => [
|
||||
'nullable',
|
||||
'date_format:Y-m-d\TH:i:s\Z',
|
||||
'after:now',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return (string) $this->input('name');
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->input('description');
|
||||
}
|
||||
|
||||
public function getIsPublic(): bool
|
||||
{
|
||||
return (bool) $this->input('is_public');
|
||||
}
|
||||
|
||||
public function getPublicUntil(): ?Carbon
|
||||
{
|
||||
$publicUntil = $this->input('public_until');
|
||||
|
||||
return $publicUntil === null ? null : Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $publicUntil);
|
||||
}
|
||||
}
|
||||
@@ -34,21 +34,23 @@ class TimeEntryAggregateExportRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
// Data format of the export
|
||||
'format' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::enum(ExportFormat::class),
|
||||
],
|
||||
// Type of first grouping
|
||||
'group' => [
|
||||
'required',
|
||||
Rule::enum(TimeEntryAggregationType::class),
|
||||
],
|
||||
|
||||
// Type of second grouping
|
||||
'sub_group' => [
|
||||
'required',
|
||||
Rule::enum(TimeEntryAggregationType::class),
|
||||
],
|
||||
|
||||
// Type of grouping of the historic aggregation (time chart)
|
||||
'history_group' => [
|
||||
'required',
|
||||
'nullable',
|
||||
@@ -158,9 +160,18 @@ class TimeEntryAggregateExportRequest extends FormRequest
|
||||
'string',
|
||||
'in:true,false',
|
||||
],
|
||||
'debug' => [
|
||||
'string',
|
||||
'in:true,false',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getDebug(): bool
|
||||
{
|
||||
return $this->input('debug') === 'true';
|
||||
}
|
||||
|
||||
public function getGroup(): TimeEntryAggregationType
|
||||
{
|
||||
return TimeEntryAggregationType::from($this->input('group'));
|
||||
@@ -178,12 +189,22 @@ class TimeEntryAggregateExportRequest extends FormRequest
|
||||
|
||||
public function getStart(): Carbon
|
||||
{
|
||||
return Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC');
|
||||
$start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC');
|
||||
if ($start === null) {
|
||||
throw new \LogicException('Start date validation is not working');
|
||||
}
|
||||
|
||||
return $start;
|
||||
}
|
||||
|
||||
public function getEnd(): Carbon
|
||||
{
|
||||
return Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC');
|
||||
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC');
|
||||
if ($end === null) {
|
||||
throw new \LogicException('End date validation is not working');
|
||||
}
|
||||
|
||||
return $end;
|
||||
}
|
||||
|
||||
public function getFormatValue(): ExportFormat
|
||||
|
||||
@@ -32,12 +32,13 @@ class TimeEntryAggregateRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
// Type of first grouping
|
||||
'group' => [
|
||||
'nullable',
|
||||
'required_with:group_2',
|
||||
'required_with:sub_group',
|
||||
Rule::enum(TimeEntryAggregationType::class),
|
||||
],
|
||||
|
||||
// Type of second grouping
|
||||
'sub_group' => [
|
||||
'nullable',
|
||||
Rule::enum(TimeEntryAggregationType::class),
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
@@ -96,14 +97,14 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
||||
],
|
||||
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
||||
'start' => [
|
||||
'nullable',
|
||||
'required',
|
||||
'string',
|
||||
'date_format:Y-m-d\TH:i:s\Z',
|
||||
'before:end',
|
||||
],
|
||||
// Filter only time entries that have a start date before the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
||||
'end' => [
|
||||
'nullable',
|
||||
'required',
|
||||
'string',
|
||||
'date_format:Y-m-d\TH:i:s\Z',
|
||||
],
|
||||
@@ -128,9 +129,38 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
||||
'string',
|
||||
'in:true,false',
|
||||
],
|
||||
'debug' => [
|
||||
'string',
|
||||
'in:true,false',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getDebug(): bool
|
||||
{
|
||||
return $this->input('debug', 'false') === 'true';
|
||||
}
|
||||
|
||||
public function getStart(): Carbon
|
||||
{
|
||||
$start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC');
|
||||
if ($start === null) {
|
||||
throw new \LogicException('Start date validation is not working');
|
||||
}
|
||||
|
||||
return $start;
|
||||
}
|
||||
|
||||
public function getEnd(): Carbon
|
||||
{
|
||||
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC');
|
||||
if ($end === null) {
|
||||
throw new \LogicException('End date validation is not working');
|
||||
}
|
||||
|
||||
return $end;
|
||||
}
|
||||
|
||||
public function getOnlyFullDates(): bool
|
||||
{
|
||||
return $this->input('only_full_dates', 'false') === 'true';
|
||||
|
||||
@@ -28,6 +28,8 @@ class PersonalMembershipResource extends BaseResource
|
||||
'id' => $this->resource->organization->id,
|
||||
/** @var string $name Name of organization */
|
||||
'name' => $this->resource->organization->name,
|
||||
/** @var string $currency Currency code (ISO 4217) of organization */
|
||||
'currency' => $this->resource->organization->currency,
|
||||
],
|
||||
/** @var string $role Role */
|
||||
'role' => $this->resource->role,
|
||||
|
||||
@@ -45,6 +45,8 @@ class OrganizationResource extends BaseResource
|
||||
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
||||
/** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */
|
||||
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
|
||||
/** @var string $currency Currency code (ISO 4217) */
|
||||
'currency' => $this->resource->currency,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ class ProjectResource extends BaseResource
|
||||
'estimated_time' => $this->resource->estimated_time,
|
||||
/** @var int $spent_time Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
|
||||
'spent_time' => $this->resource->spent_time,
|
||||
/** @var bool $is_public Whether the project is public */
|
||||
'is_public' => $this->resource->is_public,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
68
app/Http/Resources/V1/Report/DetailedReportResource.php
Normal file
68
app/Http/Resources/V1/Report/DetailedReportResource.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Report;
|
||||
|
||||
use App\Http\Resources\V1\BaseResource;
|
||||
use App\Models\Report;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* @property Report $resource
|
||||
*/
|
||||
class DetailedReportResource extends BaseResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, string|bool|int|null|array<string, string|bool|int|null|array<int, string>>>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** @var string $id ID of the report */
|
||||
'id' => $this->resource->id,
|
||||
/** @var string $name Name */
|
||||
'name' => $this->resource->name,
|
||||
/** @var string|null $email Description */
|
||||
'description' => $this->resource->description,
|
||||
/** @var bool $is_public Whether the report can be accessed via an external link */
|
||||
'is_public' => $this->resource->is_public,
|
||||
/** @var string|null $public_until Date until the report is public */
|
||||
'public_until' => $this->resource->public_until?->toIso8601ZuluString(),
|
||||
/** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
|
||||
'shareable_link' => $this->resource->getShareableLink(),
|
||||
'properties' => [
|
||||
/** @var string $group Type of first grouping */
|
||||
'group' => $this->resource->properties->group->value,
|
||||
/** @var string $sub_group Type of second grouping */
|
||||
'sub_group' => $this->resource->properties->subGroup->value,
|
||||
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */
|
||||
'history_group' => $this->resource->properties->historyGroup->value,
|
||||
/** @var string $start Start date of the report */
|
||||
'start' => $this->resource->properties->start->toIso8601ZuluString(),
|
||||
/** @var string $end End date of the report */
|
||||
'end' => $this->resource->properties->end->toIso8601ZuluString(),
|
||||
/** @var bool|null $active Whether the report is active */
|
||||
'active' => $this->resource->properties->active,
|
||||
/** @var array<string>|null $member_ids Filter by multiple member IDs, member IDs are OR combined */
|
||||
'member_ids' => $this->resource->properties->memberIds?->toArray(),
|
||||
/** @var bool|null $billable Filter by billable status */
|
||||
'billable' => $this->resource->properties->billable,
|
||||
/** @var array<string>|null $client_ids Filter by client IDs, client IDs are OR combined */
|
||||
'client_ids' => $this->resource->properties->clientIds?->toArray(),
|
||||
/** @var array<string>|null $project_ids Filter by project IDs, project IDs are OR combined */
|
||||
'project_ids' => $this->resource->properties->projectIds?->toArray(),
|
||||
/** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */
|
||||
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
|
||||
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
|
||||
'task_ids' => $this->resource->properties->taskIds?->toArray(),
|
||||
],
|
||||
/** @var string $created_at Date when the report was created */
|
||||
'created_at' => $this->resource->created_at?->toIso8601ZuluString(),
|
||||
/** @var string $updated_at Date when the report was last updated */
|
||||
'updated_at' => $this->resource->updated_at?->toIso8601ZuluString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
136
app/Http/Resources/V1/Report/DetailedWithDataReportResource.php
Normal file
136
app/Http/Resources/V1/Report/DetailedWithDataReportResource.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Report;
|
||||
|
||||
use App\Http\Resources\V1\BaseResource;
|
||||
use App\Models\Report;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* @property Report $resource
|
||||
*
|
||||
* @phpstan-type Data array{
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* description: string|null,
|
||||
* color: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* description: string|null,
|
||||
* color: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* grouped_type: null,
|
||||
* grouped_data: null
|
||||
* }>
|
||||
* }>,
|
||||
* seconds: int,
|
||||
* cost: int
|
||||
* }
|
||||
*/
|
||||
class DetailedWithDataReportResource extends BaseResource
|
||||
{
|
||||
/**
|
||||
* @var Data
|
||||
*/
|
||||
private array $data;
|
||||
|
||||
/**
|
||||
* @var Data
|
||||
*/
|
||||
private array $historyData;
|
||||
|
||||
/**
|
||||
* @param Data $data
|
||||
* @param Data $historyData
|
||||
*/
|
||||
public function __construct(Report $resource, array $data, array $historyData)
|
||||
{
|
||||
parent::__construct($resource);
|
||||
$this->data = $data;
|
||||
$this->historyData = $historyData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, string|bool|int|null|Data|array<string, string|bool|int|null|array<int, string>>>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** @var string $name Name */
|
||||
'name' => $this->resource->name,
|
||||
/** @var string|null $email Description */
|
||||
'description' => $this->resource->description,
|
||||
/** @var string|null $public_until Date until the report is public */
|
||||
'public_until' => $this->resource->public_until?->toIso8601ZuluString(),
|
||||
/** @var string $currency Currency code (ISO 4217) */
|
||||
'currency' => $this->resource->organization->currency,
|
||||
'properties' => [
|
||||
/** @var string $group Type of first grouping */
|
||||
'group' => $this->resource->properties->group->value,
|
||||
/** @var string $sub_group Type of second grouping */
|
||||
'sub_group' => $this->resource->properties->subGroup->value,
|
||||
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */
|
||||
'history_group' => $this->resource->properties->historyGroup->value,
|
||||
/** @var string $start Start date of the report */
|
||||
'start' => $this->resource->properties->start->toIso8601ZuluString(),
|
||||
/** @var string $end End date of the report */
|
||||
'end' => $this->resource->properties->end->toIso8601ZuluString(),
|
||||
],
|
||||
/** @var array{
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* description: string|null,
|
||||
* color: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* description: string|null,
|
||||
* color: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* grouped_type: null,
|
||||
* grouped_data: null
|
||||
* }>
|
||||
* }>,
|
||||
* seconds: int,
|
||||
* cost: int
|
||||
* } $data Aggregated data
|
||||
*/
|
||||
'data' => $this->data,
|
||||
/** @var array{
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* description: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* description: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* grouped_type: null,
|
||||
* grouped_data: null
|
||||
* }>
|
||||
* }>,
|
||||
* seconds: int,
|
||||
* cost: int
|
||||
* } $history_data Historic aggregated data
|
||||
*/
|
||||
'history_data' => $this->historyData,
|
||||
];
|
||||
}
|
||||
}
|
||||
18
app/Http/Resources/V1/Report/ReportCollection.php
Normal file
18
app/Http/Resources/V1/Report/ReportCollection.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Report;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class ReportCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $collects = ReportResource::class;
|
||||
}
|
||||
42
app/Http/Resources/V1/Report/ReportResource.php
Normal file
42
app/Http/Resources/V1/Report/ReportResource.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Report;
|
||||
|
||||
use App\Http\Resources\V1\BaseResource;
|
||||
use App\Models\Report;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* @property Report $resource
|
||||
*/
|
||||
class ReportResource extends BaseResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, string|bool|int|null|array<string>>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** @var string $id ID of the report */
|
||||
'id' => $this->resource->id,
|
||||
/** @var string $name Name */
|
||||
'name' => $this->resource->name,
|
||||
/** @var string|null $email Description */
|
||||
'description' => $this->resource->description,
|
||||
/** @var bool $is_public Whether the report can be accessed via an external link */
|
||||
'is_public' => $this->resource->is_public,
|
||||
/** @var string|null $public_until Date until the report is public */
|
||||
'public_until' => $this->resource->public_until?->toIso8601ZuluString(),
|
||||
/** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
|
||||
'shareable_link' => $this->resource->getShareableLink(),
|
||||
/** @var string $created_at Date when the report was created */
|
||||
'created_at' => $this->resource->created_at?->toIso8601ZuluString(),
|
||||
/** @var string $updated_at Date when the report was last updated */
|
||||
'updated_at' => $this->resource->updated_at?->toIso8601ZuluString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,13 @@ namespace App\Jobs;
|
||||
use App\Models\Project;
|
||||
use Exception;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class RecalculateSpentTimeForProject implements ShouldQueue
|
||||
class RecalculateSpentTimeForProject implements ShouldDispatchAfterCommit, ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
|
||||
@@ -7,12 +7,13 @@ namespace App\Jobs;
|
||||
use App\Models\Task;
|
||||
use Exception;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class RecalculateSpentTimeForTask implements ShouldQueue
|
||||
class RecalculateSpentTimeForTask implements ShouldDispatchAfterCommit, ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
|
||||
64
app/Models/Report.php
Normal file
64
app/Models/Report.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use App\Service\Dto\ReportPropertiesDto;
|
||||
use Database\Factories\ReportFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
* @property string $name
|
||||
* @property string|null $description
|
||||
* @property string $organization_id
|
||||
* @property bool $is_public
|
||||
* @property Carbon|null $public_until
|
||||
* @property string|null $share_secret
|
||||
* @property ReportPropertiesDto $properties
|
||||
* @property-read Organization $organization
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
*
|
||||
* @method static ReportFactory factory()
|
||||
*/
|
||||
class Report extends Model
|
||||
{
|
||||
/** @use HasFactory<ReportFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use HasUuids;
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'is_public' => 'bool',
|
||||
'public_until' => 'datetime',
|
||||
'properties' => ReportPropertiesDto::class,
|
||||
];
|
||||
|
||||
public function getShareableLink(): ?string
|
||||
{
|
||||
if ($this->is_public && $this->share_secret !== null) {
|
||||
return route('shared-report').'#'.$this->share_secret;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Organization, Report>
|
||||
*/
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organization::class, 'organization_id');
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ class TimeEntry extends Model implements AuditableContract
|
||||
|
||||
public function getClientIdComputed(): ?string
|
||||
{
|
||||
return $this->project_id === null ? null : $this->project->client_id;
|
||||
return $this->project_id === null || $this->project === null ? null : $this->project->client_id;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,7 +34,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property string $id
|
||||
* @property string $name
|
||||
* @property string $email
|
||||
* @property string|null $email_verified_at
|
||||
* @property Carbon|null $email_verified_at
|
||||
* @property string|null $password
|
||||
* @property string|null $two_factor_secret
|
||||
* @property string $timezone
|
||||
|
||||
@@ -29,7 +29,6 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -90,12 +89,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
);
|
||||
});
|
||||
|
||||
if (config('app.force_https', false)) {
|
||||
URL::forceScheme('https');
|
||||
request()->server->set('HTTPS', 'on');
|
||||
request()->headers->set('X-Forwarded-Proto', 'https');
|
||||
}
|
||||
|
||||
$this->app->scoped(PermissionStore::class, function (Application $app): PermissionStore {
|
||||
return new PermissionStore;
|
||||
});
|
||||
|
||||
@@ -126,6 +126,10 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'members:update',
|
||||
'members:delete',
|
||||
'billing',
|
||||
'reports:view',
|
||||
'reports:create',
|
||||
'reports:update',
|
||||
'reports:delete',
|
||||
])->description('Owner users can perform any action. There is only one owner per organization.');
|
||||
|
||||
Jetstream::role(Role::Admin->value, 'Administrator', [
|
||||
@@ -170,6 +174,10 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'members:view',
|
||||
'members:update',
|
||||
'members:invite-placeholder',
|
||||
'reports:view',
|
||||
'reports:create',
|
||||
'reports:update',
|
||||
'reports:delete',
|
||||
])->description('Administrator users can perform any action, except accessing the billing dashboard.');
|
||||
|
||||
Jetstream::role(Role::Manager->value, 'Manager', [
|
||||
@@ -206,6 +214,10 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'organizations:view',
|
||||
'invitations:view',
|
||||
'members:view',
|
||||
'reports:view',
|
||||
'reports:create',
|
||||
'reports:update',
|
||||
'reports:delete',
|
||||
])->description('Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).');
|
||||
|
||||
Jetstream::role(Role::Employee->value, 'Employee', [
|
||||
|
||||
@@ -33,8 +33,12 @@ class ColorService
|
||||
|
||||
private const string VALID_REGEX = '/^#[0-9a-f]{6}$/';
|
||||
|
||||
public function getRandomColor(): string
|
||||
public function getRandomColor(?string $seed = null): string
|
||||
{
|
||||
if ($seed !== null) {
|
||||
srand(crc32($seed));
|
||||
}
|
||||
|
||||
return self::COLORS[array_rand(self::COLORS)];
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\Report;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
@@ -71,6 +72,9 @@ class DeletionService
|
||||
// Delete all clients
|
||||
Client::query()->whereBelongsTo($organization, 'organization')->delete();
|
||||
|
||||
// Delete all reports
|
||||
Report::query()->whereBelongsTo($organization, 'organization')->delete();
|
||||
|
||||
// Reset the current organization
|
||||
$organization->owner()
|
||||
->where('current_team_id', $organization->getKey())
|
||||
|
||||
217
app/Service/Dto/ReportPropertiesDto.php
Normal file
217
app/Service/Dto/ReportPropertiesDto.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Dto;
|
||||
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||
use App\Enums\Weekday;
|
||||
use Illuminate\Contracts\Database\Eloquent\Castable;
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ReportPropertiesDto implements Castable
|
||||
{
|
||||
public TimeEntryAggregationType $group;
|
||||
|
||||
public TimeEntryAggregationType $subGroup;
|
||||
|
||||
public TimeEntryAggregationTypeInterval $historyGroup;
|
||||
|
||||
public Weekday $weekStart;
|
||||
|
||||
public string $timezone;
|
||||
|
||||
public Carbon $start;
|
||||
|
||||
public Carbon $end;
|
||||
|
||||
public ?bool $active = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, string>|null
|
||||
*/
|
||||
public ?Collection $memberIds = null;
|
||||
|
||||
public ?bool $billable = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, string>|null
|
||||
*/
|
||||
public ?Collection $clientIds = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, string>|null
|
||||
*/
|
||||
public ?Collection $projectIds = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, string>|null
|
||||
*/
|
||||
public ?Collection $tagIds = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, string>|null
|
||||
*/
|
||||
public ?Collection $taskIds = null;
|
||||
|
||||
/**
|
||||
* Get the caster class to use when casting from / to this cast target.
|
||||
*
|
||||
* @param array<string, mixed> $arguments
|
||||
* @return CastsAttributes<ReportPropertiesDto, ReportPropertiesDto>
|
||||
*/
|
||||
public static function castUsing(array $arguments): CastsAttributes
|
||||
{
|
||||
return new class implements CastsAttributes
|
||||
{
|
||||
private const array REQUIRED_PROPERTIES = [
|
||||
'group',
|
||||
'subGroup',
|
||||
'historyGroup',
|
||||
'weekStart',
|
||||
'timezone',
|
||||
'start',
|
||||
'end',
|
||||
'active',
|
||||
'memberIds',
|
||||
'billable',
|
||||
'clientIds',
|
||||
'projectIds',
|
||||
'tagIds',
|
||||
'taskIds',
|
||||
];
|
||||
|
||||
public function get(Model $model, string $key, mixed $value, array $attributes): ReportPropertiesDto
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
throw new \InvalidArgumentException('The given value is not a string');
|
||||
}
|
||||
$data = json_decode($value, false);
|
||||
if ($data === null) {
|
||||
throw new \InvalidArgumentException('The given value is not a JSON string');
|
||||
}
|
||||
foreach (self::REQUIRED_PROPERTIES as $property) {
|
||||
if (! property_exists($data, $property)) {
|
||||
throw new \InvalidArgumentException('The given JSON string does not contain the required property "'.$property.'"');
|
||||
}
|
||||
}
|
||||
$dto = new ReportPropertiesDto;
|
||||
$dto->end = $data->end !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->end) : null;
|
||||
$dto->start = $data->start !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->start) : null;
|
||||
$dto->active = $data->active;
|
||||
$dto->memberIds = $data->memberIds !== null ? ReportPropertiesDto::idArrayToCollection($data->memberIds) : null;
|
||||
$dto->billable = $data->billable;
|
||||
$dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null;
|
||||
$dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null;
|
||||
$dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null;
|
||||
$dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null;
|
||||
$dto->group = TimeEntryAggregationType::from($data->group);
|
||||
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
|
||||
$dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);
|
||||
$dto->weekStart = Weekday::from($data->weekStart);
|
||||
$dto->timezone = $data->timezone;
|
||||
|
||||
return $dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ReportPropertiesDto $value
|
||||
*/
|
||||
public function set(Model $model, string $key, mixed $value, array $attributes): string
|
||||
{
|
||||
if (! ($value instanceof ReportPropertiesDto)) {
|
||||
throw new \InvalidArgumentException('The given value is not an instance of ReportPropertiesDto');
|
||||
}
|
||||
|
||||
$data = (object) [
|
||||
'end' => $value->end->toIso8601ZuluString(),
|
||||
'start' => $value->start->toIso8601ZuluString(),
|
||||
'active' => $value->active,
|
||||
'memberIds' => $value->memberIds?->toArray(),
|
||||
'billable' => $value->billable,
|
||||
'clientIds' => $value->clientIds?->toArray(),
|
||||
'projectIds' => $value->projectIds?->toArray(),
|
||||
'tagIds' => $value->tagIds?->toArray(),
|
||||
'taskIds' => $value->taskIds?->toArray(),
|
||||
'group' => $value->group->value,
|
||||
'subGroup' => $value->subGroup->value,
|
||||
'historyGroup' => $value->historyGroup->value,
|
||||
'weekStart' => $value->weekStart->value,
|
||||
'timezone' => $value->timezone,
|
||||
];
|
||||
|
||||
$jsonString = json_encode($data);
|
||||
if ($jsonString === false) {
|
||||
throw new \InvalidArgumentException('Could not encode the given data to a JSON string');
|
||||
}
|
||||
|
||||
return $jsonString;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $ids
|
||||
* @return Collection<int, string>
|
||||
*/
|
||||
public static function idArrayToCollection(array $ids): Collection
|
||||
{
|
||||
$collection = new Collection;
|
||||
foreach ($ids as $id) {
|
||||
if (! is_string($id)) {
|
||||
throw new \InvalidArgumentException('The given ID is not a string');
|
||||
}
|
||||
if (! Str::isUuid($id)) {
|
||||
throw new \InvalidArgumentException('The given ID is not a valid UUID');
|
||||
}
|
||||
$collection->push($id);
|
||||
}
|
||||
|
||||
return $collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed>|null $memberIds
|
||||
*/
|
||||
public function setMemberIds(?array $memberIds): void
|
||||
{
|
||||
$this->memberIds = $memberIds !== null ? ReportPropertiesDto::idArrayToCollection($memberIds) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed>|null $clientIds
|
||||
*/
|
||||
public function setClientIds(?array $clientIds): void
|
||||
{
|
||||
$this->clientIds = $clientIds !== null ? ReportPropertiesDto::idArrayToCollection($clientIds) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed>|null $projectIds
|
||||
*/
|
||||
public function setProjectIds(?array $projectIds): void
|
||||
{
|
||||
$this->projectIds = $projectIds !== null ? ReportPropertiesDto::idArrayToCollection($projectIds) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed>|null $tagIds
|
||||
*/
|
||||
public function setTagIds(?array $tagIds): void
|
||||
{
|
||||
$this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed>|null $taskIds
|
||||
*/
|
||||
public function setTaskIds(?array $taskIds): void
|
||||
{
|
||||
$this->taskIds = $taskIds !== null ? ReportPropertiesDto::idArrayToCollection($taskIds) : null;
|
||||
}
|
||||
}
|
||||
@@ -188,6 +188,18 @@ class ImportDatabaseHelper
|
||||
return $model;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<TModel>
|
||||
*/
|
||||
public function getCachedModels(): array
|
||||
{
|
||||
if ($this->mapKeyToModel === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values($this->mapKeyToModel);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $identifierData
|
||||
* @return TModel|null
|
||||
|
||||
@@ -31,10 +31,13 @@ class ImportService
|
||||
$lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1);
|
||||
|
||||
if ($lock->get()) {
|
||||
DB::transaction(function () use (&$importer, &$data, &$timezone): void {
|
||||
$importer->importData($data, $timezone);
|
||||
});
|
||||
$lock->release();
|
||||
try {
|
||||
DB::transaction(function () use (&$importer, &$data, &$timezone): void {
|
||||
$importer->importData($data, $timezone);
|
||||
});
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
} else {
|
||||
throw new ImportException('Import is already in progress');
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ class ClockifyProjectsImporter extends DefaultImporter
|
||||
'color' => $this->colorService->getRandomColor(),
|
||||
'is_billable' => $record['Billability'] === 'Yes',
|
||||
'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;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Jobs\RecalculateSpentTimeForProject;
|
||||
use App\Jobs\RecalculateSpentTimeForTask;
|
||||
use App\Models\TimeEntry;
|
||||
use Carbon\Exceptions\InvalidFormatException;
|
||||
use Exception;
|
||||
@@ -99,6 +101,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
'project_id' => $projectId,
|
||||
'organization_id' => $this->organization->id,
|
||||
]);
|
||||
$this->taskImportHelper->getModelById($taskId);
|
||||
}
|
||||
$timeEntry = new TimeEntry;
|
||||
$timeEntry->disableAuditing();
|
||||
@@ -158,6 +161,12 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
$timeEntry->save();
|
||||
$this->timeEntriesCreated++;
|
||||
}
|
||||
foreach ($this->projectImportHelper->getCachedModels() as $usedProject) {
|
||||
RecalculateSpentTimeForProject::dispatch($usedProject);
|
||||
}
|
||||
foreach ($this->taskImportHelper->getCachedModels() as $usedTask) {
|
||||
RecalculateSpentTimeForTask::dispatch($usedTask);
|
||||
}
|
||||
} catch (ImportException $exception) {
|
||||
throw $exception;
|
||||
} catch (CsvException $exception) {
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Jobs\RecalculateSpentTimeForProject;
|
||||
use App\Jobs\RecalculateSpentTimeForTask;
|
||||
use App\Models\TimeEntry;
|
||||
use Carbon\Exceptions\InvalidFormatException;
|
||||
use Exception;
|
||||
@@ -235,6 +237,7 @@ class SolidtimeImporter extends DefaultImporter
|
||||
$taskId = null;
|
||||
if ($timeEntryRow['task_id'] !== '') {
|
||||
$taskId = $this->taskImportHelper->getKeyByExternalIdentifier($timeEntryRow['task_id']);
|
||||
$this->taskImportHelper->getModelById($taskId);
|
||||
}
|
||||
$timeEntry = new TimeEntry;
|
||||
$timeEntry->disableAuditing();
|
||||
@@ -303,6 +306,12 @@ class SolidtimeImporter extends DefaultImporter
|
||||
$timeEntry->save();
|
||||
$this->timeEntriesCreated++;
|
||||
}
|
||||
foreach ($this->projectImportHelper->getCachedModels() as $usedProject) {
|
||||
RecalculateSpentTimeForProject::dispatch($usedProject);
|
||||
}
|
||||
foreach ($this->taskImportHelper->getCachedModels() as $usedTask) {
|
||||
RecalculateSpentTimeForTask::dispatch($usedTask);
|
||||
}
|
||||
} catch (ImportException $exception) {
|
||||
throw $exception;
|
||||
} catch (Exception $exception) {
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Jobs\RecalculateSpentTimeForProject;
|
||||
use App\Jobs\RecalculateSpentTimeForTask;
|
||||
use App\Models\TimeEntry;
|
||||
use Carbon\Exceptions\InvalidFormatException;
|
||||
use Exception;
|
||||
@@ -99,6 +101,7 @@ class TogglTimeEntriesImporter extends DefaultImporter
|
||||
'project_id' => $projectId,
|
||||
'organization_id' => $this->organization->id,
|
||||
]);
|
||||
$this->taskImportHelper->getModelById($taskId);
|
||||
}
|
||||
$timeEntry = new TimeEntry;
|
||||
$timeEntry->disableAuditing();
|
||||
@@ -144,6 +147,12 @@ class TogglTimeEntriesImporter extends DefaultImporter
|
||||
$timeEntry->save();
|
||||
$this->timeEntriesCreated++;
|
||||
}
|
||||
foreach ($this->projectImportHelper->getCachedModels() as $usedProject) {
|
||||
RecalculateSpentTimeForProject::dispatch($usedProject);
|
||||
}
|
||||
foreach ($this->taskImportHelper->getCachedModels() as $usedTask) {
|
||||
RecalculateSpentTimeForTask::dispatch($usedTask);
|
||||
}
|
||||
} catch (ImportException $exception) {
|
||||
throw $exception;
|
||||
} catch (CsvException $exception) {
|
||||
|
||||
@@ -5,10 +5,21 @@ declare(strict_types=1);
|
||||
namespace App\Service;
|
||||
|
||||
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\Organization;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
use Laravel\Jetstream\Events\AddingTeamMember;
|
||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
||||
|
||||
class MemberService
|
||||
{
|
||||
@@ -19,6 +30,72 @@ class MemberService
|
||||
$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.
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
|
||||
|
||||
public function view(): View
|
||||
{
|
||||
return view('reports.time-entry-aggregate-index-excel', [
|
||||
return view('reports.time-entry-aggregate.spreadsheet', [
|
||||
'data' => $this->data,
|
||||
'currency' => $this->currency,
|
||||
'group' => $this->group,
|
||||
|
||||
15
app/Service/ReportService.php
Normal file
15
app/Service/ReportService.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ReportService
|
||||
{
|
||||
public function generateSecret(): string
|
||||
{
|
||||
return Str::random(40);
|
||||
}
|
||||
}
|
||||
@@ -146,12 +146,14 @@ class TimeEntryAggregationService
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* description: string|null,
|
||||
* color: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* description: string|null,
|
||||
* color: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* grouped_type: null,
|
||||
@@ -180,15 +182,17 @@ class TimeEntryAggregationService
|
||||
}
|
||||
}
|
||||
|
||||
$descriptionMapGroup1 = $group1Type !== null ? $this->loadDescriptionMap($keysGroup1, $group1Type) : [];
|
||||
$descriptionMapGroup2 = $group2Type !== null ? $this->loadDescriptionMap($keysGroup2, $group2Type) : [];
|
||||
$descriptionMapGroup1 = $group1Type !== null ? $this->loadDescriptorsMap($keysGroup1, $group1Type) : [];
|
||||
$descriptionMapGroup2 = $group2Type !== null ? $this->loadDescriptorsMap($keysGroup2, $group2Type) : [];
|
||||
|
||||
if ($aggregatedTimeEntries['grouped_data'] !== null) {
|
||||
foreach ($aggregatedTimeEntries['grouped_data'] as $keyGroup1 => $group1) {
|
||||
$aggregatedTimeEntries['grouped_data'][$keyGroup1]['description'] = $group1['key'] !== null ? ($descriptionMapGroup1[$group1['key']] ?? null) : null;
|
||||
$aggregatedTimeEntries['grouped_data'][$keyGroup1]['description'] = $group1['key'] !== null ? ($descriptionMapGroup1[$group1['key']]['description'] ?? null) : null;
|
||||
$aggregatedTimeEntries['grouped_data'][$keyGroup1]['color'] = $group1['key'] !== null ? ($descriptionMapGroup1[$group1['key']]['color'] ?? null) : null;
|
||||
if ($aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'] !== null) {
|
||||
foreach ($aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'] as $keyGroup2 => $group2) {
|
||||
$aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'][$keyGroup2]['description'] = $group2['key'] !== null ? ($descriptionMapGroup2[$group2['key']] ?? null) : null;
|
||||
$aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'][$keyGroup2]['description'] = $group2['key'] !== null ? ($descriptionMapGroup2[$group2['key']]['description'] ?? null) : null;
|
||||
$aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'][$keyGroup2]['color'] = $group2['key'] !== null ? ($descriptionMapGroup2[$group2['key']]['color'] ?? null) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,12 +204,14 @@ class TimeEntryAggregationService
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* description: string|null,
|
||||
* color: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* grouped_type: string|null,
|
||||
* grouped_data: null|array<array{
|
||||
* key: string|null,
|
||||
* description: string|null,
|
||||
* color: string|null,
|
||||
* seconds: int,
|
||||
* cost: int,
|
||||
* grouped_type: null,
|
||||
@@ -222,33 +228,61 @@ class TimeEntryAggregationService
|
||||
|
||||
/**
|
||||
* @param array<int, string> $keys
|
||||
* @return array<string, string>
|
||||
* @return array<string, array{
|
||||
* description: string,
|
||||
* color: string|null
|
||||
* }>
|
||||
*/
|
||||
private function loadDescriptionMap(array $keys, TimeEntryAggregationType $type): array
|
||||
private function loadDescriptorsMap(array $keys, TimeEntryAggregationType $type): array
|
||||
{
|
||||
$descriptorMap = [];
|
||||
if ($type === TimeEntryAggregationType::Client) {
|
||||
return Client::query()
|
||||
$clients = Client::query()
|
||||
->whereIn('id', $keys)
|
||||
->pluck('name', 'id')
|
||||
->toArray();
|
||||
->select('id', 'name')
|
||||
->get();
|
||||
foreach ($clients as $client) {
|
||||
$descriptorMap[$client->id] = [
|
||||
'description' => $client->name,
|
||||
'color' => null,
|
||||
];
|
||||
}
|
||||
} elseif ($type === TimeEntryAggregationType::User) {
|
||||
return User::query()
|
||||
$users = User::query()
|
||||
->whereIn('id', $keys)
|
||||
->pluck('name', 'id')
|
||||
->toArray();
|
||||
->select('id', 'name')
|
||||
->get();
|
||||
foreach ($users as $user) {
|
||||
$descriptorMap[$user->id] = [
|
||||
'description' => $user->name,
|
||||
'color' => null,
|
||||
];
|
||||
}
|
||||
} elseif ($type === TimeEntryAggregationType::Project) {
|
||||
return Project::query()
|
||||
$projects = Project::query()
|
||||
->whereIn('id', $keys)
|
||||
->pluck('name', 'id')
|
||||
->toArray();
|
||||
->select('id', 'name', 'color')
|
||||
->get();
|
||||
foreach ($projects as $project) {
|
||||
$descriptorMap[$project->id] = [
|
||||
'description' => $project->name,
|
||||
'color' => $project->color,
|
||||
];
|
||||
}
|
||||
} elseif ($type === TimeEntryAggregationType::Task) {
|
||||
return Task::query()
|
||||
$tasks = Task::query()
|
||||
->whereIn('id', $keys)
|
||||
->pluck('name', 'id')
|
||||
->toArray();
|
||||
} else {
|
||||
return [];
|
||||
->select('id', 'name')
|
||||
->get();
|
||||
foreach ($tasks as $task) {
|
||||
$descriptorMap[$task->id] = [
|
||||
'description' => $task->name,
|
||||
'color' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $descriptorMap;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,7 +30,17 @@ class TimeEntryFilter
|
||||
if ($dateTime === null) {
|
||||
return $this;
|
||||
}
|
||||
$this->builder->where('start', '<', Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $dateTime, 'UTC'));
|
||||
$this->addEnd(Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $dateTime, 'UTC'));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addEnd(?Carbon $end): self
|
||||
{
|
||||
if ($end === null) {
|
||||
return $this;
|
||||
}
|
||||
$this->builder->where('start', '<', $end);
|
||||
|
||||
return $this;
|
||||
}
|
||||
@@ -40,7 +50,17 @@ class TimeEntryFilter
|
||||
if ($dateTime === null) {
|
||||
return $this;
|
||||
}
|
||||
$this->builder->where('start', '>', Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $dateTime, 'UTC'));
|
||||
$this->addStart(Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $dateTime, 'UTC'));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addStart(?Carbon $start): self
|
||||
{
|
||||
if ($start === null) {
|
||||
return $this;
|
||||
}
|
||||
$this->builder->where('start', '>', $start);
|
||||
|
||||
return $this;
|
||||
}
|
||||
@@ -51,9 +71,21 @@ class TimeEntryFilter
|
||||
return $this;
|
||||
}
|
||||
if ($active === 'true') {
|
||||
$this->builder->whereNull('end');
|
||||
$this->addActive(true);
|
||||
} elseif ($active === 'false') {
|
||||
$this->addActive(false);
|
||||
} else {
|
||||
Log::warning('Invalid active filter value', ['value' => $active]);
|
||||
}
|
||||
if ($active === 'false') {
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addActive(?bool $active): self
|
||||
{
|
||||
if ($active) {
|
||||
$this->builder->whereNull('end');
|
||||
} else {
|
||||
$this->builder->whereNotNull('end');
|
||||
}
|
||||
|
||||
@@ -89,9 +121,9 @@ class TimeEntryFilter
|
||||
return $this;
|
||||
}
|
||||
if ($billable === 'true') {
|
||||
$this->builder->where('billable', '=', true);
|
||||
$this->addBillable(true);
|
||||
} elseif ($billable === 'false') {
|
||||
$this->builder->where('billable', '=', false);
|
||||
$this->addBillable(false);
|
||||
} else {
|
||||
Log::warning('Invalid billable filter value', ['value' => $billable]);
|
||||
}
|
||||
@@ -99,6 +131,16 @@ class TimeEntryFilter
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addBillable(?bool $billable): self
|
||||
{
|
||||
if ($billable === null) {
|
||||
return $this;
|
||||
}
|
||||
$this->builder->where('billable', '=', $billable);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string>|null $clientIds
|
||||
*/
|
||||
|
||||
@@ -5,15 +5,49 @@ declare(strict_types=1);
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Enums\Weekday;
|
||||
use App\Events\AfterCreateOrganization;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
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.
|
||||
* This is useful when a placeholder user is replaced with a real user.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"ext-zip": "*",
|
||||
"brick/money": "^0.10.0",
|
||||
"datomatic/laravel-enum-helper": "^2.0.0",
|
||||
"dedoc/scramble": "dev-main",
|
||||
"dedoc/scramble": "^0.12.2",
|
||||
"filament/filament": "^3.2",
|
||||
"flowframe/laravel-trend": "^0.3.0",
|
||||
"gotenberg/gotenberg-php": "^2.8",
|
||||
@@ -40,7 +40,7 @@
|
||||
"barryvdh/laravel-ide-helper": "^3.0",
|
||||
"brianium/paratest": "^7.3",
|
||||
"fakerphp/faker": "^1.9.1",
|
||||
"fumeapp/modeltyper": "^2.2",
|
||||
"fumeapp/modeltyper": "^3.0",
|
||||
"phpstan/phpstan": "1.12.0",
|
||||
"larastan/larastan": "^2.0",
|
||||
"laravel/pint": "^1.0",
|
||||
@@ -121,12 +121,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/korridor/scramble"
|
||||
}
|
||||
],
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
|
||||
2319
composer.lock
generated
2319
composer.lock
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user